OptStuff

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 loaderunoptimizedCustom loader
Hits /_next/imageYesNoNo
Vercel optimization costYesNoNo
Responsive srcSetYesNoYes
sizes + viewport selectionYesNoYes
Hero preloading (preload / priority)YesYesYes
Lazy loadingYesYesYes
Layout shift preventionYesYesYes
Placeholder / blur supportYesPartialYes

The custom loader approach gives you everything — without the Vercel bill.

Architecture

Key points:

  • The secretKey never leaves the server — signing happens in the API Route
  • next/image generates the full srcSet by calling the loader with each width from deviceSizes
  • 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, 3840

For 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/image is 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.

  1. Do not apply delayed reveal animation (animation-delay) to the hero image container.
  2. Pre-sign the hero URL in a Server Component and pass preSigned so the hero request skips /api/optstuff redirect.
  3. 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 uses no-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:

  1. Ensure mode is build-cache in src/lib/hero-blur-config.ts.
  2. Enable the button (request: fresh) and verify URL contains ?hero-refresh=1.
  3. In the blur debug panel, confirm Request Mode: fresh and Network Request: yes.
  4. 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-30 seconds.
  • Too small (for example 1) can cause intermittent Image unavailable on 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.com

This 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.

Last updated on

On this page