ScreenshotNeo

BlogHow-to

How to Generate Open Graph Images in Remix

Generate reliable Remix OG images with route metadata, runtime rendering, hosted assets, or Playwright build steps—and fix stale previews.

By the ScreenshotNeo team29 September 20268 min read

How to Generate Open Graph Images in Remix

Use Remix route metadata to point og:image at an absolute image URL, then choose how that URL is produced: a runtime renderer for frequently changing content, a hosted asset for simple CDN delivery, or a build-time browser screenshot for maximum CSS and React fidelity. For most projects, define a fixed 1200×630 contract, generate a deterministic URL from the post slug, and validate the deployed HTML and image response with a crawler-style request.

Remix emits Open Graph tags through a route’s meta export. In nested routing, the last matching route with a meta export controls the result unless you explicitly merge parent descriptors. Remix documents this behavior. The image itself must be publicly reachable, return an image content type, and remain available without an application session.

1. Define an image contract before writing code

A contract prevents design and caching problems later. Use the 1200×630 canvas recommended in Vercel’s OG image documentation when you want broad social compatibility. Keep important text inside a safe margin, and make the visual output deterministic for a given content ID.

  • Canvas: 1200×630 pixels.
  • Input: a slug, database ID, or other stable content identifier.
  • Output: PNG, JPEG, or WebP, served from an absolute HTTPS URL.
  • Versioning: add a version or content hash to the URL when an image changes and crawlers may retain the old asset.
  • Accessibility of the page: the page title and description still belong in HTML metadata; text inside an image is not a substitute.

2. Add Open Graph metadata to a Remix route

A route can return title, description, canonical URL, and image descriptors from its meta function. The loader supplies post data, while the metadata function turns that data into absolute URLs.

The page metadata points crawlers to a separately generated image URL.
The page metadata points crawlers to a separately generated image URL.
import type { LoaderFunctionArgs, MetaFunction } 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 getPostBySlug(params.slug!);
  if (!post) throw new Response("Not found", { status: 404 });
  return json({
    title: post.title,
    excerpt: post.excerpt,
    slug: post.slug,
  });
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  const title = data?.title ?? "Site title";
  const description = data?.excerpt ?? "";
  const slug = data?.slug ?? "";
  const canonical = `https://example.com/posts/${slug}`;
  const image = `https://example.com/og/posts/${slug}.png`;

  return [
    { title },
    { name: "description", content: description },
    { property: "og:title", content: title },
    { property: "og:description", content: description },
    { property: "og:type", content: "article" },
    { property: "og:url", content: canonical },
    { property: "og:image", content: image },
    { property: "og:image:width", content: "1200" },
    { property: "og:image:height", content: "630" },
  ];
};

export default function PostRoute() {
  const post = useLoaderData<typeof loader>();
  return <article><h1>{post.title}</h1></article>;
}

If a child route exports meta, Remix does not automatically preserve every parent descriptor. Merge shared descriptors yourself when you need site-wide values. Inspect the final server-rendered HTML rather than assuming a parent title or image survived route matching.

3. Choose a generation architecture

Approach Best fit Trade-off
Runtime @vercel/og Titles, prices, metrics, or user-specific cards that change often Uses a supported CSS subset, so advanced browser layout may not render as expected
Hosted asset (for example, Cloudinary) Stable images and CDN delivery Generation and storage become an external service concern
Build-time browser screenshot Existing React components, fonts, and CSS where pixel fidelity matters Content changes require a rebuild or an explicit regeneration job

These choices reflect the documented patterns from Vercel, Cloudinary’s Remix guide, and the remix-og-image plugin.

4. Runtime generation with @vercel/og

Vercel’s library renders HTML and CSS with Satori and Resvg, supports flexbox, absolute positioning, custom fonts, wrapping, centering, and nested images, and adds cache headers. Treat its CSS subset as a constraint: test the exact layout you intend to publish.

import { ImageResponse } from "@vercel/og";

