All insights
Engineering7 min read

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.

NC

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:

code
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:

code
// 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:

code
// 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:

code
// 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:

code
// 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:

code
// 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:

code
// 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.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.