GraphQL adapter

Server-side sort / filter / page wired to a mock GraphQL resolver. Side panel shows the live query doc so you can compare what the grid sent to the network tab.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Data). See the SvGrid documentation for the full API.

About this example

The Svelte 5 data grid wired to a GraphQL endpoint. Sorting, filtering and paging run in externalSort and externalFilter mode, so each change hands the grid state to the component, which builds a typed GraphQL query from it, sends it and resets data from the response. A side panel shows the live query document so you can compare it with the network tab; the resolver is an in-process mock that swaps for fetch('/graphql') unchanged.

Wires the grid to a GraphQL endpoint. Whenever the user sorts, filters, or pages, the grid hands the state to the consumer via the externalSort / externalFilter callbacks; we build a typed GraphQL query from that state, send it, and re-set data from the response.

The endpoint here is a mock in-process resolver so the demo runs stand-alone; swap runQuery for fetch('/graphql', ...) against your own backend and the rest of the wiring is unchanged.

Imports, features and API used

Imports: @svgrid/grid, ../shared/seed

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: orderId (Order ID), company (Company), product (Product), sellDate (Sell date), quantity (Qty), price (Price), country (Country)

Frequently asked questions

How is the query built from grid state?

onSortingChange and onFiltersChange store the sort clause and column filters; a function maps them to the orders field's page, pageSize, sort { field, dir } and filters [{ field, op, value }] arguments and serializes the document that the side panel displays.

Does this work with a GraphQL client library?

Yes. runQuery is the only place the document is sent; replace its body with your client's query call and pass the variables it produces.

How is the response mapped to rows?

The resolver returns total and items; the items array becomes the grid's data and total drives the pager.

Related documentation

Related articles

  • A Svelte Data Grid with GraphQL - Wire SvGrid to a GraphQL API with server-side sort, filter, and pagination - covering offset and cursor paging, variable translation, and the three callbacks that keep everything in sync.

Source code (72-graphql-adapter.svelte)

