Head & SEO

head() derives document metadata from validated params, search state, and public loader data. Furin renders it during SSR, SSG, and ISR, then replaces the route-owned head descriptors during SPA navigation.

src/pages/blog/[slug].tsx
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";

export const route = defineRoute("/blog/[slug]")
  .config({
    mode: "ssr",
    params: t.Object({ slug: t.String() }),
  })
  .loader(async ({ params }) => ({ post: await getPost(params.slug) }))
  .head(({ params, post }) => ({
    meta: [
      { title: post.title },
      { name: "description", content: post.excerpt },
      { property: "og:title", content: post.title },
      { property: "og:type", content: "article" },
    ],
    links: [
      { rel: "canonical", href: `https://example.com/blog/${params.slug}` },
    ],
  }))
  .page(({ post }) => <Article post={post} />);

Metadata receives validated route values and synchronous public loader fields only. Promise fields explicitly wrapped in defer() and fields returned by requestLoader() are intentionally unavailable to head(); keep metadata inputs in the synchronous loader result so SSR and SPA navigation remain deterministic.

When metadata only needs route values, call .head() directly after .config() or .requestLoader(); no loader() is required.

Descriptor Reference

Metadata

ts
head: () => ({
  meta: [
    { title: "Page title" },
    { charSet: "utf-8" },
    { name: "description", content: "Page summary" },
    { property: "og:image", content: "https://example.com/cover.png" },
    { httpEquiv: "refresh", content: "30" },
    { tagName: "meta", "data-custom": "value" },
  ],
});

<HeadContent /> already emits UTF-8 charset and viewport descriptors. Add them manually only when you intentionally need another value.

Links

ts
head: () => ({
  links: [
    { rel: "canonical", href: "https://example.com/docs" },
    { rel: "preload", href: "/public/hero.webp", as: "image" },
  ],
});

Application CSS should normally be imported from route or component code so Bun can include it in the asset graph. Use links for external stylesheets or resource hints.

JSON-LD

ts
head: ({ post }) => ({
  meta: [
    {
      "script:ld+json": {
        "@context": "https://schema.org",
        "@type": "Article",
        headline: post.title,
      },
    },
  ],
});

Furin serializes the object as escaped JSON inside application/ld+json.

Inline Scripts And Styles

ts
head: () => ({
  scripts: [{ type: "module", children: `console.log("loaded")` }],
  styles: [{ children: ".announcement { color: tomato; }" }],
});

children is raw HTML. Never interpolate user-controlled or unsanitized loader data. Route head scripts are document metadata, not a reliable SPA analytics lifecycle; track navigations from client code instead.

Root Requirement

The root layout must expose the head insertion point:

tsx
import { defineRootRoute, HeadContent, Scripts } from "@teyik0/furin";

export const route = defineRootRoute()
  .config({ mode: "ssg" })
  .layout(({ children }) => (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  ));

Keep one title and one canonical URL in a page's final descriptor set. Furin preserves declaration order and does not deduplicate competing metadata for you.

Comments