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:
- Your server generates a signed URL containing the source image, desired operations (resize, format, etc.), and a cryptographic signature
- The browser requests the signed URL from the OptStuff CDN
- 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:
| Pattern | Best for | How signing works |
|---|---|---|
| Option 1: Server Component | Static or SSR pages | Sign at render time |
| Option 2: API Route + Client | Client-side dynamic images | Client calls your API Route to get a signed URL |
Option 3: next/image Loader | Full next/image features without Vercel charges | Loader 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 Route — app/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" }}
/>
);
}Option 3: Custom next/image Loader (Recommended)
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:
<Image>calls your customloaderfunction to build eachsrc/srcSetURL- The loader points to your API Route (
/api/optstuff?url=...&w=...) - Your API Route signs the request and returns a 302 redirect to the OptStuff CDN
- The browser follows the redirect and loads the optimized image directly from OptStuff
API Route — app/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 Component — components/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 — noNEXT_PUBLIC_prefixpublicKey(pk_...) is also in an env var for centralized management.envis 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
- URL Signing — Signature formula and security properties
- Domain Whitelisting — Allowed domain configuration
- CDN and Caching — CDN integration and caching strategies
- Security Best Practices — Production recommendations
Last updated on