<script lang="ts">
  /**
   * 72. GraphQL adapter - server-side sort + filter + page
   * -----------------------------------------------------
   * Wires the grid to a GraphQL endpoint. Whenever the user sorts,
   * filters, or pages, the grid hands the state to the consumer via the
   * `externalSort` / `externalFilter` callbacks; we build a typed GraphQL
   * query from that state, send it, and re-set `data` from the response.
   *
   * The endpoint here is a mock in-process resolver so the demo runs
   * stand-alone; swap `runQuery` for `fetch('/graphql', ...)` against
   * your own backend and the rest of the wiring is unchanged.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
  } from '@svgrid/grid'
  import { makeOrders, type Order } from '../shared/seed'

  const ALL_ORDERS = makeOrders(2500)

  // ---- Mock GraphQL resolver --------------------------------------------
  type Sort   = { field: string; dir: 'asc' | 'desc' }
  type Filter = { field: string; op: 'contains' | 'eq' | 'gt' | 'lt'; value: string }
  type OrdersQuery = {
    page: number; pageSize: number
    sort?: Sort
    filters?: Filter[]
  }
  type OrdersResult = {
    items: Order[]
    total: number
    elapsedMs: number
  }

  /** "Backend". Pretends to be GraphQL: receives a typed query and returns rows. */
  async function runQuery(q: OrdersQuery): Promise<OrdersResult> {
    const started = performance.now()
    // Realistic 150-300ms server hop.
    await new Promise((r) => setTimeout(r, 150 + Math.random() * 150))

    let working = ALL_ORDERS.slice()
    for (const f of q.filters ?? []) {
      working = working.filter((row) => {
        const v = (row as Record<string, unknown>)[f.field]
        switch (f.op) {
          case 'contains': return String(v ?? '').toLowerCase().includes(f.value.toLowerCase())
          case 'eq':       return String(v ?? '') === f.value
          case 'gt':       return Number(v) > Number(f.value)
          case 'lt':       return Number(v) < Number(f.value)
        }
      })
    }
    if (q.sort) {
      const { field, dir } = q.sort
      const sign = dir === 'desc' ? -1 : 1
      working = working.slice().sort((a, b) => {
        const av = (a as Record<string, unknown>)[field]
        const bv = (b as Record<string, unknown>)[field]
        if (typeof av === 'number' && typeof bv === 'number') return sign * (av - bv)
        return sign * String(av ?? '').localeCompare(String(bv ?? ''))
      })
    }
    const total = working.length
    const start = (q.page - 1) * q.pageSize
    const items = working.slice(start, start + q.pageSize)
    return { items, total, elapsedMs: performance.now() - started }
  }

  /** Pretty-print the equivalent GraphQL doc for the side panel. */
  function toGraphQLDoc(q: OrdersQuery): string {
    const args: string[] = [`page: ${q.page}`, `pageSize: ${q.pageSize}`]
    if (q.sort) args.push(`sort: { field: "${q.sort.field}", dir: ${q.sort.dir.toUpperCase()} }`)
    if (q.filters?.length) {
      const lines = q.filters.map((f) => `    { field: "${f.field}", op: ${f.op.toUpperCase()}, value: "${f.value}" }`).join(',\n')
      args.push(`filters: [\n${lines}\n  ]`)
    }
    return `query OrdersPage {\n  orders(\n    ${args.join(',\n    ')}\n  ) {\n    total\n    items {\n      orderId company product sellDate quantity price country\n    }\n  }\n}`
  }

  // ---- Component state --------------------------------------------------
  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  let api = $state<SvGridApi<typeof features, Order> | null>(null)

  let rows = $state<Order[]>([])
  let total = $state(0)
  let page = $state(1)
  const pageSize = 25
  let sort = $state<Sort | undefined>(undefined)
  let filters = $state<Filter[]>([])
  let loading = $state(false)
  let lastDoc = $state<string>('')
  let lastElapsed = $state<number>(0)

  async function refresh() {
    loading = true
    const q: OrdersQuery = { page, pageSize, sort, filters: filters.length ? filters : undefined }
    lastDoc = toGraphQLDoc(q)
    const res = await runQuery(q)
    rows = res.items
    total = res.total
    lastElapsed = Math.round(res.elapsedMs)
    loading = false
  }

  // Initial load.
  $effect(() => { void refresh() })

  // ---- Grid wiring ------------------------------------------------------
  function onSortChange(clauses: Array<{ id: string; desc: boolean }>) {
    sort = clauses[0]
      ? { field: clauses[0].id, dir: clauses[0].desc ? 'desc' : 'asc' }
      : undefined
    page = 1
    void refresh()
  }
  function onFiltersChange(payload: {
    global: string
    columns: Array<{ id: string; operator: string; value: string }>
  }) {
    const out: Filter[] = []
    for (const c of payload.columns) {
      if (!c.value) continue
      const op: Filter['op'] = c.operator === 'equals' ? 'eq'
        : c.operator === 'greaterThan' ? 'gt'
        : c.operator === 'lessThan' ? 'lt'
        : 'contains'
      out.push({ field: c.id, op, value: c.value })
    }
    filters = out
    page = 1
    void refresh()
  }

  const totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)))

  const columns: GridColumns<Order> = [
    { field: 'orderId',  header: 'Order ID', editorType: 'text',   width: 140 },
    { field: 'company',  header: 'Company',  editorType: 'text',   width: 180 },
    { field: 'product',  header: 'Product',  editorType: 'text',   width: 180 },
    { field: 'sellDate', header: 'Sell date',editorType: 'date',   width: 130,
      format: { type: 'date', pattern: 'y-m-d' } },
    { field: 'quantity', header: 'Qty',      editorType: 'number', width: 90 },
    { field: 'price',    header: 'Price',    editorType: 'number', width: 130,
      format: { type: 'currency', currency: 'USD' } },
    { field: 'country',  header: 'Country',  editorType: 'text',   width: 110 },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
    <span class="font-medium">
      {total.toLocaleString()} rows · page {page} of {totalPages}
    </span>
    {#if loading}<span class="text-amber-600 dark:text-amber-400">Fetching…</span>
    {:else}<span class="text-emerald-600 dark:text-emerald-400">✓ {lastElapsed} ms</span>{/if}

    <div class="ml-auto inline-flex items-center gap-1">
      <button type="button" disabled={page <= 1}
        onclick={() => { page = Math.max(1, page - 1); void refresh() }}
        class="gql-btn rounded-md border px-2 py-1 disabled:opacity-40"
        >‹ Prev</button>
      <button type="button" disabled={page >= totalPages}
        onclick={() => { page = Math.min(totalPages, page + 1); void refresh() }}
        class="gql-btn rounded-md border px-2 py-1 disabled:opacity-40"
        >Next ›</button>
    </div>
  </div>

  <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 flex-1 min-h-0">
    <div class="flex-1 min-h-0 min-w-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        filterMode="menu"
        externalSort={true}
        externalFilter={true}
        selectionMode="cell"
        showRowNumbers={true}
        enableInlineEditing={false}
        enableCellSelection={true}
        rowHeight={32}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(next) => (api = next)}
        onSortingChange={onSortChange}
        onFiltersChange={onFiltersChange}
      />
    </div>

    <aside class="flex flex-col gap-2 min-h-0">
      <header class="flex items-baseline justify-between shrink-0">
        <div class="gql-label text-xs uppercase tracking-wide">Last GraphQL query</div>
        <div class="gql-meta text-[10px]">round-trip {lastElapsed} ms</div>
      </header>
      <pre class="gql-doc flex-1 min-h-0 overflow-auto text-[11px] leading-snug border rounded p-3">{lastDoc}</pre>
    </aside>
  </div>
</section>

<style>
  /* Pager + query panel chrome track the active grid theme. */
  .gql-btn {
    border-color: var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #ffffff);
    color: var(--sg-fg, #0f172a);
  }
  .gql-label { color: var(--sg-muted, #64748b); }
  .gql-meta { color: var(--sg-muted, #94a3b8); }
  .gql-doc {
    border-color: var(--sg-border, #e2e8f0);
    background: var(--sg-bg-subtle, var(--sg-header-bg, #f8fafc));
    color: var(--sg-fg, #0f172a);
  }
</style>

View this example on GitHub

More Server-Side Data examples

  • Server-Side Row Model: paged and infinite - The free half of the row model: implement one async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort/filter/page lifecycle and races stale responses away. Paged, the grid holds one 50-row page; infinite, a block cache under the scrollbar with placeholders, retry and an LRU. Here a 100,000-row in-memory server behind 250ms latency. Grouping, tree, pivot and transactions on top of the same contract are the Enterprise row model (demo 467).
  • Server-side data - Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.
  • Server-side infinite scroll - 100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.
  • Loading from REST - Fetches rows from a public REST API with loading skeleton, retry, error surface, and a Reload button.
  • Cursor (keyset) pagination - Modern alternative to offset paging: prev / next cursor tokens, stable under writes, O(log N) deep pages.