Panduan Remix SEO

How Remix's meta(), loader(), headers(), dan tautan() route exports control SEO — plus what changed sebagai Remix v2 became React Router, now pada v8.

Pertama kali diterbitkan: 26 Jun 2026 · Terakhir diperbarui: 3 Agu 2026 · Advanced
Bahasa

TL;DR — Remix adalah SSR-oleh-default, so konten ships di initial HTML dengan no render-queue dependency — architecture menghapus sebagian besar JS-SEO risk when SSR adalah what’s actually deployed, though ini doesn’t oleh itself guarantee pengindeksan, peringkat, atau Core Web Vitals outcomes. berfungsi adalah di per-route exports: meta() (judul, deskripsi, OG, JSON-LD, dan dynamic canonicals via tagName: "link"), loader() (server data + nyata HTTP 404s/redirects), headers() (Cache-Control, X-Robots-Tag), dan links() (static canonicals, preloads). signature gotcha: nested routes drop parent meta unless Anda merge ini via matches argument. Naming, dated: Remix v2’s APIs merged ke React Router v7 di 2024; React Router adalah now pada v8 (v7 masih security-didukung); Remix 3 (beta) adalah sebuah separate, newer full-stack framework, not simply React Router renamed. ini halaman covers Remix v2 / React Router v7-v8 route API.

SSR oleh default — why Remix starts ahead

Remix’s server rendering adalah sebuah framework capability whose deployment perilaku depends pada adapter dan 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 delivered HTML dan resources alih-alih assuming sebuah framework default 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 note: walkthrough below documents meta() / loader() / headers() / links() / ErrorBoundary route module API sebagai ini exists di Remix v2 dan React Router v7-v8 framework mode — what besar majority dari production Remix/React Router situs run. Remix 3 (beta) adalah sebuah separate, ground-up rewrite dengan berbeda APIs dan isn’t covered here; periksa which one Anda’re actually pada sebelum copying anything below.

Bare React (CRA, Vite + React) ships sebuah empty <div id="root"></div> dan membangun halaman di browser. Google dapat render itu, tetapi Anda’ve signed up untuk render queue, statelessness, dan DOM-parity masalah covered di JavaScript SEO. Remix doesn’t memiliki itu default: ini executes Anda nested route tree (Root → Layout → Route) pada server dan, oleh default, mengirim konten-complete HTML pada setiap document permintaan. framework mode dapat juga menjadi configured untuk statically prerender routes di bangun time atau run sebagai sebuah client-rendered SPA — so untuk apa pun given deployment, confirm actual rendering mode (via curl/View Source, below) alih-alih assuming SSR dari framework name.

Google’s own guidance lines up dengan ini. dari JavaScript SEO basics doc: “Server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript.” (terjemahan) “server-side atau pre-rendering adalah masih sebuah great idea because ini membuat Anda situs web faster untuk pengguna dan crawler, dan not semua bot dapat run JavaScript.” itu last clause adalah whole argument untuk Remix — Bingbot renders JS slowly dan incompletely, dan banyak AI crawler dan social preview bot (Twitterbot, facebookexternalhit) don’t render di semua. Remix’s SSR output menyajikan semua dari them correctly without special handling.

setelah initial muat, Remix menggunakan client-side routing untuk subsequent navigations (like setiap React framework). itu’s fine: Google discovers tautan dari SSR’d HTML dan melakukan crawl setiap URL sebagai -nya own server permintaan — setiap returning full HTML. ini doesn’t rely pada watching client-side route transitions. dan because Remix’s <Link> renders sebuah nyata <a href>, tautan adalah dapat di-crawl oleh construction — Google “can only discover your links if they are <a> HTML elements with an href attribute.” (terjemahan) “dapat hanya menemukan Anda tautan jika mereka adalah undefined HTML elements dengan sebuah undefined attribute.”

meta() export: judul, deskripsi, OG, JSON-LD

setiap route dapat export sebuah meta function returning sebuah array dari descriptor objects. Because ini dapat read loader data, metadata adalah dynamic dan set di initial HTML <head> — no 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 },
      },
    },
  ];
};

{ "script:ld+json": {...} } descriptor renders sebuah proper <script type="application/ld+json"> tag di SSR’d <head> — terlihat untuk Google’s parser without executing apa pun JavaScript. Martin Splitt’s preference applies: “We support JSON-LD in dynamically rendered content, but it’s generally better to have it in the initial HTML.” (terjemahan) “kami mendukung JSON-LD di dynamically rendered konten, tetapi ini adalah umumnya better untuk memiliki ini di initial HTML.” di Remix, initial HTML adalah default place ini lands.

