Server-side rendering
How Flexiboards renders boards on the server, and how to handle responsive boards and stored layouts.
Introduction
Flexiboards supports server-side rendering with SvelteKit. Declared layouts, or layouts supplied as server data, appear before hydration.
This works because placement is pure logic. Widget positions are computed from your declared configuration, not measured from the DOM, and idle widgets are styled with CSS grid line placement (grid-column / grid-row) rather than pixel values. The pixel-measuring parts of the library, such as dragging, resizing and pointer tracking, only activate on interaction, which doesn’t happen on a server.
Two scenarios need a provisional layout until client initialization:
- Stored layouts. A
loadLayout/loadLayoutscallback that relies on the client (such as client storage) can’t run on the server. - Responsive boards. The server can’t know the viewport, so the rendered breakpoint has to be guessed.
The examples below are integration excerpts for an existing board. Keep its target declarations and registry. boardConfig refers to that configuration; DashboardSkeleton and BoardSkeleton are fallback components supplied by your application.
Server-stored layouts
A layout the server already has, such as a user’s saved board fetched from your database, doesn’t need any of the suspense machinery below. Pass it as initialLayout: a plain layout value (not a callback), applied during the initial render pass on both the server and the client.
In this SvelteKit excerpt, getBoardLayout is your application’s database function, exported from $lib/server/boards. It returns a FlexiLayout. Fetch it in a server load function and pass it through page data:
// +page.server.ts
import type { PageServerLoad } from './$types';
import { getBoardLayout } from '$lib/server/boards';
export const load: PageServerLoad = async ({ locals }) => {
return { layout: await getBoardLayout(locals.user) };
}; The page excerpt assumes your application defines ChartWidget and TableWidget in the files imported below:
<script lang="ts">
import { FlexiBoard } from '@flexiboards/svelte';
import ChartWidget from './chart-widget.svelte';
import TableWidget from './table-widget.svelte';
let { data } = $props();
</script>
<FlexiBoard
config={{
registry: { chart: { component: ChartWidget }, table: { component: TableWidget } },
initialLayout: data.layout
}}
>
<!-- targets; any declared widgets act as a fallback for targets
the layout has no entry for -->
</FlexiBoard> The board server-renders at the layout’s final positions with no pending window. Give the client the same data so hydration matches. Like importLayout, entries resolve through the registry via their type; targets without an entry in the layout fall back to their declared widgets. On responsive boards the equivalent is initialLayouts, keyed by breakpoint.
If a client-side loadLayout is also configured (say, local drafts beating the server copy), it still runs at hydration and overrides the initial layout. The board is marked pending until it does, as described next.
Stored layouts and suspense
A board configured with loadLayout (or a responsive board with loadLayouts) usually reads from localStorage or a per-user store, which does not live on the server. Flexiboards therefore skips the callback during SSR and renders the layout declared in your markup as a stand-in (which can be empty). On the client, the callback runs during hydration and the stored layout replaces the stand-in.
Your callback never runs on the server
loadLayout only on the client. If your application calls the same function elsewhere, that caller must also run in the browser.Until that import resolves, the board’s rendered layout may be wrong, since a returning user’s saved arrangement can differ arbitrarily from the declared one. Give FlexiBoard a suspense snippet to show a fallback:
<FlexiBoard config={boardConfig}>
{#snippet suspense({ reason })}
<DashboardSkeleton />
{/snippet}
<FlexiTarget key="main">…</FlexiTarget>
</FlexiBoard> The fallback is server-rendered alongside the board and shown in its place from the very first paint, before any JavaScript runs. It unmounts the moment the layout is confirmed on the client. reason tells you why it’s showing: 'layout' here, or 'breakpoint' for the responsive case below. Since it’s ordinary markup, style it however you like, including with responsive utility classes.
Styling it yourself with CSS
If you’d rather keep the real board visible and veil it (skeleton tints over the stand-in’s geometry, say), omit the suspense fallback. A pending board always marks its root element, which you can target with plain CSS:
<div role="application" data-flexi-pending="layout" aria-busy="true">…</div> The attribute is present in the server HTML and during hydration, and is removed the moment the initial load resolves. That happens even when nothing was stored, since at that point the declared layout is confirmed final. Because it’s plain markup, you can build a CSS-only skeleton that applies from the first paint, with no JavaScript involved:
/* Disable interaction and veil widget contents behind skeleton tints. */
[data-flexi-pending] {
pointer-events: none;
}
[data-flexi-pending] [role='gridcell'] {
position: relative;
}
[data-flexi-pending] [role='gridcell'] > * {
visibility: hidden;
}
[data-flexi-pending] [role='gridcell']::after {
content: '';
position: absolute;
inset: 0;
border-radius: 10px;
background: color-mix(in oklab, currentColor 7%, transparent);
} The veil keeps the stand-in’s geometry as gray blocks, a real skeleton, and the swap to the stored layout happens underneath it.
Boards that declare no widgets
If everything on a board arrives through loadLayout (no FlexiWidget declarations at all), the server renders empty grids and the cell veil has nothing to cover. Pseudo-elements participate in grid layout as items, so you can paint ghost placeholder rows into empty pending grids:
[data-flexi-pending] [role='grid']:not(:has([role='gridcell']))::before,
[data-flexi-pending] [role='grid']:not(:has([role='gridcell']))::after {
content: '';
grid-column: 1 / -1;
min-height: 2.75rem;
border-radius: 10px;
background: color-mix(in oklab, currentColor 7%, transparent);
} Reading pending state from code
The flag is also exposed on the controller as board.layoutPending if you’d rather drive a custom loading treatment from your own markup.
Responsive boards
A ResponsiveFlexiBoard picks its breakpoint with matchMedia, which we can’t know ahead of time on the server. Flexiboards therefore chooses one breakpoint to render as a best guess. By default, this is the default breakpoint, which is usually your smallest layout, and desktop visitors, for example, would see a narrow board flash before hydration corrects it.
Declaring an SSR breakpoint
The first strategy for this is to tell the server which breakpoint to assume, with ssrBreakpoint:
<ResponsiveFlexiBoard
config={{
breakpoints: { lg: 1024, sm: 640 },
ssrBreakpoint: 'lg'
}}
>
<!-- … -->
</ResponsiveFlexiBoard> Set this to the breakpoint most of your visitors land on. That reduces how often the rendered breakpoint mismatches theirs.
Handling mismatches
The second strategy decides what a visitor sees when the breakpoint mismatches, before hydration has a chance to apply. The same suspense snippet covers this. Pass it to the FlexiBoard inside your responsive board:
<ResponsiveFlexiBoard config={{ breakpoints: { lg: 1024, sm: 640 }, ssrBreakpoint: 'lg' }}>
{#snippet children({ currentBreakpoint })}
<FlexiBoard config={boardConfig}>
{#snippet suspense({ reason })}
<BoardSkeleton />
{/snippet}
<!-- targets -->
</FlexiBoard>
{/snippet}
</ResponsiveFlexiBoard> Flexiboards derives the assumed breakpoint’s viewport range from your breakpoints config and generates the media query itself, so all three outcomes are handled with nothing hardcoded:
- The guess matches the viewport. The server board is correct: it shows untouched from first paint, fallback hidden.
- The guess doesn’t match. The fallback shows instead of the wrong-shaped board until hydration swaps in the right one. Since the fallback is your markup, responsive utility classes inside it shape it for the actual viewport.
- After hydration. The fallback unmounts and the real board is confirmed.
reason is 'breakpoint' here, with assumed carrying the guessed key. It is 'layout' when an unresolved loadLayouts means the content itself is unknown, in which case the fallback shows at every viewport.
Styling mismatches yourself with CSS
The pending attribute carries the guess too: data-flexi-pending="lg" until the client confirms, with "layout" winning when both apply. Your breakpoint thresholds are known constants, so CSS can handle the same three outcomes by hand. With ssrBreakpoint: 'lg' at a 1024px threshold, that’s:
/* Viewport matches the guess: lift the veil entirely. */
@media (width >= 1024px) {
[data-flexi-pending='lg'] {
pointer-events: auto;
}
[data-flexi-pending='lg'] [role='gridcell'] > * {
visibility: visible;
}
[data-flexi-pending='lg'] [role='gridcell']::after,
[data-flexi-pending='lg'] [role='grid']::before,
[data-flexi-pending='lg'] [role='grid']::after {
content: none;
}
}
/* Viewport doesn't match: hide the wrong-shaped cells, show stacked bars. */
@media (width < 1024px) {
[data-flexi-pending='lg'] [role='gridcell'] {
display: none;
}
[data-flexi-pending='lg'] [role='grid']::before,
[data-flexi-pending='lg'] [role='grid']::after {
content: '';
grid-column: 1 / -1;
min-height: 2.75rem;
border-radius: 10px;
background: color-mix(in oklab, currentColor 7%, transparent);
}
} The media query ranges are hardcoded to your own breakpoint thresholds. That is a small duplication, but it keeps the whole treatment in CSS, with zero layout shift for correctly-guessed visitors.
Guessing per request
ssrBreakpoint from request information if your application has it. A mobile-device hint cannot tell you the viewport width, and headers may be absent. Keep a default guess and the mismatch treatment above. Measure your own traffic before relying on request hints to improve the initial layout.The guess is also exposed on the controller as board.breakpointPending (string | null).
Client-side rendering
Summary
| Situation | Server renders | Marked with | Resolves |
|---|---|---|---|
| Declared layout, no stored state | The final board | Nothing | Already final |
initialLayout / initialLayouts (server data) | The final board | Nothing | Already final |
loadLayout / loadLayouts configured | Declared or initial stand-in | data-flexi-pending="layout" | Client import at hydration |
| Responsive board | The ssrBreakpoint (else default) layout | data-flexi-pending="<breakpoint>" | Real matchMedia at hydration |