Guide Remix SEO

How Remix's meta(), loader(), headers(), and liens() route exports contrôler SEO — plus ce que modifié as Remix v2 became React Router, now on v8.

Première publication : 26 juin 2026 · Dernière mise à jour : 3 août 2026 · Advanced
Langues

TL;DR — Remix is SSR-by-default, so content ships in the initial HTML with aucun render-queue dependency — the architecture removes la plupart JS-SEO risk quand SSR is what’s en réalité deployed, though it doesn’t by itself guarantee indexation, ranking, or Core Web Vitals outcomes. The fonctionner is in the per-route exports: meta() (titles, descriptions, OG, JSON-LD, and dynamic canonicals via tagName: "link"), loader() (server données + réel HTTP 404s/redirections), headers() (Cache-Control, X-Robots-Tag), and links() (static canonicals, preloads). The signature gotcha: nested routes drop parent meta unless vous merge it via the matches argument. Naming, dated: Remix v2’s APIs merged into React Router v7 in 2024; React Router is now on v8 (v7 encore security-supported); Remix 3 (beta) is a separate, newer full-stack framework, pas simply React Router renamed. Ce page covers the Remix v2 / React Router v7-v8 route API.

SSR by par défaut — pourquoi Remix starts ahead

Remix’s server rendering is a framework capability whose deployment behavior dépend on the adapter and route code. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: Remix: Route meta Validate the delivered HTML and resources plutôt que assuming a framework par défaut ensures SEO outcomes. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: Google: JavaScript SEO basics

Scope remarque: the walkthrough ci-dessous documents the meta() / loader() / headers() / links() / ErrorBoundary route module API as it exists in Remix v2 and React Router v7-v8 framework mode — ce que the grand majority of production Remix/React Router sites run. Remix 3 (beta) is a separate, ground-up rewrite with différent APIs and isn’t covered ici; vérifier qui un you’re en réalité on avant copying anything ci-dessous.

Bare React (CRA, Vite + React) ships an vide <div id="root"></div> and builds lune page in le navigateur. Google peut render que, but you’ve signed up pour the render queue, statelessness, and DOM-parity problems covered in JavaScript SEO. Remix doesn’t have que par défaut: it executes votre nested route tree (Root → Layout → Route) on le serveur and, by par défaut, sends content-complete HTML on every document requête. Framework mode peut aussi be configuré to statically prerender routes at construire temps or run as a client-rendered SPA — so pour quelconque donné deployment, confirmer the réel rendering mode (via curl/View Source, ci-dessous) plutôt que assuming SSR from the framework nom.

Google’s propre guidance lines up with ce. From the JavaScript SEO basics doc: “Server-side or pre-rendering is encore a great idea parce que it rend votre website faster pour utilisateurs and robots d’exploration, and pas tout bots peut run JavaScript.” Que dernier clause is the whole argument pour Remix — Bingbot renders JS slowly and incompletely, and nombreux AI robots d’exploration and social preview bots (Twitterbot, facebookexternalhit) don’t render at tout. Remix’s SSR output sert tout of les correctement sans special handling.

Après the initial charger, Remix uses client-side routing pour subsequent navigations (comme every React framework). That’s fine: Google discovers liens from the SSR’d HTML and crawls chaque URL as its propre server requête — chaque returning complet HTML. It doesn’t rely on watching client-side route transitions. And parce que Remix’s <Link> renders a réel <a href>, liens are crawlable by construction — Google “peut seulement découvrir votre liens si ils are <a> HTML elements with an href attribute.”

The meta() export: titles, descriptions, OG, JSON-LD

Every route peut export a meta function returning an array of descriptor objects. Parce que it peut lire loader données, metadata is dynamic and définir in the initial HTML <head> — aucun JS-injection delay:

// app/routes/blog.$slug.tsx
import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return [{ title: "Post Not Found" }];
  return [
    { title: `${data.post.title} | My Blog` },
    { name: "description", content: data.post.excerpt },
    { property: "og:title", content: data.post.title },
    { property: "og:image", content: data.post.ogImage },
    { property: "og:type", content: "article" },
    {
      "script:ld+json": {
        "@context": "https://schema.org",
        "@type": "Article",
        headline: data.post.title,
        datePublished: data.post.publishedAt,
        author: { "@type": "Person", name: data.post.author },
      },
    },
  ];
};

The { "script:ld+json": {...} } descriptor renders a proper <script type="application/ld+json"> tag in the SSR’d <head> — visible to Google’s parser sans executing quelconque JavaScript. Martin Splitt’s preference s’applique: “We prise en charge JSON-LD in dynamically rendered content, but it’s généralement meilleur to have it in the initial HTML.” In Remix, the initial HTML is the par défaut placer it lands.

Nested routes and meta inheritance — the courant trap

Ce is the Remix SEO mistake. Remix takes the dernier matching route with a meta export and uses that — parent meta is dropped. A child that exports its own meta() without merging silently loses every tag the root set (site-wide description, root OG tags, etc.):

