Skip to content

Instantly share code, notes, and snippets.

@anatooly
Created May 19, 2025 15:06
Show Gist options
  • Select an option

  • Save anatooly/d3678da0b75897dbb251531e269a39c0 to your computer and use it in GitHub Desktop.

Select an option

Save anatooly/d3678da0b75897dbb251531e269a39c0 to your computer and use it in GitHub Desktop.
/api/image/route.ts - next.js
import { NextRequest, NextResponse } from "next/server";
import path from "path";
import fs from "fs/promises";
import sharp from "sharp";
const SUPPORTED_FORMATS = ["avif", "webp", "jpeg", "png"] as const;
type Format = (typeof SUPPORTED_FORMATS)[number];
const PUBLIC_ROOT = path.join(process.cwd(), "public");
const CACHE_ROOT = path.join(process.cwd(), ".next/cache/images");
function sanitizeFileName(str: string) {
return str.replace(/[^a-z0-9_\-\.]/gi, "_").toLowerCase();
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const src = searchParams.get("src");
const widthParam = searchParams.get("width");
const formatParam = searchParams.get("format");
if (!src) {
return NextResponse.json({ error: 'Missing "src" parameter' }, { status: 400 });
}
const outputFormat: Format = SUPPORTED_FORMATS.includes(formatParam as Format) ? (formatParam as Format) : "webp";
const width = widthParam ? parseInt(widthParam, 10) : undefined;
const normalizedSrc = path.posix.normalize(src).replace(/^(\.\.[/\\])+/, "");
const absolutePath = path.join(PUBLIC_ROOT, normalizedSrc);
if (!absolutePath.startsWith(PUBLIC_ROOT)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const ext = path.extname(absolutePath).toLowerCase();
if (![".png", ".jpg", ".jpeg", ".webp"].includes(ext)) {
return NextResponse.json({ error: "Unsupported input image format" }, { status: 400 });
}
const baseName = path.basename(absolutePath);
if (baseName.startsWith(".") || baseName.includes("..")) {
return NextResponse.json({ error: "Forbidden file access" }, { status: 403 });
}
const cacheFileName =
sanitizeFileName(`${normalizedSrc.replace(/\//g, "_")}_w${width || "auto"}_f${outputFormat}`) + "." + outputFormat;
const cacheFilePath = path.join(CACHE_ROOT, cacheFileName);
try {
const cachedBuffer = await fs.readFile(cacheFilePath);
return new NextResponse(cachedBuffer, {
status: 200,
headers: {
"Content-Type": `image/${outputFormat}`,
"Cache-Control": "public, max-age=31536000, immutable",
"X-Cache": "HIT",
},
});
} catch {}
const imageBuffer = await fs.readFile(absolutePath);
const resizedBuffer = await sharp(imageBuffer).resize(width).toFormat(outputFormat).toBuffer();
await fs.mkdir(CACHE_ROOT, { recursive: true });
await fs.writeFile(cacheFilePath, resizedBuffer);
return new NextResponse(resizedBuffer, {
status: 200,
headers: {
"Content-Type": `image/${outputFormat}`,
"Cache-Control": "public, max-age=31536000, immutable",
"X-Cache": "MISS",
},
});
} catch (error) {
console.error("Image processing error:", error);
return NextResponse.json({ error: "Image processing failed" }, { status: 500 });
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment