Next.js for Enterprise Code Organisation, Server Components, and Avoiding Prop Drilling

Last updated Mar 8, 2026 Published Mar 8, 2026

The content here is under the Attribution 4.0 International (CC BY 4.0) license

Join Our Community

Connect with developers, architects, and tech leads who share your passion for quality software development. Discuss TDD, architecture, software engineering, and more.

→ Join Slack

Next.js is widely adopted for building production applications, and the App Router introduced in version 13 brings a model that is genuinely better suited to large codebases than the Pages Router it complements (Vercel, 2024). However, the same flexibility that makes Next.js productive at small scale can become a liability at enterprise scale, where dozens of engineers work across the same repository. Prop drilling, misplaced server components, and flat folder structures are patterns that accumulate invisible complexity over time. This post examines concrete strategies for organising a Next.js codebase so that it remains readable, testable, and maintainable as it grows.

Why enterprise Next.js demands different conventions

A solo developer can tolerate a flat app/ directory, ad hoc data-fetching, and liberal use of "use client". These shortcuts do not cause immediate problems because the mental model fits in one person’s head. At enterprise scale, the costs are different. Multiple teams share the same codebase, features are built and modified in parallel, and the blast radius of a poorly placed "use client" directive or an unbounded context provider is proportionally larger (Garlan & Shaw, 1993).

The App Router introduces a two-zone model: server components and client components (Vercel, 2024). Server components run exclusively on the server, have direct access to databases and environment secrets, and do not contribute to the JavaScript bundle sent to the browser. Client components are annotated with the "use client" directive and support hooks, event handlers, and browser APIs. The boundary between these zones is explicit: a server component can import and render a client component, but a client component cannot import a server component.

Understanding this boundary is the prerequisite for every other architectural decision covered below.

Folder structure based on features, not file types

A common mistake in early Next.js projects is to organise files by technical role: all components in components/, all hooks in hooks/, all utilities in utils/. This structure is intuitive at small scale but breaks down as the number of features grows, because every cross-feature change requires navigating multiple directories (Vercel, 2024).

A feature-based structure collocates everything a feature needs in one directory. The principle, described in depth by Martin in the context of clean architecture, is that the most frequent reason for change is the most important axis of organisation (Martin, 2017).

A practical structure for a Next.js App Router project that manages AI agent runs looks like this:

app/
  (marketing)/
    page.tsx                  # marketing home, server component
  (dashboard)/
    layout.tsx                # shared dashboard shell, server component
    page.tsx                  # dashboard overview
    agents/
      page.tsx
      loading.tsx
      error.tsx
features/
  agents/
    components/
      AgentList.tsx           # client component: interactive list
      AgentRow.tsx            # client component: row with actions
    hooks/
      useAgentFilters.ts      # client-side filter state
    server/
      fetchAgents.ts          # server-only data access
      fetchAgentById.ts
    types.ts
    index.ts                  # public API: re-exports only what other features need
  users/
    components/
      UserAvatar.tsx
    server/
      fetchCurrentUser.ts
    types.ts
    index.ts
lib/
  db/
    client.ts                 # database connection (server-only)
  auth/
    session.ts                # session utilities (server-only)

Each features/<name>/index.ts acts as a public contract for the feature. Other features import only from that file, not from internal paths. This discipline enforces the same encapsulation that module systems provide in other languages and prevents unintended cross-feature coupling.

Route files under app/ remain thin: they import from features/ and compose server components that fetch data alongside client components that handle interaction.

How this compares with pre-existing React organisation patterns

Before the App Router, the React community settled on several conventions for structuring large codebases. Understanding where feature-based organisation sits relative to those patterns clarifies why it is a better fit for Next.js at enterprise scale.

Flat type-based structure (components/, hooks/, utils/) groups files by technical category. It is the most common starting point and is easy to explain, but it requires navigating multiple top-level directories to understand or modify a single feature. As the number of features grows, the directories become landfills of unrelated files with no enforced ownership boundary.

Atomic Design, popularised by Brad Frost, organises UI into atoms, molecules, organisms, templates, and pages (Hallie & Osmani, 2023). This hierarchy is useful for design systems and component libraries, but it does not map naturally to business domains. An “organism” in Atomic Design might span multiple business features, making it unclear which team owns it or which data it is responsible for. Feature-based organisation complements Atomic Design by treating the design system as a shared library while scoping business logic to feature directories.

Container/Presentational component pattern, introduced as a way to separate data-fetching logic from rendering, pre-dates server components. In this pattern, container components fetch data (often via Redux or component-level effects) and pass it to stateless presentational components. Server components make this pattern largely redundant: the server component is, in effect, a first-class container that runs on the server with direct data access, and the client component is the presentational layer. The App Router does not eliminate the need to think about the separation of concerns; it simply moves the boundary from inside the client bundle to the server/client divide.

Relation to MVC: The App Router can be loosely mapped to Model-View-Controller (Garlan & Shaw, 1993). Server functions under features/<name>/server/ act as the model layer, providing data access without business logic leaking into the view. Route files under app/ and layout components act as thin controllers that coordinate data fetching and pass results to view components. Client components are the view. The key difference from classical MVC is that there is no explicit controller object: the coordination role is fulfilled by async server components that are rendered as part of the React tree, not by a separate request handler.

Relation to Micro Frontends: Feature-based folders are sometimes confused with micro frontends, but they are architecturally distinct. Micro frontends are independently deployable units, each owning its own build pipeline, runtime, and potentially its own framework (Garlan & Shaw, 1993). Feature-based folders inside a Next.js monorepo are a code organisation convention within a single deployable unit. They provide team-level encapsulation and reduce coupling, but they do not introduce independent deployment or runtime isolation. If independent deployment is a requirement, a monorepo with multiple Next.js applications served via a reverse proxy (or Vercel’s Edge Network) is the appropriate extension, with shared feature packages published as internal libraries.

Placing the server/client boundary correctly

The most common mistake in App Router codebases is the reflexive addition of "use client" to components that do not need it (Vercel, 2024). Every "use client" boundary turns that component and all of its imports into client components, which grows the JavaScript bundle and moves data fetching from the server to the browser.

