When you start a new Next.js project, the blank canvas can be both exciting and overwhelming. Over the years I've developed a set of patterns and conventions that help me ship production-ready applications quickly — and keep them maintainable long-term.
The Foundation
Every scalable Next.js app I build starts with the same core stack:
- Next.js App Router — for file-based routing, server components, and streaming
- TypeScript — non-negotiable for any serious project
- Tailwind CSS — utility-first styling that scales with your team
- Zod — runtime schema validation at API boundaries
// A typed API response pattern I use everywhere
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
});
type User = z.infer<typeof UserSchema>;
export async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return UserSchema.parse(data); // throws on invalid shape
}
Folder Structure
The structure I've converged on after several large projects:
src/
├── app/ # Next.js App Router pages
├── components/ # Reusable UI components
│ ├── ui/ # Primitives (Button, Input, Modal)
│ └── features/ # Domain-specific components
├── lib/ # Utilities, helpers, API clients
├── hooks/ # Custom React hooks
├── types/ # Shared TypeScript types
└── content/ # MDX content (blog, docs)
The key insight is separating UI primitives from feature components. Primitives are generic — they know nothing about your domain. Feature components are composed from primitives and contain business logic.
Server Components vs Client Components
One of the biggest mistakes I see is defaulting everything to "use client". Server Components are powerful:
// This component runs on the server — no JS sent to client
export default async function BlogList() {
const posts = await getAllPosts(); // direct DB/filesystem access
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}
Reserve "use client" for components that need:
useState/useEffect- Browser APIs
- Event listeners
- Third-party client-side libraries
Data Fetching Patterns
I use three patterns depending on the data characteristics:
1. Static — built at deploy time
export const revalidate = false; // never revalidate
2. ISR — revalidated on a schedule
export const revalidate = 3600; // revalidate every hour
3. Dynamic — fresh on every request
export const dynamic = "force-dynamic";
State Management
For most projects, React's built-in tools (useState, useContext, useReducer) are sufficient. I only reach for Zustand or Jotai when:
- State needs to be shared across many unrelated components
- The state logic becomes complex enough to benefit from a store pattern
- You need persistence (localStorage sync)
Final Thoughts
Scalability in Next.js isn't about picking the "best" library — it's about making deliberate choices early, staying consistent, and letting the framework do the heavy lifting. The App Router has made most of the hard architectural decisions for us — embrace it.
Happy shipping! 🚀