// ❌ Root's description is now gone on this route
export const meta: MetaFunction = () => {
  return [{ title: "About Us" }];
};

The fix is the matches argument — flatten and spread parent meta:

// ✅ Keep parent meta, append your own
export const meta: MetaFunction = ({ matches }) => {
  const parentMeta = matches.flatMap((match) => match.meta ?? []);
  return [...parentMeta, { title: "About Us" }];
};

// ✅ Keep parent meta but override only the title
export const meta: MetaFunction = ({ matches }) => {
  const parentMeta = matches
    .flatMap((match) => match.meta ?? [])
    .filter((meta) => !("title" in meta));
  return [...parentMeta, { title: "About Us" }];
};

Si vous définir global tags, put truly universal ones (charset, viewport) directement in root.tsx’s JSX où merging jamais strips les, and reserve meta() pour page-level signals vous en réalité vouloir to override per route.

The loader() function: réel 404s, réel redirections, dynamic meta

Loaders run server-side seulement — DB requêtes, API calls, and secrets jamais reach Googlebot. The SEO payoff is correct Code d’état HTTPs:

// app/routes/products.$id.tsx
import { json, redirect } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ params }: LoaderFunctionArgs) {
  const product = await db.products.findById(params.id);
  if (!product) {
    throw new Response("Product not found", { status: 404 });
    // ✅ real HTTP 404 — not a soft 404
  }
  if (product.movedTo) {
    throw redirect(`/products/${product.movedTo}/`, 301); // ✅ real 301
  }
  return json(product);
}

Ce is the antidote to the soft-404 problem SPAs créer — a 200 OK page que simplement dit “not found.” As John Mueller put it: “Si une page renvoie a 200 but there’s aucun content — that’s a soft 404. That’s problematic parce que we don’t know to treat it as a 404. From a exploration standpoint, we’ll simplement garder trying to explorer it.” Remix’s throw new Response(..., { status: 404 }) propagates a genuine 404, qui lines up with Google’s instruction to “utiliser a meaningful code d’état, comme a 404 pour une page que pourrait pas be trouvé.”

Whatever a loader renvoie is exposed to the client même si the component doesn’t render it — treat loaders comme public API endpoints and don’t retourner secrets.

Ce status propagation is ce que se produit on a direct document requête (the cas a robot d’exploration rend). Client-side navigations après hydration are a separate code chemin que peut behave differently, and some hosting adapters rewrite or intercept thrown réponses — vérifier les deux the direct-request and post-hydration behavior on votre réel deployment plutôt que assuming it from the loader code alone.

The headers() function: Cache-Control and X-Robots-Tag

Per-route HTTP headers — the lever pour CDN mise en cache (faster TTFB → meilleur Core Web Vitals) and pour robots directives que fonctionner même on robots d’exploration que don’t lire the HTML corps:

import type { HeadersFunction } from "@remix-run/node";

// CDN-friendly caching
export const headers: HeadersFunction = () => ({
  "Cache-Control": "max-age=300, s-maxage=3600, stale-while-revalidate=86400",
});

// HTTP-level noindex — effective for Bingbot and non-rendering crawlers
export const headers: HeadersFunction = () => ({
  "X-Robots-Tag": "noindex, nofollow",
});

X-Robots-Tag: noindex at the header level reaches robots d’exploration que jamais parse the corps. By par défaut seulement the deepest route’s headers() runs in a nested tree, so the simplest pattern is to define headers on leaf routes seulement and éviter merge complexity.

A meta-noindex caveat worth knowing regardless of framework, from Martin Splitt: “The noindex tag peut causer Google to skip rendering entirely. So si you’re trying to noindex via JavaScript, vous may be creating a situation où Google jamais même runs the JavaScript to voir the noindex.” Remix sidesteps ce parce que the directive lands in the SSR’d <head> (or an HTTP header), pas in client-only JavaScript.

links() injects <link> elements (favicons, stylesheet preloads, static canonicals). But it has aucun accès to loader données — it’s static per route module:

export const links: LinksFunction = () => [
  { rel: "canonical", href: "https://example.com/canonical-url/" },
];

Pour dynamic canonicals — paginated, filtered, or parameterized URLs — utiliser meta() with tagName: "link", qui fait voir data, params, and location:

export const meta: MetaFunction<typeof loader> = ({ data }) => [
  {
    tagName: "link",
    rel: "canonical",
    href: `https://example.com/products/${data.product.slug}/`,
  },
];

The rule: static canonical → links(); dynamic canonical → meta() with tagName: "link". Ce matches Martin Splitt’s advice to définir canonicals in HTML plutôt que JavaScript — “définir votre canonical in the HTML, pas with JavaScript… it’s simplement plus fragile.” Les deux Remix approaches render into le serveur HTML. (Voir the balise canonical deep dive.)

Error boundaries and 404/5xx pages