The decision rule is straightforward. A component should be a server component by default. It should become a client component only when it requires one of the following: useState, useReducer, useEffect, event handlers such as onClick or onChange, browser APIs such as window or localStorage, or third-party libraries that rely on browser APIs.

Consider an agent list page where the data is fetched on the server but the list supports client-side filtering by model name:

// app/(dashboard)/agents/page.tsx
// Server Component: fetches data directly, no "use client"
import { fetchAgents } from '@/features/agents/server/fetchAgents';
import { AgentList } from '@/features/agents/components/AgentList';

export default async function AgentsPage() {
  const agents = await fetchAgents();

  return <AgentList initialAgents={agents} />;
}
// features/agents/components/AgentList.tsx
'use client';

import { useState } from 'react';
import type { Agent } from '../types';
import { AgentRow } from './AgentRow';

type Props = {
  initialAgents: Agent[];
};

export function AgentList({ initialAgents }: Props) {
  const [filter, setFilter] = useState('');

  const visible = initialAgents.filter((a) =>
    a.model.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter by model"
      />
      {visible.map((agent) => (
        <AgentRow key={agent.id} agent={agent} />
      ))}
    </div>
  );
}

Test for AgentList.tsx with Jest and Testing Library:

// features/agents/components/__tests__/AgentList.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AgentList } from '../AgentList';
import { UserProvider } from '@/features/users/components/UserProvider';
import type { Agent } from '../../types';
import type { User } from '@/features/users/types';

describe('AgentList', () => {
  const mockAgents: Agent[] = [
    { id: '1', name: 'Agent A', model: 'gpt-4', status: 'running' },
    { id: '2', name: 'Agent B', model: 'claude-3', status: 'idle' },
    { id: '3', name: 'Agent C', model: 'gpt-4', status: 'running' },
  ];

  const mockUser: User = {
    id: 'user-1',
    name: 'Test User',
    email: 'test@example.com',
    role: 'member',
  };

  it('renders all agents initially', () => {
    render(
      <UserProvider user={mockUser}>
        <AgentList initialAgents={mockAgents} />
      </UserProvider>
    );

    expect(screen.getByText('Agent A')).toBeInTheDocument();
    expect(screen.getByText('Agent B')).toBeInTheDocument();
    expect(screen.getByText('Agent C')).toBeInTheDocument();
  });

  it('filters agents by model name', async () => {
    const user = userEvent.setup();
    render(
      <UserProvider user={mockUser}>
        <AgentList initialAgents={mockAgents} />
      </UserProvider>
    );

    const input = screen.getByPlaceholderText('Filter by model');
    await user.type(input, 'gpt-4');

    expect(screen.getByText('Agent A')).toBeInTheDocument();
    expect(screen.getByText('Agent C')).toBeInTheDocument();
    expect(screen.queryByText('Agent B')).not.toBeInTheDocument();
  });

  it('filters case-insensitively', async () => {
    const user = userEvent.setup();
    render(
      <UserProvider user={mockUser}>
        <AgentList initialAgents={mockAgents} />
      </UserProvider>
    );

    const input = screen.getByPlaceholderText('Filter by model');
    await user.type(input, 'CLAUDE');

    expect(screen.getByText('Agent B')).toBeInTheDocument();
    expect(screen.queryByText('Agent A')).not.toBeInTheDocument();
  });

  it('shows all agents when filter is cleared', async () => {
    const user = userEvent.setup();
    render(
      <UserProvider user={mockUser}>
        <AgentList initialAgents={mockAgents} />
      </UserProvider>
    );

    const input = screen.getByPlaceholderText('Filter by model');
    await user.type(input, 'gpt');
    expect(screen.queryByText('Agent B')).not.toBeInTheDocument();

    await user.clear(input);
    expect(screen.getByText('Agent A')).toBeInTheDocument();
    expect(screen.getByText('Agent B')).toBeInTheDocument();
    expect(screen.getByText('Agent C')).toBeInTheDocument();
  });

  it('shows empty state when no agents match filter', async () => {
    const user = userEvent.setup();
    render(
      <UserProvider user={mockUser}>
        <AgentList initialAgents={mockAgents} />
      </UserProvider>
    );

    const input = screen.getByPlaceholderText('Filter by model');
    await user.type(input, 'nonexistent-model');

    expect(screen.queryByText('Agent A')).not.toBeInTheDocument();
    expect(screen.queryByText('Agent B')).not.toBeInTheDocument();
    expect(screen.queryByText('Agent C')).not.toBeInTheDocument();
  });
});
const input = screen.getByPlaceholderText('Filter by model');
await user.type(input, 'nonexistent-model');

