Next.js 15 Static Rendering: Four Lessons from Shipping a Real Portfolio
fetch() is no longer cached, params are promises, and one careless import can de-static a whole route — what changed in Next.js 15 and how to keep pages fast.
Rebuilding this portfolio on Next.js 15 taught me more about the App Router's rendering model than any tutorial. The framework got stricter in exactly the right places — and each strictness bit me once before I understood it. Here are the four lessons, in the order I learned them.
1. fetch() is no longer cached by default
In Next.js 14, fetch in a Server Component was cached unless you opted out with cache: 'no-store'. In 15 the default flipped: every fetch is dynamic unless you opt in. That is the safer default — no more stale data surprises — but it means a page you assumed was static may now render on every request. If the data is fine to cache, say so explicitly with next: { revalidate } or wrap it in unstable_cache:
// Revalidate at most once an hour — page stays static between rebuilds
const res = await fetch("https://api.example.com/stats", {
next: { revalidate: 3600 },
});Run next build and read the route table it prints: ○ means static, ƒ means dynamic. After the upgrade, half my routes had silently become ƒ. Ten minutes of explicit caching flags fixed all of them.
2. params and searchParams are now promises
In 15, the params and searchParams props are asynchronous — you must await them, even in a fully static page. The old synchronous access still type-checks in some setups and then fails at runtime, which makes this an annoying one to catch by reading code alone. The pattern for a dynamic blog route looks like this:
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPostBySlug(slug);
// ...
}3. generateStaticParams is still the way for content sites
For a blog, docs, or any content-driven route, generateStaticParams pre-renders every slug at build time — no client-side fetching, no loading spinners, instant navigation. Pair it with dynamicParams = false and any unknown slug returns a real 404 instead of triggering an on-demand render:
export const dynamicParams = false;
export async function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}4. One dynamic import can de-static a whole route
Calling headers(), cookies(), or reading searchParams anywhere in a route's component tree opts that route into dynamic rendering — including through an innocent-looking shared component. The fix is structural, not clever: keep dynamic reads in small, isolated components (or separate route segments) so the rest of the page stays static. When in doubt, the build output's route table is the ground truth — check it after every refactor, not just at release time.
None of this is difficult once internalized, but it is easy to get wrong by momentum from Next.js 13/14 habits. The upgrade took an afternoon; the lesson — trust the build output, not your assumptions — applies to every framework.