Point0by 1gr14
  • Меню
    • GitHub
    • Блог
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Point0by 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

Plugin

  • Раздел: Points

On this page

  1. Declaring a plugin
  2. Injecting with .use()
  3. What .use() merges
  4. Encapsulation
  5. The only point you can build dynamically
  6. Auth plugins: .ctx + .with together
  7. Gate by redirect instead of error
  8. Related queries in a plugin
  9. Reference
  10. Construction
  11. Methods allowed inside a plugin
  12. Where .use() applies

A plugin is a point that does nothing on its own. Instead it bundles 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():

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()

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 server-and-client — not cut from either bundle. The methods it bundles keep their own strip categories: a plugin's .ctx is still cut from the client bundle (server-only), its .with still cut from the server bundle when ssr:false (server-ssr-and-client), and so on. .use() 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's ctx (visible in its loader and later .ctx).
  • props — props the plugin's .with returns merge into the consumer's props.
  • Input schemas — .input / .search / .body / .headers / .cookies merge 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.

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(/* ... */)

Most plugins 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 — two different mechanisms, not two copies of one check:

  • .ctx builds server context. Cut from the client bundle, 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 only when the point has a loader.
  • .with is a render wrapper. Each .with wraps 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, an Error, a redirect, an element). It never sees ctx. Cut from the SERVER bundle when ssr:false (or after a .clientOnly() earlier in the chain); kept in the client build always, 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()

.onPrefetchPage is server-and-client — not cut from either bundle: it runs on the client during prefetch and on the server before the first render (.serverOnPrefetchPage / .clientOnPrefetchPage pin it to one side). .ctx and .with keep their categories from above.

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:

  • .ctx throws the error; .with returns it. A returned Error from .with short-circuits to the error component — see Loading & error.
  • Both branches return { me } on success. That narrows me to 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()

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 server-and-client — not cut from either bundle. 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

FormNotes
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 client-only — cut from the server bundle (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's a setup-stage method — 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.

НазадBaseДалееValidation

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter