DEV Community

Cover image for What Upgrading to Next.js 16 Taught Me About Trusting Framework Defaults
Digital Craft Workshop
Digital Craft Workshop

Posted on Originally published at Medium

What Upgrading to Next.js 16 Taught Me About Trusting Framework Defaults

I used to think the cost of a framework upgrade was the stuff in the migration guide. The renamed APIs. The deprecated config. The list of things the maintainers tell you to change. You read it, you do the work, you move on.

That is the visible tax. It is not the one that hurts.

The tax that hurts is the convention that still compiles, still looks right, still matches the docs, and quietly does something different than it did in the last major version. No error at upgrade time. No warning in the console. Just a behavior that drifted out from under you while you were not looking. You find it weeks later, in production, when something that always worked stops working and nothing tells you why.

I learned this the slow way when I moved to Next.js 16. One file convention I had trusted for years took down a page, returned a clean 200 status, and gave me almost nothing to debug from. The fix was five lines. Finding it cost an hour. And the hour was the lesson, not the fix.


The convention I trusted

Back in April I shipped a public blog at drippery.app/blog. Brand-new Next.js 16 app, App Router, Turbopack (the bundler that replaced webpack as the Next 16 default), the works. Routes rendered fine in dev. /blog listed posts. /blog/[slug] rendered the article body, the breadcrumb JSON-LD, the FAQ schema, everything I expected.

Then I added a file-based opengraph-image.tsx next to the detail page, the way the docs say to, and the detail page stopped rendering.

A quick recap for anyone who has not used the convention. In the App Router, opengraph-image is one of the special file names that live inside a route segment. You drop a dynamic opengraph-image.tsx next to a page.tsx, export a default function that returns an ImageResponse from next/og, and Next.js evaluates it and injects the matching og:image meta tags into the <head> of every page in that segment. For a blog with dynamic slugs, each post gets its own social card, generated from the post title, with zero manual wiring.

The payoff is link previews. A page without a valid og:image shows up as a bare text link when shared, and I have written before about how much traffic one missing HTML tag can cost you.

So the plan was simple. Add the file, get per-post social cards. That is the workflow the convention promises, and on Next.js 14 it had worked for me exactly that way. I trusted it because it had earned the trust. That is the trap. The conventions you trust most are the ones that bite hardest, because you stop suspecting them.

Next.js file convention: what the docs promise versus what Next 16 delivered


What "broke" actually looked like

The page did not 500. It returned a 200, but the React tree was missing. The HTML shell came back, the body content did not. In the server logs:

TypeError: Cannot read properties of undefined (reading 'default')
at /app/(marketing)/blog/[slug]/page
Enter fullscreen mode Exit fullscreen mode

The error fired during metadata resolution, not during the page render itself. Turbopack was trying to import the opengraph-image module to compute the <meta> tag, and the dynamic import returned undefined. That crashed the whole metadata pipeline, which then silently bailed out of rendering the page.

Metadata resolution runs as part of serving the page, which is why the symptom is so confusing. When it throws, the page body never renders, even though your page component is perfectly healthy. The file I added for link previews was the thing breaking the page.

The stack trace makes it worse. It points at the page segment, because the import happens inside the framework's metadata machinery for that segment. Nothing in it names opengraph-image.tsx, which is a big part of why I spent the next hour blaming everything except the new file.

Removing the file made everything work again. Adding it back, even with a hello-world export, broke it again. My image code never ran far enough to matter.

This is what a silent breaking change feels like from the inside. The framework did not tell me the convention changed. The page just lied to me with a 200.


Why the upgrade guide will never catch this

Here is the uncomfortable part. There was nothing to read.

A migration guide can document what the maintainers changed on purpose. It cannot document the interaction between a new default bundler, a file convention, and generateMetadata that nobody set out to change and nobody noticed shifted. The Next 16 release notes told me Turbopack was now the default. They did not tell me that a convention I had used since Next 14 would behave differently underneath it, because as far as anyone knew, it would not.

That is the shape of the real upgrade tax. It is not in the changelog. It lives in the gap between what the framework says it does and what it actually does on your specific combination of features. The bigger the framework, the more conventions it ships, the more of those gaps exist. Every convention is a promise the framework makes on your behalf, and a major version is the moment those promises quietly get renegotiated without your signature.

I am not anti-convention. File-based routing, colocated metadata, auto-discovered images — these save real time and I use them every day. But I had been treating them as guarantees, and they are not guarantees. They are conveniences with an implicit asterisk: this works until a default underneath it changes. The upgrade is exactly when that asterisk comes due.


How I narrowed it down

I want to spell out the debugging sequence, because the 200 status code sent me in the wrong direction for a while, and the sequence is reusable on any "silent breakage" problem, not just this one.

My first suspicion was the data layer. The detail page pulls the post from the database by slug, and a missing post would explain an empty page. But the post was there, and the listing page that uses the same query rendered fine. Second suspicion was the structured data. I had added breadcrumb JSON-LD and an FAQ schema to the same page recently, and a malformed script tag can do strange things to hydration. Stripping those out changed nothing.

The browser console was clean the entire time. That is the trap: every signal on the client side says the page is fine, and only the server log contains the actual error. I have been burned by this exact shape before. When our Next.js app crashed every 24 hours, the visible symptom also pointed nowhere near the cause, and the server-side evidence was the only trail worth following. Same when my extension iframes painted solid white over dark-mode sites — the culprit was a CSS color-scheme default that nothing in the symptom hinted at.

Once I read the server log carefully, the stack trace mentioned the page segment, and the only recent change in that segment besides the JSON-LD was the new image file. From there it was a binary search with one variable. Delete opengraph-image.tsx, restart dev, page renders. Recreate it with the simplest possible content — a div with static text inside an ImageResponse — page breaks. At that point there was nothing of mine left in the file to blame.

