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 😎

Page

  • Раздел: Points

On this page

  1. Declaring a page
  2. Typed route params
  3. The route prefix is inherited
  4. Getting data into a page
  5. .relatedQuery vs .with
  6. Loading and error states
  7. Head and SEO
  8. A page is also a query
  9. Page or endpoint?
  10. Gating a page
  11. Authoring notes
  12. Reference
  13. Component props
  14. Methods that apply to a page
  15. The page's route

A page is a point. You give it a route and a component; everything else — data, loading and error states, SSR, the client-side (SPA-style) navigation — is wired through the point chain it grows from.

import { generalLayout } from '@/layouts/general'
import { ideaQuery } from '@/queries/idea'

export const ideaPage = generalLayout.lets
  .page('/ideas/:id')
  .with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
  .head(({ data: { idea } }) => idea.title)
  .page(({ data: { idea } }) => (
    <article>
      <h1>{idea.title}</h1>
      <p>{idea.content}</p>
    </article>
  ))

/ideas/123 now renders that component, with idea already loaded — no loading branch in sight.

Declaring a page

A page is opened off a parent — the root, a base, or a layout — with a route, and closed with .page(component):

export const homePage = root.lets.page('/').page(() => <h1>Home</h1>)

root.lets.page('/') is the short notation for the explicit root.lets('page', 'home', '/') — both are valid and identically typed; see points for how the short form works. You can't open a page off a query or a mutation.

The .page(component) closer is server-ssr-and-client — it's cut from the SERVER bundle when ssr:false (or after a .clientOnly() earlier in the chain): its body and imports are removed from the server build. Kept in the client build always, and in the server build only when SSR is on.

The component argument is optional — omit it and the page renders nothing (() => null). A page can also do its work in an effect and show only the inherited loading view:

export const logoutPage = root.lets
  .page('/logout')
  .page(({ LoadingComponent }) => {
    useLogoutOnMount()
    return <LoadingComponent />
  })

Typed route params

Write params into the route string and they arrive typed — Point0 parses the route at the type level:

export const ideaPage = root.lets
  .page('/ideas/:id') // params is { id: string }
  .page(({ params }) => <h1>Idea {params.id}</h1>)

Params are strings; coerce them yourself (Number(params.id)), or validate and transform them with .params(schema). Optional and wildcard segments work too — '/files/:dir?/*?' gives you params.dir and params['*'].

The route prefix is inherited

A page's public route extends its parent's. Open a page off a layout that owns /ideas, give the page /:id, and its route is /ideas/:id:

export const ideasLayout = root.lets.layout('/ideas').layout(/* ... */)

export const ideaPage = ideasLayout.lets
  .page('/:id') // final route: /ideas/:id
  .page(/* ... */)

Everything that shapes the page — its route prefix, loading and error UI, defaults — is reachable by following the parent chain from the point itself. No config in another file changes it.

Getting data into a page

A page renders only once its data is ready, so the component never sees a half-loaded state. There are two ways to attach data.

Own loader. Put a .loader on the page. It runs on the server, so your database code never ships to the browser:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .loader(async ({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: Number(params.id) },
    })
    return { idea } // becomes `data`
  })
  .page(({ data: { idea } }) => <h1>{idea.title}</h1>)

.loader is server-only — cut from the client bundle: its body and the imports it uses are removed.

Injected query. When the data lives in a reusable query, hand it to the page with .with and map the route params to its input:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
  .page(({ data: { idea } }) => <h1>{idea.title}</h1>)

Both forms feed data. Inject more than one query and read them from queries (in declaration order), or fold them into one shape with .mapper — see .with for the full range. You can also skip .with and call ideaQuery.useQuery({ id }) inside the component, handling isLoading yourself — the page doesn't force either style.

.with and .mapper are server-ssr-and-client — cut from the SERVER bundle when ssr:false (or after a .clientOnly()): their bodies and imports are removed from the server build.

.relatedQuery vs .with

.relatedQuery declares a query the page depends on. Like a .with query, it adds its query to the queries array and feeds data. The difference is prefetch: a related query is statically discoverable, so prefetch can self-fetch it without rendering the page, under the cheap policies (serverQuery / clientQuery / serverAndClientQuery). A .with query is found only by rendering, so it's prefetched only under the expensive, SSR-only pageDehydratedState* policies. Reach for .relatedQuery when you want a page's data warm before navigation without paying for a full render.

.relatedQuery is server-and-client — not cut from either bundle (isomorphic).

Loading and error states

When a page has pending data, Point0 shows the nearest .loading component up the chain; on a thrown error, the nearest .error. Set them once on the root and override per point as needed:

export const root = Point0.lets
  .root()
  .loading(() => <Spinner />)
  .error(({ error }) => <ErrorScreen error={error} />)
  .root()

A page can override either:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .loading(() => <IdeaSkeleton />)
  .loader(/* ... */)
  .page(/* ... */)

The .loading must appear before the data method that can suspend. You won't see the loading state when SSR is on (the data arrives with the HTML) or when the page is prefetched before navigation. Full rules, including prefetch policies, are in Loading & error.

.loading and .error are server-ssr-and-client — cut from the SERVER bundle when ssr:false (or after a .clientOnly()).

Head and SEO

Set the document head from the page, statically or from loaded data:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
  .head(({ data: { idea } }) => ({
    title: idea.title,
    description: idea.content.slice(0, 140),
  }))
  .page(/* ... */)

.head accepts a string (shorthand for the title) or an unhead object, and can be set per state. Details on Head.

.head is server-ssr-and-client — cut from the SERVER bundle when ssr:false (or after a .clientOnly()).

A page is also a query

A page with a loader is itself a query under the hood — Point0 attaches a self query so the page can be fetched and prefetched like any other:

