Compiler
- Category: Engine
The compiler (@point0/compiler) is the source transform that runs on every
file as the engine builds or serves your app. It started life
just stripping server code out of the client and client code out of the server,
but it grew. Today, on a single file, it:
- strips point method bodies per side — your loader and your component live in one file, and each side only ships the parts it needs (the headline feature, below);
- shakes
env.*guards and yourprocess.env.Xconstants down to a literal, then deletes the dead branch and its now-unused imports; - swaps forbidden imports for virtual modules (
server-only/client-onlymarkers,mock/deny) so a leak fails the build; - finds the points themselves and powers the short
.letsnotation by rewriting it into the explicit form; - compiles
.md/.mdx/.mdcthrough MDX and routes static assets (?url,?react, …) before its other passes; - runs your babel plugins and keeps the HMR boundary alive so any mix of points hot-reloads;
- caches every result on disk, so after the first run it only recompiles files that actually changed.
It's the same compiler in every form: a Bun plugin, a Vite plugin, and a Babel
plugin all wrap one class — so it drops into any toolchain (web, Expo, Electron,
…); see One compiler, any bundler. You rarely touch
it directly — the point0 CLI and those plugins drive it, and it's on by
default for every side. The rest of this page shows what it does to your code,
and how to inspect or tune it.
See it work: point0 compile
The fastest way to understand the compiler is to watch it transform one file.
The point0 compile command prints the compiled output for a single
file:
point0 compile src/lib/prisma.ts --server # how the file looks in the server bundle
point0 compile src/pages/home.tsx --client # ...and in the client bundleThe same source comes out different per side — that difference is the whole point of the compiler. Useful flags:
point0 compile <file> --client # compile for the client side
point0 compile <file> --server # compile for the server side
point0 compile <file> --scope <scope> # points scope (inferred from side if omitted)
point0 compile <file> --no-babel # skip your babel plugins, show point0-only transforms
point0 compile <file> --no-hmr # don't inject the HMR decoy (see below)Full flag list is in the reference at the bottom.
What gets stripped from a point
This is the headline: you write the loader and the component in the same file, and each side's bundle only carries the methods it actually runs. The compiler reads the point chain and removes the bodies that don't belong to the side it's compiling — keeping the chain shape (so the call still type-checks and runs), just emptying the callbacks that were stripped.
Take one page with a server loader and a client component:
// src/pages/idea.tsx
import { prisma } from '@/lib/prisma'
import { SomethingForClient } from '@/components/something-for-client'
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: params.id },
})
return { idea }
})
.page(({ data: { idea } }) => (
<div>
<h1>{idea.title}</h1>
<SomethingForClient />
</div>
))On the client, the server-only methods are cut from the client bundle —
their bodies and the imports they use are removed, so they never ship to the
browser. That's the loader and everything else that's server-only (.ctx,
.input, .middleware, .headers, .cookies, .body, .response,
.description, .openapi). The client still knows the point's name and route,
so it can still call the loader over the network — it just doesn't carry its
code (and prisma, now unused, is pruned with it):
// point0 compile src/pages/idea.tsx --client
import { SomethingForClient } from '@/components/something-for-client'
export const ideaPage = root.lets
.page('/ideas/:id')
.loader() // body gone — prisma never reaches the browser
.page(({ data: { idea } }) => (
<div>
<h1>{idea.title}</h1>
<SomethingForClient />
</div>
))On the server, what happens to the .page body depends on ssr.
ssr: true vs ssr: false
ssr is an engine option (per client, or a default at the top):
export const engine = Engine.create({
// ...
clients: [
{
// ...
ssr: true, // server-render the first load; default is false
},
],
})The render methods (.page, .layout, .component, .provider, .with,
.wrapper, .mapper, .head, and the .loading / .error family) are cut
from the server bundle when ssr: false — bodies and the imports they use
removed from the server build. They stay in the server build only when SSR is
on, and in the client build always.
ssr: true— nothing is cut: the server renders the first page load, so it keeps those render-method bodies, and the client ships them too for client-side navigation after that first render.ssr: false— those render bodies (and their now-unused imports) are removed from the server bundle; the server keeps only the loader (to answer data requests) and the rest of the server-only chain. The client keeps the render bodies. This is exactly the compile output of the page above whenssris off —.page()empties on the server,.loader()empties on the client.
Client-only methods are cut from the server bundle: .clientLoader,
.clientInput, .clientOn, .clientOnPrefetchPage, and the
navigation/prefetch triggers (.prefetchPageOnLinkHover, .prefetchPagePolicy,
.scrollRestore, …) have their bodies and imports removed regardless of ssr —
that code never ships to the server (it only runs in the browser). Server-only
methods (.loader, .serverOn, .serverOnPrefetchPage, …) are cut the other
way, from the client bundle. .onPrefetchPage is the one prefetch hook kept in
both bundles (it runs on the server once before the first render and on the
client during prefetch).
Every method's own page repeats this in its terms: which bundle the method is cut from, and how
ssrchanges it. Read it there for the exact rule per method.
.clientOnly() — turn off SSR for the rest of one point
.clientOnly() makes a single point behave as if ssr: false for that point
only — from that call onward in the chain, the render methods (.page,
.with, .component, …) are cut from the server bundle — their bodies and the
imports they use removed — even when the app is otherwise ssr: true. Use it
for UI that genuinely can't render on the server (it touches window, a
client-only library, etc.). You can pass a server-side fallback to show while
the client takes over, or call it with no argument at all:
export const dashboardPage = root.lets
.page('/dashboard')
.loader(async () => ({ stats: await loadStats() }))
.clientOnly(() => <Skeleton />) // server renders the skeleton…
.page(({ data }) => <Charts stats={data.stats} />) // …this body is stripped from the server
export const WidgetComponent = root.lets
.component()
.clientOnly() // no fallback — server renders nothing for this point
.component(() => <BrowserOnlyWidget />)Everything before .clientOnly() (here the .loader) still runs on the
server as usual; only the chain after it goes client-only. See SSR for
when you'd reach for this.
env shaking and virtual modules
Method stripping handles the point chain. For plain code outside the chain — secrets, server libraries, branches that should only exist on one side — the compiler offers two more tools.
env.* shaking and dead-code elimination
When you guard code with the env helpers from @point0/core, the
compiler replaces the guard with a literal true / false for the side it's
compiling, then deletes the dead branch:
import { env } from '@point0/core'
const value = env.side.is.server ? readSecret() : 'public'
// compiled for client → const value = false ? readSecret() : 'public'
// → const value = 'public' (readSecret import pruned too)env.side.define makes this explicit — the matching side keeps the value, the
other gets undefined and the code is pruned:
// examples/better-auth/src/lib/auth/server.ts
export const authServer = env.side.define.unsafe.server(
betterAuth({ database: prismaAdapter(prisma, { provider: 'sqlite' }) }),
)
// on the client this whole expression becomes `undefined` — better-auth and
// prisma never reach the browser bundleThe same shaking covers env.mode.* (production/development/test),
env.build.was, env.runtime.*, env.os.*, env.scope.*, and env.vars.*,
plus process.env.X / import.meta.env.X against your configured constants.
Each resolves to a literal at compile time; the now-dead branches and their
unused imports are removed by a dead-code-elimination pass. The full list lives
on the env page.
env.runtime.is.<X> covers browser, reactNative, nodejs, bun, deno,
and worker; env.os.is.<X> covers ios, android, linux, mac, and
windows. The compiler shakes these only when you set the matching runtime /
os option (below). See env for the full semantics.
The compiler also handles <ClientOnly>: on the server, its children are
replaced with null, so client-only UI never renders server-side.
Import stripping → virtual modules
The other half is the importer: instead of (or in addition to) env
guards, you can forbid a whole import from one side. The compiler swaps the
import for a virtual module so the real module never enters that bundle.
The server-only / client-only markers are the common case:
// examples/basic/src/lib/prisma.ts
import '@point0/core/server-only' // importing this from client code throws
export const prisma = new PrismaClient(/* ... */)When the compiler builds the client side and finds an import that reaches
@point0/core/server-only (directly or through a denied import), it replaces it
with a virtual module that throws at import time — so a leak fails the
build, not in production. The marker files themselves are empty (export {});
all the work is the compiler's.
You can also configure this in the engine, matching by import target:
// examples/expo/src/engine.ts
compiler: {
importer: {
mock: ['react-native', 'expo-router'], // replace with a no-op mock module
},
}mockswaps the import for a recursive no-op proxy (createMock()) — every access returns another mock, so the import resolves but does nothing.denyswaps it for a throwing virtual module (like the markers).- The deny error includes a
point0 trace ...command you can run to see the import path that pulled the forbidden module in.
Full configuration — patterns, cold, onDeny — is on the importer
page.
The HMR decoy
Bun's native bundler and Vite only enable React Fast Refresh for modules that look like they export a React component. A point file exports points, not components — from React's point of view a mutation, a query, and a provider aren't components at all — so without help every edit would trigger a full page reload.
The compiler fixes this by appending a _tail decoy to the last point in each
chain in the file. Put a mutation and the page that uses it side by side:
// you write — one file, a mutation and a page:
export const ideaUpdateMutation = root.lets
.mutation()
.input(z.object({ id: z.string(), title: z.string() }))
.loader(async ({ input }) =>
prisma.idea.update({ where: { id: input.id }, data: input }),
)
.mutation()
export const ideaPage = root.lets.page('/ideas/:id').page(() => <h1>Idea</h1>)// the compiler appends a decoy to each (in dev):
export const ideaUpdateMutation = root.lets /* ... */
.mutation()
._tail(() => null)
export const ideaPage = root.lets /* ... */
.page(() => <h1>Idea</h1>)
._tail(() => null)Each export is the function returned from _tail(() => null), so both Bun and
Vite treat both exports as ordinary React components and wire up Fast Refresh
for the module — even though one is really a mutation. You never access
ideaUpdateMutation directly, only its methods, and they're all still in place;
the real runtime export is decided by Point0._tail in @point0/core (a
mountable returns its mount component, everything else returns the decoy).
That's why a file can export any mix of pages, queries, and mutations and still
hot reload — see Dev for the dev/HMR story. The decoy is on by default
for the client and off for the server; point0 compile --no-hmr turns it off.
User babel plugins
Your own babel plugins and presets run inside the compiler, after its own
transforms. Set them under compiler.babel in the engine config — at the top
for a default, or per client:
// examples/basic/src/engine.ts
export const engine = Engine.create({
// ...
clients: [
{
// ...
compiler: {
babel: ['babel-plugin-react-compiler'], // array form = plugins
// or the object form for presets too:
// babel: { plugins: ['babel-plugin-react-compiler'], presets: [...] },
},
},
],
})The compiler has special handling for React Compiler. React Compiler only
memoizes things that look like top-level components, but point logic lives in
chain callbacks (.page, .layout, .component, .provider, .wrapper,
.with). So the compiler injects a "use memo" directive into those callbacks
so React Compiler memoizes them too. The behavior keys off React Compiler's own
compilationMode option, which you pass to the plugin the usual way:
compiler: {
babel: [['babel-plugin-react-compiler', { compilationMode: 'infer' }]],
}- unset /
'infer'/'syntax'→ keep the plugin and inject"use memo". 'all'→ keep the plugin, no directive (it compiles everything anyway).'point0'→ strip React Compiler from babel (you run it elsewhere, e.g. via Vite) but still inject"use memo".
MDX, Markdown, and assets
The compiler also claims .md, .mdx, and .mdc files — it compiles them to
JS (via @mdx-js/mdx) before its other passes run, so env shaking and
<ClientOnly> work inside MDX too. Configure remark/rehype/recma plugins
through compiler.markdown. Details on MDX.
Static asset imports (import logo from './logo.png', ?url, ?file, ?text,
?react for SVGR) also ride through the compiler plugin. See Assets
for modes and config.
Caching
The compiler caches every result on disk, keyed on the file's contents (via its
mtime) and the compiler's own settings. The first run of a project costs a
little extra; after that it's fast, and a file is only recompiled when it
actually changes — or when the compiler settings that produced its cache entry
change. Cache is on by default (compiler.cache); clear it with:
point0 pruneOne compiler, any bundler
The compiler is just one class, and every integration is a thin wrapper around it: a Bun plugin, a Vite plugin, and a Babel plugin all run the exact same transform. That's what lets a Point0 codebase target whatever you point it at — a web client on Bun or Vite, an Expo app, an Electron build, anything with a bundler — without a different compiler per target. You almost never construct it yourself; the engine wires the right plugin in for you:
| Bundler / context | Plugin |
|---|---|
| Bun (build + dev runtime) | @point0/compiler/plugin/bun — compilerBunPlugin |
| Vite | @point0/compiler/plugin/vite — compilerVitePlugin |
Babel (parserOverride) | @point0/compiler/plugin/babel — default export |
| Bun native dev server | @point0/compiler/plugin/bun-static |
Each plugin accepts either a CompilerOptions object (it builds a Compiler
for you) or an existing Compiler instance. These are subpath exports — they
are not in the package's main barrel.
A file is always compiled for one specific target — a fixed side, scope, and
mode — so the same source runs through fresh per side and yields two different
outputs (which is why --client and --server differ above). The compiler does
not strip TypeScript syntax itself; it parses TS, transforms it, and emits
TS, leaving the bundler (Bun / esbuild / Vite) to handle types.
Choosing Bun or Vite for the client is an engine config decision, not a compiler one — the same compiler runs on either path. See Bun or Vite.
The .lets sugar transform
A smaller convenience, last because it's the least essential: the compiler
powers the short .lets notation by rewriting it into the explicit
form. The short form only works with the compiler on; the explicit form works
anywhere and is identically typed.
// what you write (short form):
export const ideaPage = root.lets.page('/ideas/:id')./* ... */.page()
// what the compiler emits (explicit form — runtime-identical):
export const ideaPage = root.lets('page', 'idea', '/ideas/:id')./* ... */.page()It reads two things off your code: the point type (.lets.page(...) →
'page', .lets.layout(...) → 'layout', and so on) and the point name,
taken from the variable name with the type suffix stripped (ideaPage →
idea). The extension points work the same way: .lets.base(...) → 'base'
and .lets.plugin(...) → 'plugin', with the name inferred from the variable
(myPlugin → my, authBase → auth). The full name-inference rules and edge
cases are on the points page.
The compiler only desugars .lets.<type>() when the chain's base traces back to
a Point0 imported from @point0/core — a look-alike on an unrelated object is
left alone:
import { fake } from './somewhere'
export const x = fake.lets.page('/ignore') // NOT a point — left untouchedReference
point0 compile flags
point0 compile <file> — print the compiled output for one file.
| Flag | Effect |
|---|---|
--engine <path> | path to the engine file (else auto-found) |
--side <side> | which side to compile for (server / client) |
-c, --client | shorthand for --side client (wins over --side) |
-s, --server | shorthand for --side server (wins over --side) |
--scope <scope> | points scope (inferred from side when omitted) |
--mode <mode> | production / development / test |
-p, --production | shorthand for --mode production |
-d, --development | shorthand for --mode development |
-t, --test | shorthand for --mode test |
-b, --built | compile in build mode (else POINT0_BUILT env) |
-B, --no-babel | skip user babel plugins — show point0-only transforms |
-h, --hmr | force the HMR decoy on |
-H, --no-hmr | force the HMR decoy off |
-h is rebound to --hmr here; use --help for help.
Omit --scope and the compiler infers it from the side: the server side uses
the server scope, the client side uses its one client scope. With more than one
client scope it can't guess and errors, asking for --scope. With neither
--side nor --scope, a single available side is picked automatically; with
both server and client available you must pass --side.
point0 trace flags
point0 trace <target> <source> — print the import path from <source> to
<target>. This is the command the importer's deny errors tell you to run.
| Flag | Effect |
|---|---|
--engine <path> | path to the engine file |
--side <side> | which side's import graph to trace |
--scope <scope> | points scope |
--cwd <path> | base dir (default: the engine file's directory) |
Engine compiler config
The compiler option on the engine (and per side). Set it to false to turn
the compiler off for that side; true / an object turns it on. A built engine
forces it to false (a built app never compiles sources at runtime).
| Option | Type | What it does |
|---|---|---|
side | toggle | shake server/client code for this side |
scope | toggle | shake by points scope |
mode | toggle | shake env.mode.* (NODE_ENV) |
runtime | EnvRuntimeName | false | resolve env.runtime.* |
os | EnvOsName | false | resolve env.os.* |
consts | env-const table | replace env.vars.* / process.env.* |
filter | RegExp | which files the compiler claims |
ssr | toggle | set POINT0_SSR / env.side.is.ssr |
cache | boolean | on-disk transform cache (default on) |
markdown | MDX options | remark/rehype/recma plugins, MDX config |
babel | plugins / { plugins, presets } | your babel plugins (see above) |
assets | assets options / false | static-asset pipeline (assets) |
importer | importer options | mock/deny/cold imports (importer) |
importer is per-side only, not engine-level. The full engine config
surface is on Engine config; the importer options on
importer.
The default file filter
By default the compiler claims files matching:
.js .jsx .ts .tsx .cjs .mjs .cts .mts .md .mdx .mdcIt skips ids containing shim: / virtual:, and skips node_modules paths
unless the path contains point0. Override with compiler.filter.
Package exports
import { Compiler } from '@point0/compiler' // the class + helpers, via the barrel
import { compilerBunPlugin } from '@point0/compiler/plugin/bun'
import { compilerVitePlugin } from '@point0/compiler/plugin/vite'
import compilerBabelPlugin from '@point0/compiler/plugin/babel'Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️