Skip to main content

The Next.js 15 Cheat Sheet I Reach For: Caching Flips and the Server/Client Line

The two Next.js 15 changes that broke my muscle memory — fetch is no longer cached by default, and the client router cache went stale-by-zero. Real snippets, the exact pitfalls, and where to look them up fast.

Published
#nextjs #app-router #server-components #cheatsheet

The Next.js 15 Cheat Sheet I Reach For: Caching Flips and the Server/Client Line

I upgraded a side project from Next.js 14 to 15 and watched a dashboard that used to feel instant suddenly hit the database on every navigation. Nothing in my code had changed. The defaults had. That afternoon is the reason I keep the Next.js Cheatsheet pinned in a browser tab — not the routing basics, but the two behavior flips that quietly rewrote how App Router apps fetch and cache.

This post is about those two flips, plus the Server/Client boundary rule that causes more "why won't this build" messages than anything else I've debugged.

The fetch default flipped — and it matters more than it sounds

In Next.js 14, a bare fetch() inside a Server Component was cached forever unless you said otherwise. In Next.js 15, the opposite is true: fetch requests are no longer cached by default, and GET Route Handlers aren't cached either (per the Next.js 15 release notes). On top of that, the Client Router Cache staleTime for page segments dropped from 30 seconds to 0 — meaning re-navigating to a page re-renders it instead of serving the in-memory copy.

So the muscle memory of "fetch is automatically static" is now wrong. Here is the exact opt-in I now write on purpose:

Input — what I want cached for an hour:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600, tags: ['products'] },
  });
  return res.json();
}

Output behavior: the first request hits the API, the response is cached, and it's reused for 3600 seconds — or until I call revalidateTag('products') from a Server Action. Without that next option, every render is a fresh network call. The flip is small to type and large in consequences: I've seen API bills jump because someone assumed the old default still held.

When I want the old "30-second stale" feel back for navigation, I set it explicitly in next.config.js:

// next.config.js
module.exports = {
  experimental: { staleTimes: { dynamic: 30 } },
};

"use client" goes at the bottom of the tree, not the top

The mistake I made for weeks: slapping "use client" at the top of a layout to make one button interactive. That marks the whole subtree as client code and ships it all to the browser. The fix is to push the directive down to the smallest leaf that actually needs interactivity.

Real before/after from my own repo:

// BAD — entire page becomes a Client Component
"use client";
export default function Page() {
  return (
    <article>
      <LongStaticPost />   {/* now shipped as JS for no reason */}
      <LikeButton />
    </article>
  );
}
// GOOD — only the button is client-side
export default function Page() {
  return (
    <article>
      <LongStaticPost />   {/* stays a Server Component, zero JS */}
      <LikeButton />        {/* this file alone has "use client" */}
    </article>
  );
}

The output difference is measurable in the bundle analyzer: the static article markup no longer appears in any client chunk. Server Components render to HTML and send no component JavaScript at all, so the only thing crossing the wire for the "good" version is the LikeButton handler. If you're also wrangling the hooks inside that button, the React Hooks Cheatsheet lays out the dependency-array gotchas that pair with this boundary.

params and searchParams are Promises now

This one broke a not-found redirect for me with a cryptic type error. In Next.js 15, params and searchParams are async — you await them. Same for cookies() and headers().

Input that used to work and now doesn't:

export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>; // ❌ params is a Promise
}

Output — the version that compiles:

export default async function Page(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  return <h1>{slug}</h1>; // ✅
}

If your editor underlines params.slug in red with "Property 'slug' does not exist on type 'Promise<...>'", this is why. Typing the prop as Promise<...> and awaiting it is the whole fix. When you're nailing down those generic prop types, the TypeScript Cheatsheet has the utility-type patterns I lean on for route params.

Server Actions: the form-or-import rule

A Server Action ("use server") can only be invoked two ways — as a form's action prop, or imported and called from a Client Component event handler. You cannot pass one as an arbitrary prop and expect it to run server-side from anywhere.

// app/actions.ts
"use server";
import { revalidatePath } from 'next/cache';

export async function addTodo(formData: FormData) {
  const title = formData.get('title');
  await db.todo.create({ data: { title: String(title) } });
  revalidatePath('/todos'); // refresh the list after writing
}
// app/todos/page.tsx
import { addTodo } from '../actions';
export default function Page() {
  return (
    <form action={addTodo}>
      <input name="title" />
      <button type="submit">Add</button>
    </form>
  );
}

Input: type "Buy milk", submit. Output: the row is written, revalidatePath('/todos') invalidates the cached segment, and the new todo appears without a manual refetch. The pitfall is forgetting the revalidatePath — the write succeeds but the UI shows stale data, and you spend twenty minutes blaming the database.

How I actually use the cheat sheet

I don't memorize this stuff. When I'm mid-feature and can't remember whether it's revalidatePath or revalidateTag, or which signature generateMetadata wants, I search the Next.js Cheatsheet for the keyword, copy the working snippet, and keep moving. Every entry carries the pitfall inline, so I catch the Promise-typed params or the fetch-default flip before it costs me a debugging session instead of after. The eighty-plus entries are searchable and run entirely in the browser — no sign-in, no tracking, nothing uploaded.

The framework moves fast. A cheat sheet that encodes the current defaults, not the ones from two majors ago, is the difference between shipping and staring at a stack trace.


Made by Toolora · Updated 2026-06-07