OptStuff

Next.js

Integrate OptStuff into a Next.js App Router project with server-side URL signing.

Working demo — The examples/nextjs project implements all patterns below in a running app, including blur-to-sharp transitions and server-side caching. Clone it and run pnpm dev to see OptStuff in action.

How It Works

OptStuff optimizes images through signed URLs. The integration flow is:

  1. Your server generates a signed URL containing the source image, desired operations (resize, format, etc.), and a cryptographic signature
  2. The browser requests the signed URL from the OptStuff CDN
  3. OptStuff validates the signature, fetches and transforms the source image, then returns the optimized result

All signing happens server-side so your secret key is never exposed to the browser.

Environment Variables

Add to .env:

OPTSTUFF_BASE_URL="https://your-optstuff-deployment.com"
OPTSTUFF_PROJECT_SLUG="your-project-slug"

# Safe to appear in URLs (?key=pk_…), but keep in server env for centralized config
OPTSTUFF_PUBLIC_KEY="pk_abc123..."

# Secret — never expose in browser bundles
OPTSTUFF_SECRET_KEY="sk_your_secret_key_here"

Signing Utility

Create lib/optstuff-core.ts — this is the core building block that all integration patterns below depend on.

The "server-only" import ensures this module can never be bundled into client code:

import "server-only";
import crypto from "crypto";

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value || value.includes("xxx") || value.includes("your-")) {
    throw new Error(
      `Missing or placeholder env var: ${name}. Set it in .env.local`,
    );
  }
  return value;
}

const OPTSTUFF_BASE_URL = requireEnv("OPTSTUFF_BASE_URL").replace(/\/+$/, "");
const OPTSTUFF_PROJECT_SLUG = requireEnv("OPTSTUFF_PROJECT_SLUG");
const OPTSTUFF_PUBLIC_KEY = requireEnv("OPTSTUFF_PUBLIC_KEY");
const OPTSTUFF_SECRET_KEY = requireEnv("OPTSTUFF_SECRET_KEY");
const EXPIRY_BUCKET_SECONDS = 3600;

export type ImageOperation = {
  width?: number;
  height?: number;
  quality?: number;
  format?: "webp" | "avif" | "png" | "jpg";
  fit?: "cover" | "contain" | "fill";
};

function computeBucketedExpiration(expiresInSeconds: number): number {
  const nowSeconds = Math.floor(Date.now() / 1000);
  const ttlSeconds = Math.max(1, Math.floor(expiresInSeconds));
  const bucketSeconds = Math.min(EXPIRY_BUCKET_SECONDS, ttlSeconds);
  const rawExpiration = nowSeconds + ttlSeconds;
  const bucketed =
    bucketSeconds > 0 && bucketSeconds < ttlSeconds
      ? Math.floor(rawExpiration / bucketSeconds) * bucketSeconds
      : rawExpiration;
  return Math.max(nowSeconds + 1, bucketed);
}

function buildOperationString(operations: ImageOperation): string {
  const parts: string[] = [];
  if (operations.width) parts.push(`w_${operations.width}`);
  if (operations.height) parts.push(`h_${operations.height}`);
  if (operations.quality) parts.push(`q_${operations.quality}`);
  if (operations.format) parts.push(`f_${operations.format}`);
  if (operations.fit) parts.push(`fit_${operations.fit}`);
  return parts.length > 0 ? parts.join(",") : "_";
}

/**
 * Generates a signed image optimization URL.
 *
 * @param imageUrl - Source image URL (e.g., "https://images.example.com/photo.jpg")
 * @param operations - Image operations to apply
 * @param expiresIn - Signature validity in seconds (optional)
 */
export function generateOptStuffUrl(
  imageUrl: string,
  operations: ImageOperation = {},
  expiresIn?: number
): string {
  const operationString = buildOperationString(operations);

  const parsed = new URL(imageUrl);
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error("imageUrl must use http or https");
  }
  const normalizedImageUrl = `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}${parsed.pathname.replace(/\/+$/, "")}`;

  const path = `${operationString}/${normalizedImageUrl}`;

  const params = new URLSearchParams();
  params.set("key", OPTSTUFF_PUBLIC_KEY);

  let exp: number | undefined;
  if (expiresIn != null && expiresIn > 0) {
    exp = computeBucketedExpiration(expiresIn);
    params.set("exp", exp.toString());
  }

  const signPayload = exp != null ? `${path}?exp=${exp}` : path;
  const sig = crypto
    .createHmac("sha256", OPTSTUFF_SECRET_KEY)
    .update(signPayload)
    .digest("base64url")
    .substring(0, 32);

  params.set("sig", sig);

  return `${OPTSTUFF_BASE_URL}/api/v1/${OPTSTUFF_PROJECT_SLUG}/${path}?${params.toString()}`;
}