That test is the one I want you to take away. If a hello-world version of the file reproduces the failure, the failure happens before your code runs. Stop auditing your own logic and start suspecting the convention. That single move is what turns an hour of flailing into a five-minute diagnosis the next time a default betrays you.

Binary-search debugging timeline ruling out the data layer, JSON-LD and the browser console


If build notes like this are your thing, The Claude Code Memory Starter is a short free email series where I unpack the setup behind them.

The fix: stop trusting the convention, wire it yourself

The lesson translated into a concrete decision. I gave up on the file-based convention for this segment and built a regular route handler instead — the kind of explicit code the framework cannot quietly change underneath me:

// app/api/og/blog/[slug]/route.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/blog";

export const runtime = "edge";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) return new Response("Not found", { status: 404 });

  return new ImageResponse(
    (
      <div>
        {post.title}
        drippery.app
      </div>
    ),
    { width: 1200, height: 630 }
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the same ImageResponse API the convention uses under the hood, the same JSX, the same edge runtime. The file just lives under app/api as a normal route handler, with nothing implicit about it.

Two details trip people up. params has been a Promise since Next 15 (the sync fallback is gone in 16), so await it before reading the slug. And the file needs the .tsx extension because of the JSX. Both are easy to miss when you copy a route handler template from an older project.

The route by itself does nothing for your meta tags. You wire it up in the page's generateMetadata, in the openGraph.images field:

return {
  title: post.seoTitle ?? post.title,
  description: post.metaDescription,
  openGraph: {
    images: [{ url: `/api/og/blog/${slug}`, width: 1200, height: 630 }],
  },
};
Enter fullscreen mode Exit fullscreen mode

The relative URL gets resolved against your metadataBase, the base URL you set once in the root layout metadata, so the rendered tag carries the full absolute address social crawlers need. The only conceptual difference from the convention is that nothing is auto-discovered. I am wiring the URL myself.

There is a real trade-off in that sentence, so let me be fair to the convention. With the file-based approach, the framework keeps the image URL, the alt text, the dimensions, and the content type in sync for you. With the explicit route, those live in two places: the route handler defines the image, and generateMetadata declares it. Rename the route and forget the metadata object, and your og:image points at a 404 with no error to warn you.

I accept that maintenance cost on purpose. The explicit version fails as a broken preview card. The convention failed as a broken page. Those are not the same severity, and I will trade a little duplication for a failure mode I can actually see.

Request flow forking into the broken auto-discovered path and the explicit API route


Why I think it happens (and why the cause is not the point)

This is a working theory. I do not have a confirmed root cause, and I want to be honest about that.

My theory is that Turbopack's dynamic import for the file-based metadata convention is not resolved correctly in the module graph when the route segment also exports generateMetadata. The docs say you can use both together, and in Next.js 14 with webpack I did. In this Next.js 16 project, the auto-imported opengraph-image module ends up as { default: undefined } at the moment metadata is resolved, and the framework calls into the missing default export and throws.

The error string has history. The Next.js issue tracker has a closed Next 13 issue with the same TypeError, thrown from React's module-resolution layer, resolveModuleMetaData. The trigger back then was client component navigation, not an image file, but the shape matches: the metadata system imports a module, the module comes back undefined, the page fails to render. That pattern made me more comfortable blaming the resolution machinery than my own code.

I also did not isolate everything. I did not retest the segment with webpack instead of Turbopack, and I did not test the convention in a segment without generateMetadata. If the combination matters the way I think it does, one of those two changes would make the file convention work again. For my project, the API route was cheaper than the experiment.

And honestly, the precise cause is not the point. A convention I trusted broke in a way no upgrade guide warned me about, and the only durable defense was to stop trusting it for this segment. Whether the bug is in Turbopack's module graph or in some interaction three layers down, the engineering response is the same: make the implicit thing explicit and move on.


How to not get burned on the next upgrade

I cannot give you a checklist that catches every silent convention change, because by definition you do not know which convention drifted until something breaks. But the upgrade taught me a handful of habits that turn a lost hour into a quick diagnosis, and they generalize past Next.js.

Suspect the new default first, not last. When a major version ships a new bundler, runtime, or compiler default, treat every "magic" convention layered on top of it as unverified until you have seen it work in your project. The thing the release notes brag about is exactly the thing most likely to have shifted behavior underneath your conventions.

Reproduce with a hello-world before you debug your own code. If replacing your logic with the simplest possible version still fails, the failure is upstream of you. This one move would have saved me most of the hour. It is the fastest way to tell "my bug" from "the framework's bug."

Read the server log, not the browser. Anything that fails during metadata resolution, server rendering, or the build will leave its real evidence server-side while the client reports a clean 200. If the symptom is on the client but the console is empty, your trail is in the server output.

Prefer explicit wiring at the boundaries that matter. I treat file conventions as optional sugar now, not guarantees. For anything load-bearing — SEO tags, social cards, anything a silent failure would cost me real traffic on — I write the explicit route and wire it by hand. Five extra lines buys a failure mode I can see, and a thing the next major version cannot quietly renegotiate.

The migration guide tells you what changed on purpose. These habits are how you survive what changed by accident. The first kind of upgrade tax you pay once, at the top of the changelog. The second kind you pay every time you trust a default a little more than it deserves.


If you want a few more of these in your inbox, The Claude Code Memory Starter walks through one of my setups over a handful of short emails — free.


I build small tools and kits for solo creators. You can find them here: https://danielrusnok.gumroad.com

Top comments (0)