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 😎

Head

  • Раздел: Core

On this page

  1. Required setup
  2. The three forms
  3. The head object
  4. Flat key wins over a meta array
  5. titleTemplate
  6. Per-state head: .head(status, fn)
  7. universal vs global — which state they see
  8. Precedence: nearest wins, later overrides
  9. Layout above, page below
  10. SSR and what runs where
  11. Reference
  12. Where .head is available
  13. The head object — full key set
  14. .head signatures

.head sets the document <head> for a point — the title, SEO meta, the canonical link. It's built on unhead: the object you pass is an unhead head plus a flat set of SEO keys, and Point0 renders it on the server (real <meta> in the HTML) and updates it on client navigation.

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

.head reads the same per-state context every point method gets — here data is the loaded idea — so the head can come from your data, not just static strings.

Stripping: .head is server-ssr-and-client — cut from the SERVER bundle when ssr: false (or after a .clientOnly() earlier in the chain): its body and the imports it uses are removed from the server build. Kept in the client build always, and in the server build only when SSR is on. See SSR and what runs where.

Required setup

.head needs unhead's provider mounted around the app tree. The provider comes from @point0/core/unhead (it picks the client or server variant for you):

// app.client.tsx
import { UnheadProvider } from '@point0/core/unhead'

export const App = () => (
  <UnheadProvider>{/* ...router, pages... */}</UnheadProvider>
)

With the provider mounted you can also call unhead's own useHead() / useSeoMeta() directly inside a component — .head is the declarative path, not the only one.

The three forms

.head accepts a string, an object, or a function:

.head('Home')                                    // string → the title
.head({ title: 'Home', description: 'Welcome' }) // object → head + SEO keys
.head(({ data }) => ({ title: data.idea.title })) // function → returns string or object

A string is shorthand for the title:

.head('New Idea') // => <title>New Idea</title>

A function receives the point's per-state context and returns a string or an object — the head built from loaded data, params, or search:

.head(({ data: { total, idea } }) => `${total} news for idea "${idea.title}"`)
.head(({ params }) => `User #${params.sn}`)

The head object

The object form is everything unhead's useHead takes plus flat SEO keys at the same level:

.head({
  // unhead head keys
  title: 'Idea',
  titleTemplate: '%s | IdeaNick',
  htmlAttrs: { lang: 'en' },
  // flat SEO keys (unhead's useSeoMeta schema)
  description: 'A short idea',
  ogTitle: 'Idea',
  ogImage: { url: 'https://app.example.com/og.png', width: 1200 },
  robots: { index: true, follow: true },
  // special: rendered as <link rel="canonical">
  canonical: 'https://app.example.com/ideas/1',
})

Point0 splits the object into two groups and feeds them to unhead's useHead and useSeoMeta:

  • Head keys go to useHead: title, titleTemplate, templateParams, base, link, meta, style, script, noscript, htmlAttrs, bodyAttrs.
  • Everything else (e.g. description, ogTitle, ogImage, twitterCard, robots, …) is a flat SEO key, rendered as the matching <meta> tag.
  • canonical is special — it becomes <link rel="canonical" href="…">, appended after any link entries you set.
.head({ description: 'About us', ogTitle: 'About', canonical: 'https://app.example.com/about' })
// => <meta name="description" content="About us">
//    <meta property="og:title" content="About">
//    <link rel="canonical" href="https://app.example.com/about">

Unknown keys are a type error:

.head({ descriptio: 'typo' }) // type error — unknown head key

undefined-valued keys are dropped, so a conditional spread is safe:

.head({ title: 'Home', description: hasDesc ? text : undefined })
// description omitted when hasDesc is false — no empty <meta>

Flat key wins over a meta array

If a flat SEO key and a meta: [...] entry target the same tag, the flat key wins and only one tag renders:

.head({
  description: 'Flat',
  meta: [{ name: 'description', content: 'From array' }],
})
// => exactly one <meta name="description" content="Flat">  ("From array" is dropped)

titleTemplate

titleTemplate wraps the page title with a suffix or prefix. %s is the placeholder for the title. Set it once high in the chain and let each page supply just its title:

// on the root's global head (see below)
.head('global', () => ({ titleTemplate: '%s | IdeaNick' }))

// a page sets only the title
.head({ title: 'Ideas' }) // => <title>Ideas | IdeaNick</title>

Pass titleTemplate: null on a page to opt out of the template — the title shows verbatim:

.head({ title: 'IdeaNick Forever!', titleTemplate: null })
// => <title>IdeaNick Forever!</title>  (no suffix)

titleTemplate and templateParams pass straight through to unhead, which does the %s substitution — so the finer rules (more than one %s, function-form titleTemplate, separator handling, templateParams interpolation) are unhead's.

Per-state head: .head(status, fn)

A point moves through states — loading, error, success — and you can give each a different head. Pass a status as the first argument:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaQuery, ({ params }) => ({ id: Number(params.id) }))
  .head('success', ({ data: { idea } }) => ({ title: idea.title }))
  .head('error', ({ error }) => ({ title: `Error: ${error.message}` }))
  .page(/* ... */)

The five statuses are 'loading' | 'error' | 'success' | 'universal' | 'global'.

A .head with no status defaults to 'success':

.head({ title: 'Home' })        // same as .head('success', () => ({ title: 'Home' }))
.head('Home')                   // same — success-only

A success-only head still renders on a page with no loader, because a loader-less point is immediately in the success state.

Each status fires only in its state:

StatusFires whenContext it receives
loadingthe point's own chain is loadingthe point's loading state
errorthe point's own chain erroredthe point's error state (typed error)
successthe point's own chain succeededthe point's success state (typed data)
universalevery state of the point's chainthe point's current state (status-dependent)
globalevery state of the whole-page navigationthe page navigation state + location

The success / error / loading callbacks get the same per-state context as the rest of the point's methods — data typed on success, error typed on error, plus params, search, input, props, queries, location.

universal vs global — which state they see

Both run on every state, but they read different state.

universal sees the current point's own chain state — and that depends on where in the chain you put it. Placed after a .loader it sees the loading→ready transition; placed before, it never sees that loader's loading:

// after the loader → sees loading then success
.loader(/* ... */)
.head('universal', ({ loading }) => ({ title: loading ? 'Loading…' : 'Ready' }))

// before the loader → only ever 'Ready' (this loader hasn't run yet at that point)
.head('universal', ({ loading }) => ({ title: loading ? 'Loading…' : 'Ready' }))
.loader(/* ... */)

global sees the whole page's navigation state, regardless of where in the chain you declare it. It reacts to navigation, not to one point's data — so a global head before the loader still shows loading:

.head('global', ({ loading, error, initial }) => ({
  ...(loading ? { title: 'Loading…' } : {}),
  ...(error ? { title: error.message } : {}),
}))
.loader(/* ... */) // global head still reflects the page-level loading state

The global callback gets a navigation state — status, loading, error, success, initial — plus the current location. initial (the pre-navigation state) is a state only the global head sees; a success / error split alone has no head in the initial state, so the document falls back to its default <title>. Use a global head for anything that must always have a title:

.head('global', ({ initial, loading }) =>
  initial ? 'IdeaNick' : loading ? 'Loading…' : 'IdeaNick',
)

The 'global' form takes a function or an object — not a bare string — in the types. The other statuses accept a string too.

Precedence: nearest wins, later overrides

Heads accumulate along the chain and replay in declaration order. Because unhead's later calls override earlier ones for the same key, the head declared later in the chain wins per key. A plain .head placed after a universal or global head overrides it on conflicting keys, while the earlier head still fills the keys the later one doesn't touch:

.head('global', ({ loading }) => ({ title: loading ? 'Loading…' : 'Ready' }))
.loader(/* ... */)
.head(({ data }) => ({ title: `x=${data.x}` }))
// while loading → "Loading…" (from global); on success → "x=1" (the later head wins)

Layout above, page below

