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.

I resisted the Next.js App Router for longer than I should have. Our team had a perfectly functional React SPA โ client-side routing, a REST API, Redux for state management, the whole classic setup. It worked. Users didn't complain. The lighthouse scores were mediocre but nobody was really looking at those.
Then we started getting reports from users in Southeast Asia and parts of Africa where mobile connections are slower. The app would show a white screen for 6-8 seconds before anything rendered. We'd optimized the bundle, lazy-loaded routes, compressed images. Still slow. The problem wasn't the bundle size exactly โ it was the architecture. Everything had to download, parse, execute, then fetch data, then render. Four sequential steps before a user saw content.
That's what pushed us toward server-rendered React. And since Next.js was the most mature option, that's where we landed. But the migration was rough in ways I didn't expect, and I want to talk about that honestly.
The Mental Model Shift
In a traditional React SPA, every component runs in the browser. You write code, it ships to the client, it executes on the user's device. Simple mental model. The server is just a static file host โ it sends down the JavaScript bundle and gets out of the way.
The App Router changes this in a way that's disorienting at first. Every component is a Server Component by default. It runs on your server. It can query your database directly, read files from disk, access environment variables with secrets in them. And the output โ just HTML โ gets sent to the browser. No JavaScript for that component ships to the client at all.
This is great for performance but it breaks your existing intuition about React. You can't use useState in a Server Component because there's no client-side state to manage โ the component doesn't exist in the browser. You can't use useEffect because there's no browser lifecycle. You can't add an onClick handler because there's nothing to click on the server.
When you need interactivity โ a button that toggles something, a form with client-side validation, a dropdown menu โ you pull that piece into a separate file and put 'use client' at the top. That component gets hydrated in the browser like old-school 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 thing that tripped up our team the most was the directional rule. A Server Component can render a Client Component โ that's the pattern above. But a Client Component can't import a Server Component. The data flows one way: server down to client. If you need to compose them the other way, you pass the Server Component as a children prop through a Client Component wrapper. It's not hard once you understand it, but it generated a lot of confused PRs in the first couple weeks.
File System Routing
If you're coming from React Router, where you manually define route paths in code, the App Router replaces all of that with directory structure. Your routes are your folders. A file at app/dashboard/page.tsx handles the /dashboard route. A file at app/blog/[slug]/page.tsx handles /blog/anything.
Each route segment can also have special files:
layout.tsxโ wraps that route and all its children. Persists across navigation, doesn't re-render when you navigate between child routes. We use this for the dashboard sidebar.loading.tsxโ shows automatically while the page's async data is loading. It's a Suspense boundary that Next.js wires up for you.error.tsxโ catches runtime errors in that route segment. Has to be a Client Component because it uses React's error boundary API.not-found.tsxโ what shows when you callnotFound()from a Server Component.
I'll be honest, I didn't love this at first. I liked having all my routes in one file where I could see the whole URL structure at a glance. But after living with it for a few months, I've come around. The co-location of a route with its loading state, error boundary, and layout means everything about a page lives in one directory. When I need to change how /dashboard/settings works, I go to one folder and everything relevant is there.
What I still don't love is that the special filenames feel magical. layout.tsx and page.tsx and loading.tsx are framework conventions you have to memorize. New team members keep creating files called Dashboard.tsx in the wrong place and wondering why nothing renders. But that's a documentation problem, not a design problem.
The Caching Situation
This is where I lost the most time during migration. I need to talk about this because it's the part of Next.js that generates the most confusion, and the documentation โ while technically complete โ doesn't do a great job of explaining the interaction between the different layers.
Next.js caches things at four levels:
Request Memoization. During a single server render, if you call fetch() with the same URL multiple times, it deduplicates. So if three different Server Components on the same page all fetch /api/user, only one actual HTTP request goes out. This is actually nice and rarely causes problems.
The Data Cache. This is the one that got me. By default, fetch() responses in Server Components are cached across requests. Not just for the current render โ across multiple users, across multiple page loads. The first user hits the page, the fetch runs, the result gets stored. The second user hits the page, the fetch doesn't run at all. They get the cached response.
This is great for truly static data. Blog posts, product listings, stuff that doesn't change often. But if you're fetching user-specific data or anything that should be fresh, you need to opt out yourself:
// 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'] } });
I spent an afternoon debugging a page that was showing stale user data. Turns out the fetch was being cached at this layer and I hadn't set cache: 'no-store'. The fix was one line but finding the problem took hours because nothing in the UI indicated the data was cached. It just looked like the database query was returning old results.
Full Route Cache. Static routes get fully rendered at build time and served as HTML files. If your page doesn't use dynamic data, Next.js pre-renders it and serves it from the edge. Fast, but if you change the data, the old HTML keeps getting served until you redeploy or revalidate.
Router Cache. The browser keeps previously visited route segments in memory so navigation feels instant. This is mostly invisible but occasionally surprising โ if you navigate away from a page and come back, you might see stale data because the browser cached the previous render.
The interaction between these layers is where things get confusing. I don't have a clean mental model for it, to be honest. I mostly handle it by being deliberate about caching on every fetch call and using revalidatePath() or revalidateTag() after mutations.
Server Actions
This was the feature that surprised me the most. In a good way.
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 for the server action and the client calls it via a POST request. But you never see the endpoint. You just import the function and use it. The type safety flows through โ if your server action expects certain form fields, TypeScript catches missing ones at compile time.
We use this for most of our form submissions now. For complex data mutations that affect multiple tables, I still prefer a proper API route. Server Actions work well for simple create/update operations. Where they get awkward is when you need fine-grained control over the HTTP response โ things like setting specific headers or returning different status codes. For that, a traditional route handler is still better.
Our backend team was skeptical at first. Having database queries triggered from React components felt wrong to them. I get that instinct. The separation between frontend and backend has been a strong convention for years. But in practice, these components run on the server. They ARE the backend. The code that queries the database and the code that renders the HTML are in the same execution context. There's nothing wrong with that, it's just different from what we were used to.
Deployment Without Vercel
Vercel deploys Next.js apps with zero config because they built the framework. But not everyone wants to (or can) use Vercel. We're on AWS, and deploying an App Router project ourselves required some additional work.
The key is setting output: 'standalone' in your Next.js 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 your imports and copy only the files you actually use into a .next/standalone directory. Without this, you end up shipping the entire node_modules folder in your Docker image, which bloats it from maybe 150MB to over a gigabyte.
Then your Dockerfile looks something like:
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. The standalone output means we don't install node_modules in the runner stage at all.
One thing to watch for โ if you're using Next.js Image Optimization, the standalone mode includes a minimal image server, but it doesn't support all the same features as the full Next.js server. We ended up using a CDN for image transformation instead. Not a big deal, but it was an unexpected gap.
Was It Worth It
The migration took our team of four about three weeks. That's not nothing. We broke a few pages along the way, had confusing bugs around the caching layers, and our backend developers had to adjust to seeing database queries inside React components.
But the results were hard to argue with. Time-to-interactive on slow mobile connections went from over 6 seconds to about 1.5. Our JavaScript bundle shrank by nearly 40%. And some of the boilerplate we'd been maintaining โ API routes that existed only to pass data from the server to the UI, Redux reducers that existed only to store fetched data โ we just deleted that code. It was no longer needed.
I wouldn't recommend the migration to every team. If your app is mostly interactive โ lots of client state, drag-and-drop, real-time collaboration โ the SPA model is probably still better. Server Components shine when you have a lot of content-heavy pages with small islands of interactivity. Blogs, dashboards, e-commerce product pages, documentation sites.
For us, it was the right call. I'm not fully comfortable with the caching model yet, and I miss the simplicity of "everything runs in the browser," but the performance gains for our users made it worth the tradeoff. I think.
Written by
Anurag Sinha
Developer who writes about the stuff I actually use day-to-day. If I got something wrong, let me know.
Found this useful?
Share it with someone who might find it helpful too.
Comments
Loading comments...
Related Articles
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.
Accessibility Bugs I Keep Finding in Web Apps
The most frequent accessibility violations I encounter in code reviews, why they matter, and the specific fixes.
Tailwind vs CSS Modules: What We Ended Up Doing
How our team debated and resolved the Tailwind vs CSS Modules question. We didn't pick just one.