The Why: API Design Decisions Explained
"We didn't provide enough control over local database access that wasn't using fetch(). We had unstable_cache(), but it wasn't ergonomic." — Vercel, "Our Journey with Caching"
Understanding why Next.js's API is the way it is—and why it changed—is the difference between memorizing syntax and actually understanding your framework.
File-Based Routing: Simplicity as Philosophy
The Decision
In most frameworks, you define routes in configuration files. In Next.js, routes are defined by the file system:
pages/
index.js → /
about.js → /about
blog/[id].js → /blog/:id
Why This Design?
- Zero configuration — No router setup, no route registration
- Discoverability — Want to know what routes exist? Look at the files
- Automatic code splitting — Each page is a separate bundle
- Convention over configuration — The file structure IS the router
The Trade-off
"While convenient, the rigid file-system-based routing can feel restrictive for applications with complex or dynamic routing needs."
The App Router made this more complex with additional file conventions:
page.js— The route's UIlayout.js— Shared layoutloading.js— Loading stateerror.js— Error boundarynot-found.js— 404 handling
The Data Fetching Evolution
Pages Router Era (2019-2022)
Next.js 9.3 introduced three functions that defined how developers thought about data:
// Static Generation with data
export async function getStaticProps() {
const data = await fetchData();
return { props: { data } };
}
// Server-Side Rendering
export async function getServerSideProps(context) {
const data = await fetchData();
return { props: { data } };
}
// Dynamic routes for static generation
export async function getStaticPaths() {
return { paths: [...], fallback: false };
}
Why This Design?
These functions made the rendering strategy explicit:
getStaticProps= "Generate this at build time"getServerSideProps= "Generate this on every request"getStaticPaths= "These are the dynamic routes to pre-generate"
The Problems That Emerged
Mutual exclusivity:
getStaticPropsandgetServerSidePropscouldn't be used together on the same page. What if you wanted static content with some dynamic data?Page-level only: These functions could only be used at the page level, not in components. Data fetching was coupled to routing.
Blocking navigation: "It's obvious that user should not wait a few seconds in which nothing happens (because getServerSideProps is not finished loading) when he clicks a link."
No support in
_app.js: UsinggetInitialPropsin App disabled Automatic Static Optimization.
App Router Era (2022+)
The App Router replaced these explicit functions with implicit behavior:
// In App Router, this is a Server Component by default
async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{data}</div>;
}
Why This Change?
- Components can fetch their own data — No more prop-drilling from page-level functions
- Streaming and Suspense — Parts of the page can load independently
- Server Components — Some components never ship to the client
- Simpler mental model — Just use
async/await
The New Trade-offs
The get prefix was replaced with a more descriptive generate:
getStaticPaths→generateStaticParams
"Using the name generateStaticParams is more appropriate than getStaticPaths for the new model in the app directory."
The Caching Controversy
The V14 Decision
Next.js 14 made aggressive caching the default:
// This fetch is cached by default in v14
const data = await fetch('https://api.example.com/data');
Why They Did It
The philosophy was performance-first:
- Faster responses for users
- Reduced server load
- Optimized for production by default
Why Developers Hated It
"Next.js invisibly overrides the fetch() call with their own caching version that's on by default and causes stale state in unexpected places."
Problems included:
- Invisible behavior — Developers didn't know their data was being cached
- Third-party library issues — Libraries using fetch internally got cached unexpectedly
- Debugging nightmares — Stale data with no obvious cause
- No easy global disable — Had to opt-out per-fetch
The V15 Reversal
Vercel heard the feedback:
"Developers on Reddit have mixed opinions on this, with one calling it 'by far my favourite change' and another describing it as 'horrible.'"
Next.js 15 made caching opt-in:
// No caching by default in v15+
const data = await fetch('https://api.example.com/data');
// Explicit caching
const data = await fetch('https://api.example.com/data', { cache: 'force-cache' });
The use cache Future
Next.js 16 introduced a new explicit caching model:
'use cache';
async function CachedComponent() {
const data = await fetchData();
return <div>{data}</div>;
}
"Nothing is cached by default. No more hidden caches."
The Middleware Design
The Original Vision
Middleware runs code before a request is completed—perfect for authentication, redirects, A/B testing.
// middleware.js
export function middleware(request) {
if (!request.cookies.get('session')) {
return Response.redirect('/login');
}
}
The Edge Runtime Decision
Middleware was designed to run on the Edge Runtime—a lightweight environment close to users.
Why This Caused Problems
The Edge Runtime doesn't support all Node.js APIs:
- No filesystem access
- No native Node.js modules
- No persistent database connections
- Strict size limits (1MB)
- Short execution limits (5 seconds on Vercel)
Developers were "blocked even experimenting with Next 13 app directory until middleware can run as non-edge."
"What's especially frustrating is that this is self-hosted anyway. Our middleware is running in Node regardless!"
The Security Incident
In early 2025, a critical vulnerability (CVE-2025-29927) was discovered. Attackers could bypass Middleware-based authentication using a specially crafted header.
Vercel's new recommendation: "Middleware should be used only for light interception—not as your main guard."
The V15.5 Solution
Next.js 15.5 introduced stable Node.js runtime support for Middleware:
export const config = {
runtime: 'nodejs', // Now supported
};
Image Component Design
The Problem
Images are typically the largest assets on a page. Without optimization:
- Images load at full resolution
- No lazy loading
- No format optimization (WebP, AVIF)
- Layout shift as images load
The Solution
Next.js 10 introduced next/image:
import Image from 'next/image';
<Image src="/photo.jpg" width={500} height={300} alt="Photo" />
Design Decisions
- Automatic optimization — Images compressed on-demand
- Format detection — WebP/AVIF served to supporting browsers
- Lazy loading — Built-in by default
- Size prevention — Required dimensions prevent layout shift
The Trade-off
The required width and height props confuse newcomers. Why can't the component just figure it out?
The answer: preventing layout shift requires knowing dimensions before the image loads. This is a deliberate trade-off—developer inconvenience for better user experience.
Key API Design Principles
Looking across these decisions, patterns emerge:
1. Performance First
Every API exists because it makes the web faster for users. Developer convenience is secondary.
2. Convention Over Configuration
When possible, eliminate configuration. File structure is the router. Default behaviors are optimized.
3. Progressive Enhancement
Start simple, add complexity only when needed. The basic case should work without configuration.
4. Listen to Feedback
The caching reversal shows Vercel will change course when the community pushes back strongly enough.
5. Web Standards Where Possible
Use fetch(), not custom methods. Use Response, not custom objects. The closer to the platform, the more portable the code.
Key Takeaways
- File-based routing eliminates configuration but can feel restrictive
getStaticProps/getServerSidePropswere replaced by async components withfetch()- Caching was aggressive in v14, opt-in in v15+
- Middleware was Edge-only until v15.5
- The Image component requires dimensions to prevent layout shift
- API design prioritizes user performance over developer convenience