Nested routes dan meta inheritance — umum trap

ini adalah Remix SEO mistake. Remix takes last matching route dengan sebuah 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" }];
};

fix adalah matches argument — flatten dan 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" }];
};

jika Anda set global tags, put truly universal ones (charset, viewport) directly di root.tsx’s JSX where merging tidak pernah strips them, dan reserve meta() untuk halaman-tingkat signals Anda actually ingin untuk override per route.

loader() function: nyata 404s, nyata redirects, dynamic meta

Loaders run server-side hanya — DB kueri, API panggilan, dan secrets tidak pernah reach Googlebot. SEO payoff adalah correct HTTP kode status:

// 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);
}

ini adalah antidote untuk soft-404 masalah SPAs buat — sebuah 200 OK halaman itu hanya says “not found.” (terjemahan) “tidak ditemukan.” sebagai John Mueller put ini: “If a page returns a 200 but there’s no content — that’s a soft 404. That’s problematic because we don’t know to treat it as a 404. From a crawling standpoint, we’ll just keep trying to crawl it.” (terjemahan) “jika sebuah halaman mengembalikan sebuah 200 tetapi there’s tanpa konten — itu’s sebuah soft 404. itu’s problematic because kami don’t know untuk treat ini sebagai sebuah 404. dari sebuah crawling standpoint, kami’ll hanya pertahankan trying untuk crawl ini.” Remix’s throw new Response(..., { status: 404 }) propagates sebuah genuine 404, which lines up dengan Google’s instruction untuk “use a meaningful status code, like a 404 for a page that could not be found.” (terjemahan) “gunakan meaningful kode status, like sebuah 404 untuk sebuah halaman itu dapat not menjadi ditemukan.”

Whatever sebuah loader mengembalikan adalah exposed untuk client bahkan jika component doesn’t render ini — treat loaders like public API endpoints dan don’t kembalikan secrets.

ini status propagation adalah what happens pada sebuah direct document permintaan ( case sebuah crawler membuat). Client-side navigations setelah hydration adalah sebuah separate code path itu dapat behave differently, dan beberapa hosting adapters rewrite atau intercept thrown respons — verify both direct-permintaan dan post-hydration perilaku pada Anda actual deployment alih-alih assuming ini dari loader code alone.

headers() function: Cache-Control dan X-Robots-Tag

Per-route header HTTP — lever untuk CDN caching (faster TTFB → better Core Web Vitals) dan untuk robots directives itu berfungsi bahkan pada crawler itu don’t read HTML body:

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 di header tingkat reaches crawler itu tidak pernah parse body. oleh default hanya deepest route’s headers() runs di sebuah nested tree, so simplest pattern adalah untuk define headers pada leaf routes hanya dan hindari merge complexity.

sebuah meta-noindex caveat worth knowing regardless dari framework, dari Martin Splitt: “The noindex tag can cause Google to skip rendering entirely. So if you’re trying to noindex via JavaScript, you may be creating a situation where Google never even runs the JavaScript to see the noindex.” (terjemahan) “ undefined tag dapat cause Google untuk skip rendering entirely. So jika Anda’re trying untuk undefined via JavaScript, Anda dapat menjadi membuat sebuah situation where Google tidak pernah bahkan runs JavaScript untuk see noindex.” Remix sidesteps ini because directive lands di SSR’d <head> (atau sebuah header HTTP), not di client-hanya JavaScript.

links() injects <link> elements (favicons, stylesheet preloads, static canonicals). tetapi ini memiliki no access untuk loader data — ini adalah static per route module:

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

untuk dynamic canonicals — paginated, filtered, atau parameterized URLs — gunakan meta() dengan tagName: "link", which melakukan see data, params, dan location:

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

aturan: static canonical → links(); dynamic canonical → meta() dengan tagName: "link". ini matches Martin Splitt’s advice untuk set canonicals di HTML alih-alih JavaScript — “set your canonical in the HTML, not with JavaScript… it’s just more fragile.” (terjemahan) “set Anda canonical di HTML, not dengan JavaScript… ini adalah hanya more fragile.” Both Remix approaches render ke server HTML. (See tag canonical deep dive.)

Error boundaries dan 404/5xx halaman

When sebuah loader, tindakan, atau component throws, route’s ErrorBoundary renders — di place, inside surviving layout (nav, footer stay). Paired dengan sebuah thrown 404 respons, Anda get sebuah nyata 404 status dan sebuah usable halaman:

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>;
}

sebuah root-tingkat ErrorBoundary di app/root.tsx catches anything route boundaries don’t. myth itu “throwing a 404 breaks the layout” (terjemahan) “throwing sebuah 404 breaks layout” adalah salah — boundary renders di dalam route hierarchy.

