Custom next/image Loader
Replace Vercel's paid image optimization with OptStuff while keeping all next/image benefits — responsive srcSet, hero preloading (`preload` / `priority`), lazy loading, and layout shift prevention.
next/image gives you responsive srcSet, hero preloading, lazy loading, and automatic layout shift prevention. However, its default loader routes requests through Vercel's /_next/image endpoint, which bills per optimization.
This guide shows how to use OptStuff as a custom loader — keeping every next/image benefit while avoiding Vercel's image optimization costs entirely.
The Problem
When integrating OptStuff with next/image, the obvious approach is to generate a signed URL server-side and pass it with unoptimized:
<Image src={signedOptStuffUrl} unoptimized width={800} height={600} alt="Photo" />This works, but unoptimized tells Next.js to skip its entire image pipeline. You lose:
- Responsive
srcSet— the browser gets a single URL instead of multiple sizes - Automatic format/size negotiation — no viewport-aware image selection
sizes-driven loading — the browser cannot pick the optimal image for the layout
You're left with a glorified <img> tag that happens to prevent layout shift.
Three Approaches Compared
| Default loader | unoptimized | Custom loader | |
|---|---|---|---|
Hits /_next/image | Yes | No | No |
| Vercel optimization cost | Yes | No | No |
Responsive srcSet | Yes | No | Yes |
sizes + viewport selection | Yes | No | Yes |
Hero preloading (preload / priority) | Yes | Yes | Yes |
| Lazy loading | Yes | Yes | Yes |
| Layout shift prevention | Yes | Yes | Yes |
| Placeholder / blur support | Yes | Partial | Yes |
The custom loader approach gives you everything — without the Vercel bill.
Architecture
Key points:
- The
secretKeynever leaves the server — signing happens in the API Route next/imagegenerates the fullsrcSetby calling the loader with each width fromdeviceSizes- The browser only fetches one entry from the
srcSet(the best match for the current viewport and DPR), so there is only one redirect per visible image - Redirect responses can be cached at the CDN edge (
s-maxage) so repeated width variants do not re-run signing logic on every request - OptStuff returns
Cache-Control: public, immutable— subsequent loads are served from browser/CDN cache with zero network requests
Implementation
Step 1: Signing Utility
If you don't have one yet, create lib/optstuff-core.ts. See the Next.js Integration Guide for details.
For clarity:
optstuff-core.ts= core runtime signing logic (recommended to copy first)optstuff-blur.ts= optional server blur helper for hero/demo UX- Hero/playground/comparison components = demo-only UI, not required for integration
Step 2: API Route for Signing
Create an API route that accepts image parameters, signs the URL server-side, and returns a redirect:
// app/api/optstuff/route.ts
import { generateOptStuffUrl } from "@/lib/optstuff-core";
import { type NextRequest, NextResponse } from "next/server";
export function GET(request: NextRequest) {
const SIGNED_URL_TTL_SECONDS = 3600;
const REDIRECT_CACHE_SECONDS = 300;
const REDIRECT_SWR_SECONDS = 3600;
const MAX_DIMENSION = 8192;
const allowedFormats = new Set(["webp", "avif", "png", "jpg"]);
const allowedFits = new Set(["cover", "contain", "fill"]);
const allowedHosts = new Set(
(process.env.OPTSTUFF_ALLOWED_IMAGE_HOSTS ?? "images.unsplash.com")
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean)
);
const sp = request.nextUrl.searchParams;
const url = sp.get("url");
if (!url) {
return NextResponse.json({ error: "url is required" }, { status: 400 });
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return NextResponse.json({ error: "url must be a valid URL" }, { status: 400 });
}
const hostname = parsedUrl.hostname.toLowerCase();
const hostAllowed = [...allowedHosts].some(
(allowedHost) => hostname === allowedHost || hostname.endsWith(`.${allowedHost}`)
);
if (!hostAllowed) {
return NextResponse.json(
{ error: "url hostname is not allowed. Configure OPTSTUFF_ALLOWED_IMAGE_HOSTS." },
{ status: 400 }
);
}
const width = sp.get("w");
const height = sp.get("h");
const quality = sp.get("q") ?? "80";
const format = sp.get("f") ?? "webp";
const fit = sp.get("fit") ?? "cover";
const parseOptionalDimension = (value: string | null) => {
if (value === null) return { ok: true as const, value: undefined };
if (!/^\d+$/.test(value)) return { ok: false as const };
const parsed = Number(value);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return { ok: false as const };
if (parsed < 1 || parsed > MAX_DIMENSION) return { ok: false as const };
return { ok: true as const, value: parsed };
};
const parsedWidth = parseOptionalDimension(width);
const parsedHeight = parseOptionalDimension(height);
const parsedQuality = Number(quality);
if (!parsedWidth.ok || !parsedHeight.ok) {
return NextResponse.json(
{ error: `w/h must be positive integers between 1 and ${MAX_DIMENSION}` },
{ status: 400 }
);
}
if (!/^\d+$/.test(quality) || !Number.isFinite(parsedQuality) || !Number.isInteger(parsedQuality) || parsedQuality < 1 || parsedQuality > 100) {
return NextResponse.json(
{ error: "q must be an integer between 1 and 100" },
{ status: 400 }
);
}
if (!allowedFormats.has(format)) {
return NextResponse.json(
{ error: "f must be one of: webp,avif,png,jpg" },
{ status: 400 }
);
}
if (!allowedFits.has(fit)) {
return NextResponse.json(
{ error: "fit must be one of: cover,contain,fill" },
{ status: 400 }
);
}
const operations = {
width: parsedWidth.value,
height: parsedHeight.value,
quality: parsedQuality,
format: format as "webp" | "avif" | "png" | "jpg",
fit: fit as "cover" | "contain" | "fill",
};
const signedUrl = generateOptStuffUrl(parsedUrl.toString(), operations, SIGNED_URL_TTL_SECONDS);
const response = NextResponse.redirect(signedUrl, 302);
response.headers.set(
"Cache-Control",
`public, s-maxage=${REDIRECT_CACHE_SECONDS}, max-age=${REDIRECT_CACHE_SECONDS}, stale-while-revalidate=${REDIRECT_SWR_SECONDS}`
);
return response;
}The 302 redirect is lightweight (headers only, no body). The browser follows it transparently and caches the final image response from OptStuff.
The signing secret stays in the API Route. The client-side loader only constructs URLs to
/api/optstuff — it never sees OPTSTUFF_SECRET_KEY.
Use both layers of validation in production:
- Validate request params in your app's signing API route (
/api/optstuff) to fail fast. - Keep OptStuff server-side operation validation enabled as defense-in-depth.
Even if a client bypasses your app route and calls the endpoint directly, invalid operation values are still rejected server-side with 400.
The example above also validates source hostnames through
OPTSTUFF_ALLOWED_IMAGE_HOSTS (comma-separated). This prevents your signing
endpoint from being used as an open proxy.
Step 3: Image Component
Create a Client Component that wraps next/image with a custom loader pointing to the API route:
// 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} />
);
}The component must be "use client" because the loader prop is a function that cannot
be serialized across the Server/Client boundary. This does not mean the component is
client-only — it still server-side renders and hydrates normally.
Step 4: Usage
Use <OptStuffImage> exactly like next/image. All standard props (fill, sizes, preload, placeholder, etc.) work as expected:
// Fixed dimensions
<OptStuffImage
src="https://images.unsplash.com/photo-xxx"
width={800}
height={600}
alt="Landscape"
format="avif"
quality={90}
/>
// Fill mode with responsive sizes
<div style={{ position: "relative", width: "100%", aspectRatio: "16 / 9" }}>
<OptStuffImage
src="https://images.unsplash.com/photo-xxx"
fill
sizes="(min-width: 1024px) 50vw, 100vw"
preload
alt="Hero image"
style={{ objectFit: "cover" }}
/>
</div>
// Grid of images — each card is roughly 1/3 of the viewport
<div className="grid grid-cols-3 gap-4">
{images.map((img) => (
<div key={img.id} style={{ position: "relative", aspectRatio: "4 / 3" }}>
<OptStuffImage
src={img.url}
fill
sizes="(min-width: 640px) 33vw, 100vw"
alt={img.alt}
/>
</div>
))}
</div>On newer Next.js versions, prefer preload for above-the-fold images.
If your app is on an older version, use priority instead.
How srcSet Generation Works
When you render <OptStuffImage> with fill and sizes, Next.js generates a srcSet by calling your loader once per configured width. The default deviceSizes are:
640, 750, 828, 1080, 1200, 1920, 2048, 3840For each width, the loader returns a URL like /api/optstuff?url=...&w=1080&q=80&f=webp&fit=cover. Next.js assembles these into a standard <img srcSet="..."> attribute.
The browser then picks exactly one URL from the srcSet based on the sizes attribute and the device's pixel ratio. A phone on a 3-column grid at 1x DPR might request w=640; a retina laptop showing the same grid might request w=1080. No wasted bandwidth.
You can customise these widths in next.config.ts.
In this demo, next.config.ts currently sets images.qualities, and leaves
deviceSizes / imageSizes at defaults. If needed, you can tune both:
const nextConfig: NextConfig = {
images: {
qualities: [20, 75, 80, 85, 90],
// Optional tuning:
deviceSizes: [640, 828, 1080, 1280, 1920],
imageSizes: [16, 32, 64, 128, 256],
},
};Fewer unique widths mean fewer unique URLs, which improves CDN cache hit rates. Choose a set that matches your common layout breakpoints.
Cost Comparison
Vercel charges $5 per 1,000 source images with the default optimization. For a site serving 100k unique images per month, that's $500/month just for image optimization.
With the custom loader approach:
- Vercel image optimization cost: $0 —
/_next/imageis never called - OptStuff cost: your own infrastructure (self-hosted) or usage-based pricing
- Redirect overhead: one extra 302 hop per unique image per browser session — typically < 10ms
LCP / Hero Strategy
For above-the-fold hero images, keep the image path short and predictable.
Recommended Pattern
- Do not apply delayed reveal animation (
animation-delay) to the hero image container. - Pre-sign the hero URL in a Server Component and pass
preSignedso the hero request skips/api/optstuffredirect. - Load real blur data from the server; if blur fetch fails, render without placeholder.
// app/page.tsx (Server Component)
import { getBlurDataResult } from "@/lib/optstuff-blur";
import { generateOptStuffUrl } from "@/lib/optstuff-core";
const heroImageUrl = generateOptStuffUrl(
"https://images.unsplash.com/photo-1506744038136-46273834b3fb",
{ width: 1600, quality: 85, format: "webp", fit: "cover" },
7200
);
const heroBlurResult = await getBlurDataResult(
"https://images.unsplash.com/photo-1506744038136-46273834b3fb"
);
const heroBlurDataUrl =
heroBlurResult.status === "ok" ? heroBlurResult.dataUrl : undefined;
const hasHeroBlur = heroBlurDataUrl !== undefined;
<OptStuffImage
src={heroImageUrl}
fill
alt="Hero image"
preload
blurPlaceholder={hasHeroBlur}
blurDataUrl={heroBlurDataUrl}
preSigned
/>Why This Improves UX
- Removing delayed animation prevents intentional blank time in the hero slot.
- Direct signed URL avoids an extra client-visible redirect round trip for your LCP image.
Real Blur Modes (build-cache vs realtime)
If you want the hero to wait for a real blur image instead of a fixed fallback, expose a mode switch in your app config:
build-cache(default): blur generation uses cached fetch behavior and works well with build/static caching.realtime: blur generation usesno-store, so every request recomputes/fetches blur.
In build-cache mode, use separate TTLs for positive and negative cache entries:
- Success (
blur available): long TTL (for example, 1 hour) - Miss (
blur missing): short TTL (for example, 30-60 seconds)
This prevents a single transient failure from being cached as a long-lived miss.
// src/lib/hero-blur-config.ts
export const HERO_BLUR_MODE = {
BUILD_CACHE: "build-cache",
REALTIME: "realtime",
} as const;
export const HERO_BLUR_CONFIG = {
mode: HERO_BLUR_MODE.BUILD_CACHE,
fetchTimeoutMs: 6000,
successCacheMs: 3600000,
missCacheMs: 10000,
} as const;// app/page.tsx
import { HERO_BLUR_CONFIG, HERO_BLUR_MODE } from "@/lib/hero-blur-config";
import { cacheLife, unstable_noStore as noStore } from "next/cache";
import { getBlurDataResult } from "@/lib/optstuff-blur";
const heroBlurMode = HERO_BLUR_CONFIG.mode;
if (heroBlurMode === HERO_BLUR_MODE.REALTIME) {
noStore();
}
const heroBlurResult = await getBlurDataResult(
HERO_IMAGE_URL,
{ width: 32, quality: 20, format: "webp", fit: "cover" },
{ mode: heroBlurMode }
);
const heroBlurDataUrl =
heroBlurResult.status === "ok" ? heroBlurResult.dataUrl : undefined;In development, add a small debug panel for mode, source, network-request, reason, status, content-type, and duration so timeout/routing issues are visible immediately.
For local debugging, use the Dev Tool: Hero Request Mode card above the Hero image.
Quick test flow:
- Ensure
modeisbuild-cacheinsrc/lib/hero-blur-config.ts. - Enable the button (
request: fresh) and verify URL contains?hero-refresh=1. - In the blur debug panel, confirm
Request Mode: freshandNetwork Request: yes. - Disable the button (or remove
?hero-refresh=1) to restore normal cache behavior.
HERO_FORCE_REFRESH_URL_TTL_SECONDS (in src/app/page.tsx) controls how long the force-refresh signed hero URL stays valid (exp).
- Keep it short in local debug so force refresh tends to produce a fresh signed URL.
- Recommended range:
10-30seconds. - Too small (for example
1) can cause intermittentImage unavailableon rapid reload because the URL may expire before the image request fires.
In realtime mode, blur already requests from network; this button still helps force a fresh sharp hero image URL.
FAQ
Does this actually bypass Vercel billing?
Yes. Vercel bills for requests to /_next/image. When you provide a custom loader, Next.js generates srcSet URLs that point directly to your loader's return value — in this case /api/optstuff. The /_next/image endpoint is never invoked. You can verify by checking the Network tab: no requests to /_next/image should appear.
Is the secret key safe?
Yes. The loader function runs in the browser, but it only constructs URLs to /api/optstuff — a Next.js API Route that runs server-side. The actual signing (using OPTSTUFF_SECRET_KEY) happens entirely within that API Route.
Why do I get url hostname is not allowed?
Your API Route is rejecting image origins not in your allowlist.
Set OPTSTUFF_ALLOWED_IMAGE_HOSTS as a comma-separated list, for example:
OPTSTUFF_ALLOWED_IMAGE_HOSTS=images.unsplash.com,cdn.example.comThis is expected and recommended in production. It prevents your signing route from becoming an unrestricted image proxy.
What about the redirect overhead?
Each image request adds one 302 redirect before reaching OptStuff. This redirect is headers-only (no body) and typically completes in under 10ms. In production, you can edge-cache the redirect route (s-maxage) and keep exp bucketed so repeated variants reuse the same signed target URL. After the first load, the browser also caches the final image (OptStuff returns Cache-Control: public, immutable), so subsequent page views have near-zero redirect overhead.
Can I avoid the redirect entirely?
Yes — make the API Route proxy the image bytes instead of redirecting:
export async function GET(request: NextRequest) {
// ... same signing logic ...
const response = await fetch(signedUrl);
return new NextResponse(response.body, {
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "image/webp",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}This eliminates the redirect hop but routes all image traffic through your Next.js server. The redirect approach is better for production since it offloads bandwidth to OptStuff / your CDN.
Why "use client" if it still SSRs?
The "use client" directive marks the component as part of the client bundle — it can use hooks, event handlers, and function props (like loader). It does not mean the component is client-rendered only. Next.js still server-side renders it; the HTML includes the fully resolved <img> tag with the srcSet. The directive is required solely because loader is a function that cannot be serialized across the React Server Components boundary.
Related Documentation
- Next.js Integration Guide — Environment setup, signing utility, and other integration options
- CDN and Caching — Cache behavior and CDN configuration
- URL Signing — Signature formula and security properties
Last updated on