Skip to main content

A Saturday upgrading my dashboard to Next.js 16

The codemod, proxy.ts, Turbopack builds, properly async params, Cache Components and deleting most of my useMemo calls, in the order I ran into them.

Updated 7 min readMarkdown
A Saturday upgrading my dashboard to Next.js 16

Next.js 16 came out on Tuesday, and I gave it Saturday morning on one of my side projects: a small dashboard with a few dozen routes, a couple of route handlers, auth in middleware and Postgres behind it. It turned into most of the day, but nearly all of that was one feature. Here's the diff that mattered, roughly in the order I hit it.

Start with the codemod:

npx @next/codemod@canary upgrade latest

It bumps next, react and react-dom and runs the transforms it knows about, which turned out to be more than I expected. It renamed my middleware, made most params access async and moved a few config keys around. Where it wasn't sure, it left a // TODO comment instead of guessing, which I was grateful for. I committed straight after it ran and before touching anything by hand, so my own changes would be a separate diff I could actually read.

middleware.ts becomes proxy.ts

The file is renamed and so is the function it exports:

- // middleware.ts
- export function middleware(request: NextRequest) {
+ // proxy.ts
+ export function proxy(request: NextRequest) {
    const session = request.cookies.get("session");
    if (!session && request.nextUrl.pathname.startsWith("/app")) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
  }

I think the new name is more honest about what the file is for. Since the middleware bypass in March, the Next team has been fairly clear that this layer is for redirects, rewrites and headers at the edge of your app, and shouldn't be your only auth check. proxy.ts runs on the Node.js runtime. middleware.ts still works for now but is deprecated, so you might as well rename it while you're in there. My redirect stayed, but the real check already lives next to the query in the data layer. If yours doesn't, this is as good an excuse as any.

Turbopack builds

next dev and next build both use Turbopack by default now. I'd been on it for dev since 15, so the new part was the production build, which was clearly quicker on my machine (one small app and a couple of runs, so don't take that as a benchmark).

One thing broke. I had a custom webpack() function in next.config.ts adding an SVG loader, and with Turbopack as the default the build now fails when it finds a webpack config rather than silently ignoring it. Moving it across was easy enough:

  const nextConfig: NextConfig = {
-   webpack(config) {
-     config.module.rules.push({ test: /\.svg$/, use: ["@svgr/webpack"] });
-     return config;
-   },
+   turbopack: {
+     rules: {
+       "*.svg": { loaders: ["@svgr/webpack"], as: "*.js" },
+     },
+   },
  };

If you're not ready for that, next build --webpack still works.

Async params, for real this time

In 15, params and searchParams became promises but synchronous access still worked with a warning. In 16 the fallback has gone. The codemod handled the obvious pages and missed a couple of helper functions that took params as an argument, which the type checker found for me:

- export default function Page({ params }: { params: { id: string } }) {
-   const report = getReport(params.id);
+ export default async function Page({ params }: PageProps<"/reports/[id]">) {
+   const { id } = await params;
+   const report = await getReport(id);

PageProps<"/reports/[id]"> is a global type that gets generated (after next dev or next typegen) and is typed from the route itself, so I'm no longer hand-writing { params: Promise<{ id: string }> } and getting it slightly wrong. The same goes for cookies(), headers() and draftMode(). Await them.

Cache Components

This is the big conceptual change, and where most of my day went. You switch it on in config:

const nextConfig: NextConfig = {
  cacheComponents: true,
};

With it on, nothing is cached unless you ask. Pages are dynamic by default, and you opt particular functions or components into caching with the "use cache" directive. It's more or less Partial Prerendering grown up: a static shell, with the dynamic parts streaming in behind <Suspense>.

I'd been fighting the old implicit caching rules for about a year (fetch cached here but not there, revalidate = 0 sprinkled around until things behaved), so this was the change I actually wanted. My reports list ended up like this:

async function getRecentReports() {
  "use cache";
  cacheLife("minutes");
  cacheTag("reports");
  return db.report.findMany({ orderBy: { createdAt: "desc" }, take: 20 });
}

When someone creates a report, the server action calls updateTag("reports") so they see their new row straight away. There's also revalidateTag, which now wants a cache-life profile as its second argument, for the stale-while-revalidate kind of refresh where a slightly old value is fine.

The slow part wasn't writing that. It was that turning Cache Components on surfaced every place where I read cookies deep inside a component with no Suspense boundary above it, and there were more of those than I'd have guessed. The error messages do point at the right component, which helped a lot.

React Compiler, and deleting useMemo

React Compiler reached 1.0 earlier this month and Next 16 supports it as a stable option:

const nextConfig: NextConfig = {
  reactCompiler: true,
};

You need babel-plugin-react-compiler installed too, and builds are a little slower with it on because it goes through Babel. Then came the satisfying bit. I searched for useMemo and useCallback and removed the ones that only existed to keep references stable:

- const filtered = useMemo(
-   () => rows.filter((r) => r.status === status),
-   [rows, status],
- );
- const onSelect = useCallback((id: string) => setSelected(id), []);
+ const filtered = rows.filter((r) => r.status === status);
+ const onSelect = (id: string) => setSelected(id);

That was most of them. I kept a couple where the work really is expensive, mainly so the intent stays visible to whoever reads it next. React DevTools puts a "Memo" badge on components the compiler has optimised, which is how I checked it was doing anything at all. And if a component breaks the rules of React (mutating props, reading refs during render) the compiler just skips it, and the ESLint plugin tells you where. That list was useful on its own.

Two things from React 19.2

Next 16 ships with React 19.2, and I used two of its additions straight away. <Activity> hides part of the tree without unmounting it. I've got a tabbed filter panel that used to lose its state whenever you switched tabs:

<Activity mode={tab === "filters" ? "visible" : "hidden"}>
  <FiltersPanel />
</Activity>

Hidden content keeps its state and has its effects cleaned up while it's hidden, which is what I'd been faking with display: none, minus the effects carrying on in the background.

And useEffectEvent fixed an effect that reconnected a websocket every time the theme changed, because the theme was used inside the message handler:

const onMessage = useEffectEvent((msg: Message) => {
  showToast(msg, theme);
});

useEffect(() => {
  const ws = connect(roomId, onMessage);
  return () => ws.close();
}, [roomId]);

The handler always sees the latest theme, the effect only depends on roomId, and the lint rule knows not to ask for onMessage in the dependency list.

A few smaller things to finish. next lint has gone, so I run ESLint directly. Node 20.9 is now the minimum. Some next/image defaults changed, and if you use local images with query strings you'll need images.localPatterns.

The app builds faster, the caching is finally something I could explain to someone else without a diagram, and there are far fewer hooks in it than there were on Friday.


First published on my blog. Cover photo: OathOn, CC0, via Wikimedia Commons (source).

About the author

  • Full stack engineer in London with 5+ years on production web platforms. I lead AI-assisted modernisation at Xanda: React and Go for the UK energy market, a government claims portal, and moving old Java and C++ systems onto the cloud. Outside work I run a small home server behind a Cloudflare Tunnel with a handful of public tools on it: Doorstep (rental alerts across Rightmove, OnTheMarket and OpenRent), SeenSooner (NHS A&E and planned-care waits), GridPulse (live GB grid carbon), Sift (LLM ticket triage that survives model failures) and AI Radar (a news digest written hourly by an n8n workflow). I write here about React and Next.js upgrades, Node, coding with AI agents, and the CVEs I've had to patch. Originals live at blogs.kishankanejiiya.tech.

No comments yet. Be the first to comment.