computeBucketedExpiration keeps signatures stable within each bucket window, improving CDN cache hit rates while still bounding the TTL. The final Math.max ensures the expiration is always in the future, even under edge-case rounding.

The snippet above is intentionally minimal. The examples/nextjs project includes a hardened version with input validation, query-string encoding, and allowed-format whitelists — use it as a starting point for production.

Choose Your Integration Pattern

All three options use the same signing utility above. Pick the one that fits your use case:

PatternBest forHow signing works
Option 1: Server ComponentStatic or SSR pagesSign at render time
Option 2: API Route + ClientClient-side dynamic imagesClient calls your API Route to get a signed URL
Option 3: next/image LoaderFull next/image features without Vercel chargesLoader points to your API Route; replaces Vercel Image Optimization

Option 1: Server Component

The simplest approach — generateOptStuffUrl runs during server rendering and the signed URL is embedded directly in the HTML:

import { generateOptStuffUrl } from "@/lib/optstuff-core";

export default function HomePage() {
  const heroImage = generateOptStuffUrl(
    "https://cdn.example.com/hero.jpg",
    { width: 1200, format: "webp" },
    86400
  );

  return (
    <main>
      <img src={heroImage} alt="Hero" width={1200} height={600} />
    </main>
  );
}

Option 2: API Route + Client Component

When a client component needs to load images dynamically (e.g. user-uploaded content or search results), create an API Route that signs on behalf of the client:

API Routeapp/api/optstuff/route.ts (POST handler):

import { generateOptStuffUrl } from "@/lib/optstuff-core";
import { NextRequest, NextResponse } from "next/server";

const ALLOWED_SOURCE_HOSTS = new Set(["cdn.example.com", "images.example.com"]);

export async function POST(request: NextRequest) {
  let body: Record<string, unknown>;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const imageUrl = body.imageUrl as string | undefined;
  if (!imageUrl) {
    return NextResponse.json({ error: "Missing imageUrl" }, { status: 400 });
  }

  let hostname: string;
  try {
    hostname = new URL(imageUrl).hostname;
  } catch {
    return NextResponse.json({ error: "Invalid imageUrl" }, { status: 400 });
  }

  if (!ALLOWED_SOURCE_HOSTS.has(hostname)) {
    return NextResponse.json({ error: "Domain not allowed" }, { status: 403 });
  }

  const signedUrl = generateOptStuffUrl(
    imageUrl,
    {
      width: body.width ? Number(body.width) : undefined,
      height: body.height ? Number(body.height) : undefined,
      format: (body.format as "webp" | "avif" | "png" | "jpg") ?? "webp",
    },
    3600,
  );

  const response = NextResponse.json({ url: signedUrl });
  response.headers.set(
    "Cache-Control",
    "public, s-maxage=300, stale-while-revalidate=3600",
  );
  return response;
}

This endpoint is public by default. In production, add session authentication and/or rate-limiting to prevent abuse.

Client Component — calls the API Route to get a signed URL, then renders:

"use client";

import { useState, useEffect } from "react";

type OptimizedImageProps = {
  readonly src: string;
  readonly width?: number;
  readonly alt: string;
};

export function OptimizedImage({ src, width, alt }: OptimizedImageProps) {
  const [imageUrl, setImageUrl] = useState<string>("");
  const [error, setError] = useState(false);

  useEffect(() => {
    setError(false);
    setImageUrl("");

    const controller = new AbortController();

    const fetchUrl = async () => {
      try {
        const res = await fetch("/api/optstuff", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ imageUrl: src, width }),
          signal: controller.signal,
        });
        if (!res.ok) {
          setError(true);
          return;
        }
        const { url } = await res.json();
        if (!controller.signal.aborted) {
          setImageUrl(url);
        }
      } catch (err) {
        if (err instanceof Error && err.name === "AbortError") return;
        setError(true);
      }
    };

    fetchUrl();

    return () => controller.abort();
  }, [src, width]);

  if (error) return <div>Failed to load image</div>;
  if (!imageUrl) return <div>Loading...</div>;

  return (
    <img
      src={imageUrl}
      alt={alt}
      width={width}
      style={{ aspectRatio: "4 / 3" }}
    />
  );
}