ideaPage.useQuery({ id: 123 })
ideaPage.useSuspenseQuery({ id: 123 }) // data non-optional, suspends into `.loading`
ideaPage.fetchQuery({ id: 123 })
ideaPage.getQueryKey({ id: 123 })
ideaPage.prefetchQuery({ id: 123 })

This is what makes SSR and navigation prefetch work without you wiring anything. The page's self query is finite by default. To make it infinite, close the chain with .infiniteQuery({...}) after the loader — wire the cursor to search with pageParamFromInput:

export const ideaListPage = root.lets
  .page('/ideas')
  .search(z.object({ page: z.coerce.number().default(0) }))
  .loader(async ({ search }) => {
    /* ... */ return { ideas, nextCursor }
  })
  .infiniteQuery({
    getNextPageParam: (last) => last.nextCursor,
    initialPageParam: 0,
    pageParamFromInput: '?.page',
  })
  .mapper(({ data }) => ({ ideas: data.pages.flatMap((p) => p.ideas) }))
  .page(({ data: { ideas }, queries: [query] }) => {
    /* ...query.fetchNextPage() */
  })

.infiniteQuery and .query (the self-query closers) are server-and-client — not cut from either bundle (isomorphic).

A page with no loader issues no request and exposes no useful query — calling .useQuery() on it returns an empty result, not an error.

Page or endpoint?

Not every page is an HTTP endpoint. A query, a mutation, or an action is always a real endpoint (its own path, in the OpenAPI spec); a page is only sometimes one:

  • SSR on → the page is server-rendered, so it's an endpoint.
  • SSR off → the page is an endpoint only if it has a server .loader() (the loader needs a URL to fetch). A page that only composes other queries — no server loader — is a pure mountable: client-only, no endpoint.

Either way, a page with a route is always reachable in the client (after the initial SSR render, navigation is client-side, SPA-style). "Endpoint" is about whether the server serves it directly.

Gating a page

The page component is browser code. The server-only parts of the chain — your loader body, secrets, DB calls — are stripped from the client bundle at compile time, so they never leak, but a render that depends on access has to be guarded in code that always runs:

  • Don't put secret content in the rendered markup expecting it to be server-only — the markup ships to the browser.
  • Gate access in a .with wrapper (or a plugin that combines .ctx and .with), not with .ctx alone. .ctx is server-only (its body is stripped from the client bundle). A page's .ctx runs only when the page has a loader — a loader-less page makes no server request, so its .ctx never executes and can't protect anything.
import { authPlugin } from '@/lib/auth' // a plugin that resolves the user into props.me
import { AppError } from '@/lib/error' // your own error class — ErrorPoint0 or any compatible one

export const adminPage = root.lets
  .page('/admin')
  .use(authPlugin)
  .with(({ props: { me } }) => {
    if (!me?.isAdmin) return new AppError('Forbidden', { code: 'FORBIDDEN' })
    return { me }
  })
  .page(/* ... */)

me has to come from somewhere — here a plugin puts it in props first. Returning an AppError from .with short-circuits to the error component.

Authoring notes

  • Pages are lazy by default. Each page is its own dynamically imported chunk, loaded on navigation. Turn this off in the client's codegen config — client: { generate: { points: { lazy: false } } } — there is no per-page method for it.
  • Put pages anywhere. Any file, any folder, several per file. Point0 finds them by static analysis; hot-reload survives mixing pages, queries, and mutations in one file.
  • Wrap the render with .wrapper. .wrapper(({ children }) => …) wraps the page's tree; a wrapper can also short-circuit before the data loads.

Reference

Component props

The page component receives one object:

PropTypeWhen
datamapper output, or the first query's dataalways ({} if none)
queriestuple of loaded query results, in .with orderalways ([] if none)
paramsparsed route paramswhen the route has params / .params
searchparsed query stringwhen .search is set
setSearchupdate the URL query (replace / patch / drop)always (client-only; SSR no-op)
propsprops contributed by .with wrappersalways ({} if none)
locationthe current location (location.params, …)always (pages have a route)
LoadingComponentthe resolved loading componentalways
ErrorComponentthe resolved error component ({ error })always

A page has no input prop — pages use params/search, not .input. Writing .input or .body on a page is a type error.

Methods that apply to a page

Data & context: .loader, .clientLoader, .ctx, .with, .mapper, .relatedQuery, .params / .search, .headers, .cookies. .headers / .cookies validate the incoming request and type it into the loader, so they only do something on a page with a .loader — a loader-less page has no request to parse.

UI: .head, .loading, .error, .wrapper, .layout.

Navigation & prefetch: .prefetchPageOnNavigate, .prefetchPageOnLinkHover, .prefetchPagePolicy, .onPrefetchPage (plus .serverOnPrefetchPage / .clientOnPrefetchPage), .scrollRestore, .scrollPosition, .clientOnly. See Navigation (and SSR) for the prefetch policies.

Shared: .use (plugins), .middleware, .on / .serverOn / .clientOn (events), .tag, .description. .middleware runs server-side around the page's request, so it fires only when the page is served as an endpoint (a loader, or SSR on).

.infiniteQuery and .query are valid only to finalize a loader-bearing page's self query; they're otherwise unavailable on a page.

The per-status .head forms work on a page the same as on any mountable: .head('global', …) is the app-wide base head, .head('universal', …) applies in every render state, and .head('loading' | 'error' | 'success', …) targets one state. A bare .head(value) defaults to the 'success' state.

The page's route

page.route is a callable Route0 route:

page.route({ id: 123 }) // => "/ideas/123"   (relative)
page.route.abs({ id: 123 }) // => "https://app.example.com/ideas/123"
page.route.getRelation(href) // match the current URL against this route
НазадPointsДалееLayout

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