С любовью ко всем разработчикам на свете ❤️
.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:
.headis server-ssr-and-client — cut from the SERVER bundle whenssr: 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.
.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.
.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 objectA 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 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:
useHead: title, titleTemplate, templateParams,
base, link, meta, style, script, noscript, htmlAttrs,
bodyAttrs.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 keyundefined-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>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 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.
.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-onlyA 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:
| Status | Fires when | Context it receives |
|---|---|---|
loading | the point's own chain is loading | the point's loading state |
error | the point's own chain errored | the point's error state (typed error) |
success | the point's own chain succeeded | the point's success state (typed data) |
universal | every state of the point's chain | the point's current state (status-dependent) |
global | every state of the whole-page navigation | the 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 seeBoth 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 stateThe 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.
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).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.
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.
.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 |
|---|---|
| root | yes (flows down; renders at the page/layout below) |
| base | yes (flows down) |
| page | yes (renders here) |
| layout | yes (renders here) |
| component | no |
| provider | no |
| plugin | no |
| query / infinite-query / mutation / action | no |
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.
.head({...}) is
ResolvableHead & DeepResolvableProperties<MetaFlat> & { canonical }:
| Group | Keys | Renders as |
|---|---|---|
| Head keys | title, titleTemplate, templateParams, base, link, meta, style, script, noscript, htmlAttrs, bodyAttrs | passed to unhead useHead |
| Flat SEO | description, ogTitle, ogImage, ogDescription, twitterCard, robots, … (every useSeoMeta key) | the matching <meta> tag |
canonical | canonical: 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' typesThe 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).