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:
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 typesThe _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
These patterns took real projects and real mistakes to develop. Hope they save you some pain.