SSR and caching
Hydrate from server-rendered data, cut Next.js boilerplate, and use the zero-dependency cache or TanStack Query.
Server-side rendering (initialData)
By default the list fetches on the client: the server renders an empty/loading shell and rows appear after hydration. For SEO, a faster first paint, and no loading flash, fetch the first page on the server and hand it to <ListView> as initialData — it renders those rows in the initial HTML and skips the client's first fetch. Paging and filtering afterwards still run on the client.
The catch: the server must compute the same query the client will derive from the URL, or the two renders disagree and React warns about a hydration mismatch. buildListQuery (from listkit/server) does exactly that — use its result both to fetch and as initialQuery:
// app/orders/page.tsx — a React Server Component
import { buildListQuery } from 'listkit/server'
import { ordersConfig } from './config'
import { listOrders } from './actions'
import { OrdersList } from './OrdersList'
export default async function OrdersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const query = buildListQuery(ordersConfig, await searchParams)
const initial = await listOrders(query) // { data, total }
return <OrdersList initialData={initial} initialQuery={query} />
}Since the config is now read in a Server Component, build it with
defineListConfigfromlistkit/server(not the main entry). The main entry,/next,/react-routerand/react-queryship with a'use client'banner: everything they export is a client reference, so a shared config module the server evaluates may still import a component from the main entry —RowActionsin a card renderer, say — and render it as JSX. What it may not do is call a function from there during the RSC render;defineListConfig,resolveListConfigandListSkeletonhave/servertwins for exactly that. Both the server page and the client list view can import the same config module when it's defined this way.
// config.ts — shared by the server page and the client list view
import { defineListConfig } from 'listkit/server'
export const ordersConfig = defineListConfig<Order>({
/* … */
})// OrdersList.tsx — a Client Component
'use client'
import { ListView, serverActionAdapter } from 'listkit'
import type { ListQuery, ListResult } from 'listkit'
import { ordersConfig } from './config'
import { listOrders } from './actions'
export function OrdersList({
initialData,
initialQuery,
}: {
initialData: ListResult<Order>
initialQuery: ListQuery
}) {
const adapter = serverActionAdapter<Order>(q => listOrders(q))
return (
<ListView
config={ordersConfig}
adapter={adapter}
initialData={initialData} // rendered in the server HTML
initialQuery={initialQuery} // used only while the URL still matches
/>
)
}The same server action (listOrders) powers both the server's first page and the client's later fetches — no duplicated fetching logic. initialData is used only while the live query equals initialQuery; the moment the user changes a page/filter (or calls useListRefresh()), the list fetches normally. It's fully opt-in: lists without initialData keep client-fetching unchanged.
Less boilerplate (Next.js)
Three helpers cover the wiring every SSR/Next app would otherwise hand-roll:
NextListView(listkit/next) —<ListView>pre-wired with the App Router adapter, so search/page/filters/sort sync to the URL. No manualListKitProvider+useNextRouterAdapter. Passthemehere, or set it once on a root<ListKitProvider theme={…}>andNextListViewinherits it (a provider inherits any prop you don't pass).useNextHistoryRouterAdapter(listkit/next) — the App Router adapter that writes throughhistory.replaceStateinstead ofrouter.replace. Arouter.replaceonto the current page is a same-page navigation, and Next refetches the page's RSC segment for those — every filter, search keystroke or page change re-renders the server page and remounts the list (skeleton flash, lost scroll). Next keepsuseSearchParamsin sync with the History API, so the list still re-queries through its adapter; only the server round-trip goes. Also the adapter for a list mounted under a URL that carries more than its own params (a detail overlay's/items/{id}), where a router navigation would render that route on top of the live list. Pass it toListKitProvider;NextListViewkeepsuseNextRouterAdapter.loadInitialList(config, searchParams, fetcher)(listkit/server) — wrapsbuildListQuery+ the first-page fetch and degrades to a client fetch on error. Returns{ initialData, initialQuery }.ListSkeleton— a ready-made<Suspense>fallback (toolbar bar + skeleton table) for the streaming SSR pattern. Import it fromlistkit/serverin a Server Component (the page), or fromlistkitin client code.
// app/orders/page.tsx — Server Component
import { Suspense } from 'react'
// Import both from /server in RSC — the main barrel is a client boundary.
import { ListSkeleton, loadInitialList } from 'listkit/server'
import { ordersConfig } from './config'
import { listOrders } from './actions'
import { OrdersList } from './OrdersList'
export default function OrdersPage({ searchParams }) {
return (
<Suspense fallback={<ListSkeleton />}>
<OrdersData searchParams={searchParams} />
</Suspense>
)
}
async function OrdersData({ searchParams }) {
const { initialData, initialQuery } = await loadInitialList(
ordersConfig,
await searchParams,
listOrders
)
return <OrdersList initialData={initialData} initialQuery={initialQuery} />
}// OrdersList.tsx — Client Component
'use client'
import { NextListView } from 'listkit/next'
import { serverActionAdapter } from 'listkit'
import { ordersConfig } from './config'
import { listOrders } from './actions'
export function OrdersList({ initialData, initialQuery }) {
const adapter = serverActionAdapter(q => listOrders(q))
return (
<NextListView
theme='blue'
config={ordersConfig}
adapter={adapter}
initialData={initialData}
initialQuery={initialQuery}
/>
)
}Built-in cache (zero dependencies)
By default useListData keeps the last response in memory for 30 seconds (staleTime). This means:
- Going back to a page you already visited shows data instantly — no loading flash.
- If the cache is stale, the old data is shown immediately while a background refresh runs (stale-while-revalidate).
- Identical in-flight requests are deduplicated so rapid filter changes don't fire duplicate calls.
- Calling
useListRefresh()invalidates this list's cached pages and refetches (see Refreshing after a mutation);invalidateListCache(id?)does the same imperatively from anywhere. - The cache is bounded (least-recently-used eviction, ~100 entries shared across all lists), so a long-running app can't grow it without limit. If you need a larger, GC-tunable, cross-component cache, inject TanStack Query (below) and let it own the lifecycle.
You can tune or disable it per list:
// Cache responses for 5 minutes
<ListView config={config} adapter={adapter} staleTime={5 * 60 * 1000} />
// Disable cache (always fetch)
<ListView config={config} adapter={adapter} staleTime={0} />The list id identifies the dataset, not the view
The cache keys every response on config.id + the query (page, pageSize, search, filters, sort). So the id must uniquely identify which dataset the list shows. Any scope that changes the rows but isn't part of the query — a studentId or customerId the adapter closes over, a parent record the list hangs off — is invisible to the cache.
When one config is mounted in several such scopes, they collide: visit the list under scope A, then scope B with the same query within staleTime, and listkit serves A's cached rows to B without hitting the server. It's intermittent by nature — it only bites when a matching entry is still warm.
Pass the scope as cacheScope and listkit folds it into the cache id (`${config.id}::${cacheScope}`) so each view gets its own bucket — no config cloning, no id mutation:
// One planeacionesConfig, one instance per student — no cross-student bleed.
<ListView
config={planeacionesConfig}
adapter={adapter}
cacheScope={studentId}
/>Rules of thumb:
- Rendered once, globally (e.g. an admin
/userspage) → nothing to do; theidalone is unique. - The scope already lives in the
id(e.g.id: `orders-${year}`) → nothing to do; it's already in the key. - One
configreused across scopes (a per-parent tab, a detail-page sub-list) → setcacheScopeto the scope value.
invalidateListCache(config.id) still clears every scope of that id (it matches on the id:: prefix), so a mutation that affects all scopes refreshes them all; useListRefresh() inside a scoped view refreshes only that view. In development, listkit console.warns when it sees the same resolved id mounted on more than one route — the signature of a missing cacheScope.
Refreshing after a mutation
With an async adapter, listkit fetches on the client, so a server mutation won't show until the query changes. Call useListRefresh() from any descendant of <ListView> (a row's delete button, a modal) to force a refetch — no full page reload. It's a no-op outside a ListView, so shared buttons stay safe:
import { useListRefresh } from 'listkit'
function DeleteButton({ onConfirm }) {
const refresh = useListRefresh()
return (
<button
onClick={async () => {
await onConfirm() // server action
refresh() // row disappears immediately
}}
>
Delete
</button>
)
}refresh() truly invalidates this list's cached pages (it doesn't just bump a token), so the refetched data also wins on a later remount — a deleted row can't reappear when you navigate away and back.
For mutations that happen outside the list tree (e.g. a separate create/edit page), you have two options:
- Call
revalidatePath(...)in the server action. On return, the server re-renders and hands<ListView>a freshinitialDataseed, which is treated as authoritative on mount — no stale flash. - Or invalidate imperatively from anywhere:
import { invalidateListCache } from 'listkit'theninvalidateListCache('your-config-id')(omit the id to clear all lists, e.g. on sign-out).
In-memory lists (the data prop) refresh automatically when data changes — this is only needed for async adapters.
Using with TanStack Query
If your app already uses TanStack Query and you want its cross-component cache, background refetch, retries, and devtools, back your lists with React Query instead of the built-in cache. Import the ready-made hook from listkit/react-query — no need to hand-roll one:
import { ListView } from 'listkit'
import { useReactQueryListData, invalidateList } from 'listkit/react-query'
// A QueryClientProvider must sit above the list.
;<ListView
config={customersConfig}
adapter={customersAdapter}
useListData={useReactQueryListData}
/>The hook keys each page by the list's config.id + query, honors the staleTime listkit passes, keeps the current rows visible while the next page loads (keepPreviousData), and uses an SSR seed as initialData when present.
@tanstack/react-query is an optional peer dependency — install it only if you use this module.
Refresh after a mutation. useListRefresh() works as usual (it bumps a token that's part of the query key). For mutations that run outside the list tree, call invalidateList(queryClient, listId) — the React Query counterpart to invalidateListCache:
await deleteCustomer(id)
invalidateList(queryClient, 'customers') // refetch this list; omit the id for allRoll your own. Prefer full control over the query options? Inject any UseListDataHook — when you pass useListData, listkit delegates every fetch to it and never touches the built-in Map cache:
import { useQuery } from '@tanstack/react-query'
import type { UseListDataHook } from 'listkit'
const useCachedListData: UseListDataHook<Customer> = (
adapter,
query,
refreshToken
) => {
const { data, isLoading, error } = useQuery({
queryKey: ['customers', 'list', query, refreshToken],
queryFn: () => adapter.fetch(query),
staleTime: 5 * 60 * 1000,
})
return {
data: data?.data ?? [],
total: data?.total ?? 0,
isLoading,
error,
}
}