Component Architecture in Large Next.js Applications
As Next.js apps grow, component organization becomes the main source of cognitive overhead. These patterns keep large codebases navigable and modification-safe.
The Problem with Unstructured Components
Every Next.js project starts with a components/ folder. A year later, it has 200 files, no clear organization, and every developer has a different mental model of what lives where.
The result: duplication (someone builds a component that already exists), implicit coupling (changing one component breaks three others), and decision fatigue (where do I put this new thing?).
The solution is a consistent, explicit organizational system — not enforced by a tool, but by team convention.
The Feature-Based Structure
Organize components by the feature they belong to, not by their technical role:
src/
├── app/ — Next.js App Router pages
├── components/
│ ├── ui/ — Generic, reusable UI primitives
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── DataTable/
│ └── features/ — Feature-specific components
│ ├── projects/
│ │ ├── ProjectCard.tsx
│ │ ├── ProjectList.tsx
│ │ ├── CreateProjectModal.tsx
│ │ └── index.ts
│ ├── billing/
│ │ ├── PricingTable.tsx
│ │ ├── UpgradePrompt.tsx
│ │ └── index.ts
│ └── onboarding/
│ ├── OnboardingChecklist.tsx
│ ├── StepProgress.tsx
│ └── index.ts
└── lib/ — Utilities, queries, actions, hooks
ui/ components have no business logic and no direct data fetching. They receive all their data via props.
features/ components may be connected to data (via Server Components or React Query) and contain business-specific display logic.
The Component Index Pattern
Each feature directory exports through a public API:
// components/features/projects/index.ts
export { ProjectCard } from './ProjectCard';
export { ProjectList } from './ProjectList';
export { CreateProjectModal } from './CreateProjectModal';
// Internal implementation components are NOT exported
Consuming code imports from the feature, not from the specific file:
// Good — imports from the public API
import { ProjectCard, ProjectList } from '@/components/features/projects';
// Bad — imports from the internal file (creates tight coupling)
import { ProjectCard } from '@/components/features/projects/ProjectCard';
This lets you refactor internal file structure without breaking consumers.
The Compound Component Pattern
For complex UI that has multiple related parts, compound components provide a clean API:
// Bad — prop drilling nightmare for complex tables
<DataTable
columns={columns}
data={data}
sortable={true}
filterable={true}
bulkActions={['delete', 'archive']}
filterColumns={['name', 'status', 'createdAt']}
onBulkAction={handleBulkAction}
emptyState={<CustomEmptyState />}
/>
// Good — compound component with explicit composition
<DataTable data={data}>
<DataTable.Toolbar>
<DataTable.Search />
<DataTable.FilterMenu columns={['name', 'status']} />
</DataTable.Toolbar>
<DataTable.Content>
<DataTable.Column key="name" header="Name" sortable />
<DataTable.Column key="status" header="Status" />
</DataTable.Content>
<DataTable.BulkActions>
<DataTable.BulkAction label="Delete" onAction={handleDelete} />
</DataTable.BulkActions>
<DataTable.EmptyState><CustomEmptyState /></DataTable.EmptyState>
</DataTable>
The compound pattern uses Context internally to share state between sub-components without prop drilling:
// Simplified compound component implementation
const DataTableContext = createContext<DataTableContextValue>(null!);
function DataTable({ data, children }: DataTableProps) {
const [sort, setSort] = useState<SortState>(null);
const [filter, setFilter] = useState<FilterState>({});
return (
<DataTableContext.Provider value={{ data, sort, setSort, filter, setFilter }}>
<div className="data-table">{children}</div>
</DataTableContext.Provider>
);
}
DataTable.Toolbar = function Toolbar({ children }: { children: ReactNode }) {
return <div className="table-toolbar">{children}</div>;
};
DataTable.Column = function Column({ key, header, sortable }: ColumnProps) {
const { sort, setSort } = useContext(DataTableContext);
// ...
};
When to Split a Component
Split a component when:
- It's over 150–200 lines (hard rule is 300)
- It has more than 5–6 props
- Part of it is reused elsewhere
- Different parts of it change for different reasons (single responsibility)
- It mixes data-fetching concerns with presentation
Don't split when:
- The split would produce components that are only ever used together
- Splitting creates prop drilling that's worse than the original size
- The component is inherently complex and splitting would obscure the logic
The Presentation / Container Split (Modern Version)
The classic "smart/dumb component" pattern maps to Server/Client Components in the App Router:
// Server Component — fetches data, no interactivity
// app/projects/page.tsx
export default async function ProjectsPage() {
const projects = await getUserProjects();
return <ProjectList projects={projects} />; // passes data to presentation
}
// Client Component — presentation + interactivity, receives data via props
// components/features/projects/ProjectList.tsx
'use client';
export function ProjectList({ projects }: { projects: Project[] }) {
const [filter, setFilter] = useState('all');
const filtered = projects.filter(/* ... */);
return (
<div>
<FilterBar value={filter} onChange={setFilter} />
{filtered.map(p => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
The page (Server Component) owns data fetching. The list (Client Component) owns interactivity. Neither does both.
Storybook for Component Development
For teams maintaining a component library, Storybook isolates components from application logic:
// components/ui/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
parameters: { layout: 'centered' },
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Click me' },
};
export const Loading: Story = {
args: { variant: 'primary', loading: true, children: 'Loading' },
};
export const Destructive: Story = {
args: { variant: 'destructive', children: 'Delete' },
};
Storybook becomes your living component documentation — designers can review component states in isolation, and developers can develop new states without wiring up a full page context.
The discipline of writing stories also improves component API design: if a component is hard to story, its prop interface is probably too complex.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources