Using SvGrid with SvelteKit and Server-Side Rendering - SvGrid blog illustration

Using SvGrid with SvelteKit and Server-Side Rendering

SvGrid renders real HTML on the server and hydrates cleanly - no browser guards, no blank first paint, and no special adapter required.

Most data grid libraries fail their first SSR test silently. You get a blank grid region, a hydration warning in the console, and eventually a {#if browser} wrapper that makes the whole SSR exercise pointless. The failure mode is always the same: the library reaches for window, document, or ResizeObserver before the first cell renders, which blows up in a Node.js context.

Using SvGrid with SvelteKit and Server-Side Rendering

SvGrid separates its row model - sorting, filtering, grouping, pagination - from the DOM layer entirely. During a SvelteKit server render, the row model runs as plain JavaScript on Node.js and emits real <table>, <thead>, and <td> elements into the HTML response. The browser receives meaningful markup, hydration attaches reactive bindings to existing nodes, and the grid is interactive within the time it takes the JS bundle to execute. No configuration required.

Wiring the load function

The most direct SSR pattern is a +page.ts load function that fetches data on the server and passes it to the page component through data. SvelteKit's built-in fetch is the right tool here - it deduplicates requests during prerendering and correctly forwards cookies in SSR context. A raw globalThis.fetch call will work in the browser but loses those guarantees on the server.

// src/routes/people/+page.ts
import type { PageLoad } from './$types'

export interface Employee {
  id: number
  firstName: string
  lastName: string
  department: string
  country: string
  role: string
  salary: number
  startDate: string
}

export const load: PageLoad = async ({ fetch, url }) => {
  const department = url.searchParams.get('dept') ?? ''
  const endpoint = department
    ? `/api/employees?dept=${encodeURIComponent(department)}&limit=250`
    : `/api/employees?limit=250`

  const res = await fetch(endpoint)
  if (!res.ok) throw new Error(`Employee fetch failed: ${res.status}`)

  const employees: Employee[] = await res.json()
  return { employees, department }
}

250 rows serialised to JSON with this shape runs about 60-80 KB inline in the HTML payload. That is comfortable for SSR. If you are looking at 1,000+ rows, the inline payload becomes a problem - that situation calls for createServerDataSource, covered later.

The page component - no browser guards needed

The page component is straightforward. features, columns, and the <SvGrid> component are all defined at the top level of <script>, with no {#if browser} wrapper and no onMount guard. The grid renders identically on the server and the client.

<!-- src/routes/people/+page.svelte -->
<script lang="ts">
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    type ColumnDef,
    type SvGridApi,
  } from '@svgrid/grid'
  import type { PageData } from './$types'
  import type { Employee } from './+page.ts'

  let { data }: { data: PageData } = $props()

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
  })

  const columns: ColumnDef<typeof features, Employee>[] = [
    { id: 'firstName',  field: 'firstName',  header: 'First name',  width: 130 },
    { id: 'lastName',   field: 'lastName',   header: 'Last name',   width: 130 },
    { id: 'department', field: 'department', header: 'Department',  width: 160 },
    { id: 'country',    field: 'country',    header: 'Country',     width: 110 },
    { id: 'role',       field: 'role',       header: 'Role',        width: 160 },
    { id: 'startDate',  field: 'startDate',  header: 'Start date',  width: 120 },
    {
      id: 'salary',
      field: 'salary',
      header: 'Salary',
      width: 140,
      type: 'number',
      align: 'right',
    },
  ]

  let api = $state<SvGridApi<typeof features, Employee> | null>(null)

  function onApiReady(ready: SvGridApi<typeof features, Employee>) {
    api = ready
    // Default sort: most senior employees first.
    api.setSort('startDate', 'asc')
  }

  function exportSelected() {
    if (!api) return
    const rows = api.getSelectedRows()
    console.log('export', rows)
  }
</script>

<div class="toolbar">
  <button onclick={exportSelected}>Export selected</button>
</div>

<SvGrid
  {features}
  {columns}
  rows={data.employees}
  sortable
  filterable
  showFilterRow={true}
  enableCellSelection={true}
  height={560}
  {onApiReady}
/>

One detail worth repeating: features and columns are defined outside any reactive context. They are stable references. If you define them inside a reactive block or an $effect, Svelte will re-create the grid on every dependency change, which is not what you want.

The onApiReady callback fires after hydration completes in the browser. Calling api.setSort there is correct. Calling it at the top level of <script> is not - the API object does not exist during the server render pass, and you will get a null-dereference error.

When inline row data is not enough

At around 500-600 rows, the inline HTML payload starts affecting Time to First Byte measurably. At 2,000 rows you are adding 500 KB+ to the initial response. The right pattern at that scale is createServerDataSource, which renders an empty shell on the server and loads page one immediately after hydration.

// src/routes/orders/+page.svelte (partial)
import {
  SvGrid,
  tableFeatures,
  rowSortingFeature,
  columnFilteringFeature,
  rowPaginationFeature,
  createServerDataSource,
  type ColumnDef,
} from '@svgrid/grid'

const features = tableFeatures({
  rowSortingFeature,
  columnFilteringFeature,
  rowPaginationFeature,
})

const ds = createServerDataSource({
  fetch: async ({ page, pageSize, sort, filters }) => {
    const params = new URLSearchParams({
      page: String(page),
      size: String(pageSize),
    })
    if (sort.length) {
      params.set('sort', sort[0].id)
      params.set('dir', sort[0].desc ? 'desc' : 'asc')
    }
    filters.forEach(f => {
      params.set(`filter_${f.id}`, String(f.value))
    })

    const res = await fetch(`/api/orders?${params}`)
    const json = await res.json()
    return { rows: json.data, total: json.total }
  },
})

With createServerDataSource, the server emits an empty grid shell with correct dimensions. The browser loads page one of rows immediately after hydration, and subsequent pages arrive as the user navigates. Sorting and filtering trigger new fetch calls with the updated parameters. This pattern handles millions of rows without any change to the grid component markup.

The ICU data problem in Docker

If your production Node.js image was built with --with-intl=small-icu or no ICU at all, Intl.NumberFormat and Intl.DateTimeFormat will produce different output on the server than in the browser. A salary formatted as $95,000 on the server might come out as 95000 or use a different thousands separator, and Svelte's hydration will detect the mismatch and log a warning.

The fix: use a Node.js image that includes full ICU data, or install the full-icu npm package and set:

NODE_ICU_DATA=node_modules/full-icu

in your Docker entrypoint or .env. This is not a SvGrid-specific issue - any locale-aware formatting in SSR hits this - but data grids tend to surface it because they format a lot of values in one pass.

Prerendering: useful but limited

export const prerender = true on a route works. SvGrid SSRs the rows into static HTML at build time, Vite writes the file, and the CDN serves it. It is fast and crawlable. The obvious tradeoff is staleness: any record change after the build is invisible until the next deploy.

For reference data (country lists, product catalogs, configuration tables) prerendering is a good fit. For live operational data - orders, people, transactions - use server rendering (the SvelteKit default) so each request gets fresh data.

A flash of unstyled rows

One issue that comes up repeatedly: the grid HTML exists in the DOM but has no layout or visual styling for a moment on first paint - no row borders, no alternating row colors, wrong cell padding. There's no manual stylesheet import to add here: <SvGrid> bundles its own CSS as a side effect of the component itself, so wherever you import { SvGrid } from '@svgrid/grid', Vite pulls its stylesheet into the same chunk automatically.

The actual cause is almost always that <SvGrid> is behind a lazy boundary - a dynamic import(), a route-based code-split, or a client-only wrapper - so its CSS chunk loads asynchronously, after the rest of the page has already painted. Import SvGrid eagerly (a normal top-level import in the component/route that needs it visible on first paint) rather than dynamically, and its stylesheet lands in the same critical-path chunk as everything else.

Tagged: SvelteKit