AI News Hub Logo

AI News Hub

head.tsx Is Just a React Component: Dynamic SEO Meta from Loader Data

DEV Community
reactuse.com

Look at how most frameworks handle : // Next.js export const metadata = { title: 'Blog Post', description: '...', openGraph: { title: '...', images: [...] }, } // Remix export const meta: MetaFunction = ({ data }) => [ { title: 'Blog Post' }, { name: 'description', content: '...' }, { property: 'og:image', content: data.post.coverImage }, ] Metadata is a config object. You shape strings and key-value pairs into whatever schema the framework prescribed, and the framework turns them into HTML tags. Pareto does something different. In Pareto, head.tsx is a React component that returns JSX: // app/head.tsx export default function Head() { return ( <> My App ) } That's it. No config schema to learn. No special MetaDescriptor type. You write and tags, and React 19 hoists them into the document . This post is about why that's a better design, and what it unlocks — especially for dynamic SEO where the meta tags depend on data you fetched on the server. Three reasons, in order of importance. A config object is static data. If you want "include this meta tag only when the user has a premium account," you end up building the object imperatively before returning it, or stuffing conditional logic into values. A component is code. You express conditions the normal way: export default function Head({ loaderData }: HeadProps) { const data = loaderData as LoaderData return ( <> {data.product.name} {data.product.coverImage && ( )} {data.product.keywords.map((kw) => ( ))} {data.product.isPaid && ( )} ) } Loops, guards, conditional rendering — everything React already does. No metadataProvider, no generateMetadata signature to remember. It's just JSX. Want to pull shared OG tags into a helper? It's a React component: function OpenGraphTags({ title, description, image }: OGProps) { return ( <> ) } export default function Head({ loaderData }: HeadProps) { const { post } = loaderData as { post: Post } return ( <> {post.title} — My Blog ) } In a config-object world, this is a helper function that returns an array that you spread into another array. Here, it's a component. Read the tree and you see the HTML that will end up in . This is the feature that makes the whole thing work. In React 19, any , , or tag you render anywhere in the tree gets hoisted into the document — both during SSR and during client-side navigation. There's no framework-specific MetaProvider collecting and serializing metadata. It's a React platform feature. Pareto doesn't implement the hoisting. React does. Pareto just decides where to render your head.tsx component in the tree (between root and page) so the hoisting has something to pick up. Head components render from root to page. Each level contributes tags. When two levels render the same tag (say, two s), the browser uses the last one — so the deepest route wins automatically. app/ head.tsx ← site defaults blog/ head.tsx ← overrides for /blog section [slug]/ head.tsx ← overrides for individual posts The root sets defaults. The section level overrides them. The leaf route overrides again. This matches how you actually think about SEO — most tags are site-wide, a section diverges a little, and individual pages add their own specifics. // app/head.tsx — site defaults export default function Head() { return ( <> My App ) } // app/blog/[slug]/head.tsx — overrides for individual posts import type { HeadProps } from '@paretojs/core' export default function Head({ loaderData }: HeadProps) { const { post } = loaderData as { post: BlogPost } return ( <> {post.title} — My App ) } The final for /blog/hello-world merges both: favicon and twitter defaults from root, title and every OG-specific tag from the post's head.tsx. Same pattern, just a component tree. No metadataMerge function, no deep-merge semantics to learn. Every head component receives two props: interface HeadProps { loaderData: unknown params: Record } loaderData is whatever that route's loader returned. It's typed unknown because head.tsx has no way to know your loader's schema at runtime — cast it to your actual type: export default function Head({ loaderData, params }: HeadProps) { const { post } = loaderData as { post: BlogPost } return {post.title} } This is the piece that makes dynamic SEO fall into place. The loader fetched the post. The head component gets the exact same data. Writing uses the same object. No separate generateMetadata call that re-fetches the post. The data flows: loader → page + head, both render with the same result. Here's what shipping real per-page SEO for a product catalog looks like. // app/products/[id]/loader.ts import type { LoaderContext } from '@paretojs/core' export async function loader(ctx: LoaderContext) { const product = await db.product.findUnique({ where: { id: ctx.params.id }, include: { images: true, category: true }, }) if (!product) { throw new Response('Not found', { status: 404 }) } return { product } } // app/products/[id]/head.tsx import type { HeadProps } from '@paretojs/core' interface Product { id: string name: string description: string price: number currency: string images: { url: string }[] inStock: boolean } export default function Head({ loaderData }: HeadProps) { const { product } = loaderData as { product: Product } const canonicalUrl = `https://shop.example.com/products/${product.id}` const primaryImage = product.images[0]?.url ?? '/default-og.png' const jsonLd = { '@context': 'https://schema.org', '@type': 'Product', name: product.name, description: product.description, image: product.images.map((img) => img.url), offers: { '@type': 'Offer', price: product.price, priceCurrency: product.currency, availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', url: canonicalUrl, }, } return ( <> {`${product.name} — Our Shop`} ) } One file. Dynamic title, full Open Graph, Twitter cards, canonical URL, and JSON-LD structured data — all derived from the same product object the page component uses. No duplicate fetches. No separate metadata API. Occasionally you need a script to execute before the page paints — the classic example is setting a dark-mode class on based on localStorage, so the user doesn't see a flash of the wrong theme. Inline scripts work inside head.tsx: export default function Head() { return ( <> My App ) } One caveat: React 19 hoists , , and during client-side navigation, but not tags. This is fine for initialization scripts that only need to run once on page load. Pareto's head system is a convention on top of a React 19 feature: head.tsx is a React component that returns JSX React 19 hoists , , into automatically Head components receive loaderData and params as props The tree renders root-to-page; the last tag of a given kind wins There's no separate metadata API to learn. If you know React, you know how to write meta tags. For anything dynamic — blog posts, product pages, user profiles, search results — the pattern is always the same: loader returns the data, head.tsx renders JSX that uses it, React 19 hoists the tags. SEO shipped. npx create-pareto@latest my-app cd my-app && npm install && npm run dev Pareto is a lightweight, streaming-first React SSR framework built on Vite. Documentation