expect(screen.queryByTestId('agent-row-1')).not.toBeInTheDocument();
expect(screen.queryByTestId('agent-row-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('agent-row-3')).not.toBeInTheDocument();   }); }); ```

The page component is a server component: it runs on the server, fetches data, and sends the result to AgentList as a prop. AgentList is a client component because it uses useState. The boundary is explicit, the bundle includes only the client component, and the data fetch has zero network overhead from the browser’s perspective.

A related pattern is “lifting server data down” (Vercel, 2024). When a deeply nested client component needs server data, the correct approach is to fetch that data at the nearest server component ancestor and pass it down, rather than converting intermediate components to client components or introducing a new client-side fetch.

Consider a case where AgentStatusBadge (a client component that polls for live status updates) is nested inside AgentSection, which itself is rendered by the agent detail page. Without this pattern, a developer might mark AgentSection as "use client" just to thread the agent data down to AgentStatusBadge, even though AgentSection itself has no interactive behaviour. The correct approach is:

// app/(dashboard)/agents/[id]/page.tsx
// Server Component: the nearest ancestor with access to the route param
import { fetchAgentById } from '@/features/agents/server/fetchAgentById';
import { AgentSection } from '@/features/agents/components/AgentSection';

export default async function AgentDetailPage({
  params,
}: {
  params: { id: string };
}) {
  // Data fetched here, on the server, close to where it is needed.
  const agent = await fetchAgentById(params.id);

  return <AgentSection agent={agent} />;
}
// features/agents/components/AgentSection.tsx
// Server Component: no "use client" — renders static metadata on the server
import type { Agent } from '../types';
import { AgentStatusBadge } from './AgentStatusBadge'; // Client Component

type Props = { agent: Agent };

export function AgentSection({ agent }: Props) {
  return (
    <div>
      <h2>{agent.name}</h2>
      <p>Model: {agent.model}</p>
      {/* AgentStatusBadge is a client component that polls for live updates */}
      <AgentStatusBadge agentId={agent.id} initialStatus={agent.status} />
    </div>
  );
}
// features/agents/components/AgentStatusBadge.tsx
'use client';

import { useState, useEffect } from 'react';

type Props = { agentId: string; initialStatus: string };

export function AgentStatusBadge({ agentId, initialStatus }: Props) {
  const [status, setStatus] = useState(initialStatus);

  useEffect(() => {
    const id = setInterval(async () => {
      const res = await fetch(`/api/agents/${agentId}/status`);
      const { status: live } = await res.json();
      setStatus(live);
    }, 5000);
    return () => clearInterval(id);
  }, [agentId]);

  return <span data-status={status}>{status}</span>;
}

Test for AgentStatusBadge with Jest and Testing Library:

// features/agents/components/__tests__/AgentStatusBadge.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { AgentStatusBadge } from '../AgentStatusBadge';

// Mock the global fetch
global.fetch = jest.fn();
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;

describe('AgentStatusBadge', () => {
  beforeEach(() => {\n    jest.clearAllMocks();
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  it('renders initial status', () => {
    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'running' }),
    } as Response);

    render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="idle" />\n    );

    expect(screen.getByText('idle')).toBeInTheDocument();
  });

  it('sets up polling interval on mount', async () => {
    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'running' }),
    } as Response);

    render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="idle" />\n    );

    // Fast-forward to first poll
    jest.advanceTimersByTime(5000);

    await waitFor(() => {
      expect(mockFetch).toHaveBeenCalledWith('/api/agents/agent-1/status');
    });
  });

  it('updates status when polling returns new status', async () => {
    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'completed' }),
    } as Response);

    render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="running" />\n    );

    expect(screen.getByText('running')).toBeInTheDocument();

    // First poll
    jest.advanceTimersByTime(5000);

    await waitFor(() => {
      expect(screen.getByText('completed')).toBeInTheDocument();
    });
  });

  it('clears interval on unmount', () => {
    const clearIntervalSpy = jest.spyOn(global, 'clearInterval');

    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'running' }),
    } as Response);

    const { unmount } = render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="idle" />\n    );

    jest.advanceTimersByTime(5000);

    unmount();

    expect(clearIntervalSpy).toHaveBeenCalled();
  });

  it('polls at correct intervals', async () => {
    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'running' }),
    } as Response);

    render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="idle" />\n    );

    // First poll at 5000ms
    jest.advanceTimersByTime(5000);
    expect(mockFetch).toHaveBeenCalledTimes(1);

    // Second poll at 10000ms
    jest.advanceTimersByTime(5000);
    expect(mockFetch).toHaveBeenCalledTimes(2);

    // Third poll at 15000ms
    jest.advanceTimersByTime(5000);
    expect(mockFetch).toHaveBeenCalledTimes(3);
  });

  it('includes correct data-status attribute', async () => {
    mockFetch.mockResolvedValue({
      json: () => Promise.resolve({ status: 'failed' }),
    } as Response);

    const { rerender } = render(\n      <AgentStatusBadge agentId="agent-1" initialStatus="idle" />\n    );

    expect(screen.getByText('idle')).toHaveAttribute('data-status', 'idle');

    // Update status via polling
    jest.advanceTimersByTime(5000);

    await waitFor(() => {
      expect(screen.getByText('failed')).toHaveAttribute('data-status', 'failed');
    });
  });
});\n```

`AgentSection` stays a server component and renders static metadata directly from the prop. Only `AgentStatusBadge` is a client component, because it needs `useEffect` for polling. The server component hierarchy up to the page level never becomes a client component just because a leaf node needs interactivity.

## Avoiding prop drilling at enterprise scale

Prop drilling becomes expensive when data must pass through three or more component layers that do not use it themselves <a class="citation" href="#react2024createcontext">(React Team, 2024)</a>. In a large application, the authenticated user object, the current locale, and the active feature flags are examples of values that almost every component in the tree eventually needs.

The React Context API was designed to solve this problem, but it cannot be used directly in server components. The boundary is architectural: context is a runtime mechanism that requires a browser lifecycle, and server components have no such lifecycle <a class="citation" href="#reactrfc2020servercomponents">(Abramov &amp; Lunaruan, 2020)</a>. The solution is to fetch the shared data on the server, then hand it to a client provider component as a prop. That provider wraps the subtree and makes the value available to any client component via `useContext`, regardless of depth.

```tsx
// features/users/components/UserProvider.tsx
'use client';

import { createContext, useContext } from 'react';
import type { User } from '../types';

type UserContextValue = {
  user: User;
};

const UserContext = createContext<UserContextValue | null>(null);

export function UserProvider({
  user,
  children,
}: {
  user: User;
  children: React.ReactNode;
}) {
  return (
    <UserContext.Provider value=>
      {children}
    </UserContext.Provider>
  );
}

export function useUser(): UserContextValue {
  const ctx = useContext(UserContext);
  if (!ctx) {
    throw new Error('useUser must be called inside UserProvider');
  }
  return ctx;
}

Test for UserProvider with Jest and Testing Library:

// features/users/components/__tests__/UserProvider.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProvider, useUser } from '../UserProvider';
import type { User } from '../../types';

// Test component that uses the hook
function TestComponent() {
  const { user } = useUser();
  return <div>{user.name}</div>;
}

