All posts
Next.jsReactArchitectureTypeScript

Building a Scalable Next.js Application from Scratch

A deep dive into the architecture decisions, patterns, and tooling I use when building production-grade Next.js applications that scale.

15 March 20263 min read

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! 🚀