export const runtime = "edge";

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

  return new ImageResponse(
    (
      <div
        style={{
          width: "1200px",
          height: "630px",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: "72px",
          background: "#111827",
          color: "white",
          fontSize: 64,
          fontWeight: 700,
        }}
      >
        {title}
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Point the route metadata at the endpoint:

{ property: "og:image", content: `https://example.com/og/${data.slug}` }

Keep the endpoint publicly accessible and return a stable response for the same input. Cache immutable cards aggressively. For content that can change, include a version query or path segment and invalidate the old URL intentionally.

5. Hosted images with Cloudinary

A hosted workflow generates or uploads the card, then places its absolute delivery URL in og:image. Cloudinary’s Remix example identifies og:title, og:type, og:image, and og:url as the required Open Graph properties and also demonstrates og:description.

export const meta: MetaFunction<typeof loader> = ({ data }) => [
  { property: "og:title", content: data.post.title },
  { property: "og:type", content: "article" },
  { property: "og:url", content: `https://example.com/posts/${data.post.slug}` },
  {
    property: "og:image",
    content: `https://res.cloudinary.com/demo/image/upload/og/${data.post.slug}.png`,
  },
  { property: "og:description", content: data.post.excerpt },
];

Use a stable path for an unchanged card or a versioned path when the content changes. Verify that the delivery URL works without cookies or authorization.

6. Build-time browser screenshots with Playwright

The remix-og-image plugin visits routes in Chromium through Playwright, screenshots a selected element, and writes PNG, JPEG, or WebP files to the build output. It can handle dynamic entries, reuse your React and CSS, and upload files through a custom write hook.

npm i remix-og-image
npm i -D playwright
// app/routes/posts.$slug.tsx
export function openGraphImage() {
  return {
    element: "#og-card",
    path: "/og/posts/:slug.png",
  };
}

export default function OgCard() {
  return (
    <div id="og-card" style={{ width: 1200, height: 630 }}>
      {/* Use the same React components and CSS as your site. */}
    </div>
  );
}

Configure the plugin in your Vite setup according to its README, then ensure your route’s metadata references the generated asset path. This approach has no image-rendering invocation at request time, but a post edit must trigger a rebuild or regeneration step.

7. Decide between build time and request time

  1. Use runtime generation when the card includes rapidly changing data or personalization. Design within the renderer’s CSS and execution limits.
  2. Use hosted assets when you want simple absolute URLs, CDN delivery, and independent image storage.
  3. Use browser screenshots when your card depends on existing fonts, complex CSS, or exact browser rendering. Budget for Chromium in CI and rebuilds after content changes.

Consider freshness, personalization, rendering fidelity, cache behavior, deployment complexity, and runtime cost together. A static blog may prefer build-time files; a dashboard with user-specific previews generally needs a runtime endpoint.

8. Validation and crawler debugging

  1. Deploy the route and fetch its HTML with an unauthenticated request.
  2. Confirm there is one intended absolute og:image URL.
  3. Fetch that URL directly and verify a successful status and an image content type.
  4. Check that the image is actually 1200×630 (or the dimensions your contract specifies).
  5. Run the URL through the Facebook Sharing Debugger and an X/Twitter card preview workflow, as shown in Cloudinary’s guide.
  6. If a preview is stale, change the asset URL or cache version, then re-check the crawler-facing HTML.

Useful shell checks:

curl -L https://example.com/posts/my-post | rg 'og:image|og:title|og:url'
curl -I -L https://example.com/og/posts/my-post.png

9. Common errors and fixes

Symptom Likely cause Fix
No image in the preview The tag is missing, relative, or emitted only in client JavaScript Return an absolute URL from the server-side meta export and inspect deployed HTML
Parent metadata disappeared A nested route’s meta export replaced the matching descriptor list Merge parent descriptors explicitly
Image returns 404 Slug encoding, route parameters, or build output path is wrong Log the generated path, test the exact URL, and encode identifiers safely
Image returns HTML Authentication, redirect, or error middleware intercepted the request Allow unauthenticated access and confirm the final response content type
Runtime card has broken layout CSS is outside the renderer’s supported subset Replace unsupported layout rules with documented flexbox or absolute positioning
Text is clipped Unexpected title length or missing wrapping constraints Set a maximum width, test long titles, and provide a fallback font
Preview remains old Social crawler or CDN cache retained the previous URL Version the image URL and purge the relevant cache
Build fails in CI Chromium is unavailable or fonts/assets are not present Install Playwright browsers in CI and make every required resource available during the build

10. Performance, reliability, and cost notes

  • Cache deterministic output. A slug plus content version makes cache keys predictable and avoids rendering the same card repeatedly.
  • Keep assets self-contained. Runtime renderers may need local files or remote fetches for fonts and images; verify those resources from the deployment environment.
  • Limit input size. Truncate or wrap untrusted titles and descriptions so one request cannot create an unusable card.
  • Use timeouts and fallbacks. If a dynamic data lookup fails, return a valid generic card rather than an HTML error page.
  • Separate generation from delivery. Build-time files and hosted assets can be served from a CDN, while runtime endpoints should send cache headers appropriate to content mutability.
  • Observe crawler requests. Record status, generation duration, and cache hits without putting secrets into URLs or logs.
  • Respect bundle limits. Vercel documents a 500 KB bundle limit for its OG implementation; keep templates, fonts, and dependencies within the deployment limit.

11. Or skip the browser setup

ScreenshotNeo can capture a rendered Remix route with one request when you want the browser result without maintaining Playwright infrastructure. Its API accepts a URL and returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation for the complete option list.

A clean capture removes obstructing overlays before producing the preview asset.
A clean capture removes obstructing overlays before producing the preview asset.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/og/posts/my-post -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/og/posts/my-post"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/og/posts/my-post' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie and consent banners are accepted and removed before capture, along with more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and whether the shot was billed. You can also wait for a selector or network idle, run custom JavaScript, apply CSS, hide elements, choose a device or viewport, set a retina scale, use dark mode, capture one CSS-selected element, load lazy images, provide headers and cookies, and choose caching or signed links. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

The free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.

12. FAQ

Can I use a relative og:image URL?

Use an absolute HTTPS URL. Crawlers fetch the image independently of the page’s origin and may not resolve relative paths consistently.

Should every post have a unique image URL?

Use a deterministic URL per post, then add a version when the visual changes. This gives caches a stable key while allowing deliberate invalidation.

Does Remix generate the image itself?

No. Remix emits metadata. A runtime renderer, hosted asset pipeline, or browser screenshot process produces the image.

Can the image endpoint require a logged-in user?

No for public social previews. The crawler must retrieve it without application authentication.

What should I do when a title is extremely long?

Define a maximum line count or character length, wrap within a fixed width, and test the longest real titles before publishing.