Building a Production Next.js App: Lessons Learned
Shipping a Next.js app to production is straightforward. Shipping one that stays reliable, performs well, and doesn't cost you sleep at 2am is a different story. Here's what I've learned after building production apps with Next.js App Router.
Route Architecture Matters More Than You Think
The App Router's nested layout system is powerful, but it's easy to create layouts that fetch data redundantly or block rendering unnecessarily. A few rules I follow:
- Put data fetching as close to the component that needs it as possible. Don't reach up to a layout when a page can fetch its own data.
- Use
loading.tsxaggressively. Suspense boundaries give you streaming by default. - Separate static and dynamic segments.
force-dynamicon one page shouldn't force everything to be dynamic.
Server Components vs Client Components
The default should always be Server Component. Opt into "use client" only when you actually need it:
// ✅ Server Component — database query, no interactivity
export default async function ProjectList() {
const projects = await prisma.project.findMany();
return <ul>{projects.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// ✅ Client Component — needs useState for interactive filter
"use client";
export function ProjectFilter({ projects }: { projects: Project[] }) {
const [filter, setFilter] = useState("all");
// ...
}
The boundary rule: push "use client" to the leaves. A large tree of client components kills the benefits of RSC.
Database Patterns with Prisma
Don't call prisma.something.findMany() in multiple places without thinking about N+1. With Prisma:
// ❌ N+1 — separate query per project's tags
const projects = await prisma.project.findMany();
for (const p of projects) {
p.tags = await prisma.tag.findMany({ where: { projectId: p.id } });
}
// ✅ Single query with include
const projects = await prisma.project.findMany({
include: { tags: true },
});
Also: always use select when you don't need every field. Sending 40 columns to the client when you need 3 is wasteful.
Type-Safe Environment Variables
Use zod to validate your env at startup:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
RESEND_API_KEY: z.string().startsWith("re_"),
});
export const env = envSchema.parse(process.env);
This catches misconfigured deployments at build time, not at runtime when a user hits the broken endpoint.
Error Boundaries and Loading States
Every async boundary needs both a loading and error state. In App Router:
app/
dashboard/
page.tsx # actual content
loading.tsx # shown during server render
error.tsx # shown on unhandled throws
Don't rely on the global error boundary for recoverable errors. Use notFound(), redirect(), and unauthorized() from next/navigation explicitly.
Deployment Checklist
Before every production deploy I run through:
-
DATABASE_URLpoints to production DB - All
force-dynamicpages are actually dynamic and not accidentally cached - OG images return 200 for all slugs
- Sitemap includes all new routes
- No
console.logwith sensitive data left in server components
Final Thought
The best Next.js codebase I've worked on wasn't the one with the most clever abstractions — it was the one where any engineer could open a file, understand what it does, and know exactly where to make a change. Boring, predictable code wins in production.