Building Production-Ready Apps With Next.js: The Architecture Shift
Tracing the migration path from traditional React SPAs to the Next.js App Router, addressing routing mechanics, caching layers, and server action boundaries.

White screen for 6-8 seconds on mobile connections in Southeast Asia. That was the report that started the conversation about changing our architecture. Users staring at nothing while our React SPA downloaded, parsed, and executed JavaScript before it could even begin fetching data. Four sequential steps between the user tapping a link and seeing content. We'd optimized the bundle, lazy-loaded routes, compressed images. Still slow. Because the bottleneck wasn't any particular asset โ it was the architectural decision to do everything client-side.
Our team had a perfectly functional React SPA โ client-side routing, REST API, Redux for state management. It worked. Nobody on the team was complaining. Lighthouse scores were mediocre but nobody was checking them regularly. The mobile performance reports from actual users in the field were what forced the question.
Server-rendered React was the direction. Next.js was the most mature implementation. The migration was rougher than expected in some specific ways, and I want to talk about those directly rather than just showing the happy-path code samples.
The Mental Model Change
Traditional React SPA: every component runs in the browser. The server is a static file host that sends down the JavaScript bundle and gets out of the way. Simple to reason about because there's one execution environment.
The App Router flips this. Every component is a Server Component by default. Runs on your server. Can query your database directly, read files from disk, access environment variables containing secrets. The output โ just HTML โ gets sent to the browser. No JavaScript for that component ships to the client at all.
Great for performance. Disorienting for developers who've been writing React for years. No useState in a Server Component โ there's no client-side state to manage, because the component doesn't exist in the browser. No useEffect โ no browser lifecycle. No onClick โ nothing to click on the server.
When you need interactivity โ a toggle button, client-side form validation, a dropdown menu โ you extract that piece into a separate file with 'use client' at the top. That component gets hydrated in the browser like traditional React. Everything else stays on the server.
// Server Component โ runs on your backend, no JS shipped to browser
import { db } from '@/lib/database';
import { LikeButton } from './LikeButton';
async function BlogPosts() {
const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC');
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<LikeButton postId={post.id} initialCount={post.likes} />
</article>
))}
</div>
);
}
'use client';
import { useState } from 'react';
export function LikeButton({ postId, initialCount }) {
const [likes, setLikes] = useState(initialCount);
async function handleLike() {
setLikes(prev => prev + 1);
await fetch('/api/likes', { method: 'POST', body: JSON.stringify({ postId }) });
}
return <button onClick={handleLike}>โค๏ธ {likes}</button>;
}
The directional rule tripped up our team the most. Server Components can render Client Components โ that's the pattern above. But Client Components can't import Server Components. Data flows one direction: server down to client. If you need the reverse composition, you pass the Server Component as children through a Client Component wrapper. Not hard once understood. Generated a lot of confused PRs during the first couple weeks. For more on the migration experience and the specific gotchas, I wrote about that separately in what we learned migrating to React Server Components.
File System Routing
Coming from React Router where you manually define route paths in code, the App Router replaces all of that with directory structure. Routes are folders. app/dashboard/page.tsx handles /dashboard. app/blog/[slug]/page.tsx handles /blog/anything.
Each route segment can also have special files:
layout.tsxโ wraps the route and all children. Persists across navigation. Doesn't re-render when navigating between child routes. We use this for the dashboard sidebar.loading.tsxโ shows automatically while the page's async data loads. A Suspense boundary Next.js wires up for you.error.tsxโ catches runtime errors in that segment. Must be a Client Component because it uses React's error boundary API.not-found.tsxโ what renders when you callnotFound()from a Server Component.
Didn't love this at first. Liked having all routes in one file where the full URL structure was visible at a glance. After a few months, came around. Co-locating a route with its loading state, error boundary, and layout means everything about a page lives in one directory. Need to change how /dashboard/settings works? One folder. Everything relevant is there.
What I still don't love: special filenames feel magical. layout.tsx, page.tsx, loading.tsx are framework conventions you memorize. New team members keep creating files called Dashboard.tsx in the wrong location and wondering why nothing renders. Documentation problem, not a design problem.
Caching โ Where the Most Time Was Lost
Need to cover this because it generated the most confusion during migration, and the documentation doesn't do a great job explaining how the different layers interact.
Next.js caches at four levels:
Request Memoization. During a single server render, duplicate fetch() calls to the same URL get deduplicated. Three Server Components on the same page all fetching /api/user? One actual HTTP request. Rarely causes problems.
The Data Cache. By default, fetch() responses in Server Components are cached across requests. Not just the current render โ across multiple users, multiple page loads. First visitor triggers the fetch. Second visitor gets the cached result without the fetch running at all.
Great for static data. Blog posts, product listings, content that doesn't change frequently. For user-specific data or anything that should be fresh, you opt out:
// Never cache โ always hit the origin
fetch('https://api.example.com/data', { cache: 'no-store' });
// Cache for 60 seconds, then revalidate
fetch('https://api.example.com/data', { next: { revalidate: 60 } });
// Cache until manually invalidated
fetch('https://api.example.com/posts', { next: { tags: ['posts'] } });
Spent an afternoon debugging a page showing stale user data. The fetch was cached at this layer without cache: 'no-store'. One-line fix. Finding the problem took hours because nothing in the UI indicated the data was cached โ it just looked like the database returning old results.
Full Route Cache. Static routes are fully rendered at build time and served as HTML. If your page doesn't use dynamic data, Next.js pre-renders it and serves from the edge. Fast, but content changes require a redeploy or revalidation.
Router Cache. The browser keeps previously visited route segments in memory for instant navigation feel. Mostly invisible but occasionally surprising โ navigate away and come back, you might see stale data because the browser cached the previous render.
The interaction between these layers is where confusion lives. Don't have a clean mental model for it even now, from what I can tell. Mostly handle it by being deliberate about caching on every fetch call and using revalidatePath() or revalidateTag() after mutations.
Server Actions
The feature that surprised me most. Positively.
Instead of building REST endpoints for every form submission, Server Actions let you define a function with 'use server' that runs on the backend and gets called directly from a Client Component. No API route. No fetch call. No endpoint URL to manage.
// app/actions.ts
'use server';
export async function submitContactForm(formData: FormData) {
const email = formData.get('email');
if (!email) throw new Error('Email is required');
await db.insert('contacts', { email });
return { success: true };
}
// app/ContactForm.tsx
'use client';
import { submitContactForm } from './actions';
export function ContactForm() {
return (
<form action={submitContactForm}>
<input name="email" type="email" />
<button type="submit">Submit</button>
</form>
);
}
Under the hood, Next.js creates an HTTP endpoint and the client calls it via POST. But you never see the endpoint. Import the function and use it. Type safety flows through โ TypeScript catches missing form fields at compile time.
We use this for most form submissions now. Complex data mutations affecting multiple tables still get a proper API route. Server Actions work well for simple create/update operations. They get awkward, in my experience, when you need fine-grained control over HTTP responses โ specific headers, custom status codes. Traditional route handlers handle that better.
The backend team was, not surprisingly, skeptical. Database queries triggered from React components felt wrong to them. Understandable instinct โ the separation between frontend and backend has been convention for years. But in practice, Server Components run on the server. They are the backend. The code querying the database and the code rendering HTML share the same execution context. Nothing wrong with that. Just different from what everyone was used to.
Deploying Without Vercel
Vercel deploys Next.js with zero config โ they built the framework. Not everyone wants to or can use Vercel. We're on AWS. Self-hosting an App Router project required some extra work.
Key setting: output: 'standalone' in the config:
// next.config.ts
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' }
]
}
};
export default nextConfig;
This tells Next.js to trace imports and copy only the files actually used into .next/standalone. Without it, the entire node_modules ships in your Docker image โ bloating from maybe 150MB to over a gigabyte.
Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]
Multi-stage build keeps the final image small. Standalone output means no node_modules in the runner stage. If Dockerfiles are new territory, my beginner's guide to Docker covers image layers and multi-stage builds.
One thing to watch: standalone mode's minimal image server doesn't support all the same features as the full Next.js server for Image Optimization, as far as I can tell. We ended up using a CDN for image transformation instead. Not a dealbreaker. Just an unexpected gap.
Was It Worth It
Three weeks for a team of four. Broke a few pages along the way. Confusing bugs around caching. Backend developers adjusting to database queries inside React components.
Results were hard to argue with, not sure. Time-to-interactive on slow mobile connections: over 6 seconds โ about 1.5. JavaScript bundle: down nearly 40%, from what I've seen. Some boilerplate we'd been maintaining โ API routes that existed only to pass data from server to UI, Redux reducers that existed only to store fetched data โ just deleted. No longer needed.
Wouldn't recommend the migration for every team. If your app is mostly interactive โ heavy client state, drag-and-drop, real-time collaboration โ the SPA model is probably still better. Understanding how the JavaScript event loop works matters regardless, since async behavior in both client and server code follows the same queue mechanics. Server Components shine for content-heavy pages with small islands of interactivity. Blogs, dashboards, e-commerce product pages, documentation sites.
Middleware and Edge Functions
One more piece of the App Router worth mentioning because it came up during our migration. Next.js middleware runs before a request reaches your route handler. It's useful for authentication checks, redirects, geolocation-based routing, and A/B testing.
The middleware file lives at the root of the app directory:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session_token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
Runs on the edge โ closer to the user than your origin server. The redirect happens before the page even starts rendering. For our authentication flow, this meant unauthenticated users get redirected to login without downloading any of the dashboard code first. Clean UX and a minor security improvement (no flash of authenticated content).
The constraint: middleware runs in the Edge Runtime, which is a stripped-down JavaScript environment. No Node.js APIs. No fs. No child_process. Most npm packages that depend on Node internals won't work. This caught us when we tried to verify JWTs in middleware using a library that depended on Node's crypto module. Had to swap to a Web Crypto API-compatible JWT library.
Static Generation vs. Server-Side Rendering
The App Router supports both, and understanding when each applies saves debugging time.
If your page component doesn't use dynamic data โ no cookies, no headers, no search params, no cache: 'no-store' on fetch calls โ Next.js statically generates it at build time. The page is served as a pre-built HTML file. Fast. Cheap. No server-side computation per request.
If the page uses anything dynamic, it's rendered on each request (SSR). This is where caching configuration becomes important โ you might want the page to be dynamic but still cached for 60 seconds using revalidate, rather than re-rendering on every single request.
The distinction tripped us up because it's implicit. There's no "this is a static page" declaration. Next.js infers it from what your code does. Change from fetch(url) to fetch(url, { cache: 'no-store' }) and the page silently switches from static to dynamic rendering. No warning. The behavior change is significant โ static pages are served from the CDN edge, dynamic pages hit your origin server. Your hosting costs and latency characteristics change based on one fetch option.
We now have a comment at the top of each page file: // Rendering: static or // Rendering: dynamic (uses cookies). Not enforced by anything automated, just a convention so developers know what to expect when modifying the page.
Error Handling Across the Boundary
One thing that bit us repeatedly during migration: error handling behaves differently depending on whether you're in a Server Component or a Client Component, and the boundary between them can swallow useful information.
A Server Component throws an error during rendering? The nearest error.tsx boundary catches it. But the error message that reaches the client is generic โ Next.js strips details to avoid leaking server internals. Good for security. Terrible for debugging during development. You see "An error occurred" in the browser while the actual stack trace is buried in the server logs. Our team spent a lot of time staring at unhelpful browser errors before building the habit of checking server-side logs first.
Server Actions have their own error quirks. If a Server Action throws, the error propagates to the calling Client Component. But if you're using the useFormState pattern, the error needs to be returned as part of the state object โ not thrown. Throwing from a Server Action inside a form action causes an unhandled rejection in some cases. We standardized on returning { success: false, error: "message" } from every Server Action and checking that on the client side. Slightly more boilerplate than throwing. Way more predictable.
The pattern we settled on: every Server Action wraps its body in a try/catch, logs the full error server-side, and returns a sanitized error message to the client. Server Components do the same. The error.tsx boundaries exist as a safety net, but the primary error path is explicit return values. Felt over-engineered at first, if I'm being honest. After the third time a thrown error disappeared into the void between server and client, everyone agreed it was worth it.
The Verdict
For us, right call. Not fully comfortable with the caching model yet. Miss the simplicity of "everything runs in the browser." But the performance gains for our users โ particularly the ones on slow mobile connections who were getting the worst experience โ made the tradeoff worth accepting.
The App Router is a bet on server-side rendering as the default, with client-side interactivity as the exception. For content-heavy applications, that bet pays off clearly. For interaction-heavy applications, it's a worse fit. Know which category your project falls into before migrating, and the decision becomes simpler.
Further Resources
- Next.js Documentation โ The official docs cover the App Router, Server Components, caching, and deployment in detail with interactive examples.
- Vercel Blog โ Engineering posts from the team behind Next.js, covering architecture decisions, performance optimizations, and new features.
- React Server Components RFC โ The original RFC explaining the design rationale behind Server Components and how they fit into the React ecosystem.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

Next.js App Router Deep Dive โ Server Components, Streaming, and the Caching Trap
What I learned after six months of building with the App Router: when server components shine, when client components are the right call, and why caching will waste your afternoon.

What We Learned Migrating to React Server Components
Notes from migrating our SPA to the Next.js App Router and React Server Components. What improved, what broke, and what surprised us.

Frontend Testing โ What to Actually Test and What's a Waste of Time
My honest take on unit vs integration vs e2e testing after years of writing tests that caught nothing and missing tests that would have caught everything.