Next.js has become the go-to framework for building production React applications. With version 15, it's more capable than ever. This guide covers what you need to know to build and deploy production-ready Next.js applications.
Why Next.js for Production
Next.js solves problems you'll inevitably face when building production React apps: server-side rendering, routing, code splitting, and deployment. Rather than assembling these pieces yourself, Next.js provides an integrated solution that works out of the box.
The framework's opinionated approach means less configuration and more consistency across projects. When you hire a developer who knows Next.js, they can contribute immediately because the project structure is predictable.
App Router vs Pages Router
Next.js 15 supports both the newer App Router and the legacy Pages Router. For new projects, use the App Router;it's the future of the framework and enables features like React Server Components.
The App Router uses a file-system based routing inside the app directory. Here's how the folder structure maps to your application's URLs. Each folder becomes a route segment:
app/
├── page.tsx # /
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/my-post
└── layout.tsx # Shared layout
Each page.tsx file becomes a route. Folders with brackets like [slug] create dynamic segments that capture URL parameters. The layout.tsx file wraps child pages and persists across navigation, making it perfect for headers, footers, and sidebars.
Server Components and When to Use Them
React Server Components (RSC) are the headline feature of modern Next.js. They render on the server, send HTML to the client, and never ship their JavaScript to the browser.
Use Server Components by default. They're perfect for:
- Fetching data from your database or API
- Displaying static content
- Components that don't need interactivity
Add the 'use client' directive only when you need:
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
- React hooks (useState, useEffect)
The following example demonstrates both component types. The ProductList runs entirely on the server and can query your database directly, while AddToCartButton ships JavaScript to the browser because it manages local state and handles user interaction:
// Server Component (default)
async function ProductList() {
const products = await db.products.findMany();
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
// Client Component
'use client'
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
await addToCart(productId);
setLoading(false);
}
return <button onClick={handleClick}>{loading ? 'Adding...' : 'Add to Cart'}</button>;
}
Notice that the Server Component uses async/await directly in the function body. You can't do this in Client Components because they run in the browser. The pattern here is to keep data fetching in Server Components and pass serializable data down to Client Components as props.
Data Fetching Patterns
In the App Router, data fetching happens directly in Server Components using async/await. No special hooks required. You simply await your data at the top of your component function:
async function BlogPost({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`);
return <article>{post.content}</article>;
}
This simplicity is one of the App Router's best features. No useEffect, no loading states to manage manually, no data fetching libraries required.
Next.js extends the native fetch API with caching and revalidation. You control caching behavior through options passed to fetch, allowing you to balance between static performance and data freshness:
// Cache forever (static)
fetch(url, { cache: 'force-cache' });
// Never cache (dynamic)
fetch(url, { cache: 'no-store' });
// Revalidate every hour
fetch(url, { next: { revalidate: 3600 } });
The first option is great for data that rarely changes, like marketing pages. The second ensures every request gets fresh data. The third strikes a balance;serve cached data but refresh it periodically.
For database queries without fetch, use the unstable_cache function or route segment configuration to control caching behavior.
API Routes and Server Actions
API routes live in the app/api directory. Each route.ts file exports functions for HTTP methods. You'll use this approach when you need to expose an API endpoint for external clients or webhooks:
// app/api/users/route.ts
export async function GET(request: Request) {
const users = await db.users.findMany();
return Response.json(users);
}
export async function POST(request: Request) {
const data = await request.json();
const user = await db.users.create({ data });
return Response.json(user, { status: 201 });
}
The function names match HTTP methods. You can also export PUT, DELETE, PATCH, and OPTIONS handlers. The request parameter gives you access to headers, query parameters, and the request body.
Server Actions let you call server-side functions directly from client components without creating API routes. This is often simpler for form submissions and data mutations within your own application:
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ data: { title } });
revalidatePath('/posts');
}
// Component
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
The 'use server' directive marks the function as a Server Action. When the form submits, Next.js automatically calls this function on the server, handles the serialization, and updates the page. The revalidatePath call ensures the cached page data refreshes after the mutation. This eliminates the need to write API routes for simple CRUD operations.
Performance Optimization
Next.js includes several built-in optimizations. Use them:
Image Component: Automatically optimizes images, serves modern formats, and handles lazy loading. Always prefer this over native <img> tags for significant performance gains:
import Image from 'next/image';
<Image src="/hero.jpg" width={1200} height={600} alt="Hero" />
The Image component automatically serves WebP or AVIF formats where supported, resizes images for different devices, and lazy loads images below the fold.
Font Optimization: Self-hosts fonts and eliminates layout shift. Import fonts from next/font/google to automatically download and serve them from your own domain:
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
Apply the font by adding inter.className to your root layout's body element. This eliminates the flash of unstyled text that occurs with traditional font loading.
Route Prefetching: Links are automatically prefetched when they enter the viewport. Users experience instant navigation.
Static Generation: Pages without dynamic data are pre-rendered at build time. Add generateStaticParams for dynamic routes you want to pre-render.
Deployment Options
Vercel is the path of least resistance. Push to GitHub and your app deploys automatically with edge functions, image optimization, and analytics included.
Self-hosted works too. Run next build and next start on any Node.js server. You'll need to handle CDN, SSL, and scaling yourself.
Docker deployment requires a multi-stage build to keep your image size small. The following Dockerfile uses the standalone output mode, which bundles only the necessary files for production:
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"]
Set output: 'standalone' in your Next.js config to enable this optimized Docker build. The standalone output includes a minimal Node.js server that doesn't require the full node_modules directory, resulting in Docker images under 100MB instead of potentially gigabytes.
Common Pitfalls
Over-using 'use client': Start with Server Components and only add the client directive when needed. It's easy to accidentally make your entire tree client-rendered.
Not understanding caching: Next.js caches aggressively. Learn the caching layers (request memoization, data cache, full route cache) and how to invalidate them.
Ignoring the build output: Check next build output regularly. It tells you which routes are static vs dynamic and warns about common issues.
Forgetting environment variables: Client-side environment variables must be prefixed with NEXT_PUBLIC_. Server-only variables stay secret.
Conclusion
Next.js 15 provides everything you need to build production React applications. The App Router and Server Components represent a significant shift in how we build React apps, moving more work to the server and reducing client-side JavaScript.
Start with Server Components by default, add interactivity where needed, and leverage the built-in optimizations. The framework handles the complexity so you can focus on building your product.