stoneware

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.

routes/blog/[slug].tsxtsx
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>
the order a page is assembled in
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.

in a pagetsx
<Image src="/hero.jpg" width={1200} height={600} alt="Stoneware" priority />
<Image src="/feature.jpg" width={800} height={500} alt="Feature" />
what is renderedtxt
<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
the line, and why it is there

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.