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.
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
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
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
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
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:
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.