describe('UserProvider', () => {
  const mockUser: User = {
    id: '1',
    name: 'John Doe',
    email: 'john@example.com',
    role: 'admin',
  };

  it('provides user context to children', () => {
    render(
      <UserProvider user={mockUser}>
        <TestComponent />
      </UserProvider>
    );

    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });

  it('throws error when useUser is called outside provider', () => {
    // Suppress console.error for this test
    const spy = jest.spyOn(console, 'error').mockImplementation();

    expect(() => render(<TestComponent />)).toThrow(
      'useUser must be called inside UserProvider'
    );

    spy.mockRestore();
  });

  it('provides the correct user object', () => {
    const { rerender } = render(
      <UserProvider user={mockUser}>
        <TestComponent />
      </UserProvider>
    );

    expect(screen.getByText('John Doe')).toBeInTheDocument();

    const updatedUser: User = {
      ...mockUser,
      name: 'Jane Doe',
    };

    rerender(
      <UserProvider user={updatedUser}>
        <TestComponent />
      </UserProvider>
    );

    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
  });
});
// app/(dashboard)/layout.tsx
// Server Component: fetches user once for the entire dashboard subtree
import { fetchCurrentUser } from '@/features/users/server/fetchCurrentUser';
import { UserProvider } from '@/features/users/components/UserProvider';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await fetchCurrentUser();

  return <UserProvider user={user}>{children}</UserProvider>;
}
// features/agents/components/AgentRow.tsx
'use client';

import { useUser } from '@/features/users/components/UserProvider';
import type { Agent } from '../types';

type Props = { agent: Agent };

export function AgentRow({ agent }: Props) {
  const { user } = useUser();

  return (
    <div>
      <span>{agent.name}</span>
      <span className="text-sm text-gray-500">{agent.model}</span>
      {user.role === 'admin' && <button>Delete</button>}
    </div>
  );
}

Test for AgentRow with Jest and Testing Library:

// features/agents/components/__tests__/AgentRow.test.tsx
import { render, screen } from '@testing-library/react';
import { AgentRow } from '../AgentRow';
import { UserProvider } from '@/features/users/components/UserProvider';
import type { Agent } from '../../types';
import type { User } from '@/features/users/types';

describe('AgentRow', () => {
  const mockAgent: Agent = {
    id: '1',
    name: 'Analysis Agent',
    model: 'gpt-4',
    status: 'running',
  };

  const adminUser: User = {
    id: 'admin-1',
    name: 'Admin User',
    email: 'admin@example.com',
    role: 'admin',
  };

  const regularUser: User = {
    id: 'user-1',
    name: 'Regular User',
    email: 'user@example.com',
    role: 'member',
  };

  it('displays agent name and model', () => {
    render(
      <UserProvider user={regularUser}>
        <AgentRow agent={mockAgent} />
      </UserProvider>
    );

    expect(screen.getByText('Analysis Agent')).toBeInTheDocument();
    expect(screen.getByText('gpt-4')).toBeInTheDocument();
  });

  it('shows delete button for admin users', () => {
    render(
      <UserProvider user={adminUser}>
        <AgentRow agent={mockAgent} />
      </UserProvider>
    );

    expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
  });

  it('hides delete button for non-admin users', () => {
    render(
      <UserProvider user={regularUser}>
        <AgentRow agent={mockAgent} />
      </UserProvider>
    );

    expect(
      screen.queryByRole('button', { name: /delete/i })
    ).not.toBeInTheDocument();
  });
});

The user is fetched exactly once, at the layout level, and is available to every client component in the dashboard without a single intermediate prop. The server zone stays focused on data access, the client zone handles interactivity, and the context boundary is a single, explicit prop on the provider (Vercel, 2024).

Data-fetching patterns that prevent waterfalls

A data waterfall occurs when a component fetches data, renders, and a child component then initiates its own fetch, which could have started in parallel (Vercel, 2024). In the App Router, server components support async/await natively, and the Promise.all pattern is available for parallel fetches.

// app/(dashboard)/agents/[id]/page.tsx
import { fetchAgentById } from '@/features/agents/server/fetchAgentById';
import { fetchCurrentUser } from '@/features/users/server/fetchCurrentUser';
import { AgentDetail } from '@/features/agents/components/AgentDetail';
import { UserProvider } from '@/features/users/components/UserProvider';

export default async function AgentPage({
  params,
}: {
  params: { id: string };
}) {
  // Both fetches run concurrently, not sequentially.
  const [agent, user] = await Promise.all([
    fetchAgentById(params.id),
    fetchCurrentUser(),
  ]);

  return (
    <UserProvider user={user}>
      <AgentDetail agent={agent} />
    </UserProvider>
  );
}

Tests for server-side fetch functions:

// features/agents/server/__tests__/fetchAgentById.test.ts
import { fetchAgentById } from '../fetchAgentById';
import { db } from '@/lib/db/client';

jest.mock('@/lib/db/client');

const mockDb = db as jest.Mocked<typeof db>;

describe('fetchAgentById', () => {
  it('fetches agent by id from database', async () => {
    const mockAgent = {
      id: 'agent-1',
      name: 'Test Agent',
      model: 'gpt-4',
      status: 'running',
    };

    mockDb.agents.findUnique.mockResolvedValue(mockAgent);

    const result = await fetchAgentById('agent-1');

    expect(result).toEqual(mockAgent);
    expect(mockDb.agents.findUnique).toHaveBeenCalledWith({
      where: { id: 'agent-1' },
    });
  });

  it('throws error when agent not found', async () => {
    mockDb.agents.findUnique.mockResolvedValue(null);

    await expect(fetchAgentById('nonexistent')).rejects.toThrow(
      'Agent not found'\n    );
  });

  it('passes through database errors', async () => {
    const dbError = new Error('Database connection failed');
    mockDb.agents.findUnique.mockRejectedValue(dbError);

    await expect(fetchAgentById('agent-1')).rejects.toThrow(\n      'Database connection failed'\n    );
  });
});\n```

```typescript
// features/users/server/__tests__/fetchCurrentUser.test.ts
import { fetchCurrentUser } from '../fetchCurrentUser';
import { getSession } from '@/lib/auth/session';
import { db } from '@/lib/db/client';

jest.mock('@/lib/auth/session');
jest.mock('@/lib/db/client');

const mockGetSession = getSession as jest.MockedFunction<typeof getSession>;
const mockDb = db as jest.Mocked<typeof db>;

