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

Change page titles and meta tags

Beginner6 min

Pass an extend function to withHead to adjust a page's title, description and meta tags without rewriting its head.

Goal: change what a page puts in <head>: its title, its description, or a meta tag such as robots, while the engine keeps producing everything else (share tags, canonical link, structured data).

Mechanism: each built-in page's route file sets head: withHead(Module). withHead takes a second, optional argument: an extend function. It receives the tags the engine built for this page (as one object, a HeadDescriptor), the engine's context and the page's loader data, and returns the object to use instead. You adjust that object; withHead turns it into the tags.

1. Try it

A real product from the demo store. Type a title suffix: that is an extend function at work. Switch the view to tags to see what the router receives.

The search-engine and share tags the Product route gives a real product from this store: first as the HeadDescriptor Product.head returns, then as the tags withHead hands the router.Try this: open jsonLd in the HeadDescriptor view, then switch to tags and find the same data as a script. A suffix only changes the tags view.
Storefront canvas · en · LTR
Runs in the browser…
Controls
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { Product } from '@salla.sa/twilight-theme-engine/routes/product';
import type { ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';
import { ProductDetailSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
  loader: ({ params }): Promise<ProductPageProps> =>
    Product.loader({ params: { id: params.id }, locale: params.locale }),
  head: withHead(Product),
  pendingComponent: () => <ProductDetailSkeleton />,
  component: ProductComponent,
});

function ProductComponent() {
  const data: ProductPageProps = Route.useLoaderData();
  return <Product.Component {...data} />;
}

2. Take the route file over

The head lives in the page's route file, which the engine generated. Open app/routes/$slug.p$id.tsx and delete its first line, // @auto-generated: from then on the engine leaves the file alone. (Or point the path at a new file, as in Fork a built-in page.)

3. Pass an extend function

app/routes/$slug.p$id.tsx
import { createFileRoute } from '@tanstack/react-router';
import { Product } from '@salla.sa/twilight-theme-engine/routes/product';
import type { ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';
import { ProductDetailSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
import { mergeHead } from '@salla.sa/twilight-theme-engine/utils/head';

export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
  loader: ({ params }): Promise<ProductPageProps> =>
    Product.loader({ params: { id: params.id }, locale: params.locale }),
  head: withHead(Product, (result, ctx, data) =>
    // mergeHead keeps everything the engine set and adds or replaces what you pass.
    mergeHead(result, {
      title: `${data.product.name} | ${ctx.settings.store?.name ?? ''}`,
      meta: [{ property: 'product:availability', content: data.product.is_available ? 'in stock' : 'out of stock' }],
    })
  ),
  pendingComponent: () => <ProductDetailSkeleton />,
  component: ProductComponent,
});

function ProductComponent() {
  const data: ProductPageProps = Route.useLoaderData();
  return <Product.Component {...data} />;
}

For one field, spreading is enough. For example, keep search results out of search engines:

app/routes/search.tsx (excerpt)
// app/routes/search.tsx: only the head line changes
head: withHead(ProductListing, (result) => ({ ...result, robots: 'noindex, follow' })),
In engine terms

withHead(route, extend?) (src/tanstack/head.ts) returns the function TanStack Router calls with the match. It returns {} when the match has no loaderData; otherwise it runs route.head(getTwilightContext(), loaderData), passes the result through extend(result, context, loaderData) and converts the descriptor with tanstackHeadAdapter into meta, links, styles and scripts (JSON-LD becomes a script). mergeHead(base, extension) (src/utils/head.ts) spreads extension over base, appends meta, links, styles and scripts, and merges openGraph and twitter one level deep. The root route adds the store-wide tags (rootHead), including robots: index, follow and a canonical link to the store.

Traps