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.
Nextcraft Engineering Team
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
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.