describe('fetchCurrentUser', () => {
  it('fetches current user from session', async () => {
    const mockUser = {
      id: 'user-1',
      name: 'John Doe',
      email: 'john@example.com',
      role: 'member',
    };

    mockGetSession.mockResolvedValue({ userId: 'user-1' });
    mockDb.users.findUnique.mockResolvedValue(mockUser);

    const result = await fetchCurrentUser();

    expect(result).toEqual(mockUser);
    expect(mockGetSession).toHaveBeenCalled();
    expect(mockDb.users.findUnique).toHaveBeenCalledWith({
      where: { id: 'user-1' },
    });
  });

  it('throws error when user is not authenticated', async () => {
    mockGetSession.mockResolvedValue(null);

    await expect(fetchCurrentUser()).rejects.toThrow(\n      'User not authenticated'\n    );
  });

  it('throws error when user not found in database', async () => {
    mockGetSession.mockResolvedValue({ userId: 'user-1' });
    mockDb.users.findUnique.mockResolvedValue(null);

    await expect(fetchCurrentUser()).rejects.toThrow('User not found');
  });
});\n```

**Test for concurrent Promise.all pattern (checking waterfalls don't occur):**

```typescript
// app/(dashboard)/agents/[id]/__tests__/page.test.tsx
import { fetchAgentById } from '@/features/agents/server/fetchAgentById';
import { fetchCurrentUser } from '@/features/users/server/fetchCurrentUser';

jest.mock('@/features/agents/server/fetchAgentById');
jest.mock('@/features/users/server/fetchCurrentUser');

const mockFetchAgentById = fetchAgentById as jest.MockedFunction<typeof fetchAgentById>;
const mockFetchCurrentUser = fetchCurrentUser as jest.MockedFunction<typeof fetchCurrentUser>;

describe('AgentPage data fetching', () => {
  it('fetches agent and user concurrently', async () => {
    const startTime = Date.now();

    mockFetchAgentById.mockImplementation(
      () => new Promise((resolve) => setTimeout(() => resolve({ id: 'agent-1', name: 'Test' }), 100))\n    );
    mockFetchCurrentUser.mockImplementation(
      () => new Promise((resolve) => setTimeout(() => resolve({ id: 'user-1', name: 'John' }), 100))\n    );

    await Promise.all([
      mockFetchAgentById('agent-1'),
      mockFetchCurrentUser(),
    ]);

    const endTime = Date.now();
    const duration = endTime - startTime;

    // Should complete in ~100ms (concurrent), not ~200ms (sequential)
    expect(duration).toBeLessThan(150);
  });

  it('handles Promise.all rejection correctly', async () => {
    mockFetchAgentById.mockRejectedValue(new Error('Failed to fetch agent'));
    mockFetchCurrentUser.mockResolvedValue({ id: 'user-1', name: 'John' });

    await expect(
      Promise.all([
        mockFetchAgentById('agent-1'),
        mockFetchCurrentUser(),
      ])
    ).rejects.toThrow('Failed to fetch agent');
  });
});

Promise.all versus Promise.allSettled

Promise.all and Promise.allSettled are both appropriate for concurrent fetches, but they behave differently when one of the promises rejects, and the choice has real consequences for user experience.

Promise.all rejects as soon as any single promise rejects. In a Next.js page, this means the entire render aborts and the nearest error boundary is triggered. This is the right choice when all data is required to render the page meaningfully: an agent detail page that cannot load either the agent or the current user has nothing useful to show, and surfacing the error immediately is the correct response.

Promise.allSettled always waits for every promise to settle, returning an array of result objects each tagged with "fulfilled" or "rejected". This is appropriate when partial data is sufficient: a dashboard that can show the agent list even if the usage statistics fetch fails is a better experience than a full error screen.

// Partial-data example with Promise.allSettled
const [agentResult, statsResult] = await Promise.allSettled([
  fetchAgentById(params.id),
  fetchAgentStats(params.id),
]);

const agent =
  agentResult.status === 'fulfilled' ? agentResult.value : null;
const stats =
  statsResult.status === 'fulfilled' ? statsResult.value : null;

// Render with whatever data is available; missing parts show fallback UI.

Test for Promise.allSettled pattern with partial failure handling:

// app/(dashboard)/agents/__tests__/page.test.tsx
import { fetchAgentById } from '@/features/agents/server/fetchAgentById';
import { fetchAgentStats } from '@/features/agents/server/fetchAgentStats';

jest.mock('@/features/agents/server/fetchAgentById');
jest.mock('@/features/agents/server/fetchAgentStats');

const mockFetchAgentById = fetchAgentById as jest.MockedFunction<typeof fetchAgentById>;
const mockFetchAgentStats = fetchAgentStats as jest.MockedFunction<typeof fetchAgentStats>;

describe('Agent page with Promise.allSettled', () => {
  it('handles one fetch failing while other succeeds', async () => {
    const mockAgent = { id: 'agent-1', name: 'Test Agent' };

    mockFetchAgentById.mockResolvedValue(mockAgent);
    mockFetchAgentStats.mockRejectedValue(new Error('Stats service unavailable'));

    const [agentResult, statsResult] = await Promise.allSettled([
      mockFetchAgentById('agent-1'),
      mockFetchAgentStats('agent-1'),
    ]);

    // Agent should be available
    expect(agentResult.status).toBe('fulfilled');
    if (agentResult.status === 'fulfilled') {
      expect(agentResult.value).toEqual(mockAgent);
    }

    // Stats should be marked as failed
    expect(statsResult.status).toBe('rejected');
    if (statsResult.status === 'rejected') {
      expect(statsResult.reason.message).toBe('Stats service unavailable');
    }
  });

  it('safely handles both fetches failing', async () => {
    mockFetchAgentById.mockRejectedValue(new Error('Agent not found'));
    mockFetchAgentStats.mockRejectedValue(new Error('Stats unavailable'));

    const [agentResult, statsResult] = await Promise.allSettled([
      mockFetchAgentById('nonexistent'),
      mockFetchAgentStats('nonexistent'),
    ]);

    expect(agentResult.status).toBe('rejected');
    expect(statsResult.status).toBe('rejected');
  });

  it('safely handles both fetches succeeding', async () => {
    const mockAgent = { id: 'agent-1', name: 'Test Agent' };
    const mockStats = { totalRuns: 100, avgDuration: 45 };

    mockFetchAgentById.mockResolvedValue(mockAgent);
    mockFetchAgentStats.mockResolvedValue(mockStats);

    const [agentResult, statsResult] = await Promise.allSettled([
      mockFetchAgentById('agent-1'),
      mockFetchAgentStats('agent-1'),
    ]);

    expect(agentResult.status).toBe('fulfilled');
    if (agentResult.status === 'fulfilled') {
      expect(agentResult.value).toEqual(mockAgent);
    }

    expect(statsResult.status).toBe('fulfilled');
    if (statsResult.status === 'fulfilled') {
      expect(statsResult.value).toEqual(mockStats);
    }
  });

  it('finalizes data extraction handling fulfilled and rejected states', async () => {
    mockFetchAgentById.mockResolvedValue({ id: 'agent-1', name: 'Agent' });
    mockFetchAgentStats.mockRejectedValue(new Error('Unavailable'));

    const [agentResult, statsResult] = await Promise.allSettled([
      mockFetchAgentById('agent-1'),
      mockFetchAgentStats('agent-1'),
    ]);

    // Extract with safe fallbacks
    const agent =
      agentResult.status === 'fulfilled' ? agentResult.value : null;
    const stats =
      statsResult.status === 'fulfilled' ? statsResult.value : null;

    expect(agent).toEqual({ id: 'agent-1', name: 'Agent' });
    expect(stats).toBeNull();
  });
});\n```

The sequence diagram below illustrates the difference between the two strategies when one fetch fails:

```plantuml
@startuml
'This PlantUML code will be rendered soon. Meanwhile, you can check the source:

