SvGrid Studio · relations
Foreign-key lookup fields end to end. Contacts belong to a Company: the contact form renders a searchable Company picker (SvLookupInput + createRelationLookup) that queries the Companies source and stores the id, while the grid shows the resolved company NAME. The lookup runs over the same ServerDataSource contract, so related options can come from Supabase / REST / SQL / in-memory (here both entities are in-memory). (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Studio). See the SvGrid documentation for the full API.
About this example
Foreign-key lookup fields end to end in the SvGrid Studio stack. Contacts belong to a Company: the contact's companyId is a relation field, so the edit form renders a searchable picker built from SvLookupInput and createRelationLookup that queries the Companies source and stores the id, while the grid shows the resolved company name. The lookup runs over the same ServerDataSource contract as everything else, so related options can come from Supabase, REST, SQL or, as here, memory.
Data-app Studio · relations - a foreign-key "lookup" field end to end.
Contacts belong to a Company. The contact's companyId field is a relation, so the edit form renders a SEARCHABLE picker (SvLookupInput + createRelationLookup) that searches the Companies source and stores the company id - while the grid shows the company NAME.
The lookup runs over the same ServerDataSource contract as everything else, so the related options could just as well come from Supabase, REST, or SQL. Here both entities are in-memory.
Imports, features and API used
Imports: @svgrid/grid, @svgrid/enterprise, ../shared/SvGridStudio.svelte
Frequently asked questions
How does the form know to render a picker?
The schema marks companyId as a relation field; SvGridEditPanel receives a lookups map with createRelationLookup for that field and renders SvLookupInput, which searches the Companies source as the user types.
How does the grid show the name instead of the id?
The column for companyId resolves the id through the same lookup, so the cell displays the company name while the stored value stays the id.
Can the related options come from a server?
Yes. createRelationLookup takes any ServerDataSource, so the Companies list can be a Supabase table, a REST endpoint or SQL; the picker sends its search text as a filter.
Related documentation
Related articles
- SvGrid Studio Tips and Tricks: Build Svelte Data Apps, Fast - Practical SvGrid Studio tips - AI app generation, a designer that emits real SvelteKit, schema-driven edit forms, code-behind with ctx.grid, production auth/data/deploy toggles, and real data-source binding - each with a snippet.
- SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
- SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
Source code (195-studio-relations.svelte)
<script lang="ts">
/**
* Data-app Studio · relations - a foreign-key "lookup" field end to end.
*
* Contacts belong to a Company. The contact's `companyId` field is a
* `relation`, so the edit form renders a SEARCHABLE picker
* (`SvLookupInput` + `createRelationLookup`) that searches the Companies
* source and stores the company id - while the grid shows the company NAME.
*
* The lookup runs over the same `ServerDataSource` contract as everything
* else, so the related options could just as well come from Supabase, REST, or
* SQL. Here both entities are in-memory.
*/
import { SvGrid, createServerDataSource, type ServerState, type ServerDataSource } from '@svgrid/grid'
import {
SvGridEditPanel,
createInMemoryDataSource,
createRelationLookup,
schemaToColumns,
type EntitySchema,
} from '@svgrid/enterprise'
import SvGridStudio from '../shared/SvGridStudio.svelte'
type Company = { id: number; name: string }
type Contact = { id: number; name: string; email: string; title: string; companyId: number | string; company?: string }
const companies: Company[] = [
{ id: 1, name: 'Acme Inc' },
{ id: 2, name: 'Globex' },
{ id: 3, name: 'Initech' },
{ id: 4, name: 'Umbrella Corp' },
{ id: 5, name: 'Stark Industries' },
{ id: 6, name: 'Wonka Industries' },
]
const nameById = new Map(companies.map((c) => [String(c.id), c.name]))
const companySchema: EntitySchema<Company> = {
name: 'companies',
label: 'Company',
idField: 'id',
fields: [
{ field: 'id', type: 'number', primaryKey: true, readonly: true },
{ field: 'name', type: 'text', required: true },
],
}
const contactSchema: EntitySchema<Contact> = {
name: 'contacts',
label: 'Contact',
idField: 'id',
fields: [
{ field: 'id', type: 'number', primaryKey: true, readonly: true, hidden: { form: true } },
{ field: 'name', type: 'text', required: true, minLength: 2 },
{ field: 'email', type: 'text', label: 'Email', format: 'email' },
{ field: 'title', type: 'text' },
// The FK: a searchable picker in the form, hidden from the grid.
{ field: 'companyId', type: 'relation', label: 'Company', relation: { entity: 'companies', labelField: 'name' }, hidden: { grid: true } },
// Display-only: the resolved company name, shown in the grid, not the form.
{ field: 'company', type: 'text', label: 'Company', readonly: true, hidden: { form: true } },
],
}
const seed: Contact[] = [
{ id: 1, name: 'Ada Lovelace', email: '[email protected]', title: 'CTO', companyId: 1 },
{ id: 2, name: 'Alan Turing', email: '[email protected]', title: 'Engineer', companyId: 2 },
{ id: 3, name: 'Grace Hopper', email: '[email protected]', title: 'Admiral', companyId: 3 },
{ id: 4, name: 'Tony Stark', email: '[email protected]', title: 'CEO', companyId: 5 },
{ id: 5, name: 'Barbara Liskov', email: '[email protected]', title: 'Principal', companyId: 1 },
{ id: 6, name: 'Willy Wonka', email: '[email protected]', title: 'Founder', companyId: 6 },
{ id: 7, name: 'Linus Torvalds', email: '[email protected]', title: 'Maintainer', companyId: 2 },
{ id: 8, name: 'Radia Perlman', email: '[email protected]', title: 'Architect', companyId: 4 },
]
const companiesSource = createInMemoryDataSource(companies, companySchema)
const rawContacts = createInMemoryDataSource(seed, contactSchema)
// Enrich each contact with its company NAME (a join). Nothing is denormalized
// in storage; the grid just gets a `company` label. IMPORTANT: enrich EVERY
// method's rows, not just getRows - optimistic create/update reconcile the
// local row with what create/updateRow return, so they must have the same
// shape or the display field vanishes after an edit.
const withCompany = (r: Contact): Contact => ({ ...r, company: nameById.get(String(r.companyId)) ?? '' })
const contactsSource: ServerDataSource<Contact> = {
...rawContacts,
async getRows(req) {
const res = await rawContacts.getRows(req)
return { ...res, rows: res.rows.map(withCompany) }
},
async createRow(input) {
return withCompany(await rawContacts.createRow(input))
},
async updateRow(id, patch) {
return withCompany(await rawContacts.updateRow(id, patch))
},
}
const companyLookup = createRelationLookup<Company>({ source: companiesSource, schema: companySchema, labelField: 'name' })
const columns = schemaToColumns(contactSchema)
let view = $state<ServerState<Contact>>({
rows: [], total: 0, loading: false, saving: false, error: null,
pageIndex: 0, pageSize: 8, pageCount: 1, sortModel: [], filterModel: {},
})
const controller = createServerDataSource(contactsSource, {
pageSize: 8, optimistic: true, getRowId: (r) => String(r.id), onChange: (s) => (view = s),
})
controller.refresh()
let editing = $state<Contact | null | undefined>(undefined)
let selected = $state<Contact[]>([])
let genId = seed.length + 1
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<Contact> }) {
if (mode === 'create') {
await controller.createRow({ id: genId++, ...values })
controller.setPage(view.pageCount - 1)
} else if (id) {
await controller.updateRow(id, values)
}
editing = undefined
}
async function removeSelected() {
for (const row of selected) await controller.deleteRow(String(row.id))
selected = []
}
</script>
<SvGridStudio
title="Relations"
subtitle="Contacts belong to a <strong>Company</strong>. The form's Company field is a searchable <code>relation</code> lookup; the grid shows the resolved name."
>
{#snippet toolbar()}
<button class="st-btn st-btn--primary" onclick={() => (editing = null)}>+ New contact</button>
<button class="st-btn" disabled={selected.length === 0} onclick={removeSelected}>
Delete{selected.length ? ` (${selected.length})` : ''}
</button>
<span class="st-hint">Double-click a row to edit · the Company field searches the Companies source</span>
{/snippet}
<SvGrid responsive={true}
columnResize
data={view.rows}
{columns}
loading={view.loading}
fitColumns
showRowSelection
sortable
externalSort
onSortingChange={(s) => controller.setSort(s)}
filterable
filterMode="row"
showGlobalFilter
externalFilter
onFiltersChange={(f) =>
controller.setFilter({
global: f.global || undefined,
columns: Object.fromEntries(
f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }]),
),
})}
onRowDoubleClick={(e) => (editing = e.row)}
onRowSelectionChange={(_sel, rows) => (selected = rows)}
showPagination
externalPagination
rowCount={view.total}
pageIndex={view.pageIndex}
pageSize={view.pageSize}
onPaginationChange={({ pageIndex, pageSize }) => {
if (pageSize !== view.pageSize) controller.setPageSize(pageSize)
else controller.setPage(pageIndex)
}}
containerHeight={320}
/>
{#if editing !== undefined}
<SvGridEditPanel
schema={contactSchema}
row={editing}
presentation="modal" persistKey="studio"
lookups={{ companyId: companyLookup }}
onSubmit={save}
onCancel={() => (editing = undefined)}
/>
{/if}
</SvGridStudio>More Studio examples
- SvGrid Studio · live SQL - The Studio stack backed by a REAL Postgres running in the browser via PGlite (WASM), no server. createSqlDataSource turns the grid's sort / filter / page requests into parameterized SQL run through PGlite; the executed query is shown live under the toolbar. Full CRUD with optimistic updates against actual Postgres.
- Northwind on PGlite - The classic Northwind sample DB (categories, customers, products, orders, order_details) seeded into a real in-browser Postgres (PGlite, WASM). Switch between a five-table JOIN exposed as a read-only VIEW and the editable base tables - each via createSqlDataSource. Sort / filter / page / edit all run as parameterized SQL; new rows get an auto id from a Postgres IDENTITY sequence.
- SvGrid Studio · Supabase - The Studio stack over hosted Postgres on Supabase, straight from the browser via supabase-js (PostgREST) and your project's public anon key. createSupabaseDataSource maps the grid's sort / filter / page / CRUD onto the query builder, introspectSupabaseTable adapts to any table AND detects foreign keys (a FK column auto-becomes a searchable lookup in the form), and createSupabaseRealtime makes it LIVE - change a row in the Supabase dashboard and it flashes in the grid (toggle the Live pill). Paste your URL + anon key, run the one-time setup SQL, done. RLS keeps the anon key safe.
- SvGrid Studio · secured - A secured screen: SvAuthGate requires a signed-in user (Supabase Auth via createSupabaseAuth) before showing the Studio grid, with a login / sign-up form and a signed-in bar. Uses a mock auth client so any email + password works here; swap for your supabase-js client and Row-Level Security scopes each user to their own rows (auth = who, RLS = what).
- SvGrid Studio · chart - A live chart panel beside the grid, driven by the same data source: rowsToChartSpec aggregates the rows (group by tier / active, reduce MRR by sum / avg / count) and SvGridChart renders bar / pie / line - no external chart library. The chart doubles as a filter control: click a bar or slice to filter the grid to that category. Create / edit / delete and the chart updates.