The server-state rewrite.
One typed server-state architecture across customer, market and admin — TanStack Query wired under Nuxt, a Query client generated from our controllers, and a marketplace that navigates instantly.
agorastore.fr, after the migration. Lighthouse on the live marketplace — server-rendered, hydrated from one Query cache.
Why
Three apps, three ways to fetch, zero shared memory. Every screen paid the full price of every navigation.
1.1Life before: three async layers
Server data flowed through useAsyncState, useAsyncMutation and Nuxt’s useAsyncData — three overlapping abstractions, each reinventing loading flags, refresh wiring and error handling. Nothing was cached. Navigating back to a page you saw four seconds ago refetched everything, behind a blocking spinner.
Worse, the state itself was duplicated: stores mirrored server responses into local refs, watchers synchronized the copies, and every mutation ended with someone remembering — or forgetting — which list to refresh by hand.
// fetch: one-shot, uncached, per-component
const { data: addresses, pending, refresh } = useAsyncState(
() => $api.addresses.getAddresses(userStore.organisationShortId),
{ default: [] },
)
// mutate: then manually refresh whatever might have changed
const changed = await action()
if (changed) tableApi.refresh()
// store: mirror the server value, then keep the copies in sync
const user = ref<User | null>(null)
watch(sessionUser, (value) => { user.value = value })1.2The bet
Make TanStack Query the single owner of server state — and make it invisible to write. We keep Nuxt. We keep our controllers as plain, inferred TypeScript. What changes is that keys, query options, cache writes and invalidation rules are generated from the controllers instead of hand-written at every call site.
1.3What “migrated” means
Not a pilot, not a coexistence layer. The migration is complete across application code — useAsyncState, useAsyncMutation and useAsyncData no longer exist in the migrated runtime surface.
| Surface | Status | Covered behavior |
|---|---|---|
| customer-front | Migrated | Session bootstrap, stores, seller tables & grids, drafts, appointments, onboarding, forms, actions, autosave |
| market-front | Migrated | SSR pages, catalogs & facets, Prismic, query-owned page context, progressive item detail, PubNub patches, route & catalog prefetching |
| admin-front | Migrated | Entity tables, detail queries, forms, actions, reports, remote option sources |
| shared-business | Migrated | Query runtime, per-app configuration, shared stores, SSR hydration, prefetch orchestration, cancellation & error policy |
| shared-ui | Integrated | Shared useQuery, page-context binding, DataList, catalog compiler, form options, devtools, loading states |
| api-sdk | Integrated | Typed raw & Query clients, standardized operations, generated keys & options, signals, controller invalidation |
The wins
Three audiences, three payoffs: the visitor gets speed, the developer gets a client that writes itself, the user gets an interface that never goes blank.
2.1Performance — market-front flies
The marketplace is now aggressive about doing work before you ask for it and never doing it twice. The server renders from a per-request Query cache, dehydrates it into the page, and the browser hydrates the same cache — so the client never refetches what the server already fetched. From there, smart prefetching warms the exact queries of the page you’re about to visit.
Dehydrate on the server, hydrate on the client. SEO metadata, breadcrumbs and content read the same cache the page renders from.
Hovering a link prefetches the destination’s component and its exact data plan. By the time you click, the page renders from cache.
Cached pages render immediately while stale entries revalidate in the background. Going “back” costs zero network round trips.
Every generated query forwards its AbortSignal. Rapid filter, sort or route changes kill stale requests instead of racing them.
2.2Developer experience — the client writes itself
Classify a controller method as a query or a mutation, and the SDK generates everything else: typed keys, queryOptions, mutationOptions, direct calls and same-controller invalidation. Types flow from the controller signature into the cache — getQueryData, setQueryData, prefetch and invalidation are all inferred, end to end.
const { data: item } = useQuery(() =>
$client.items.get.queryOptions({ input: itemShortId.value }),
)2.3User experience — nothing blocks anymore
Initial loading and background refresh are now deliberately different states. A first visit keeps the existing skeletons; everything after happens behind the data you already see. Tables keep their rows interactive under a thin progress bar, grids get a compact floating spinner, and mutations refresh mounted data silently. Blank table shells are gone.
Architecture
Four owners, one cache. Each layer does the thing it’s uniquely positioned to do — and nothing else.
3.1Three layers, one cache
- Apps decide cache behavior, because customer, admin and market have different freshness requirements.
- shared-business creates and provides the clients, because business stores and plugins call the API before app components exist.
- shared-ui resolves the provided QueryClient for tables, catalogs, forms and the shared
useQuery— it never creates a second cache.
3.2The three client surfaces
| Surface | Use it for | Cache behavior |
|---|---|---|
$client | Normal API reads and commands — the default path | Generates typed Query options and keys; mutations invalidate their controller’s active queries |
$api | Raw transport where cache behavior would be wrong or handled elsewhere | No cache read, write or invalidation — deliberately inert |
$queryClient | Explicit orchestration: prefetching, cross-controller effects | Direct access to the application-owned QueryClient |
Keeping $api visible rather than hiding it behind Query is a design choice: cache side effects are opt-in at the client surface, and exceptional flows keep a clean escape hatch.
3.3Each app owns its cache policy
Same architecture, different freshness. Admin wants near-real-time and refetch-on-focus; the marketplace tolerates 30 seconds of staleness in exchange for instant navigation.
export const queryConfig = defineQueryConfig(() => ({
queryClient: new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: {
gcTime: 30 * 60_000,
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30_000,
},
},
}),
}))The generated client
Controllers stay plain TypeScript. The SDK turns them into a fully typed Query client — keys, options, calls and invalidation included.
4.1Classify, don’t rewrite
The only thing a controller author declares is whether a method is a query or a mutation. Parameters and results are inferred from the methods themselves — no schema DSL, no codegen step, no duplicate type declarations.
export const ItemController = createController((clientLoader) => {
const httpClient = clientLoader([...itemsApiSchema, ...searchApiSchema])
return {
queries: {
get(itemShortId: string) {
return httpClient.getItemById({ params: { itemShortId } })
.then((items) => items[0])
.then((item) => (item ? normalizeItem(item) : item))
},
search(params: z.infer<typeof searchParamsDto>) {
return httpClient.searchItems(params).then(normalizeSearchItemsResult)
},
searchFilters(params: z.infer<typeof filtersSearchParamsDto>) {
return httpClient.searchItemsFilters(params)
},
},
mutations: {
update({ itemShortId, item }: { itemShortId: string; item: UpdateItem }) {
return httpClient.updateItem(item, { params: { itemShortId } })
.then((res) => res?.[1]?.[0])
},
},
}
})Note where search lives: search endpoints now belong to the entity controller they return. The former standalone search controller is gone — so item detail, item tables and item catalogs all share the same controller invalidation boundary for free.
4.2Every operation, four capabilities
// every query operation exposes:
$client.items.get.call(itemShortId) // direct typed call
$client.items.get.key() // operation prefix key
$client.items.get.queryKey({ input: itemShortId }) // exact, data-tagged key
$client.items.get.queryOptions({ input: itemShortId }) // plug into any Query API
// every mutation operation exposes:
$client.items.update.call({ itemShortId, item }) // call + auto-invalidation
$client.items.update.mutationKey()
$client.items.update.mutationOptions() // plug into useMutationInput follows the controller signature instead of forcing everything into envelopes: no argument means no input, one argument passes directly, several pass as a tuple. Mutations take one variables object, so useMutation always sees a stable variables type.
4.3Keys as a typed hierarchy
Keys are generated, hierarchical and data-tagged — the cache knows what type lives at each key, so getQueryData, setQueryData, invalidation and prefetch all infer their data and error types.
Generated-client tests protect exact and partial keys, uniqueness across every controller operation, zero/one/multiple argument signatures, AbortSignal forwarding, result inference, mutation invalidation and invalidation opt-out — 497 tests across 46 files.
4.4One naming grammar
Controller names carry the entity, so operations stay short and consistent across all three apps. Renames were atomic across controllers and consumers — an alias would give one request two cache identities.
| Intent | Convention | Example |
|---|---|---|
| Primary detail | get | $client.organisations.get |
| Ordinary collection | list | $client.organisations.list |
| Full-text search | search | $client.items.search |
| Advanced DB query | query | $client.organisations.query |
| Base mutation | create / update / delete | $client.organisations.update |
| Alternate lookup | getByX | $client.organisations.getBySlug |
| Bulk mutation | verb + Many | $client.items.updateMany |
| Domain command | semantic verb | $client.organisations.block |
Mutations & invalidation
The end of “remember to refresh the table”. Mutations know which server state they touched — because the controller told them.
5.1Two mutation paths
Use .call() when a command only needs its result. Use useMutation when the component needs pending state, optimistic lifecycle hooks, serialization scope or local reconciliation:
const { isPending: isSaving, mutateAsync: saveDraft } = useMutation(() =>
$client.drafts.update.mutationOptions({
scope: { id: `draft-${itemShortId.value}` },
onSuccess(updated, variables) {
if (!updated) return
setItem((current) =>
current && isDirty(variables.item, current)
? mergeObjects(updated, current)
: updated,
)
},
}),
)5.2Invalidation you never write
Both paths invalidate active queries under their own controller after a successful mutation. Invalidations are batched over a 16 ms frame and use refetchType: 'active' — mounted data refreshes, inactive entries just become stale. No background traffic storms.
const changed = await action()
if (changed) tableApi.refresh()
// ...and hope nothing else
// was showing item dataawait $client.items.performAction.call({
itemShortIds,
action: 'PUBLISH',
})
// mounted item tables, details and
// catalogs refetch — automaticallyBecause search, detail reads and supporting item queries all live under items, one publish command refreshes the mounted item table and any open detail observer — automatically. A controller can still opt out per mutation with invalidates: { mutationName: false } when refetching would be wrong.
5.3When explicit cache work is still right
| Scenario | Pattern |
|---|---|
| Same-controller mutation | Rely on generated invalidation |
| Immediate visible reconciliation | onSuccess, writable query data, or the table API — in addition |
| Cross-controller effect | Explicitly invalidate via useQueryClient() |
Raw $api command | Handle cache effects yourself — raw transport is intentionally inert |
| External event (PubNub) | Patch the exact query through its setData helper |
Application patterns
One shared useQuery, auto-imported everywhere. Native TanStack observer underneath, plus exactly the sugar that made the old composables useful — and nothing more.
6.1useQuery, the app-facing API
const { data: addresses, pending, refresh } = useAsyncState(
() => $api.addresses.getAddresses(userStore.organisationShortId),
{ default: [] },
)const { data: addresses, isLoading: pending, refetch: refresh } = useQuery(
() => $client.addresses.list.queryOptions({
input: userStore.organisationShortId,
}),
{ defaultValue: [] },
)defaultValue kills the fallback computeds, native enabled handles conditions, and native select keeps derivation with the query that owns it. What used to take a query variable plus two extra computeds is now one declaration:
const { data: contracts, isLoading: loadingContracts } = useQuery(
() => $client.contracts.list.queryOptions({
enabled: Boolean(organisationShortId.value),
input: organisationShortId.value,
select: (loaded) => loaded.filter((contract) =>
contract.indexedMeta?.status === 'ACTIVE' &&
contract.shopShortIds.includes(shopStore.currentShopId),
),
}),
{ defaultValue: [] },
)6.2Writable query data
When select preserves the result shape, data becomes a writable model over the cache — with exact-key helpers that always resolve the current reactive key. This replaced hand-written computed setters and repeated setQueryData plumbing across all three apps.
const { data: user, patchData: patchUser, clearData: clearUser } = useQuery(
() => $client.auth.loggedUser.queryOptions({
enabled: Boolean(beaconToken.value),
}),
{ defaultValue: null },
)
user.value = updatedUser // writes through to the cache
patchUser({ firstName: 'Ada' }) // deep-partial patch, typed
clearUser() // removes the exact entryIf select changes the shape, data is readonly — writing a selected shape into a cache holding the raw shape would be unsafe, and the type system enforces that.
6.3Query-local effects
onData and onError replace the watchers whose only job was reacting to one query — navigation guards, emits, form prefills. They observe hydration, background updates and local cache writes through the same observer.
const { data: item } = useQuery(
() => $client.drafts.get.queryOptions({ input: itemShortId.value }),
{
defaultValue: null,
onData(item) {
if (!includes(item.status, ['DRAFT', 'TO_MODIFY']))
return navigateTo({ name: 'seller-drafts' })
},
onError: () => showError({ statusCode: 404 }),
},
)6.4Query-owned page context
Route-shell data — breadcrumb entities, page titles — used to be mirrored into context by watchers. Now the query that owns the data is the context: pageContext: true binds the result to the route, a string contributes a named value, a selector exposes only what the shell needs.
const { data: seller } = useQuery(
() => $client.organisations.getBySlug.queryOptions({ input: sellerSlug.value }),
{ pageContext: 'seller' },
)
const prismic = usePrismicContent()
const { data: document } = useQuery(prismic.byUidQueryOptions('page', uid.value), {
pageContext: (doc) => ({ title: asText(doc.data.title) }),
})The binding updates synchronously from the observer, so SSR hydration, prefetched cache data, normal fetches and later cache writes all flow through one path. Server-rendered dynamic breadcrumbs come from the same mechanism — no second data-loading abstraction.
6.5Stores stop mirroring
Remote values live in the Query cache, full stop. Pinia stores keep what they’re actually for — business derivation, session orchestration, access helpers — and write through the query result instead of synchronizing a second ref. User, active organisation, contracts, shop, categories, reference data: all cached server values now, zero watchers keeping copies aligned.
market-front, the speed run
The marketplace is the performance-critical surface, and it got a dedicated treatment: one cache from server to client, and a prefetch pipeline that makes navigation feel ridiculous.
7.1One cache, server to client
Every SSR request gets a fresh QueryClient. After rendering, the runtime dehydrates it into the payload; the browser hydrates the same cache before any observer runs. The client never refetches what the server already loaded — and SEO metadata, structured data, canonical URLs and breadcrumbs derive from those same hydrated values instead of running a parallel content path.
const { queryClient, pluginOptions } = await app.runWithContext(() => queryConfig({ client }))
bindQueryClient(queryClient)
app.vueApp.use(VueQueryPlugin, { ...pluginOptions, queryClient })
if (import.meta.server)
app.hook('app:rendered', () => {
queryState.value = dehydrate(queryClient)
})
if (import.meta.client && queryState.value) hydrate(queryClient, queryState.value)Prismic follows the same model — singleQueryOptions, firstByTypeQueryOptions and byUidQueryOptions carry the active language in their keys, so pages and prefetching always address the same cache entry. Item detail renders its cached primary entity immediately and lets secondary data resolve progressively; PubNub live updates patch the query result directly instead of maintaining a parallel copy.
7.2defineQueryPrefetch — data declared beside the route
Pages declare their navigation-critical data with a macro. The route name is checked against Vue Router’s generated RouteMap, so route.params is inferred for that exact page. A build transform attaches the declaration to route metadata — consumers never call prefetchQuery themselves.
defineQueryPrefetch('item-itemShortId', ({ client, route }) => [
client.items.getFull.queryOptions({ input: route.params.itemShortId }),
client.characteristics.list.queryOptions(),
])One shared prefetchPage(to) entry point preloads the destination component and runs its data declaration through the existing QueryClient. NuxtLink’s native link:prefetch hook calls it automatically on hover or focus; non-link interactions call it directly — the header search bar warms the search page the moment you focus the input:
function prefetchSearchPage() {
return prefetchPage({ path: localePath({ path: '/search' }) })
}Matching work is deduplicated by Query key and destination fullPath. And because the destination page uses the same generated query options, navigation stays correct when a prefetch never fires — it just falls back to a normal fetch.
7.3The catalog prefetch compiler
Catalog pages are the hard case: rows depend on route filters, facets, sorting, pagination and context queries. The catalog now exposes a first-class prefetch compiler — prefetchCatalog reads the destination query string and the schema’s defaults, then executes a staged plan: context queries resolve first, then the compiler derives the exact request and warms rows, facets and query-backed filter options.
defineQueryPrefetch('category-categoryShortId', ({ client, route }) => [
client.categories.get.queryOptions({ input: [route.params.categoryShortId] }),
prefetchCatalog({
route,
schema: itemCatalogSchema({ categoryShortIds: [route.params.categoryShortId] }),
}),
])The mounted catalog uses those same resolver functions — prefetch and rendering cannot drift into different keys or payloads. Staged plans run through ensureQueryData({ revalidateIfStale: true }): cached destinations render immediately while stale values refresh behind them.
7.4Prefetch policies, per link
NuxtLink stays the policy surface, so each link decides when its route’s declaration runs:
<!-- default: prefetch on pointer/focus interaction -->
<NuxtLink :to="itemUrl" />
<!-- small, high-value rails: prefetch as cards enter the viewport -->
<ItemCard :item="item" prefetch-on="visibility" />
<!-- explicit opt-out where speculation isn't useful -->
<NuxtLink :to="target" no-prefetch />- Item destinations prefetch their primary detail; category, seller, auction, search and catalog destinations prefetch the exact catalog plan derived from the destination route.
- Editorial destinations (landing, FAQ, support, become-seller) prefetch their locale-aware Prismic documents.
- Featured and related item rails use viewport prefetching; dense catalogs keep interaction policy to avoid fetching every visible result.
- A successful prefetch only warms the cache — no page mount, no observer. If it misses, the page fetches normally.
7.5Try it: hover to warm
This is a simulation of the real pipeline — link:prefetch → prefetchPage() → QueryClient. Hover the card, watch the cache warm, then navigate. Then reset and navigate cold to feel the difference.
7.6The long tail
The migration exposed rendering costs the old blocking loaders used to hide — so the perf pass went deep:
- Stable row references — table cells don’t rerender for observer-only state changes;
rowKeysupplies identity directly. - Shallow ownership for row payloads and Query observers where deep proxies add nothing.
- Cache-aware
suspense()— blocks only a cold navigation; revisiting prefetched pages costs zero round trips. - Deferred hydration — menus, footer and lower-page Prismic slices hydrate after the critical shell.
- Responsive hero images — the browser picks the right source instead of downloading desktop assets on mobile.
- Debounced search + cancellation — page reset happens before key derivation, so no intermediate request for the previous page with new filters.
- Lazy wizard steps — form schemas and their option observers mount on first visit, not at wizard creation.
- Prebundled sanitizer — removes first-use stalls; grid rendering stays virtualized.
Tables, forms & drafts
Customer and admin got the same engine upgrade: schemas declare where data comes from, the engine owns everything else.
8.1DataList: declarative sources
A table schema declares its source; the engine owns Query observation, cancellation, route state, pagination, sorting, filter payloads and loading semantics. Two modes exist because the product uses two — the untested generic adapter was deleted, not preserved.
source: defineTableSource({
mode: 'remote',
query: ({ payload }) =>
$client.items.search.queryOptions({
input: payload,
}),
})source: {
mode: 'client',
query: () =>
$client.addresses.list.queryOptions({
input: organisationShortId,
}),
}Catalog uses the same model with marketplace extras — independently cacheable rows and facets, previous nonzero facets preserved during background validation, and route-scoped query writers that stop writing the moment navigation leaves the owning route.
8.2Loading vs refreshing
| State | Data visible | Treatment |
|---|---|---|
| Initial request, no usable data | No | Existing blocking skeleton / spinner |
| Background validation with cached data | Yes | Non-blocking secondary indicator |
| Table background validation | Rows stay interactive | Thin progress bar that advances, completes, fades |
| Grid background validation | Cards stay interactive | Compact floating spinner |
| Catalog background validation | Cards stay interactive | Compact floating spinner above the grid |
Refresh button, filter changes, sorting, pagination, action-triggered invalidation and focus validation all share the same isFetching state. The engine also preserves prior rows during key changes — no more content disappearing behind a loader.
8.3Form options are queries now
Remote select options return Query options, so the form engine understands dependencies, loading, cancellation, caching and refresh — without every field reimplementing those concerns.
options: async ({ deps }) => {
if (!deps.manufacturerShortId) return []
return $api.models.getModels({ manufacturerShortId: deps.manufacturerShortId })
}options: {
refreshOn: ['manufacturerShortId'],
source: ({ deps }) =>
$client.models.list.queryOptions({
enabled: Boolean(deps.manufacturerShortId),
input: { manufacturerShortId: deps.manufacturerShortId },
select: (models) =>
models.map(({ modelShortId: value, name: label }) => ({ value, label })),
}),
externalDependencies: [$i18n.locale],
}In the draft form, changing the category can enable or disable manufacturer and model fields without discarding their options; changing the manufacturer reloads models. Obsolete option requests get cancelled, previous options stay visible while the next set loads, invalid selections are removed after the authoritative set arrives, and fields sharing a generated key share the cached data.
8.4The draft editor & autosave
The writable query is the canonical editable draft. Autosave snapshots it, the save mutation serializes per draft via scope, and successful responses merge without overwriting edits made while the request was in flight. Focus and reconnect refetch are disabled during editing, and useAutoSave now schedules a trailing save when you edit during an in-flight request — the last local change can’t be lost.
What this unlocks
The migration pays for itself today. But the reason it’s shaped this way is what comes next.
9.1Agorastock: the inventory app, offline-ready by design
The next product is a warehouse inventory app — used on the floor, on flaky networks. Before this PR, that meant building a bespoke offline stack. Now server state lives in one QueryClient behind one typed client, and TanStack Query’s persistence and network-mode capabilities plug in as app-level configuration, exactly like each app already configures stale time and GC today.
The architecture leaves QueryClient persistence open per app — cache to storage, restore on launch, no new data layer.
Query’s network-mode semantics — pause, resume, retry — apply to every generated operation for free.
Writable query data, serialized mutations and trailing autosave are the exact primitives an inventory workflow needs. No re-implementation.
DataList, FormRenderer and the generated client are app-agnostic. A fourth app starts from the full toolkit.
Scope honesty: offline persistence is deliberately not implemented in this PR. The point is that it no longer requires an architecture change — only configuration and app-specific policy.
9.2The paths now open
- Optimistic updates, selectively — the plumbing exists; if the backend converges list and detail on one canonical entity shape, normalized optimistic updates become straightforward.
- Cross-controller invalidation graph — explicit today by design; the key hierarchy is ready if a declarative graph ever earns its complexity.
- Observability for free — Query devtools are mounted through shared AppRoot in development, resolving the real application cache.
- Per-app tuning forever — freshness, retries and focus behavior are one config file per app, not a rewrite.
Shipping & proof
A migration this size doesn’t get to be “probably fine”. It was validated layer by layer, app by app, in the browser.
10.1Validation
- Repository lint and format check
- api-sdk: 497 tests across 46 files — keys, inference, signals, metadata, invalidation and opt-out
- shared-ui suites: query observer, DataList, catalog prefetch, facets, form options
- shared-business suites: Query runtime, route-prefetch transform, shop cache hydration
- Production builds for all three apps
- Browser verification — customer: session, tables, filters, sorting, pagination, refresh indicators, drafts · market: catalogs, facets, item detail, route behavior · admin: migrated entity screens
10.2CI & deployments
The final CI run is green across dependency policy, formatting, package builds, lint, typechecks, tests and production images. Everything is live on Integration and UAT — production deliberately waits for review.
| Application | Image | Integration | UAT |
|---|---|---|---|
| customer-front | v278 | Deployed | Deployed |
| market-front | v269 | Deployed | Deployed |
| admin-front | v287 | Deployed | Deployed |
10.3How to review 99 commits
The commits are granular on purpose. The contract layers come first — every consumer depends on them:
- 1 · api-sdk — controller classification, key hierarchy, inference, signal forwarding, invalidation
- 2 · shared-business runtime — per-app config, client provision, SSR dehydrate/hydrate, cancellation
- 3 · shared-ui query API — writable data, defaults, effects, selected-data safety
- 4 · shared-ui abstractions — DataList, catalog, form options, loading semantics
- 5 · customer-front — the reference implementation for stores, drafts, forms and tables
- 6 · market-front — SSR, facets, hydration, page context, prefetch policies
- 7 · admin-front — same patterns, no separate admin model
- 8 · skills & tests — the contracts future changes are expected to follow
10.4Deliberate boundaries
Just as important as what changed is what didn’t:
- Nuxt stays. Smart prefetching composes Nuxt’s own route and component preload lifecycle — no framework change.
- Controllers stay plain TypeScript. No RPC declaration DSL; changing the authoring model didn’t earn its migration cost.
- Pinia stays for business state and orchestration; Query owns server state.
- Cross-controller invalidation stays explicit. Automatic same-controller invalidation covers the dominant case without a second dependency graph.
- Offline is deferred, not blocked. The architecture leaves persistence open as app configuration.