Skip to content
Twilight React Playground
ثيم رائدaren

withHead

functionBeginnerserverbrowserlive demo

Wraps a route module's head function into the head option of a TanStack route, with an optional step to change the result.

import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

In plain words

Each built-in page (a route module such as Product or Cart) describes its <head> tags (title, description, social preview, canonical link) in a framework-neutral object. A TanStack route expects its head option in a different shape.

head: withHead(Product) connects the two. When the page loads, it gives the module's head the engine context and the data the page's loader returned, then converts the result. Every generated route file uses it, and any object with a head(ctx, data) function works, including your own.

The optional second argument, extend, changes the result before it is converted, for example to add the store name to a title.

Signature

function withHead<T>(
  route: { head: (ctx: TwilightContext, data: T) => HeadDescriptor },
  extend?: (result: HeadDescriptor, ctx: TwilightContext, data: T) => HeadDescriptor
): (match: Record<string, unknown>) => Record<string, unknown>
// The returned function reads match.loaderData (the data) and match.matches (for hydration).

Try it live

withHead(Offer) turns a route module's head into the function TanStack Router calls. Here it runs with this page's real context.Try this: turn off "the loader returned data": the result is {}, no tags at all. Then add the store name through extend.
Storefront canvas · ar · RTL
Runs in the browser…
Controls
extend: add the store name to the title
the loader returned data
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
import { Offer } from '../route-modules/offer';

export const Route = createFileRoute('/{-$locale}/offer')({
  loader: () => Offer.loader(),
  head: withHead(Offer),
  component: OfferPage,
});

Example

app/routes/faq.tsx
// Declared in app/routes.ts: route('/faq', 'faq.tsx')
import { createFileRoute } from '@tanstack/react-router';
import type { HeadDescriptor } from '@salla.sa/twilight-theme-engine/utils/head';
import { withHead, type TwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';

interface FaqData {
  questions: Array<{ q: string; a: string }>;
}

// Any object with head(ctx, data) works, not only the engine's route modules.
const Faq = {
  head: (ctx: TwilightContext, data: FaqData): HeadDescriptor => ({
    title: `FAQ | ${ctx.settings.store?.name ?? ''}`,
    description: data.questions[0]?.q,
  }),
};

export const Route = createFileRoute('/{-$locale}/faq')({
  loader: async (): Promise<FaqData> => ({
    questions: [{ q: 'How long is delivery?', a: 'Two to four days.' }],
  }),
  head: withHead(Faq),
  component: FaqPage,
});

function FaqPage() {
  const { questions } = Route.useLoaderData();
  return <ul>{questions.map((item) => <li key={item.q}>{item.q}</li>)}</ul>;
}

How it behaves

  • Before calling your head, it fills the twilight context from the router matches when the context has no settings yet, which can happen while the page hydrates.

  • A SettingsError thrown by head or extend is caught and the route contributes no tags; any other error is thrown again. A throw during hydration would make React discard the server HTML.

  • extend receives the finished descriptor, the context and the loader data. Its return value replaces the result, so start from ...result.

  • TanStack merges the heads of every matched route: the deepest route's title wins, a meta with the same name or property is replaced by the deepest route's, and links, styles and scripts are appended with only exact duplicates removed. A page's canonical therefore sits next to the root head's own, not in its place.

Gotchas

  • No loader, no tags: without loaderData the returned function returns {} before calling head, so a route without a loader shows only the root's title. Give the route a loader, or write a plain TanStack head for pages without data.

  • docs/03-routing-system.md shows ctx.store?.name inside extend. The context has no store field: read ctx.settings.store?.name.

Related

Source and docs