participant "Server Component" as SC
participant "fetchAgentById" as FA
participant "fetchAgentStats" as FS

== Promise.all (fail-fast) ==

SC -> FA : start (concurrent)
SC -> FS : start (concurrent)
FA --> SC : resolved: agent data
FS --> SC : rejected: timeout error
SC -> SC : Promise.all rejects immediately\nrender aborted — error boundary triggered

== Promise.allSettled (wait-for-all) ==

SC -> FA : start (concurrent)
SC -> FS : start (concurrent)
FA --> SC : {status: "fulfilled", value: agent}
FS --> SC : {status: "rejected", reason: timeout}
SC -> SC : all settled\nrender agent data\nshow fallback for stats

@enduml

Sequence diagram comparing Promise.all and Promise.allSettled behaviour when one fetch rejects

When dependencies between fetches exist (for example, when the second fetch requires a value from the first), the sequential form is unavoidable. In that case, collocating both fetches in a single server component and passing combined data to child components is preferable to splitting them across the component tree, because it keeps the data dependency graph visible in one place.

Next.js also provides request-level memoisation for fetch calls (Vercel, 2024). When the same fetch URL with the same options is called multiple times during a single render, Next.js deduplicates the network request. This means that the same user fetch in the layout and on a page will only result in one HTTP call. The practical implication is that data can be fetched close to where it is needed without worrying about redundant network requests.

Shared client state without prop drilling

Not all shared state originates on the server. Filter values, modal open states, and form drafts are examples of client-only state that multiple components in the tree may need to read or update. For this category, React Context is sufficient when the update surface is small. When state becomes complex (many actions, derived values, or cross-slice dependencies), a dedicated library such as Zustand provides a more predictable model.

Zustand stores are defined as module-level singletons:

// features/agents/store/useAgentStore.ts
import { create } from 'zustand';

type AgentFilters = {
  status: string;
  model: string;
};

type AgentStore = {
  filters: AgentFilters;
  setStatus: (status: string) => void;
  setModel: (model: string) => void;
};

export const useAgentStore = create<AgentStore>((set) => ({
  filters: { status: 'all', model: '' },
  setStatus: (status) =>
    set((state) => ({ filters: { ...state.filters, status } })),
  setModel: (model) =>
    set((state) => ({ filters: { ...state.filters, model } })),
}));

Test for useAgentStore with Jest:

// features/agents/store/__tests__/useAgentStore.test.ts
import { act, renderHook } from '@testing-library/react';
import { useAgentStore } from '../useAgentStore';

describe('useAgentStore', () => {
  beforeEach(() => {
    // Reset store state before each test
    const { result } = renderHook(() => useAgentStore());
    act(() => {
      result.current.setStatus('all');
      result.current.setModel('');
    });
  });

  it('initializes with default filters', () => {
    const { result } = renderHook(() => useAgentStore());

    expect(result.current.filters.status).toBe('all');
    expect(result.current.filters.model).toBe('');
  });

  it('updates status filter', () => {
    const { result } = renderHook(() => useAgentStore());

    act(() => {
      result.current.setStatus('running');
    });

    expect(result.current.filters.status).toBe('running');
    // Ensure model is not affected
    expect(result.current.filters.model).toBe('');
  });

  it('updates model filter', () => {
    const { result } = renderHook(() => useAgentStore());

    act(() => {
      result.current.setModel('gpt-4');
    });

    expect(result.current.filters.model).toBe('gpt-4');
    // Ensure status is not affected
    expect(result.current.filters.status).toBe('all');
  });

  it('can update multiple filters sequentially', () => {
    const { result } = renderHook(() => useAgentStore());

    act(() => {
      result.current.setStatus('idle');
      result.current.setModel('claude-3');
    });

    expect(result.current.filters.status).toBe('idle');
    expect(result.current.filters.model).toBe('claude-3');
  });
});

Any client component in the tree can call useAgentStore and receive the current filters or dispatch an action without a provider or intermediate props. The store is scoped to the feature directory, which prevents filter state from leaking into unrelated features.

The important constraint is that Zustand stores, like all client state, must not be initialised or accessed inside server components. Attempting to call useAgentStore in a server component produces a runtime error because hooks are not available in the server zone.

Keeping the UI in sync after server mutations

Client-only state solves the case where data never leaves the browser. A different problem arises when a user action must persist to the server and the UI must immediately reflect the new state without a full page reload. In the App Router, the mechanism for this is a Server Action: a function marked "use server" that the client can invoke as if it were a normal async function (Vercel, 2024).

Consider the following scenario: a page shows an AI agent’s current configuration. A dropdown button reveals an edit form. The user updates the agent name and model and clicks Save. The change must persist to the database, and the page must reflect the new values immediately — without a reload and with a brief fade-in animation to signal that the content has changed.

