Remix v3 (React Router v7) Open Graph: Meta Tags With the New Routing
How to add og:title, og:image, and og:description in Remix v3 / React Router v7 — with the new meta() export, loader data, and framework mode SSR.
What changed in Remix v3 / React Router v7
Remix v3 was released as React Router v7, completing the convergence of Remix and React Router. The meta API is largely unchanged from Remix v2 — meta() still works the same way — but the project structure and framework mode conventions changed.
If you're migrating from Remix v2, your OG tag code should work with minimal changes. The main changes are in the routing file structure and how you declare loader types.
OG tags in React Router v7 (framework mode)
// app/routes/blog.$slug.tsx
import type { Route } from "./+types/blog.$slug";
// Loader fetches data server-side
export async function loader({ params }: Route.LoaderArgs) {
const post = await getPost(params.slug);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return { post };
}
// meta() receives loader data
export function meta({ data }: Route.MetaArgs) {
if (!data) {
return [{ title: "Post Not Found" }];
}
const { post } = data;
const ogImage = post.ogImage || `https://yoursite.com/og?title=${encodeURIComponent(post.title)}`;
return [
{ title: post.seoTitle || post.title },
{ name: "description", content: post.excerpt },
// Open Graph
{ property: "og:title", content: post.seoTitle || post.title },
{ property: "og:description", content: post.excerpt },
{ property: "og:image", content: ogImage },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:type", content: "article" },
{ property: "og:url", content: `https://yoursite.com/blog/${post.slug}` },
{ property: "article:published_time", content: post.publishedAt },
// Twitter Card
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: post.seoTitle || post.title },
{ name: "twitter:description", content: post.excerpt },
{ name: "twitter:image", content: ogImage },
];
}
export default function BlogPost({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Route configuration (React Router v7)
React Router v7 uses a app/routes.ts file for route configuration instead of file-based routing (though file conventions are also available):
// app/routes.ts
import { type RouteConfig, route, layout, index } from "@react-router/dev/routes";
export default [
layout("layouts/main.tsx", [
index("routes/home.tsx"),
route("blog/:slug", "routes/blog.$slug.tsx"),
route("about", "routes/about.tsx"),
]),
] satisfies RouteConfig;Global default OG in root.tsx
// app/root.tsx
import type { Route } from "./+types/root";
import { Links, Meta, Outlet, Scripts } from "react-router";
// Default metadata for all pages
export function meta(): Route.MetaDescriptors {
return [
{ title: "My Site" },
{ name: "description", content: "Default site description" },
{ property: "og:title", content: "My Site" },
{ property: "og:description", content: "Default site description" },
{ property: "og:image", content: "https://yoursite.com/og/default.png" },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ name: "twitter:card", content: "summary_large_image" },
];
}
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Links />
<Meta />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}Dynamic OG image route in React Router v7
// app/routes/og.tsx (resource route)
import type { Route } from "./+types/og";
import { renderToStaticMarkup } from "react-dom/server";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const title = url.searchParams.get("title") || "My Site";
// Using satori for edge-like OG image generation in RR7
const { default: satori } = await import("satori");
const { default: sharp } = await import("sharp");
const svg = await satori(
<div
style={{
display: "flex",
background: "#0a0a0a",
width: "100%",
height: "100%",
padding: "60px",
flexDirection: "column",
justifyContent: "flex-end",
}}
>
<div style={{ fontSize: 56, fontWeight: 900, color: "#fff" }}>
{title}
</div>
<div style={{ fontSize: 24, color: "#8b5cf6", marginTop: 16 }}>
yoursite.com
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [], // add loaded fonts here
}
);
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return new Response(png, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}Migrating from Remix v2 to v3
- meta() function: Identical API — no changes needed
- loader return types: Use new
Route.LoaderArgstype from./+types/route-name - useLoaderData → loaderData prop: React Router v7 passes loader data as a component prop (
loaderData) instead of a hook - File routing: If using file-based routing, the
flat-filesconvention still works - OG images: Resource routes work identically — just update the Route type imports
Test your React Router v7 OG tags
After deploying, paste any page URL into OGFixer to verify your OG tags render correctly on Twitter, LinkedIn, Discord, and Slack.
Check your OG tags free →