Background: By default, Next.js <Image> generates URLs like /_next/image?url=...&w=...&q=.... When you deploy to Vercel, this endpoint is handled by Vercel's Image Optimization service, which is billed per transformation. On self-hosted setups, it uses Sharp on your server.

With a custom loader, you can redirect <Image> to use OptStuff instead. The browser never calls /_next/image — all image processing is handled by OptStuff's CDN. This means you keep all next/image frontend features (responsive srcSet, lazy loading, layout shift prevention) without paying for Vercel Image Optimization.

How it works:

  1. <Image> calls your custom loader function to build each src / srcSet URL
  2. The loader points to your API Route (/api/optstuff?url=...&w=...)
  3. Your API Route signs the request and returns a 302 redirect to the OptStuff CDN
  4. The browser follows the redirect and loads the optimized image directly from OptStuff

API Routeapp/api/optstuff/route.ts (GET handler — if you also use Option 2, both handlers share this file):

import { generateOptStuffUrl } from "@/lib/optstuff-core";
import { NextRequest, NextResponse } from "next/server";

const ALLOWED_SOURCE_HOSTS = new Set(["cdn.example.com", "images.example.com"]);

export function GET(request: NextRequest) {
  const sp = request.nextUrl.searchParams;
  const url = sp.get("url");
  const w = sp.get("w");
  const q = sp.get("q");
  const f = sp.get("f") || "webp";
  const fit = sp.get("fit") || "cover";

  if (!url) {
    return NextResponse.json({ error: "Missing url" }, { status: 400 });
  }

  let hostname: string;
  try {
    hostname = new URL(url).hostname;
  } catch {
    return NextResponse.json({ error: "Invalid url" }, { status: 400 });
  }

  if (!ALLOWED_SOURCE_HOSTS.has(hostname)) {
    return NextResponse.json({ error: "Domain not allowed" }, { status: 403 });
  }

  const signedUrl = generateOptStuffUrl(
    url,
    {
      width: w ? Number(w) : undefined,
      quality: q ? Number(q) : 80,
      format: f as "webp" | "avif" | "png" | "jpg",
      fit: fit as "cover" | "contain" | "fill",
    },
    3600,
  );

  const response = NextResponse.redirect(signedUrl, 302);
  response.headers.set(
    "Cache-Control",
    "public, s-maxage=300, stale-while-revalidate=3600",
  );
  return response;
}

Client Componentcomponents/optstuff-image.tsx:

// components/optstuff-image.tsx
"use client";

import Image, { type ImageProps, type ImageLoaderProps } from "next/image";

type OptStuffImageProps = Omit<ImageProps, "src" | "loader"> & {
  src: string;
  format?: "webp" | "avif" | "png" | "jpg";
  fit?: "cover" | "contain" | "fill";
  /** When true, src is treated as an already-signed OptStuff URL. */
  preSigned?: boolean;
};

export function OptStuffImage({
  src,
  alt,
  format = "webp",
  fit = "cover",
  preSigned = false,
  quality = 80,
  ...rest
}: OptStuffImageProps) {
  if (preSigned) {
    return <Image {...rest} src={src} alt={alt} quality={quality} unoptimized />;
  }

  const loader = ({ src: loaderSrc, width, quality: q }: ImageLoaderProps) => {
    const params = new URLSearchParams({
      url: loaderSrc,
      w: String(width),
      q: String(q ?? 80),
      f: format,
      fit,
    });
    return `/api/optstuff?${params}`;
  };

  return (
    <Image {...rest} src={src} alt={alt} quality={quality} loader={loader} />
  );
}

Do not pass generateOptStuffUrl directly as a loader — the loader runs client-side, which would expose your secret key. Always delegate signing to an API Route.

For advanced configuration (srcSet tuning, production hardening, FAQ), see Custom next/image Loader.

Pre-Launch Checklist

Before going to production, verify the following:

  • secretKey (sk_...) is in a server-only env var — no NEXT_PUBLIC_ prefix
  • publicKey (pk_...) is also in an env var for centralized management
  • .env is listed in .gitignore
  • Signing logic runs only in Server Components or API Routes
  • No client code references the secret key
  • Signed URLs include an appropriate exp
  • Domain whitelists are configured in the dashboard

If you still get 403 errors after verifying these items, see Error Codes.

Further Reading

Last updated on

On this page