One Next.js 16 site, one working day, three failures. None of them showed up on the development machine. All three passed the build. Each one broke a single feature quietly while the rest of the site looked fine.
- Image uploads in the admin panel returned 422 for every file.
- Files uploaded after the server started returned 404 until the next restart.
- Scheduled posts went live in the database but never appeared on the site.
They share one root cause: the environment you test in is not the one you ship. Below is each failure, how it showed up, the fix, and the check that would have caught it before a user did.
1. The image library that compiled to nothing
Symptom
The admin upload endpoint re-encodes every image with sharp (strip metadata, resize, convert to WebP). In production it answered every request with:
{ "hata": "Görsel işlenemedi" }
That's "image could not be processed", the message from our own catch block. The interesting part was what the catch block was hiding.
Why it only happened in production
Locally we built with next build --webpack, because a font download failed under Turbopack on that Windows machine. The server ran a plain next build, which in Next.js 16 means Turbopack.
In the Turbopack output, the static import
import sharp from "sharp";
had been replaced with (void 0). The handler compiled, the route existed, and then the first call failed at runtime.
The usual advice is to list the package in serverExternalPackages. sharp is already on Next's built-in list, and adding it explicitly made no difference in that build.
Fix
Resolve the module at runtime, from the app's own node_modules, so that the bundler has nothing to analyse statically:
import { createRequire } from "node:module";
import path from "node:path";
type Sharp = typeof import("sharp");
const MODULE_NAME = "sharp"; // a variable, not a literal: nothing to resolve at build time
let cached: Sharp | null = null;
export function loadSharp(): Sharp {
cached ??= createRequire(path.join(process.cwd(), "package.json"))(MODULE_NAME) as Sharp;
return cached;
}
The type import keeps full typings, and loadSharp()(buffer).webp().toBuffer() behaves exactly as before.
The check that would have caught it
Build in CI with the same bundler you deploy with. If your local build needs --webpack to work around something, that is a second build pipeline, and only one of the two runs in production. The upload was tested, but against the webpack build.
2. Uploads that 404 until the next restart
Symptom
Once uploads worked, a new one appeared: the API returned a URL like /uploads/logos/abc123.webp, the file was on disk with correct permissions, and the URL returned 404. After a process restart the same URL worked.
Why
next start serves the public/ files it found when the process started. Anything written there afterwards is invisible to the static file handler: the same request goes from 404 to 200 after nothing but a restart. The dev server is more forgiving, which is why nobody noticed.
public/ is meant for assets that ship with the build, not for user content. Most guides tell you to move uploads to object storage, and that is the right long-term answer. But if you self-host on a single server and want to keep files on local disk, there is a small fix.
Fix
Keep writing to public/uploads/, and add a catch-all route handler that serves from that directory only when the static handler misses:
// app/uploads/[...path]/route.ts
import path from "node:path";
import { promises as fs } from "node:fs";
const ROOT = path.join(process.cwd(), "public", "uploads");
const TYPES: Record<string, string> = {
".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".gif": "image/gif", ".avif": "image/avif",
};
export async function GET(_req: Request, { params }: { params: Promise<{ path: string[] }> }) {
const { path: parts } = await params;
if (!parts.length || parts.some((p) => !p || p === "." || p === ".." || p.includes("\\") || p.includes("\0"))) {
return new Response("Not found", { status: 404 });
}
const file = path.resolve(ROOT, ...parts);
const type = TYPES[path.extname(file).toLowerCase()];
if (!type || !file.startsWith(ROOT + path.sep)) return new Response("Not found", { status: 404 });
try {
const st = await fs.stat(file);
if (!st.isFile()) return new Response("Not found", { status: 404 });
return new Response(new Uint8Array(await fs.readFile(file)), {
headers: {
"Content-Type": type,
"Content-Length": String(st.size),
"Cache-Control": "public, max-age=31536000, immutable", // names are random and never reused
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return new Response("Not found", { status: 404 });
}
}
Three details matter more than the rest:
-
Path traversal. Reject
..segments and confirm that the resolved path is still under the root. Either check alone has gaps. -
No SVG. An SVG can carry script. If you need SVG uploads, serve them as
Content-Disposition: attachmentor from another origin. -
Immutable caching is only safe because file names are random. If your uploads keep their original names and can be overwritten, drop
immutable.
Files present at startup are still served by the static handler. The route only handles the rest.
The check that would have caught it
Test upload-then-fetch against a production build, without restarting in between. "Upload returns 200" is not the test. "The returned URL serves the bytes" is.
3. Scheduled posts that published but never appeared
Symptom
A cron job calls an endpoint every ten minutes. The endpoint flips scheduled posts whose time has come to published, then invalidates the posts cache tag. The database showed the posts as published. The home page, the sidebar and the "latest posts" blocks still showed the old list, indefinitely.
The cron used curl -s ... > /dev/null, so its failures went nowhere. The process log had the answer:
Error: updateTag can only be called from within a Server Action. To invalidate
cache tags in Route Handlers or other contexts, use revalidateTag instead.
Why
Next.js 16 split tag invalidation in two:
-
updateTag(tag): Server Actions only. Read-your-own-writes semantics, so the user who submitted the form sees the change immediately. -
revalidateTag(tag, profile): works anywhere on the server, including Route Handlers, and now takes a second argument.
The endpoint called updateTag. The database write happened first, then the call threw, and the cache was never touched. Because the post was already published, the next cron run found nothing to do, so it never retried. And because data cached with unstable_cache and no revalidate time stays until its tag is invalidated, "eventually" never came.
Fix
import { revalidateTag } from "next/cache";
// updateTag only works inside Server Actions; route handlers use revalidateTag.
if (result.count > 0) revalidateTag("posts", { expire: 0 });
{ expire: 0 } expires the entries immediately. The "max" profile would serve stale data once and refresh in the background. For "the post should be visible now", expire immediately.
We verified it end to end: inserted a scheduled test post, called the endpoint (a JSON body reporting one published post, instead of a 500), requested a page and saw the post in the sidebar, then deleted the test row.
One more trap: deleting a row with raw SQL invalidates nothing. After removing the test post, it stayed in the cached sidebar until the tag was invalidated again. Any out-of-band write (a migration, a manual fix, an import script) needs a tag invalidation afterwards, or a way to trigger one.
The check that would have caught it
Don't send cron output to /dev/null. Log the response body, or at least fail loudly on a non-2xx status (curl -fsS). The endpoint had been returning 500 for every run that had work to do.
What the three have in common
| Failure | Dev | Prod | What actually differed |
|---|---|---|---|
sharp was undefined
|
worked | 422 | webpack locally, Turbopack on the server |
| new uploads 404 | worked | 404 until restart | dev server re-scans public/, prod doesn't |
| cache never refreshed | not exercised | stale forever | the code path only ran from cron |
None of these is a Next.js bug in the sense of "the framework is broken". Two are documented behaviours and one is a bundler edge case with a clean workaround. All three survived because the only environment that ran the code path was production, and production was set up to stay quiet about failures.
The checklist we now run before calling a deploy done:
- Build with the production bundler, no flags that the server doesn't use.
- Upload a file, then fetch the returned URL without restarting.
- Run every cron endpoint once by hand and read the response body.
- After any manual database change, invalidate the affected cache tags.
We build news, e-commerce and restaurant POS software at Alesta WEB. These notes come from moving our own site to a new stack this week.
Top comments (0)