Remix's "the route is the API" mental model lines up cleanly with screenshot generation. Resource routes return any Response, loaders run server-side with full env access, and the framework gets out of your way. There's no special "OG image" convention to learn — you just write a route that returns image/png.
This tutorial walks through three real Remix integrations: a resource route for arbitrary screenshots, dynamic OG images for blog posts, and a loader-based preview system for a directory app. Code is copy-pasteable for Remix v2.
Prerequisites
- Node.js 20+ and pnpm.
- A Remix v2 app (
npx create-remix@latest). - A free SnapSharp API key from snapsharp.dev/sign-up.
- Basic familiarity with
loader,action, and resource routes.
Step 1: install the SDK and configure env
pnpm add @snapsharp/sdkSet the API key in .env:
SNAPSHARP_API_KEY=sk_live_YOUR_API_KEYRemix doesn't auto-load .env in production — but it does in dev via remix dev. For production, use your hosting platform's secret manager (Fly, Vercel, Render, etc.).
Centralize the client in app/lib/snapsharp.server.ts. The .server.ts suffix is a Remix convention that guarantees the file is excluded from client bundles.
import { SnapSharp } from '@snapsharp/sdk';
if (!process.env.SNAPSHARP_API_KEY) {
throw new Error('SNAPSHARP_API_KEY is required');
}
export const snap = new SnapSharp(process.env.SNAPSHARP_API_KEY);If you forget .server.ts, Remix will warn during build that you're importing server-only code into a client component. Don't ignore that warning — your API key would leak.
Step 2: a resource route for screenshots
Resource routes are routes that don't render UI. They return any Response. Perfect for binary content like images.
Create app/routes/api.screenshot.tsx:
import type { LoaderFunctionArgs } from '@remix-run/node';
import { snap } from '~/lib/snapsharp.server';
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const target = url.searchParams.get('url');
if (!target) {
return Response.json({ error: 'url required' }, { status: 400 });
}
try {
const image = await snap.screenshot(target, {
width: 1280,
height: 720,
format: 'png',
blockAds: true,
});
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
} catch (err) {
console.error('Screenshot failed:', err);
return Response.json({ error: 'capture failed' }, { status: 502 });
}
}Visit /api/screenshot?url=https://github.com and you'll see the captured image. The Cache-Control header tells Fly's edge or Vercel's CDN to serve cached images for a day, with stale-while-revalidate fallback.
Step 3: dynamic OG images per blog post
Remix doesn't have an opengraph-image convention — instead, you build it as a resource route and reference it from the page's <meta> tags. This is actually more flexible than Next.js's convention because you control caching and headers explicitly.
app/routes/blog.$slug.og[.png].tsx:
import type { LoaderFunctionArgs } from '@remix-run/node';
import { snap } from '~/lib/snapsharp.server';
import { getPost } from '~/lib/posts.server';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
if (!post) {
return new Response('Not found', { status: 404 });
}
const image = await snap.ogImage({
template: 'blog-post',
variables: {
title: post.title,
author: post.author,
date: post.date,
tag: post.tags?.[0] ?? '',
},
width: 1200,
height: 630,
});
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
},
});
}The unusual filename blog.$slug.og[.png].tsx matches the URL /blog/:slug/og.png. The [.png] escapes the dot as a literal character — Remix routing treats unescaped dots as nested segments.
Now reference it from the post page's meta function:
// 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';
import { getPost } from '~/lib/posts.server';
export async function loader({ params, request }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
if (!post) throw new Response('Not found', { status: 404 });
const origin = new URL(request.url).origin;
return json({ post, ogImage: `${origin}/blog/${params.slug}/og.png` });
}
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [
{ title: data.post.title },
{ property: 'og:title', content: data.post.title },
{ property: 'og:description', content: data.post.excerpt },
{ property: 'og:image', content: data.ogImage },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: data.ogImage },
];
};
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}When LinkedIn or Twitter scrapes the post URL, they'll find the og:image tag pointing at your generated PNG. See OG image best practices for design guidance.
Step 4: loader-based preview pattern
For a directory app where each entry shows a screenshot of its target URL, use a loader to compute the screenshot URL (not the binary itself) and let the browser fetch it via your resource route.
// app/routes/sites.$domain.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const domain = params.domain!;
if (!/^[a-z0-9.-]+$/i.test(domain)) {
throw new Response('Invalid domain', { status: 400 });
}
const targetUrl = `https://${domain}`;
const screenshotUrl = `/api/screenshot?url=${encodeURIComponent(targetUrl)}`;
return json({ domain, targetUrl, screenshotUrl });
}
export default function Site() {
const { domain, targetUrl, screenshotUrl } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl p-6">
<h1 className="text-3xl font-bold">{domain}</h1>
<a className="text-blue-600 underline" href={targetUrl} target="_blank" rel="noreferrer">
{targetUrl}
</a>
<img
src={screenshotUrl}
alt={`Screenshot of ${domain}`}
width={1280}
height={720}
loading="lazy"
className="mt-4 w-full rounded-lg border"
/>
</main>
);
}The loader hands a URL to the client. The browser fetches that URL, hits your resource route, and gets a cached image. Because the URL is deterministic, repeat views skip SnapSharp entirely and serve from the CDN.
Step 5: production patterns
Resource routes with form actions
Sometimes you want users to submit a URL via a form and immediately see the screenshot. Use Remix's form action to validate and redirect:
// app/routes/preview.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { Form } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const data = await request.formData();
const url = String(data.get('url') ?? '');
try {
new URL(url);
} catch {
throw new Response('Invalid URL', { status: 400 });
}
return redirect(`/preview/result?url=${encodeURIComponent(url)}`);
}
export default function PreviewForm() {
return (
<Form method="post">
<input name="url" type="url" required placeholder="https://example.com" />
<button type="submit">Preview</button>
</Form>
);
}The result page reads the URL from the query string and embeds the resource route's image. No client-side JavaScript needed.
Caching with platform headers
The Cache-Control header on resource routes is the difference between "we're paying SnapSharp every page view" and "we pay once, the CDN serves the rest." Use:
Cache-Control: public, max-age=86400, stale-while-revalidate=604800This tells edge caches (Fly, Vercel, Cloudflare) to keep the image hot for 24 hours and serve stale for 7 days while revalidating in the background.
Error boundaries
Remix has built-in error boundaries via ErrorBoundary exports. For our resource route, a thrown Response propagates to the parent route's error boundary. You can render a fallback image inline:
export function ErrorBoundary() {
return (
<div className="rounded-lg bg-gray-100 p-6 text-gray-500">
Preview unavailable. Try again later.
</div>
);
}Rate limiting
For public preview features, add a per-IP limit:
// app/lib/ratelimit.server.ts
const buckets = new Map<string, { count: number; reset: number }>();
export function rateLimit(ip: string, limit = 10, windowMs = 60_000) {
const now = Date.now();
const bucket = buckets.get(ip);
if (!bucket || bucket.reset < now) {
buckets.set(ip, { count: 1, reset: now + windowMs });
return true;
}
if (bucket.count >= limit) return false;
bucket.count++;
return true;
}Use it in the resource route:
const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown';
if (!rateLimit(ip)) {
return Response.json({ error: 'rate limited' }, { status: 429 });
}For multi-instance production deploys, swap the Map for Redis.
Step 6: deploying
Fly.io — set the secret with fly secrets set SNAPSHARP_API_KEY=sk_live_.... Fly's edge network caches resource route responses based on Cache-Control automatically.
Vercel — add the env var in project settings. To pin function region near SnapSharp (EU), use vercel.json:
{
"functions": {
"api/screenshot.func": { "regions": ["fra1"] }
}
}Self-hosted Node — use a process manager (PM2, systemd) and pass env vars via your deployment script. No special config.
Common pitfalls
Pitfall 1: forgetting .server.ts. Importing ~/lib/snapsharp (without .server) into a component bundles it for the client. Your API key ships in JS. Always use the .server.ts suffix for any module that touches secrets.
Pitfall 2: missing meta tags. OG images need to be in HTML, not injected by client JS. Social scrapers don't run JavaScript. Use Remix's meta export, not useEffect.
Pitfall 3: dot escaping in route filenames. blog.$slug.og.png.tsx becomes /blog/:slug/og/png — wrong. Use [.png] to escape: blog.$slug.og[.png].tsx → /blog/:slug/og.png.
Pitfall 4: resource route timeouts. Vercel free tier kills functions at 10s. Full-page screenshots of huge pages exceed that. Use full_page_max_height: 8000 to cap height, or switch to /v1/async/screenshot for jobs that need more time.
Pitfall 5: caching wrong query strings. If you put a timestamp or session ID in the screenshot URL, every request bypasses the cache. Keep params stable.
Final code
Three files:
app/lib/snapsharp.server.ts— typed client.app/routes/api.screenshot.tsx— resource route.app/routes/blog.$slug.og[.png].tsx— per-post OG image.
Plus the meta export on your post page. ~70 lines total.
Conclusion
Remix's resource routes are made for binary content. The Response API is native, headers are explicit, and there's no framework-specific OG convention to fight against. You write three small handlers and you have screenshots, OG images, and previews — caching, error boundaries, and platform integrations all included.
Next steps: explore the OG image API, read about custom OG templates, or compare patterns in the Next.js App Router tutorial.
Related: OG image best practices · Pricing · Screenshot API comparison 2026