Plugin
- Category: Points
A plugin is a point that does nothing on its own. Instead it bundles up methods
— .ctx, .with, .middleware, input schemas, related queries — and injects
them into another point's chain when you call .use(plugin). It's the tool for
sharing setup across points in your app, not a third-party extension system.
import { Point0 } from '@point0/core'
import { getMe } from '@/modules/auth/server'
import { getMeQuery } from '@/modules/auth/api'
export const mePlugin = Point0.lets
.plugin()
.ctx(async ({ request }) => {
return { me: await getMe({ request }) } // server: resolve the user, put it in ctx
})
.with(({ resolve }) => {
// client: resolve the same user into props (returned from `with` so it lands in props, not queries)
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.plugin()Any point can now .use(mePlugin) and read me — from ctx in its loader,
from props in its component:
export const generalLayout = root.lets
.layout() // no argument — the route defaults to `/`, so this is a root-level layout
.use(mePlugin)
.layout(({ children, props: { me } }) => (
<div>
{me ? <SignOut /> : <SignIn />}
{children}
</div>
))Declaring a plugin
Open with .plugin(), add methods, close with .plugin() — the same name
appears twice (open the chain, close it):
export const tagsPlugin = Point0.lets
.plugin()
.search(z.object({ tag: z.string().optional() }))
.plugin()The explicit equivalent is Point0.lets('plugin', 'me') — the second argument
is the name, and the scope is always the literal 'plugin' (it's reserved):
export const mePlugin = Point0.lets('plugin', 'me') // explicit form
.ctx(/* ... */)
.plugin()Both forms type-identically. Unlike every other point, a plugin always grows
from Point0 directly, not from a root / base / layout — it isn't tied to
any client or route. See points for the notation.
The .plugin() closer itself is not cut from either bundle — kept in both
(isomorphic), nothing pruned (server-and-client). The methods it bundles keep
their own strip categories: a plugin's .ctx is still cut from the client
bundle (server-only), its .with is still cut from the server bundle when
ssr:false (server-ssr-and-client), and so on. .use() just merges those
bodies into the consumer at the call site; it doesn't change what gets cut from
which bundle.
A plugin is never an endpoint and never has a route of its own. It carries no
.loader, .clientLoader, .mapper, .head, .params, or .query — writing
any of those on a plugin is a type error. It only carries methods that merge
into the point that uses it.
Injecting with .use()
.use(plugin) is available on every point that has a setup stage — root,
base, page, layout, component, provider, query, infiniteQuery,
mutation, action, and plugin itself (so plugins can nest). Call it
before the point is finalized — before .loader() / before the closing
terminal:
export const profilePage = generalLayout.lets
.page('/profile')
.use(redirectUnauthorizedPlugin) // me is guaranteed non-null after this
.page(({ props: { me } }) => <h1>{me.user.name}</h1>)You can .use() as many plugins as you like, interleaved with the point's own
methods. Each plugin's middleware, ctx, and with-functions slot in at the call
site, in order.
The argument must be a finalized plugin. Pass anything else and you get a type
error; bypass the type with as any and it throws at startup:
.use(somePage) // throws: .use() expects a plugin created via .plugin(), but received a point of type "page" (...). Used on point <consumer>.What .use() merges
A plugin contributes everything it declared into the consuming point:
ctx— appended to the consumer'sctx(visible in its loader and later.ctx).props— props the plugin's.withreturns merge into the consumer'sprops.- Input schemas —
.input/.search/.body/.headers/.cookiesmerge down (a schema can only narrow, never widen, what's already declared). .middleware— runs at the.use()call site, between the consumer's before- and after-middlewares.- Related queries, event subscriptions, wrappers, tags, and description — all fold in.
const accountPage = root.lets
.page('/account')
.ctx(() => ({ page: 'account' }))
.use(mePlugin) // adds `me` to ctx
.loader(({ ctx }) => {
ctx.page // 'account' — the page's own ctx
ctx.me // from the plugin
})
.page(/* ... */)Encapsulation
A plugin sees only its own accumulated ctx/props, never the consumer's
surrounding state — so a plugin can't accidentally depend on whatever point used
it. The flow is one-way: the consumer gains the plugin's output, the plugin
stays sealed.
const plugin = Point0.lets
.plugin()
.ctx(({ ctx }) => {
ctx.page // ❌ does not exist — the consumer's ctx is invisible here
return { fromPlugin: true }
})
.plugin()Nesting is the exception in one direction: when a plugin does
.use(otherPlugin), the inner plugin's ctx/props are visible to the outer
one — that's how the gating plugins below build on mePlugin.
The only point you can build dynamically
Every other point must be exported exactly as written, so the compiler can find and analyze it statically — you can't create one inside a function. A plugin is the exception: you can wrap it in a factory and parametrize it.
export const requirePermission = (permission: string) =>
Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me?.permissions.includes(permission)) {
throw new AppError('Forbidden', { code: 'FORBIDDEN' })
}
return { me }
})
.plugin()
// each call site gets its own configured plugin:
const ideaPage = root.lets
.page('/ideas/:id')
.use(requirePermission('ideaRead'))
.page(/* ... */)A plugin can come from a function like this, but most read better as a plain
exported const (mePlugin, authorizedOnlyPlugin). Reach for a factory only
when you need to parametrize.
Auth plugins: .ctx + .with together
The headline use of a plugin is an authorization gate. It needs both .ctx
and .with — and these are two genuinely different mechanisms, not two copies
of one check:
.ctxbuilds server context. Cut from the client bundle — its body and the imports it uses are removed, so it never ships to the browser (server-only). This is where you resolve a value from the request — the session, the current user — for the server data path; it runs on the server, and only when the point has a loader..withis a render wrapper. Each.withwraps the rendered remainder of the chain, exactly like nesting one React component inside another; it just reads as a builder method. It runs at render — on the client, and on the server under SSR — can call hooks, and decides what renders next by what it returns (props, a query, anError, aredirect, an element). It never seesctx. Cut from the SERVER bundle whenssr:false(or after a.clientOnly()earlier in the chain) — body and imports removed from the server build; kept in the client build always, and in the server build only when SSR is on (server-ssr-and-client).
So a gate written in .ctx alone protects only the server loader path: a
loader-less page is rendered straight on the client, where .ctx never runs,
and nothing stops it. The render wrapper is what actually guards what the user
sees, so the gate has to live in .with. You add the matching .ctx not to
duplicate the check but to cover the other path — to resolve the same value
server-side for loaders and short-circuit there before any data work happens.
One side or the other always runs, on any point — page, query, mutation, layout.
See .with and ctx for each side in depth.
First, the building block — resolve the user once, on both sides:
export const mePlugin = Point0.lets
.plugin()
.onPrefetchPage(async () => {
await getMeQuery.prefetchQuery() // warm the client cache to avoid a waterfall
})
.ctx(async ({ request }) => {
return { me: await getMe({ request }) }
})
.with(({ resolve }) => {
// returned from `with` (not a bare `useQuery()`) so `me` lands only in props, not in `queries`
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.plugin()Three different strip categories sit in this one chain: .onPrefetchPage stays
in both bundles (server-and-client — it runs on the client during prefetch
and on the server before the first render; .serverOnPrefetchPage /
.clientOnPrefetchPage pin it to one side); .ctx is cut from the client
bundle — body and imports removed (server-only); .with is cut from the server
bundle when ssr:false (server-ssr-and-client).
Then the gate — .use(mePlugin) to get me, throw on the server, return the
error on the client:
export const authorizedOnlyPlugin = Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me) {
throw new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
}
return { me } // returning the narrowed value makes `me` non-null downstream
})
.with(({ props: { me } }) => {
if (!me) {
return new AppError('Only for authorized users', { code: 'UNAUTHORIZED' })
}
return { me }
})
.plugin()Two details worth copying:
.ctxthrows the error;.withreturns it. A returnedErrorfrom.withshort-circuits to the error component — see Loading & error.- Both branches
return { me }on success. That narrowsmeto non-null for every point that uses the plugin, so consumers never re-check it.
AppError here stands in for your own error class. Point0's built-in
ErrorPoint0 works just as well; you can swap in any class of the same-or-wider
shape via .errorClass(...) — see Error handling.
Gate by redirect instead of error
Same shape, but return a redirect instead of throwing —
friendlier for sign-in flows:
export const redirectUnauthorizedPlugin = Point0.lets
.plugin()
.use(mePlugin)
.ctx(({ ctx: { me } }) => {
if (!me) {
return redirect('signIn')
}
return { me } // return so the type narrows to me !== null
})
.with(({ props: { me } }) => {
if (!me) {
return redirect('signIn')
}
return { me }
})
.plugin()A nice pattern: apply the gate once on a base or layout and
every point beneath it inherits the check — e.g. an adminBase that
.use(adminOnlyPlugin), so admin pages get the gate for free.
Related queries in a plugin
A plugin can declare a .relatedQuery; it merges into whatever
mountable point uses the plugin, even though the plugin isn't bound to a route.
Inside the related query's input getter, location is typed as a generic
location (the plugin doesn't know which route it'll end up on):
export const sidebarPlugin = Point0.lets
.plugin()
.relatedQuery(sidebarQuery, ({ location }) => ({ path: location.pathname }))
.plugin().relatedQuery is not cut from either bundle — kept in both (isomorphic),
server-and-client. It adds its query to the consumer's queries array, exactly
like a .with(query) result; the difference is prefetch. A related query is
statically discoverable, so prefetch self-fetches it without rendering under
the cheap policies (serverQuery/clientQuery/serverAndClientQuery), whereas
a .with(query) is only found by rendering and so is prefetched only under the
expensive, SSR-only pageDehydratedState*. See query.
Reference
Construction
| Form | Notes |
|---|---|
Point0.lets.plugin() | name read from the variable |
Point0.lets('plugin', 'name') | explicit name; scope is always 'plugin' |
Close the chain with .plugin(). A plugin always grows from Point0, never
from a root / base / layout.
Methods allowed inside a plugin
A plugin only bundles these methods; each keeps the strip category it has
anywhere else, and .use() carries that category into the consumer unchanged.
Setup & data: .ctx (server-only), .with
(server-ssr-and-client), .use (nesting; server-and-client),
.middleware (server-only), .relatedQuery (server-and-client),
.input (server-only) / .clientInput (client-only) / .sharedInput
(server-and-client), .search / .body / .headers / .cookies
(server-only), .wrapper (server-ssr-and-client).
Defaults & UI: .queryOptions / .pageQueryOptions / .componentQueryOptions
/ .layoutQueryOptions / .mutationOptions / .infiniteQueryOptions,
.fetchOptions — all server-and-client. .openapi is server-only. .loading /
.error (and per-state variants) are server-ssr-and-client. .scrollPosition /
.scrollRestore are cut from the server bundle — body and imports removed
(client-only; see navigation). .onPrefetchPage is
server-and-client — it runs on the client during prefetch and on the server
before the first render; .serverOnPrefetchPage (server-only) /
.clientOnPrefetchPage (client-only) pin it to one side.
Events & meta: .on / .use config (server-and-client), .serverOn
(server-only), .clientOn (client-only), .clientOnly (the switch — flips the
rest of the chain to behave as ssr:false), .tag (server-and-client),
.description (server-only).
Not available on a plugin: .params, .loader, .clientLoader, .mapper,
.head, .query, .layout, .transformer, .serverUrl / .clientUrl /
.basePath, .prefetchPageOnNavigate / .prefetchPageOnLinkHover. A plugin
contributes methods; it never is a mountable or routable point.
Where .use() applies
.use(plugin) is on every point with a setup stage: root, base, page,
layout, component, provider, query, infiniteQuery, mutation,
action, and plugin. It must come before the point is finalized — it's a
setup-stage method, so it's a type error after .loader() or once the point is
closed.
When two plugins (or a plugin and its consumer) declare the same input key, the schemas merge by key: a plugin can only narrow a key, never widen it, and the type rejects a widening or a kind clash.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️