Back to blog
Web DevJune 20, 20256 min read

Managing SEO Meta Tags in TanStack Router Without the Chaos

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:

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

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

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

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

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

More articles