Sitemaps dan robots.txt sebagai routes

Remix memiliki no dibangun-di sitemap generator; Anda bangun them sebagai resource routes. bracket notation escapes dot so URL adalah 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 mengikuti yang sama app/routes/robots[.txt].tsx pattern, returning text/plain.

Remix, React Router, dan 2026 product split — sebuah dated timeline

ini memiliki gotten more layered since original 2024 merge, so treat ini sebagai history plus saat ini state alih-alih sebuah single fact:

  • Dec 2024 — Remix v2 merges ke React Router v7. @remix-run/react dan @remix-run/node consolidate ke react-router; @remix-run/* packages masih berfungsi sebagai sebuah bridge. What’s SEO-relevant dari ini move:
  • routes.ts — sebuah routing config file; routes dapat menjadi defined programmatically untuk more flexible struktur URL.
  • Static pre-rendering — individual routes dapat opt ke bangun-time HTML generation (SSG-style), atau SPA mode — configuration choices, not automatic.
  • End-untuk-end jenis safetyloader kembalikan jenis flow ke meta(), reducing bugs di dynamic meta generation.
  • Vite — default bundler since Remix v2; route-tingkat code splitting mempertahankan client bundles lean (baik untuk TBT/LCP).
  • What didn’t perubahan: meta()/loader()/headers()/links()/ ErrorBoundary pattern dan <Meta /> / <Links /> / <Scripts /> components di root.tsx. Existing Remix v2 SEO code carries di atas dengan minimal perubahan.
  • June 2026 — React Router v8 ships. React Router v7 remains security-didukung; Remix v2 dan React Router v6 adalah now end-dari-life. route module SEO API ini halaman documents (meta, loader, headers, links, ErrorBoundary) adalah yang sama shape di seluruh v7 dan v8 — periksa Anda installed versi’s changelog untuk apa pun descriptor-tingkat perubahan sebelum copying code verbatim.
  • juga sebagai dari 2026 — Remix adalah no longer hanya “the old name for React Router.” (terjemahan) “ old name untuk React Router.” Remix team describes React Router sebagai mereka React meta-framework, dan Remix (now di sebuah “Remix 3” (terjemahan) “Remix 3” beta) sebagai sebuah separate, newer full-stack framework — “one team, two projects.” (terjemahan) “one team, two projects.” jika Anda’re auditing atau membangun pada @remix-run/* / react-router route exports, ini artikel’s API guidance applies directly. jika Anda’re pada Remix 3, verify -nya documentation independently — meta/loader/headers/tautan pattern described here adalah not confirmed untuk carry di atas unchanged.

Bottom line untuk sebagian besar situs di production today: “Remix SEO” (terjemahan) “Remix SEO” dan “React Router SEO” (terjemahan) “React Router SEO” adalah masih yang sama route API dan yang sama practical topic. hanya don’t assume itu juga covers Remix 3.

Anda masih don’t perlu dynamic rendering

Because Remix menyajikan HTML pada server, Anda tidak pernah perlu Rendertron atau Prerender.io. Google deprecated dynamic rendering anyway: “Dynamic rendering was a workaround and not a long-term solution… it creates additional complexities and resource requirements.” (terjemahan) “Dynamic rendering adalah sebuah workaround dan not sebuah panjang-istilah solusi… ini membuat additional complexities dan resource requirements.” SSR adalah recommended path, dan Remix gives ini untuk Anda oleh default.

Auditing sebuah Remix situs

  • periksa initial HTML dengan curl atau View Source — not DevTools Elements (which menampilkan post-hydration DOM).
  • Verify nyata HTTP kode status pada missing halaman (404, not 200) dan redirects (301/302).
  • Test dengan GSC pemeriksaan URL — Remix’s disajikan HTML seharusnya match rendered view.
  • cari nested-meta merge bugs — confirm child halaman masih carry situs-wide deskripsi dan OG tags.
  • Validate JSON-LD dengan Rich hasil Test, dan run sebuah crawl (Ahrefs situs Audit, Screaming Frog) untuk catch blocked assets dan depth issues.

None dari above adalah sebuah guarantee dari pengindeksan, rankings, atau Core Web Vitals — SSR, correct kode status, dan clean metadata hapus architecture-tingkat risk; rest masih depends pada konten quality, deployment adapter, dan how halaman performs terhadap everything else di SERP.

untuk base library underneath Remix, see React SEO; untuk framework-agnostic rendering aturan, see JavaScript SEO dan JavaScript frameworks hub.

Add an expert note

Pin an expert quote

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