Documentation
Head and images
Per-page metadata, and an <Image> that fixes layout shift without a build pipeline.
A page can contribute to <head> without owning the whole document. Export head alongside the default export; it receives the same props and runs in the same render context, so it can await data and call the same helpers. For metadata specifically, seo() writes these tags for you — see SEO and sharing.
export function head({ params }: PageProps) {
const post = getPost(params.slug);
return (
<>
<title>{post.title}</title>
<meta name="description" content={post.summary} />
<link rel="canonical" href={`https://example.com/blog/${params.slug}`} />
</>
);
}A <title> here replaces the default rather than joining it. Two titles in one document is never what was meant, so the framework picks yours instead of emitting both.
- It receives the same props as the page — params, request and url — so a title can come from the same slug the page rendered.
- It may be async. It runs inside the page's render context, so it can await data and call the same helpers the page can.
- Return null and nothing is added. There is no requirement to export it, and no penalty for exporting one that sometimes declines.
- It works whether or not the page owns its document. A page returning a bare fragment gets the framework's shell; one returning a whole <html> has the markup injected before its own </head>.
One ordering detail worth knowing: head runs after the body, not before it. That is what lets a priority <Image> buried deep in the page contribute a preload that still lands in <head> — by the time the document is assembled, the body has already been rendered and everything it asked for is known.
1. body renders <Image priority> registers a preload
2. head() runs your <title>, <meta>, seo(...)
3. document assembled preloads, then head(), then the stylesheet
-> all inside <head>This site uses it: every documentation page exports a head that calls seo(), while the layout keeps the title. Two sources, no duplicate tags, because a <title> from head replaces rather than joins.
Images
<Image> writes the markup that hand-rolled img tags usually get wrong. Nothing to install, and nothing to configure.
<Image src="/hero.jpg" width={1200} height={600} alt="Stoneware" priority />
<Image src="/feature.jpg" width={800} height={500} alt="Feature" /><img src="/hero.jpg" width="1200" height="600" alt="Stoneware"
fetchpriority="high" decoding="async">
<img src="/feature.jpg" width="800" height="500" alt="Feature"
loading="lazy" decoding="async">priority also puts a <link rel="preload" as="image"> in the head — with imagesrcset and imagesizes when you pass srcset and sizes, so the preloader picks the same candidate the img will rather than racing it to a different file.
That tag is written in the body but belongs in the head, which the document assembler passed long before. It travels backwards through the render context — the same mechanism csrfToken() uses to reach the response layer.
Three things that are errors
- A missing alt. alt="" is a real answer — it marks the image decorative — but it has to be deliberate rather than forgotten.
- A missing or zero width/height. The intrinsic ratio reserves space before the bytes arrive; without it the page shifts when they do. CSS can still size the element however it likes.
- sizes without srcset, which does nothing at all.
What it deliberately does not do
width / height yes alt validation yes loading="lazy" yes decoding="async" yes fetchpriority + preload yes srcset / sizes yes ───────────────────────────────────────────────────────────── resize WebP AVIF compression no — needs a codec
Bun ships no image codec, so re-encoding would mean a native dependency: a platform-specific binary and roughly thirty times the install size, for a framework whose entire dependency list is one 4 kB package. <Image> is correct markup, not a pipeline — bring your own, or ship the files you have.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.