.head renders only on pages and layouts (not on a query, mutation, component, or provider). When a page sits under a layout, both run their heads; the page renders below the layout, so it applies later — the page wins on any conflicting key (nearest-to-leaf wins). The canonical pattern is a titleTemplate on the layout (or root global head) and a plain title on each page.

A .head('global', …) on the root or a base does not render there directly — it flows down and is applied at the page or layout that mounts. That's why you put the app-wide titleTemplate and loading/error title on the root's global head:

// root.tsx — applies to every page/layout below
.head('global', ({ loading, error }) => ({
  ...(loading ? { title: 'Loading...' } : {}),
  ...(error ? { title: error.message } : {}),
  titleTemplate: '%s | IdeaNick',
  htmlAttrs: { lang: 'en' },
}))

Flat SEO keys follow the same rule, and so does canonical: unhead treats it as a singleton link, so when more than one point up the chain sets it, the nearer-to-leaf one wins.

SSR and what runs where

When SSR is enabled, the first page load is server-rendered: the head renders on the server through unhead's server provider, producing real <title>, <meta>, and <link> in the HTML — so search engines and link unfurlers see them without running your JS. After that first render, navigation between pages is client-side (SPA-style), and the head updates there too.

.head is a render-side method, so it strips like the others — it's server-ssr-and-client. When the point is not under SSR (ssr: false, or after a .clientOnly() call), the .head body and the imports it uses are cut from the server bundle, like .with, .mapper, .loading, and .error — the server never renders it. It's kept in the client build always, and in the server build only when SSR is on: both renders must produce the same <head>, so the body stays in both bundles and runs on each side. Whatever you return from a .head that runs is part of the public document — keep secrets out of head values; gate access to a page with a .with wrapper, not by hiding it in a head string.

Each head action records an ssr flag at declaration time, but nothing reads it back: the render loop calls useHead / useSeoMeta unconditionally, so a head that reaches a render always runs. What keeps a non-SSR head off the server is the compiler stripping its body from the server bundle, not this runtime flag.

Reference

Where .head is available

.head is a stage-method — you call it while building a point, before the finalizer (.page / .layout / .root / the base path) turns it into a ready-method point. It's available on:

Point type.head
rootyes (flows down; renders at the page/layout below)
baseyes (flows down)
pageyes (renders here)
layoutyes (renders here)
componentno
providerno
pluginno
query / infinite-query / mutation / actionno

It is not exposed on any finalized (ready) point — only while the chain is still open. Strip category: server-ssr-and-client — cut from the server bundle when ssr: false (body and imports removed from the server build); kept in the client build always, and in the server build only when SSR is on.

A base-level .head flows down like everything else on a base: it becomes a mount action on the chain and replays at the page or layout that mounts, so it never renders on the base itself.

The head object — full key set

.head({...}) is ResolvableHead & DeepResolvableProperties<MetaFlat> & { canonical }:

GroupKeysRenders as
Head keystitle, titleTemplate, templateParams, base, link, meta, style, script, noscript, htmlAttrs, bodyAttrspassed to unhead useHead
Flat SEOdescription, ogTitle, ogImage, ogDescription, twitterCard, robots, … (every useSeoMeta key)the matching <meta> tag
canonicalcanonical: string<link rel="canonical" href="…">

Every value may be a resolvable (a function), per unhead. A flat SEO key beats a meta array entry for the same tag; canonical is appended after existing link entries.

Point0 only sorts keys into the two buckets (the head keys are the headOwnKeys set in head.ts; every other key goes to useSeoMeta) — it does not interpret them, so what each one renders is unhead's job.

.head signatures

// no status → defaults to 'success'
.head(headFn | headObject | string)

// explicit status
.head('loading' | 'error' | 'success' | 'universal', headFn | headObject | string)
.head('global', globalHeadFn | headObject)   // no bare string in the 'global' types

The function form returns HeadObject | string. The types require at least one argument: an arg-less .head() would produce an empty, meaningless head, so the typings don't offer it (the runtime would accept it).

НазадEnvДалееMDX

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