Every page needs its own title, description, and OG tags. Here is a clean pattern that does not turn into a mess as your route count grows.
TanStack Router is excellent for file-based routing in Vite apps. The built-in head management through the head() function on each route is clean and colocated. The problem is that as you add more routes, you start repeating yourself constantly, especially for the OG image, Twitter card settings, and other tags that are the same across most pages.
Here is the pattern we use to keep it DRY while still allowing full per-route customization.
Base head config
Create a utility that generates the base head config with sensible defaults:
type HeadOptions = {
title: string;
description: string;
path: string;
ogImage?: string;
noIndex?: boolean;
};
const BASE_URL = "https://execross.com";
const DEFAULT_OG = `${BASE_URL}/og-image.png`;
export function buildHead(opts: HeadOptions) {
const fullTitle = `${opts.title} | Execross`;
const image = opts.ogImage ?? DEFAULT_OG;
const url = `${BASE_URL}${opts.path}`;
return {
meta: [
{ title: fullTitle },
{ name: "description", content: opts.description },
{ property: "og:title", content: fullTitle },
{ property: "og:description", content: opts.description },
{ property: "og:image", content: image },
{ property: "og:url", content: url },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: fullTitle },
{ name: "twitter:description", content: opts.description },
{ name: "twitter:image", content: image },
{ name: "robots", content: opts.noIndex ? "noindex" : "index, follow" },
{ name: "canonical", content: url },
],
};
}Using it in routes
Now each route just calls buildHead with the minimum required info:
export const Route = createFileRoute("/blog/$slug")({
head: ({ params }) => {
const post = getPost(params.slug);
return buildHead({
title: post.title,
description: post.excerpt,
path: `/blog/${params.slug}`,
});
},
component: BlogPostPage,
});For pages with a custom OG image, just pass it in:
export const Route = createFileRoute("/")({
head: () =>
buildHead({
title: "Developer-Centric Automation Platform",
description: "Build bots, integrate APIs, and scale systems with Execross.",
path: "/",
ogImage: "https://execross.com/og-image-home.png",
}),
component: HomePage,
});The canonical tag problem
TanStack Router's head function does not automatically insert a canonical link tag, which is annoying because canonical is critical for SEO. We add it as a regular meta with name "canonical" and handle it in the HTML template:
<script>
// In index.html, dynamically set canonical from meta tag
document.addEventListener("DOMContentLoaded", () => {
const canonicalMeta = document.querySelector('meta[name="canonical"]');
if (canonicalMeta) {
let link = document.querySelector('link[rel="canonical"]');
if (!link) {
link = document.createElement("link");
link.rel = "canonical";
document.head.appendChild(link);
}
link.href = canonicalMeta.getAttribute("content");
}
});
</script>Not perfect but it works. For prerendering this also gets handled correctly because the script runs during the Playwright visit.
Per-site customization
Different Execross properties have different brand names and OG images. We pass the site identity through an environment variable and pick up the defaults from there:
const SITE_NAME = import.meta.env.VITE_SITE_NAME ?? "Execross";
const SITE_BASE_URL = import.meta.env.VITE_BASE_URL;One config file, consistent behavior, easy to override when needed.