@code-plate/next-image-serve
v1.0.0
Published
Configurable Next.js App Router route handler for safely serving local images with ETag/304 support, in-memory LRU caching, and streaming for large files.
Maintainers
Readme
@code-plate/next-image-serve
A configurable Next.js App Router route handler for serving local images with in-memory LRU caching, ETag/304 support, and automatic streaming for large files.
Instead of writing this route handler from scratch for every project, you get one function — createImageHandler(options) — that returns a ready-to-export GET handler, with almost every behavior overridable.
Features
- Path traversal protection — every segment is sanitized and the resolved path is checked against
basePath - Two-layer caching
- Browser caching via a fully configurable
Cache-Controlheader - Server-side in-memory LRU cache (via
lru-cache) for small files, togglable
- Browser caching via a fully configurable
- ETag + 304 responses to avoid re-downloading unchanged files
- Automatic streaming for files above a configurable size threshold, so large files are never fully buffered in memory
- Three addressing modes:
"query"(default) —?folder=icons&file=logo.svg, with fully renameable param names"path"— a Next.js catch-all segment, e.g./api/images/icons/logo.svg"custom"— you supply your own function to extract folder/file from the request
- Configurable MIME whitelist, headers,
Content-Disposition, and error responses - Dual ESM + CJS build with full TypeScript types
Install
npm install @code-plate/next-image-servePeer dependencies: next >= 13.4, react >= 18
Basic usage (query mode, default)
// app/api/images/route.ts
import path from "path";
import { createImageHandler } from "@code-plate/next-image-serve";
export const { GET } = createImageHandler({
basePath: path.join(process.cwd(), "assets/images"),
});// any component
import Image from "next/image";
<Image
src="/api/images?folder=products&file=sneaker-01.webp"
alt="Running sneaker"
width={400}
height={400}
unoptimized
/>;Or build the URL with the type-safe helper:
import { buildImageUrl } from "@code-plate/next-image-serve";
const src = buildImageUrl({ base: "/api/images", folder: "products", file: "sneaker-01.webp" });
// => "/api/images?folder=products&file=sneaker-01.webp"Renaming the query params
Every part of the query string is configurable — pick whatever names fit your API:
export const { GET } = createImageHandler({
basePath: path.join(process.cwd(), "assets/images"),
queryParams: { folder: "dir", file: "name" },
});const src = buildImageUrl({
base: "/api/images",
folder: "products",
file: "sneaker-01.webp",
queryParams: { folder: "dir", file: "name" },
});
// => "/api/images?dir=products&name=sneaker-01.webp"Path mode
For cleaner URLs like /api/images/products/sneaker-01.webp:
// app/api/images/[...segments]/route.ts
import { createImageHandler } from "@code-plate/next-image-serve";
export const { GET } = createImageHandler({ mode: "path" });import { buildImageUrl } from "@code-plate/next-image-serve";
const src = buildImageUrl({
base: "/api/images",
folder: "products",
file: "sneaker-01.webp",
mode: "path",
});
// => "/api/images/products/sneaker-01.webp"Custom mode
When neither query nor path addressing fits — e.g. you want to read the file id from a signed token, a header, or a completely different URL shape — take full control:
export const { GET } = createImageHandler({
mode: "custom",
basePath: path.join(process.cwd(), "assets/avatars"),
resolveSegments: async (req) => {
const userId = req.headers.get("x-user-id");
return { folder: "avatars", file: `${userId}.webp` };
},
});All options
createImageHandler({
// Absolute directory images are served from
basePath: path.join(process.cwd(), "assets/images"), // default: "public/images"
// How the request identifies the file
mode: "query", // "query" | "path" | "custom"
// Query param names (mode: "query" only) — name them anything you like
queryParams: { folder: "folder", file: "file" },
// Required when mode === "custom"
resolveSegments: async (req, ctx) => ({ folder: "icons", file: "logo.svg" }),
// Allowed MIME types
allowedTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml", "image/avif"],
// Browser caching
browserCacheMaxAge: 86400, // seconds
immutable: true,
// Full override, takes precedence over the two options above:
cacheControl: (contentType) => "public, max-age=31536000, immutable",
// Content-Disposition header, or `false` to omit it entirely
contentDisposition: "inline", // "inline" | "attachment" | false
// X-Content-Type-Options: nosniff toggle
xContentTypeOptions: true,
// Extra headers merged into every 200 response
extraHeaders: (contentType) => ({ "X-Served-By": "next-image-serve" }),
// Buffer + server-cache files at/below this size; stream everything larger
streamThreshold: 512 * 1024, // bytes
// Server-side LRU cache
serverCache: {
enabled: true,
max: 200,
maxSize: 50 * 1024 * 1024,
ttl: 1000 * 60 * 60,
},
// Custom sanitizer applied to each path segment
sanitize: (segment) => segment ?? "",
// Custom error logger (defaults to console.error)
onError: (error, ctx) => logger.error(error, ctx),
// Per-case body/status overrides
responses: {
missingParam: { status: 400, body: { message: "file parameter is required" } },
invalidPath: { status: 400 },
notFound: { status: 404, body: { message: "not found" } },
invalidType: { status: 400 },
serverError: { status: 500 },
},
});Notes
- The server-side LRU cache only holds files at or below
streamThreshold; larger files always stream and are never cached in memory, to keep memory usage predictable. - The cache is per-instance/per-process, not shared across replicas. This package doesn't replace a distributed cache (e.g. Redis) — it only caches within a single running process.
basePathmust be an absolute filesystem path on the server, not a public URL.- Because the image is served from an API route rather than the built-in Next.js image loader, you'll typically want
unoptimizedonnext/image(as in the examples above), or you can wire this route up as a custom image loader.
Build
npm run buildProduces a dual ESM (dist/index.js) + CJS (dist/index.cjs) build with type declarations (dist/index.d.ts), via tsup.