Quand a loader, action, or component throws, the route’s ErrorBoundary renders — in placer, à l’intérieur the surviving layout (nav, footer stay). Paired with a thrown 404 Réponse, vous obtenir a réel 404 status and a usable page:

import { isRouteErrorResponse, useRouteError } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();
  if (isRouteErrorResponse(error) && error.status === 404) {
    return <div><h1>404 — Page Not Found</h1></div>;
  }
  return <div>Something went wrong.</div>;
}

A root-level ErrorBoundary in app/root.tsx catches anything route boundaries don’t. The myth que “throwing a 404 breaks the layout” is faux — the boundary renders dans the route hierarchy.

Sitemaps and robots.txt as routes

Remix has aucun built-in sitemap generator; vous construire les as resource routes. The bracket notation escapes the dot so l’URL is literally /sitemap.xml:

// app/routes/sitemap[.xml].tsx
export async function loader() {
  const posts = await db.posts.findMany({ where: { published: true } });
  const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${posts.map((p) => `  <url><loc>https://example.com/blog/${p.slug}/</loc><lastmod>${p.updatedAt.toISOString()}</lastmod></url>`).join("\n")}
</urlset>`;
  return new Response(body, {
    headers: { "Content-Type": "application/xml", "Cache-Control": "public, max-age=3600" },
  });
}

robots.txt follows the même app/routes/robots[.txt].tsx pattern, returning text/plain.

Remix, React Router, and the 2026 product split — a dated timeline

Ce has gotten plus layered since the original 2024 merge, so treat it as history plus current state plutôt que a unique fact:

  • Dec 2024 — Remix v2 merges into React Router v7. @remix-run/react and @remix-run/node consolidate into react-router; the @remix-run/* packages encore fonctionner as a bridge. What’s SEO-relevant from ce déplacer:
    • routes.ts — a routing config fichier; routes peut be défini programmatically pour plus flexible Structure d’URLs.
    • Static pre-rendering — individual routes peut opt into build-time HTML generation (SSG-style), or SPA mode — configuration choices, pas automatic.
    • End-to-end type safetyloader retourner types flow into meta(), reducing bugs in dynamic meta generation.
    • Vite — the par défaut bundler since Remix v2; route-level code splitting garde client bundles lean (bon pour TBT/LCP).
    • Ce que didn’t modifier: the meta()/loader()/headers()/links()/ ErrorBoundary pattern and the <Meta /> / <Links /> / <Scripts /> components in root.tsx. Existing Remix v2 SEO code carries over with minimal changements.
  • June 2026 — React Router v8 ships. React Router v7 remains security-supported; Remix v2 and React Router v6 are now end-of-life. The route module SEO API ce page documents (meta, loader, headers, links, ErrorBoundary) is the même shape à travers v7 and v8 — vérifier votre installed version’s changelog pour quelconque descriptor-level changements avant copying code verbatim.
  • Aussi as of 2026 — Remix is ne … plus simplement “the old name for React Router.” The Remix team describes React Router as leur React meta-framework, and Remix (now in a “Remix 3” beta) as a separate, newer full-stack framework — “un team, two projects.” Si you’re auditing or building on @remix-run/* / react-router route exports, ce article’s API guidance s’applique directement. Si you’re on Remix 3, vérifier its documentation independently — the meta/loader/headers/liens pattern décrit ici n’est pas confirmed to carry over unchanged.

Bottom line pour la plupart sites in production today: “Remix SEO” and “React Router SEO” are encore the même route API and the même practical topic. Simplement don’t assume que aussi covers Remix 3.

Vous encore don’t besoin dynamic rendering

Parce que Remix sert HTML on le serveur, vous jamais besoin Rendertron or Prerender.io. Google deprecated dynamic rendering anyway: “Dynamic rendering was a workaround and pas a long-term solution… it creates additional complexities and resource requirements.” SSR is the recommended chemin, and Remix donne it to vous by par défaut.

Auditing a Remix site

  • Vérifier the initial HTML with curl or View Source — pas DevTools Elements (qui montre the post-hydration DOM).
  • Vérifier réel Code d’état HTTPs on manquant pages (404, pas 200) and redirections (301/302).
  • Tester with GSC Inspection d’URL — Remix’s served HTML devrait match the rendered view.
  • Regarder pour nested-meta merge bugs — confirmer child pages encore carry the site-wide description and OG tags.
  • Validate JSON-LD with the Résultats enrichis Tester, and run a explorer (Ahrefs Site Audit, Screaming Frog) to catch blocked assets and depth problèmes.

None of the ci-dessus is a guarantee of indexation, rankings, or Core Web Vitals — SSR, correct code d’états, and clean metadata supprimer architecture-level risk; the rest encore dépend on content quality, the deployment adapter, and how the page performs contre everything sinon in the SERP.

Pour the base library underneath Remix, voir React SEO; pour the framework-agnostic rendering rules, voir JavaScript SEO and the JavaScript frameworks hub.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin a quote first.