The sequence is:

  1. The Server Component page fetches the agent and renders it alongside a Client Component form.
  2. The user clicks the dropdown; the form fades in (client-side useState).
  3. The user fills the form and submits.
  4. The Client Component invokes a Server Action, which persists the change and calls revalidatePath.
  5. Next.js re-renders the Server Component tree with fresh data from the database.
  6. The updated values arrive in the page and the client applies a fade-in CSS transition.

The Server Action handles the persistence and cache invalidation:

// features/agents/actions/updateAgent.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db/client';

export async function updateAgent(agentId: string, formData: FormData) {
  const name = formData.get('name') as string;
  const model = formData.get('model') as string;

  // Production code should validate inputs (e.g. with Zod), verify that the
  // authenticated user owns this agent, and handle database errors explicitly.
  await db.agents.update({ where: { id: agentId }, data: { name, model } });

  // Marks the cached page as stale; the next render fetches fresh data.
  revalidatePath(`/agents/${agentId}`);
}

The Client Component owns the dropdown state, calls the action via useTransition, and applies a CSS fade-in class to the form:

// features/agents/components/AgentEditForm.tsx
'use client';

import { useTransition, useState } from 'react';
import { updateAgent } from '../actions/updateAgent';

type Props = {
  agentId: string;
  initialName: string;
  initialModel: string;
};

export function AgentEditForm({ agentId, initialName, initialModel }: Props) {
  const [open, setOpen] = useState(false);
  const [isPending, startTransition] = useTransition();

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await updateAgent(agentId, formData);
      setOpen(false);
    });
  }

  return (
    <div>
      <button onClick={() => setOpen((v) => !v)}>
        {open ? 'Cancel' : 'Edit agent'}
      </button>

      {open && (
        <form action={handleSubmit} className="fade-in">
          <label>
            Name
            <input name="name" defaultValue={initialName} />
          </label>
          <label>
            Model
            <input name="model" defaultValue={initialModel} />
          </label>
          <button type="submit" disabled={isPending}>
            {isPending ? 'Saving…' : 'Save changes'}
          </button>
        </form>
      )}
    </div>
  );
}

Test for AgentEditForm with Jest and Testing Library:

// features/agents/components/__tests__/AgentEditForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AgentEditForm } from '../AgentEditForm';

// Mock the server action
jest.mock('../actions/updateAgent', () => ({
  updateAgent: jest.fn(),
}));

import { updateAgent } from '../actions/updateAgent';

const mockUpdateAgent = updateAgent as jest.MockedFunction<typeof updateAgent>;

describe('AgentEditForm', () => {
  const defaultProps = {
    agentId: 'agent-1',
    initialName: 'Analysis Agent',
    initialModel: 'gpt-4',
  };

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('renders edit button initially', () => {
    render(<AgentEditForm {...defaultProps} />);

    expect(screen.getByRole('button', { name: /edit agent/i })).toBeInTheDocument();
    expect(
      screen.queryByRole('button', { name: /cancel/i })
    ).not.toBeInTheDocument();
  });

  it('toggles form visibility on button click', async () => {
    const user = userEvent.setup();
    render(<AgentEditForm {...defaultProps} />);

    const editButton = screen.getByRole('button', { name: /edit agent/i });
    await user.click(editButton);

    expect(screen.getByDisplayValue(defaultProps.initialName)).toBeInTheDocument();
    expect(screen.getByDisplayValue(defaultProps.initialModel)).toBeInTheDocument();

    const cancelButton = screen.getByRole('button', { name: /cancel/i });
    await user.click(cancelButton);

    expect(
      screen.queryByDisplayValue(defaultProps.initialName)
    ).not.toBeInTheDocument();
  });

  it('submits form with updated values', async () => {
    const user = userEvent.setup();
    mockUpdateAgent.mockResolvedValue(undefined);

    render(<AgentEditForm {...defaultProps} />);

    const editButton = screen.getByRole('button', { name: /edit agent/i });
    await user.click(editButton);

    const nameInput = screen.getByDisplayValue(defaultProps.initialName);
    const modelInput = screen.getByDisplayValue(defaultProps.initialModel);

    await user.clear(nameInput);
    await user.type(nameInput, 'Updated Agent');

    await user.clear(modelInput);
    await user.type(modelInput, 'claude-3');

    const submitButton = screen.getByRole('button', { name: /save changes/i });
    await user.click(submitButton);

    await waitFor(() => {
      expect(mockUpdateAgent).toHaveBeenCalledWith(
        'agent-1',
        expect.any(FormData)
      );
    });
  });

  it('disables submit button while pending', async () => {
    const user = userEvent.setup();
    mockUpdateAgent.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 100))
    );

    render(<AgentEditForm {...defaultProps} />);

    const editButton = screen.getByRole('button', { name: /edit agent/i });
    await user.click(editButton);

    const submitButton = screen.getByRole('button', { name: /save changes/i });
    await user.click(submitButton);

    await waitFor(() => {
      expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
    });
  });

  it('closes form after successful submission', async () => {
    const user = userEvent.setup();
    mockUpdateAgent.mockResolvedValue(undefined);

    render(<AgentEditForm {...defaultProps} />);

    const editButton = screen.getByRole('button', { name: /edit agent/i });
    await user.click(editButton);

    const submitButton = screen.getByRole('button', { name: /save changes/i });
    await user.click(submitButton);

    await waitFor(() => {
      expect(
        screen.queryByDisplayValue(defaultProps.initialName)
      ).not.toBeInTheDocument();
    });
  });
});

Test for Server Action updateAgent (integration test):

// features/agents/actions/__tests__/updateAgent.test.ts
import { updateAgent } from '../updateAgent';
import { db } from '@/lib/db/client';
import * as nextCache from 'next/cache';

// Mock database and Next.js cache functions
jest.mock('@/lib/db/client');
jest.mock('next/cache');

const mockDb = db as jest.Mocked<typeof db>;
const mockRevalidatePath = nextCache.revalidatePath as jest.MockedFunction<
  typeof nextCache.revalidatePath
>;

