Menu
remixtutorialscreenshot-apiog-imagereact

Remix Screenshot API Tutorial — Loaders, Resource Routes, and OG Images

SnapSharp Team·April 26, 2026·5 min read

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/sdk

Set the API key in .env:

SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY

Remix 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=604800

This 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

Remix Screenshot API Tutorial — Loaders, Resource Routes, and OG Images — SnapSharp Blog