С любовью ко всем разработчикам на свете ❤️
.transformer sets how Point0 serializes data crossing the wire — query inputs
and outputs, request bodies, the SSR dehydrated state. By default that's plain
JSON, which can't carry a Date, Map, Set, or BigInt. Pass superjson
and those types survive the round trip, on both ends and inside the query key.
import superjson from 'superjson'
export const root = Point0.lets
.root()
.serverUrl(sharedEnv.SERVER_URL)
.clientUrl(sharedEnv.CLIENT_URL)
.transformer(superjson) // Date / Map / Set / BigInt now round-trip
.schemaHelper(zodSchemaHelper())
.errorClass(AppError) // optional — your own error class; default is ErrorPoint0
.root()This is the canonical production wiring: one transformer on the root, applied to every point beneath it.
Stripping: .transformer is server-and-client — it's a root setter, kept on
both bundles and never stripped, because serialization has to run identically on
the server and in the browser.
.transformer is a root method — you call it on Point0.lets.root()… and
nowhere else. It's not available on a page, query,
mutation, action, or layout; writing it there is
a type error. One root, one transformer, shared by every point in that root's
scope.
superjson is not a Point0 dependency — install it yourself:
bun add superjson.transformer takes any { serialize, deserialize } pair (see
Reference); superjson satisfies that out of the box.
Without a transformer, the wire is plain JSON, and the type lies. A loader can
return a Date, the type says Date, but what arrives on the client is a
string:
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(() => ({ createdAt: new Date('2026-01-01') }))
.page(({ data }) => {
// type says data.createdAt: Date
// runtime (no transformer): data.createdAt === '2026-01-01T00:00:00.000Z' — a string
return <time>{String(data.createdAt)}</time>
})This is the most common surprise. JSON.stringify turns a Date into an ISO
string, drops a Map/Set to {}, and throws on a BigInt. The data degrades
on the way out and never reconstructs on the way in.
Set .transformer(superjson) on the root and the same loader gives the page a
real Date:
.page(({ data }) => {
// runtime (with superjson): data.createdAt instanceof Date === true
return <time>{data.createdAt.toISOString()}</time>
})The same holds for request bodies. A BigInt in a mutation or action
body survives both directions only when the root has a transformer:
// action body schema: z.object({ amount: z.bigint() })
await transferAction.fetch({ body: { amount: 100n } })
// server receives amount === 100n (with superjson) — not a string, not a throwOn the server, validation runs after the transformer deserializes, so a
z.bigint() schema sees a real bigint and accepts it. With plain JSON the
same schema would reject the value.
Point0 hands serialization to your transformer, so any type superjson supports
— Date, BigInt, Map, Set, and more — round-trips. See
superjson's supported types.
When you never call .transformer, Point0 uses a blank transformer:
serialize/deserialize are identity (pass-through), and the wire format is
plain JSON with stable key order (it stringifies via
safe-stable-stringify, a Point0 runtime dependency):
// default transformer, serializing { date, string }:
'{"date":"2017-01-01T00:00:00.000Z","string":"value"}'
// keys sorted; the Date is already a string — its type is gonesuperjson instead wraps the value in a { json, meta } envelope, where meta
records which fields need reconstructing:
// superjson, same input:
'{"json":{"date":"2017-01-01T00:00:00.000Z","string":"value"},' +
'"meta":{"v":1,"values":{"date":["Date"]}}}'
// meta.values.date = ["Date"] tells deserialize to rebuild a DateThat meta is what carries the type across the wire — and it's why the
serialized form is larger than plain JSON.
The transformer is part of the cache key, not only the wire. A query key's
input field is the transformer's stringified input:
ideaQuery.getQueryKey({ id: 123 })
// [
// 'point0',
// { scope, type, name, mode, finiteness, tags, output,
// input: '{"id":123}' }, // = transformer.stringify(routedInput)
// ]input is safe-stable-stringify(transformer.serialize(routedInput)). With the
default transformer that's plain stable JSON. With superjson, the superjson
serialization (its { json, meta } shape, then stable-stringified) is what keys
the cache — so an input containing a Date produces a distinct,
reconstructable key instead of a lossy string.
Stable stringification means key order doesn't matter: { a, b } and { b, a }
hit the same cache entry. See Query for the full key
shape; for what counts as routedInput (page/layout search filtering, action
sections), see Validation.
Because the transformer is in the key, changing it changes cache keys
app-wide. If you add or swap .transformer in an existing app, keys built
with the old transformer won't match the new ones, and Point0 does nothing to
reconcile them. In-flight keys sort themselves out on the next fetch; persisted
keys are your concern — if you persist the query cache through TanStack's
persistQueryClient, change its buster string when you change the transformer
so the stale cache is discarded.
Two other surfaces take a transformer with the same { serialize, deserialize }
shape, but they're separate knobs from the root .transformer:
CookieStore accepts a per-store transformer option.SsrStore / SuperStore expose setTransformer(transformer).Both default to the blank transformer independently. Setting .transformer on
the root does not configure them, and vice versa.
.transformer(transformer: DataTransformer): this // root only, chainableDataTransformer is the minimal pair both serialize and deserialize round
through:
type DataTransformer = {
serialize: (data: any) => any
deserialize: (data: any) => any
}Any object of that shape works — superjson is the recommended default, but a
hand-rolled transformer is valid too. Point0 wraps whatever you pass to also
derive a stable stringify/parse pair for query keys and the wire
(stringify = safe-stable-stringify(serialize(data)), parse =
deserialize(JSON.parse(str))).
| Boundary | Direction | What it does |
|---|---|---|
| query / mutation / action request | client → server | serialize the body |
| request parse | server reads | deserialize the body |
| query / loader output | server → client | stringify the result |
| page dehydrated state (SSR) | server → client | stringify the dehydrated state |
| response read | client reads | deserialize the JSON |
query key input | both | stringify the routed input |
serialize returning undefined is an error. If a custom transformer
returns undefined for an input, Point0 throws
Transformer returned undefined for input … on point … rather than sending an
empty body..transformer twice is last-wins. Each call overwrites the root's
transformer with the new one — they don't compose — so the last .transformer
in the chain is the one that runs.