describe('updateAgent Server Action', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('updates agent record in database', async () => {
    mockDb.agents.update.mockResolvedValue({
      id: 'agent-1',
      name: 'Updated Name',
      model: 'claude-3',
      status: 'idle',
    });

    const formData = new FormData();
    formData.set('name', 'Updated Name');
    formData.set('model', 'claude-3');

    await updateAgent('agent-1', formData);

    expect(mockDb.agents.update).toHaveBeenCalledWith({
      where: { id: 'agent-1' },
      data: { name: 'Updated Name', model: 'claude-3' },
    });
  });

  it('revalidates the agent detail page after update', async () => {
    mockDb.agents.update.mockResolvedValue({
      id: 'agent-1',
      name: 'Updated Name',
      model: 'claude-3',
      status: 'idle',
    });

    const formData = new FormData();
    formData.set('name', 'Updated Name');
    formData.set('model', 'claude-3');

    await updateAgent('agent-1', formData);

    expect(mockRevalidatePath).toHaveBeenCalledWith('/agents/agent-1');
  });

  it('extracts form data correctly', async () => {
    mockDb.agents.update.mockResolvedValue({
      id: 'agent-1',
      name: 'New Name',
      model: 'gpt-4-turbo',
      status: 'idle',
    });

    const formData = new FormData();
    formData.set('name', 'New Name');
    formData.set('model', 'gpt-4-turbo');

    await updateAgent('agent-1', formData);

    expect(mockDb.agents.update).toHaveBeenCalledWith({
      where: { id: 'agent-1' },
      data: { name: 'New Name', model: 'gpt-4-turbo' },
    });
  });
});

The fade-in animation is a single CSS keyframe applied to the element that appears after the state change:

/* _sass/components/_animations.scss */
.fade-in {
  animation: fadeIn 0.3s ease-in;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}

The Server Component page composes both the read path and the write path:

// app/(dashboard)/agents/[id]/page.tsx
import { fetchAgentById } from '@/features/agents/server/fetchAgentById';
import { AgentSection } from '@/features/agents/components/AgentSection';
import { AgentEditForm } from '@/features/agents/components/AgentEditForm';

export default async function AgentDetailPage({
  params,
}: {
  params: { id: string };
}) {
  const agent = await fetchAgentById(params.id);

  return (
    <div>
      <AgentSection agent={agent} />
      <AgentEditForm
        agentId={agent.id}
        initialName={agent.name}
        initialModel={agent.model}
      />
    </div>
  );
}

revalidatePath is the connective tissue between the write path and the read path. When the action completes, Next.js discards the cached render for the specified path. The next request to that path triggers a fresh server render, which re-executes fetchAgentById and sends the updated data to the client. useTransition keeps the UI responsive during the server round-trip by exposing an isPending flag, which disables the submit button and prevents double submissions. The fade-in animation runs once the new data arrives, giving the user a clear visual signal that the content has changed.

The sequence diagram below illustrates the complete flow:

@startuml
'This PlantUML code will be rendered soon. Meanwhile, you can check the source:

actor User
participant "AgentEditForm\n(Client)" as Form
participant "Server Action\nupdateAgent" as Action
participant "Database" as DB
participant "AgentDetailPage\n(Server)" as Page

User -> Form : click "Edit agent"
Form -> Form : setOpen(true)\nform fades in

User -> Form : fill fields\nclick "Save"
Form -> Action : invoke updateAgent(agentId, formData)
activate Action

Action -> DB : UPDATE agent record
DB --> Action : OK
Action -> Action : revalidatePath(`/agents/${agentId}`)
Action --> Form : action settled
deactivate Action

Form -> Form : setOpen(false)
Page -> DB : fetchAgentById(id)
DB --> Page : fresh agent data
Page --> Form : re-render with updated props
Form --> User : updated content\nfades in

@enduml

Sequence diagram of the form submission and server revalidation flow

A checklist for maintainable enterprise Next.js

The following practices, taken together, produce a codebase that scales with team size:

  • Organise by feature, not by file type. Each feature directory exports a public API from its index.ts and other features import only from that file.
  • Treat server components as the default. Add "use client" only when hooks, event handlers, or browser APIs are required.
  • Fetch data at the nearest server component ancestor that has access to all necessary context (route params, session, etc.) and pass the result down as props or through a client context provider.
  • Isolate the server/client boundary at the provider level. The provider receives server-fetched data as a prop and makes it available to the entire client subtree via useContext.
  • Choose Promise.all when all data is required to render the page and Promise.allSettled when partial data is acceptable and partial fallback UI is preferable to a full error screen.
  • Use Server Actions with revalidatePath for mutations. Pair with useTransition to expose a pending state and a CSS transition to fade in the updated content.
  • Scope client state to features. Avoid global context or store slices that mix concerns from different features.
  • Enforce encapsulation with module boundaries. Linters such as ESLint with the import/no-internal-modules rule can prevent direct imports from feature internals.

These are not abstract recommendations. Each addresses a specific failure mode observed in production Next.js applications at scale: bundle bloat from excess client components, stale or duplicated data from uncoordinated fetches, and implicit coupling from prop-threaded data.

References

  1. Vercel. (2024). Next.js App Router Documentation. https://nextjs.org/docs/app
  2. Garlan, D., & Shaw, M. (1993). An Introduction to Software Architecture. Advances in Software Engineering and Knowledge Engineering. https://doi.org/10.1142/9789812798428_0001
  3. Vercel. (2024). Rendering: Server and Client Components. https://nextjs.org/docs/app/building-your-application/rendering
  4. Vercel. (2024). Next.js Project Structure. https://nextjs.org/docs/app/getting-started/project-structure
  5. Martin, R. C. (2017). Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall.
  6. Hallie, L., & Osmani, A. (2023). Patterns.dev. https://www.patterns.dev/
  7. Vercel. (2024). Server and Client Composition Patterns. https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns
  8. React Team. (2024). createContext. https://react.dev/reference/react/createContext
  9. Abramov, D., & Lunaruan, L. (2020). React Server Components RFC. Meta Open Source. https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md
  10. Vercel. (2024). React Context and State Management in Next.js. https://vercel.com/kb/guide/react-context-state-management-nextjs
  11. Vercel. (2024). Next.js Data Fetching Patterns. https://nextjs.org/docs/app/building-your-application/data-fetching/patterns
  12. Vercel. (2024). Next.js Caching. https://nextjs.org/docs/app/building-your-application/caching
  13. Vercel. (2024). Next.js Server Actions and Mutations. https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

About this post

This post content s was assisted by AI, which helped with research, curate content and code suggestions.

You also might like