Web DevelopmentFebruary 18, 2025· 9 min read

Next.js Architecture Patterns I've Learned the Hard Way

Real-world patterns for structuring Next.js apps at scale — data fetching, component design, and the mistakes I made so you don't have to.

By Connor Delia·Next.jsArchitectureTypeScriptReact

Next.js Architecture Patterns I've Learned the Hard Way

After building a dozen production Next.js applications — from dashboards to e-commerce to AI tools — I've developed strong opinions about how to structure these projects. This is what I actually do, not textbook theory.

The App Router Mental Model

Next.js 14's App Router fundamentally changed how I think about data flow. The key insight: Server Components are the default for a reason.

Before reaching for 'use client', ask:

  • Does this component need state? → Maybe client
  • Does this component need event listeners? → Client
  • Does this component just render data? → **Keep it server**
  • Folder Structure That Scales

    After many iterations, here's what works:

    app/
      (routes)/          # Route groups — no URL impact
      _components/       # Private route-specific components
      page.tsx
    components/
      ui/                # Headless, reusable primitives  
      features/          # Feature-specific composite components
      layout/            # Nav, footer, sidebars
    lib/
      data/              # Data fetching functions
      schemas/           # Zod validation schemas
      utils/             # Pure utility functions
    hooks/               # Custom React hooks
    types/               # Shared TypeScript types

    The _components convention keeps route-specific components co-located without making them publicly accessible routes.

    Data Fetching Patterns

    Pattern 1: Fetch at the page level, pass down

    // app/dashboard/page.tsx — SERVER COMPONENT
    export default async function DashboardPage() {
      const [user, metrics] = await Promise.all([
        getUser(),
        getDashboardMetrics()
      ]);
      
      return <Dashboard user={user} metrics={metrics} />;
    }

    Pattern 2: Parallel fetching with Suspense

    export default function DashboardPage() {
      return (
        <div>
          <Suspense fallback={<MetricsSkeleton />}>
            <MetricsSection />
          </Suspense>
          <Suspense fallback={<ChartSkeleton />}>
            <ChartsSection />
          </Suspense>
        </div>
      );
    }

    Pattern 2 is better for perceived performance — sections load independently.

    TypeScript Patterns That Actually Help

    Type your API responses at the boundary, then trust them everywhere else:

    // lib/types.ts
    export interface APIResponse<T> {
      data: T | null;
      error: string | null;
      timestamp: number;
    }
    
    // lib/data/users.ts
    export async function getUser(id: string): Promise<APIResponse<User>> {
      try {
        const user = await db.user.findUnique({ where: { id } });
        return { data: user, error: null, timestamp: Date.now() };
      } catch (e) {
        return { data: null, error: 'Failed to fetch user', timestamp: Date.now() };
      }
    }

    Consistent error handling across your entire app comes from consistent return types.

    The Mistakes I Made

  • **Putting everything in client components** — Learned about Server Components too late
  • **Not using React Query for client-side fetching** — Rolling your own cache is a waste
  • **Skipping Zod validation** — "I know what shape the API returns" is famous last words
  • **Not planning route groups early** — Retrofitting route groups into an existing structure is painful
  • These patterns took real projects and real mistakes to develop. Hope they save you some pain.