Remix vs Next.js OG Images: How Open Graph Meta Tags Work in Each Framework

Comparing how Remix and Next.js handle open graph meta tags, og:image generation, and social preview cards — with code examples for both frameworks and when to use each approach.

Updated March 2026

The Core Difference: Metadata APIs

Both Remix and Next.js render HTML on the server, so social crawlers receive meta tags in the initial response. But their APIs for setting those tags differ significantly in design philosophy.

Next.js App Router uses a typed Metadata object (or generateMetadata function) exported from each page file. The framework merges this with parent layout metadata.

Remix uses a meta export function that returns an array of tag objects. Remix routes merge (or override) metadata from parent routes via route meta chain.

Next.js App Router: OG Tags

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

type Props = { params: { slug: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await fetchPost(params.slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [
        {
          url: `https://mysite.com/og?title=${encodeURIComponent(post.title)}`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
      type: "article",
      publishedTime: post.publishedAt,
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [`https://mysite.com/og?title=${encodeURIComponent(post.title)}`],
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const post = await fetchPost(params.slug);
  return <article>{/* render post */}</article>;
}

Next.js handles the <head> injection automatically. You never write <meta> tags manually — the framework generates them from the Metadata object.

Next.js Pages Router: OG Tags

// pages/blog/[slug].tsx
import Head from "next/head";
import { GetStaticProps } from "next";

export default function BlogPost({ post }) {
  const ogImageUrl = `https://mysite.com/og?title=${encodeURIComponent(post.title)}`;
  return (
    <>
      <Head>
        <title>{post.title}</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={ogImageUrl} />
        <meta property="og:image:width" content="1200" />
        <meta property="og:image:height" content="630" />
        <meta property="og:type" content="article" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content={ogImageUrl} />
      </Head>
      <article>{/* render post */}</article>
    </>
  );
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetchPost(params.slug as string);
  return { props: { post } };
};

Remix: OG Tags

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

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await fetchPost(params.slug!);
  return json({ post });
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data?.post) return [{ title: "Post not found" }];

  const { post } = data;
  const ogImageUrl = `https://mysite.com/og?title=${encodeURIComponent(post.title)}`;

  return [
    { title: post.title },
    { name: "description", content: post.excerpt },
    { property: "og:title", content: post.title },
    { property: "og:description", content: post.excerpt },
    { property: "og:image", content: ogImageUrl },
    { property: "og:image:width", content: "1200" },
    { property: "og:image:height", content: "630" },
    { property: "og:type", content: "article" },
    { name: "twitter:card", content: "summary_large_image" },
    { name: "twitter:image", content: ogImageUrl },
  ];
};

export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();
  return <article>{/* render post */}</article>;
}

Dynamic OG Image Generation

Next.js: @vercel/og

Next.js has first-class support for dynamic OG images via the ImageResponse API (built on Satori). See the dynamic OG images guide for a full walkthrough.

// app/og/route.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") ?? "Untitled";
  return new ImageResponse(<div style={{ fontSize: 64 }}>{title}</div>, {
    width: 1200,
    height: 630,
  });
}

Remix: Satori-based Route

Remix doesn't have built-in OG image generation, but you can create a resource route that returns a PNG using Satori directly:

// app/routes/og.tsx (resource route — no default export)
import type { LoaderFunctionArgs } from "@remix-run/node";
import satori from "satori";
import sharp from "sharp";
import fs from "fs";

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const title = url.searchParams.get("title") ?? "Untitled";

  const fontData = fs.readFileSync("./public/fonts/Inter-Bold.ttf");

  const svg = await satori(
    <div style={{ background: "#0f0f0f", color: "white", fontSize: 60, padding: 80 }}>
      {title}
    </div>,
    { width: 1200, height: 630, fonts: [{ name: "Inter", data: fontData, weight: 700 }] }
  );

  const png = await sharp(Buffer.from(svg)).png().toBuffer();

  return new Response(png, {
    headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=31536000" },
  });
}

Side-by-Side Comparison

FeatureNext.js App RouterRemix
Meta APITyped Metadata object / generateMetadata()meta export returning array of objects
Type safety✅ Full TypeScript types via Next.js⚠️ Partial (tag array, not structured)
Dynamic OG images✅ Built-in ImageResponse / @vercel/og⚠️ Manual Satori resource route required
Async metadataasync generateMetadata()✅ Via loader data passed to meta()
Layout metadata✅ Automatic merging from layouts⚠️ Manual parent metadata matching
Head injectionAutomatic — no <Head> neededAutomatic via <Meta /> component in root

Which Should You Use?

Choose Next.js if: you want first-class TypeScript metadata types, built-in dynamic OG image generation, automatic layout metadata merging, and tight Vercel integration.

Choose Remix if: you prioritize web standards, prefer explicit data loading, and are deploying to non-Vercel platforms. Remix's meta API is more verbose for OG tags but works well once set up.

For OG image generation specifically, Next.js has the edge due to the built-in ImageResponse API — but Remix can achieve the same with a Satori resource route.

Verify Your OG Tags Are Working

Whichever framework you choose, verify your OG tags with OGFixer to see live previews of your pages in Twitter, LinkedIn, Discord, and Slack card formats.

Test your Remix or Next.js OG tags →

Preview my OG tags →