# Next.js 16 App Router Project Structure: The Definitive Guide

> Learn how to structure a production-grade Next.js 16 App Router project using Turborepo, route groups, feature packages, and battle-tested patterns from shipping real SaaS applications.

*Published: 2024-12-18*
*Canonical: https://makerkit.dev/blog/tutorials/nextjs-app-router-project-structure*

---

A well-structured Next.js 16 App Router project uses Turborepo for monorepo management, route groups for URL organization, feature packages for code reuse, and colocated `_components` and `_lib` directories for route-specific code. This structure separates marketing pages from authenticated routes, validates configuration with Zod schemas, and keeps server-side code in dedicated `server/` directories to prevent accidental client bundling.

{% definition title="What is Next.js App Router project structure?" %}
Next.js App Router project structure is the organization of files and folders in a Next.js application that uses the App Router (introduced in Next.js 13, default since Next.js 14). It defines where routes, components, configuration, and server code live—using conventions like route groups `(name)`, dynamic segments `[param]`, and private folders `_folder` to create maintainable, scalable applications.
{% /definition %}

*Tested with Next.js 16.1.0, React 19.2, Turborepo 2.3, and Drizzle ORM 0.38.*

---

Next.js is the most popular React framework for building web applications, but it doesn't mandate a specific architecture. This flexibility becomes a liability at scale—without clear conventions, codebases turn into unmaintainable sprawls.

This guide documents the project structure behind [Makerkit](/)—a production SaaS starter kit used by thousands of developers. These patterns emerged from building and maintaining multiple Next.js applications, each serving real users with real money on the line.

After shipping 5+ SaaS starter kits (Next.js + Supabase, Next.js + Drizzle, React Router + Supabase, and more) and supporting thousands of developers, we've refined this structure through countless iterations. The patterns here aren't theoretical—they're what survives contact with production.

{% callout type="info" title="Why we built it this way" %}
Every architectural decision in Makerkit came from pain. We tried flat structures—they collapsed at 50+ files. We put business logic in Server Actions—we couldn't test it. We skipped Zod validation — we shipped bugs to production. This guide shares what we learned so you don't repeat our mistakes.
{% /callout %}

## When to Use This Structure

{% decision-rules
   title="Project structure decision guide"
   useWhen=["Building a SaaS or production web application", "Working with a team of 2+ developers", "Planning to scale beyond 10 routes", "Need to share code between multiple apps", "Want explicit boundaries between features"]
   avoidWhen=["Building a simple marketing site (<5 pages)", "Prototyping or MVPs where speed trumps structure", "Solo projects you'll abandon in 3 months"]
   ifUnsure="Start with the monorepo structure. It's easier to simplify later than to untangle a messy codebase."
/%}

---

## Using Monorepos for Production Next.js Projects

A **monorepo** is a single repository containing multiple packages or applications. For Next.js projects, this means your main app, shared packages, and tooling all live together.

**Why this matters for production:**

1. **Explicit APIs**: Packages define clear boundaries. When auth logic lives in `@kit/auth`, you know exactly where to look.
2. **Atomic changes**: Update a shared component and its consumers in one commit. No version mismatches.
3. **Build caching**: Turborepo skips unchanged packages. A type change in your billing package doesn't rebuild your UI components.
4. **Consistent tooling**: One ESLint config. One Prettier config. One TypeScript version. No drift.

We started Makerkit as a single Next.js app. At around 100 files, onboarding new developers became painful—"where does auth code go?" had three different answers depending on who you asked. The monorepo structure forces explicit boundaries. Now when someone asks "where's the billing logic?", the answer is always `packages/billing/`.

{% callout type="info" title="Makerkit uses Turborepo" %}
Turborepo orchestrates builds across packages, handles caching, and runs tasks in parallel. It's built by Vercel—the same team behind Next.js.
{% /callout %}

### The Turborepo Structure

```
/
├── apps/
│   ├── web/              # Main Next.js 16 application
│   └── e2e/              # Playwright end-to-end tests
├── packages/
│   ├── ui/               # Shared UI components (shadcn/ui)
│   ├── database/         # Database schema and client (Drizzle ORM)
│   ├── auth/             # Authentication utilities
│   ├── billing/          # Payment integrations (Stripe, Polar)
│   ├── organization/     # Multi-tenant organization logic
│   ├── account/          # User account management
│   ├── admin/            # Admin dashboard features
│   ├── mailers/          # Email providers (Resend, Nodemailer)
│   ├── email-templates/  # React Email templates
│   ├── i18n/             # Internationalization
│   ├── monitoring/       # Error tracking (Sentry)
│   ├── analytics/        # Analytics integration
│   ├── shared/           # Utilities, types, environment validation
│   └── ...
├── tooling/
│   ├── eslint/           # Shared ESLint configuration
│   ├── prettier/         # Shared Prettier configuration
│   ├── typescript/       # Shared TypeScript configuration
│   └── tailwind/         # Shared Tailwind CSS configuration
├── turbo.json            # Turborepo task configuration
├── pnpm-workspace.yaml   # Workspace definition
└── package.json          # Root dependencies
```

**90% of your work happens in `apps/web/`**. The packages provide infrastructure; you build your product in the app.

```
┌─────────────────────────────────────────────────────────────┐
│                        apps/web                              │
│                   (Your Next.js App)                         │
└─────────────────────────┬───────────────────────────────────┘
                          │
         ┌────────────────┼────────────────┐
         │                │                │
         ▼                ▼                ▼
    ┌─────────┐     ┌──────────┐    ┌──────────┐
    │   ui    │     │   auth   │    │ billing  │
    │(shadcn) │     │(sessions)│    │(Stripe)  │
    └─────────┘     └────┬─────┘    └────┬─────┘
                         │               │
                         ▼               ▼
                   ┌──────────┐    ┌──────────┐
                   │ database │    │  shared  │
                   │(Drizzle) │    │ (utils)  │
                   └──────────┘    └──────────┘
```

For a deeper dive into navigating the codebase, see the [Navigating the Codebase](/docs/next-supabase-turbo/installation/navigating-codebase) documentation.

---

## The Main Application Structure

Inside `apps/web/`, your Next.js 16 application follows this structure:

```
apps/web/
├── app/              # Next.js App Router routes
├── components/       # App-specific shared components
├── config/           # Application configuration (validated with Zod)
├── lib/              # App-specific utilities and services
├── styles/           # Global CSS and theme
├── content/          # CMS content (Keystatic, MDX)
└── public/           # Static assets
```

| Directory | Purpose | When to Add Code Here |
|-----------|---------|----------------------|
| `app/` | Routes, layouts, pages | Adding new pages or API routes |
| `components/` | App-specific components | Components used across multiple routes |
| `config/` | Configuration files | Adding feature flags, paths, settings |
| `lib/` | Utilities, services, loaders | Business logic specific to this app |
| `styles/` | Global styles, themes | Modifying design system |
| `content/` | CMS content | Adding blog posts, documentation |

{% callout type="warning" title="Don't confuse app components with package components" %}
Components in `apps/web/components/` are specific to this application. Reusable components across multiple apps belong in `packages/ui/`.
{% /callout %}

---

## Route Organization with App Router

The `app/` directory in Next.js 16 uses file-based routing where folders map to URL segments. Makerkit uses **route groups** to organize routes without affecting URLs.

```
app/
├── [locale]/                    # i18n: /en, /de, /fr
│   ├── (public)/                # Marketing pages (no URL prefix)
│   │   ├── page.tsx             # → /
│   │   ├── pricing/page.tsx     # → /pricing
│   │   ├── blog/page.tsx        # → /blog
│   │   └── (legal)/
│   │       ├── privacy-policy/  # → /privacy-policy
│   │       └── terms-of-service/
│   ├── auth/                    # Authentication routes
│   │   ├── sign-in/page.tsx     # → /auth/sign-in
│   │   ├── sign-up/page.tsx     # → /auth/sign-up
│   │   └── password-reset/
│   ├── (internal)/              # Protected dashboard routes
│   │   ├── dashboard/page.tsx   # → /dashboard
│   │   ├── settings/            # → /settings/*
│   │   │   ├── page.tsx
│   │   │   ├── billing/
│   │   │   ├── members/
│   │   │   └── security/
│   │   └── [feature]/           # Dynamic feature routes
│   ├── admin/                   # Super admin panel
│   │   ├── page.tsx             # → /admin
│   │   ├── users/
│   │   └── organizations/
│   └── (invitation)/            # Team invitation flow
│       └── accept-invitation/
└── api/                         # API routes (not under [locale])
    ├── auth/[...all]/           # Auth endpoints
    ├── webhooks/stripe/
    └── healthcheck/
```

### Route Group Conventions

| Pattern | Example | URL Result | Purpose |
|---------|---------|------------|---------|
| `(name)` | `(public)` | No URL segment | Organize routes without affecting path |
| `[param]` | `[locale]` | Dynamic segment | URL parameters like `/en/dashboard` |
| `[...slug]` | `[...slug]` | Catch-all | Match multiple segments like `/docs/a/b/c` |
| `_folder` | `_components` | Excluded from routing | Private folders for colocated code |

**Why route groups matter:**

- **`(public)`**: Marketing pages share a layout without auth checks
- **`(internal)`**: Dashboard pages share a sidebar layout with auth protection
- **`(legal)`**: Legal pages grouped for organizational clarity
- **`auth/`**: Explicit path prefix for middleware protection

---

## Feature Organization: The Colocated Pattern

Within each route, colocate related code using underscore-prefixed directories:

```
app/[locale]/(internal)/boards/
├── page.tsx                      # Route entry point
├── layout.tsx                    # Route-specific layout
├── loading.tsx                   # Suspense loading UI
├── _components/                  # Route-specific components
│   ├── board-list.tsx
│   ├── create-board-dialog.tsx
│   ├── edit-board-dialog.tsx
│   └── delete-board-dialog.tsx
├── _lib/                         # Route-specific logic
│   ├── boards.loader.ts          # Server-side data fetching
│   ├── boards.actions.ts         # Server Actions (thin)
│   ├── boards.service.ts         # Business logic (testable)
│   ├── boards.service.test.ts    # Vitest unit tests
│   └── boards.schema.ts          # Zod validation schemas
└── [boardId]/                    # Nested dynamic route
    ├── page.tsx
    ├── _components/
    │   ├── feedback-list.tsx
    │   ├── feedback-filters.tsx
    │   └── vote-button.tsx
    └── _lib/
        ├── feedback.loader.ts
        ├── feedback.actions.ts
        └── feedback.service.ts
```

{% pitfalls title="Common colocation mistakes" items=[
  "Putting route-specific components in the global `components/` directory—leads to confusion about where code is used",
  "Creating a `utils.ts` file that grows to 1,000+ lines—break into focused modules",
  "Forgetting the underscore prefix—`components/` becomes a route, `_components/` doesn't",
  "Mixing Server Actions with client code—keep actions in `_lib/server/` or with `.server.ts` suffix",
  "Nesting too deep: `_components/dialogs/boards/create/form/index.tsx`—flatten when possible",
  "Importing server loaders in Client Components—causes build errors or exposes sensitive code",
  "Putting business logic in Server Actions—extract to services for testability and reuse"
] /%}

{% callout type="tip" title="Production insight" %}
In early Makerkit versions, we placed all Server Actions in a central `lib/actions/` directory. This became unmanageable at 50+ actions—finding related code required searching across files. Colocating actions with their routes (`_lib/boards.actions.ts`) solved this immediately.
{% /callout %}

### Loader and Action Pattern

Makerkit uses a consistent pattern for data fetching and mutations:

```typescript
// _lib/boards.loader.ts - Server-side data loading
import { cache } from 'react';

export const loadBoards = cache(async () => {
  const organizationId = await requireActiveOrganizationId();

  return db.query.boards.findMany({
    where: eq(boards.organizationId, organizationId),
    orderBy: desc(boards.createdAt),
  });
});

// page.tsx - Use loader in Server Component
export default async function BoardsPage() {
  const boards = await loadBoards();
  return <BoardList boards={boards} />;
}
```

```typescript
// _lib/boards.actions.ts - Server Actions
'use server';

import { authenticatedActionClient } from '@kit/action-middleware';
import { createBoardSchema } from './boards.schema';

export const createBoardAction = authenticatedActionClient
  .inputSchema(createBoardSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { user, organization } = ctx;

    const [board] = await db.insert(boards).values({
      name: parsedInput.name,
      organizationId: organization.id,
      createdBy: user.id,
    }).returning();

    revalidatePath('/boards');
    return { board };
  });
```

### Keep Server Actions Thin: Extract Business Logic to Services

Server Actions should be thin orchestration layers—validate input, call a service, handle the response. **Don't put business logic directly in actions.** Why?

1. **Testability**: Server Actions are hard to unit test (they depend on Next.js internals, cookies, headers). Services are pure functions you can test with Vitest.
2. **Reusability**: The same business logic might be needed in a webhook handler, a cron job, or an API route.
3. **Readability**: Actions stay short and scannable. Complex logic lives in dedicated, focused services.

This lesson cost us two weeks. We had a 200-line Server Action handling team invitations—validation, permission checks, email sending, database writes, billing seat updates. When the Stripe webhook needed the same logic, we copy-pasted. When we found a bug, we fixed it in one place but not the other. Extracting to `InvitationService` solved both problems: one source of truth, fully testable.

{% callout type="warning" title="Anti-pattern: Fat Server Actions" %}
If your Server Action is longer than 20 lines, you're probably doing too much. Extract the business logic to a service.
{% /callout %}

**The pattern:**

```
_lib/
├── boards.actions.ts      # Thin: validates, calls service, revalidates
├── boards.service.ts      # Thick: all business logic lives here
├── boards.loader.ts       # Data fetching
└── boards.schema.ts       # Zod schemas (shared by actions + services)
```

**Example: Thin Action + Service**

```typescript
// _lib/boards.service.ts - Business logic (testable, reusable)
import { db, eq } from '@kit/database';
import { boards } from '@kit/database/schema';

export class BoardsService {
  async createBoard(params: {
    name: string;
    organizationId: string;
    createdBy: string;
  }) {
    // Business rule: board names must be unique per organization
    const existing = await db.query.boards.findFirst({
      where: and(
        eq(boards.organizationId, params.organizationId),
        eq(boards.name, params.name)
      ),
    });

    if (existing) {
      throw new Error('A board with this name already exists');
    }

    const [board] = await db.insert(boards).values({
      name: params.name,
      organizationId: params.organizationId,
      createdBy: params.createdBy,
    }).returning();

    return board;
  }

  async deleteBoard(boardId: string, organizationId: string) {
    // Business rule: can't delete boards with active feedback
    const feedbackCount = await db
      .select({ count: count() })
      .from(feedback)
      .where(eq(feedback.boardId, boardId));

    if (feedbackCount[0].count > 0) {
      throw new Error('Cannot delete board with existing feedback');
    }

    await db.delete(boards).where(
      and(eq(boards.id, boardId), eq(boards.organizationId, organizationId))
    );
  }
}

export const boardsService = new BoardsService();
```

```typescript
// _lib/boards.actions.ts - Thin orchestration layer
'use server';

import { revalidatePath } from 'next/cache';
import { authenticatedActionClient } from '@kit/action-middleware';
import { createBoardSchema, deleteBoardSchema } from './boards.schema';
import { boardsService } from './boards.service';

export const createBoardAction = authenticatedActionClient
  .inputSchema(createBoardSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Action is thin: validate → call service → revalidate
    const board = await boardsService.createBoard({
      name: parsedInput.name,
      organizationId: ctx.organization.id,
      createdBy: ctx.user.id,
    });

    revalidatePath('/boards');
    return { board };
  });

export const deleteBoardAction = authenticatedActionClient
  .inputSchema(deleteBoardSchema)
  .action(async ({ parsedInput, ctx }) => {
    await boardsService.deleteBoard(parsedInput.boardId, ctx.organization.id);

    revalidatePath('/boards');
    return { success: true };
  });
```

### Testing Services with Vitest

Because services are pure business logic with no Next.js dependencies, they're trivial to test with [Vitest](https://vitest.dev/):

```typescript
// _lib/boards.service.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { boardsService } from './boards.service';

describe('BoardsService', () => {
  beforeEach(async () => {
    // Reset test database or use transactions
    await resetTestDatabase();
  });

  describe('createBoard', () => {
    it('creates a board with valid input', async () => {
      const board = await boardsService.createBoard({
        name: 'Feature Requests',
        organizationId: 'org-123',
        createdBy: 'user-456',
      });

      expect(board.name).toBe('Feature Requests');
      expect(board.organizationId).toBe('org-123');
    });

    it('throws when board name already exists in organization', async () => {
      await boardsService.createBoard({
        name: 'Bugs',
        organizationId: 'org-123',
        createdBy: 'user-456',
      });

      await expect(
        boardsService.createBoard({
          name: 'Bugs',
          organizationId: 'org-123',
          createdBy: 'user-456',
        })
      ).rejects.toThrow('A board with this name already exists');
    });

    it('allows same board name in different organizations', async () => {
      await boardsService.createBoard({
        name: 'Bugs',
        organizationId: 'org-123',
        createdBy: 'user-456',
      });

      // Different org - should succeed
      const board = await boardsService.createBoard({
        name: 'Bugs',
        organizationId: 'org-789',
        createdBy: 'user-456',
      });

      expect(board.name).toBe('Bugs');
    });
  });

  describe('deleteBoard', () => {
    it('throws when board has existing feedback', async () => {
      const board = await createBoardWithFeedback(); // test helper

      await expect(
        boardsService.deleteBoard(board.id, board.organizationId)
      ).rejects.toThrow('Cannot delete board with existing feedback');
    });
  });
});
```

Run tests with:

```bash
pnpm --filter web test           # Run all tests
pnpm --filter web test:watch     # Watch mode during development
pnpm --filter web test:coverage  # Generate coverage report
```

{% callout type="tip" title="Test database strategy" %}
Makerkit uses [PGlite](https://pglite.dev/) for fast, in-memory PostgreSQL tests. Each test gets an isolated database that's destroyed after the test—no cleanup needed, tests run in parallel without conflicts. Our test suite of 150+ service tests runs in under 4 seconds.
{% /callout %}

---

## Server vs Client Components

Next.js 16 with React 19 defaults to Server Components. Use Client Components only when you need interactivity.

```tsx
// page.tsx - Server Component (default)
// Can directly access database, no "use client" directive
import { loadBoards } from './_lib/boards.loader';
import { BoardList } from './_components/board-list';

export default async function BoardsPage() {
  const boards = await loadBoards();
  return <BoardList boards={boards} />;
}
```

```tsx
// _components/create-board-dialog.tsx - Client Component
'use client';

import { useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { createBoardAction } from '../_lib/boards.actions';

export function CreateBoardDialog() {
  const [open, setOpen] = useState(false);
  const { execute, status } = useAction(createBoardAction);
  const isPending = status === 'executing';

  // Interactive form logic...
}
```

**Use Server Components for:**
- Data fetching
- Accessing backend resources (database, file system)
- Keeping sensitive logic and API keys server-side
- Reducing JavaScript bundle size

**Use Client Components for:**
- Event handlers (`onClick`, `onChange`, `onSubmit`)
- Browser APIs (`localStorage`, `navigator`)
- React hooks (`useState`, `useEffect`, `useRef`)
- Real-time updates and subscriptions

For a deeper understanding of Server Components patterns, see our guide on [Next.js Server Components](/blog/tutorials/next-server-components) and [Server Only Code in Next.js](/blog/tutorials/server-only-code-nextjs).

---

## Configuration Management

Configuration files live in `apps/web/config/` and use Zod for validation:

```
config/
├── app.config.ts           # Application metadata
├── auth.config.ts          # Authentication providers
├── feature-flags.config.ts # Feature toggles
├── account-mode.config.ts  # B2B vs B2C mode
└── paths.config.ts         # Route constants
```

### Why Validate Configuration with Zod?

We shipped a production bug because `STRIPE_WEBHOOK_SECRET` was undefined in one environment. The app started fine, processed payments fine, but webhook verification silently failed. Customers paid but their subscriptions never activated. Three days of debugging later, we added Zod validation. Now missing or malformed config crashes the build immediately—not production.

Environment variables are strings. Without validation, you get runtime surprises:

```typescript
// Without validation - dangerous
const config = {
  maxUploadSize: process.env.MAX_UPLOAD_SIZE,     // string "5000000"
  enableDarkMode: process.env.ENABLE_DARK_MODE,   // string "true"
  productName: process.env.NEXT_PUBLIC_PRODUCT_NAME, // possibly undefined
};

// typeof config.maxUploadSize === 'string' - not a number!
// typeof config.enableDarkMode === 'string' - not a boolean!
```

```typescript
// With Zod validation - type-safe
import { z } from 'zod';

const AppConfigSchema = z.object({
  name: z.string().min(1, 'NEXT_PUBLIC_PRODUCT_NAME is required'),
  url: z.string().url('NEXT_PUBLIC_SITE_URL must be a valid URL'),
  maxUploadSize: z.coerce.number().min(1000000, 'At least 1MB'),
  features: z.object({
    darkMode: z.coerce.boolean().default(false),
    teams: z.coerce.boolean().default(true),
  }),
});

// Validates at build time - fails fast with clear errors
export const appConfig = AppConfigSchema.parse({
  name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
  url: process.env.NEXT_PUBLIC_SITE_URL,
  maxUploadSize: process.env.MAX_UPLOAD_SIZE,
  features: {
    darkMode: process.env.NEXT_PUBLIC_ENABLE_DARK_MODE === 'true',
    teams: process.env.NEXT_PUBLIC_ENABLE_TEAMS === 'true',
  },
});

// Now TypeScript knows the exact types
// typeof appConfig.maxUploadSize === 'number'
// typeof appConfig.features.darkMode === 'boolean'
```

### Real-World Configuration Example

Here's how Makerkit validates feature flags:

```typescript
// config/feature-flags.config.ts
import { z } from 'zod';

const FeatureFlagsSchema = z.object({
  auth: z.object({
    password: z.coerce.boolean().default(true),
    magicLink: z.coerce.boolean().default(false),
    oAuth: z.coerce.boolean().default(true),
    mfa: z.coerce.boolean().default(false),
  }),
  billing: z.object({
    enabled: z.coerce.boolean().default(true),
    provider: z.enum(['stripe', 'polar']).default('stripe'),
  }),
  teams: z.object({
    enabled: z.coerce.boolean().default(true),
    maxMembers: z.coerce.number().min(2).default(10),
  }),
});

export const featureFlags = FeatureFlagsSchema.parse({
  auth: {
    password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
    magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
    oAuth: process.env.NEXT_PUBLIC_AUTH_OAUTH === 'true',
    mfa: process.env.NEXT_PUBLIC_AUTH_MFA === 'true',
  },
  billing: {
    enabled: process.env.NEXT_PUBLIC_BILLING_ENABLED === 'true',
    provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
  },
  teams: {
    enabled: process.env.NEXT_PUBLIC_TEAMS_ENABLED === 'true',
    maxMembers: process.env.NEXT_PUBLIC_MAX_TEAM_MEMBERS,
  },
});
```

---

## Package Architecture

Makerkit splits features into packages with clear boundaries. Each package follows a consistent internal structure:

```
packages/organization/
├── core/                    # Business logic
│   ├── src/
│   │   ├── services/        # Service classes
│   │   ├── schemas/         # Zod validation
│   │   └── index.ts         # Public API
│   └── package.json
├── hooks/                   # Authorization hooks
│   ├── src/
│   │   └── use-organization.ts
│   └── package.json
├── policies/                # Access control
│   ├── src/
│   │   └── organization.policies.ts
│   └── package.json
└── ui/                      # React components
    ├── src/
    │   ├── components/
    │   └── index.ts
    └── package.json
```

### Package Export Rules

1. **Separate server and client code**—never mix in one export:

```typescript
// Don't do this
export { serverFunction, ClientComponent };

// Do this instead
// organization/server.ts
export { createOrganization, deleteOrganization };

// organization/components.ts
export { OrganizationSwitcher, InviteForm };
```

2. **Structure exports for tree shaking**:

```json
{
  "exports": {
    "./server": "./src/server/index.ts",
    "./components": "./src/components/index.ts",
    "./hooks": "./src/hooks/index.ts",
    "./schemas": "./src/schemas/index.ts"
  }
}
```

3. **Configuration comes from the consumer**:

```typescript
// Don't hardcode in packages
const API_URL = 'https://api.example.com';

// Accept configuration
export function createBillingClient(config: BillingConfig) {
  return new BillingClient(config);
}
```

### Adding a Package as a Dependency

To add an internal package to your app:

```bash
pnpm add --filter web "@kit/organization-ui@workspace:*"
```

Then import in your code:

```typescript
import { OrganizationSwitcher } from '@kit/organization-ui';
import { createOrganization } from '@kit/organization-core/server';
```

---

## Protecting Server-Side Code

Server-side code accidentally bundled into the client is a security risk and increases bundle size. Use these safeguards:

### 1. Use the `server-only` Package

```typescript
// lib/server/secrets.ts
import 'server-only';

export const API_SECRET = process.env.API_SECRET;
export async function fetchFromInternalAPI() {
  // This will throw at build time if imported in a Client Component
}
```

### 2. Organize Server Code in Dedicated Directories

```
lib/
├── server/              # Server-only code
│   ├── api.ts
│   └── secrets.ts
├── hooks/               # Client hooks
└── utils/               # Shared utilities (isomorphic)
```

### 3. Use File Extensions

Some teams use `.server.ts` and `.client.ts` extensions:

```
_lib/
├── boards.loader.server.ts    # Server-only
├── boards.actions.server.ts   # Server-only
└── boards.schema.ts           # Shared (Zod schemas work anywhere)
```

---

## Import Organization

Consistent import ordering improves readability. Makerkit uses [prettier-plugin-sort-imports](https://github.com/trivago/prettier-plugin-sort-imports):

```javascript
// prettier.config.js
importOrder: [
  '/^(?!.*\\.css).*/',
  '^server-only$',
  '^react$',
  '^react-dom$',
  '^next$',
  '^next/(.*)$',
  '<THIRD_PARTY_MODULES>',
  '^@kit/(.*)$',     // Monorepo packages
  '^~/(.*)$',        // App-specific imports
  '^[./]',           // Relative imports
],
```

**Example result:**

```typescript
import 'server-only';

import React from 'react';
import { redirect } from 'next/navigation';

import { z } from 'zod';

import { db } from '@kit/database';
import { Button } from '@kit/ui/button';

import { appConfig } from '~/config/app.config';

import { loadBoards } from './_lib/boards.loader';
```

---

## TypeScript Path Aliases

Configure path aliases in `tsconfig.json` for cleaner imports:

```json
{
  "compilerOptions": {
    "paths": {
      "~/*": ["./app/*"],
      "~/config/*": ["./config/*"],
      "~/components/*": ["./components/*"],
      "~/lib/*": ["./lib/*"]
    }
  }
}
```

**Before:**
```typescript
import { appConfig } from '../../../config/app.config';
```

**After:**
```typescript
import { appConfig } from '~/config/app.config';
```

---

## Database Organization

For projects using Drizzle ORM, the database package follows this structure:

```
packages/database/
├── src/
│   ├── index.ts            # Main exports
│   ├── client.ts           # Drizzle client
│   ├── schema/
│   │   ├── schema.ts       # Barrel export
│   │   ├── core.ts         # User, session, account tables
│   │   ├── boards.ts       # Feature-specific tables
│   │   └── feedback.ts
│   └── services/
│       └── rate-limit.service.ts
├── drizzle/
│   └── migrations/         # Generated migrations
└── drizzle.config.ts       # Drizzle Kit configuration
```

**Usage in your app:**

```typescript
import { db, eq, desc } from '@kit/database';
import { boards, feedback } from '@kit/database/schema';

const results = await db.query.boards.findMany({
  where: eq(boards.organizationId, orgId),
  orderBy: desc(boards.createdAt),
  with: {
    feedback: true,
  },
});
```

---

## Development Commands

```bash
# Start development (all packages + database + email)
pnpm dev

# Type-check all packages
pnpm typecheck

# Lint and auto-fix
pnpm lint:fix

# Format code
pnpm format:fix

# Build for production
pnpm build

# Database commands (Drizzle)
pnpm --filter @kit/database drizzle:generate  # Generate migrations
pnpm --filter @kit/database drizzle:migrate   # Run migrations
pnpm --filter @kit/database drizzle:studio    # Open Drizzle Studio

# Run E2E tests
pnpm --filter e2e test
```

**Development workflow after changes:**

```bash
pnpm typecheck && pnpm lint:fix && pnpm format:fix
```

---

## Best Practices Summary

1. **Route Organization**:
   - Marketing pages in `(public)` route group
   - Dashboard pages in `(internal)` route group
   - Auth pages in `auth/` with explicit prefix
   - API routes in `api/` outside locale

2. **Component Organization**:
   - Route-specific: `_components/` in route folder
   - App-wide: `apps/web/components/`
   - Shared across apps: `packages/ui/`

3. **Server Code**:
   - Keep in `_lib/server/` or use `.server.ts` suffix
   - Use `server-only` package for sensitive code
   - Never import server code in Client Components

4. **Business Logic**:
   - Keep Server Actions thin (validate → call service → revalidate)
   - Extract logic to `*.service.ts` files for testability
   - Test services with Vitest, not actions
   - Colocate tests: `boards.service.test.ts` next to `boards.service.ts`

5. **Configuration**:
   - Validate with Zod schemas
   - Keep in `config/` directory
   - Fail fast at build time, not runtime

6. **Packages**:
   - Separate server/client exports
   - Accept configuration from consumers
   - Export only the public API

---

## Frequently Asked Questions

{% faq
   title="Next.js Project Structure FAQ"
   items=[
     {"question": "Why use a monorepo instead of a single Next.js app?", "answer": "Monorepos provide explicit API boundaries between features, enable code sharing across multiple apps, and allow Turborepo to cache unchanged packages. For production SaaS applications, the upfront complexity pays off in maintainability and team scalability."},
     {"question": "What's the difference between route groups and regular folders?", "answer": "Route groups (parentheses like `(marketing)`) organize routes without adding URL segments. A page at `(marketing)/pricing/page.tsx` has the URL `/pricing`, not `/marketing/pricing`. Regular folders like `dashboard/` add to the URL path."},
     {"question": "Should I use the Pages Router or App Router in Next.js 16?", "answer": "Use the App Router for new projects. It's the recommended architecture with React 19 Server Components, better caching control with the 'use cache' directive, and improved streaming. The Pages Router is in maintenance mode."},
     {"question": "How do I decide if code belongs in a package or the app?", "answer": "If the code is used by multiple apps or could be published as an npm package, put it in packages/. If it's specific to one application (like your landing page components), keep it in apps/web/. When in doubt, start in the app and extract to a package when you need reuse."},
     {"question": "Why use underscore prefixes for _components and _lib?", "answer": "Next.js App Router treats every folder in app/ as a potential route. Underscore-prefixed folders are excluded from routing, letting you colocate components and utilities with their routes without accidentally creating pages."},
     {"question": "How do Server Actions compare to API routes?", "answer": "Server Actions are functions that run on the server but can be called directly from Client Components. They're ideal for form submissions and mutations. Use API routes when you need a REST endpoint for external clients, webhooks, or non-form interactions."},
     {"question": "Why extract business logic from Server Actions into services?", "answer": "Server Actions depend on Next.js internals (cookies, headers, revalidation) making them hard to unit test. Services are pure functions that can be tested with Vitest in isolation. They're also reusable—the same logic works in webhooks, cron jobs, or API routes without duplication."}
   ]
/%}

---

## Next Steps

This structure is battle-tested across thousands of production SaaS applications. While you can adapt it to your needs, these patterns help you build robust applications faster.

Ready to start building? Check out Makerkit — a complete [Next.js SaaS starter kit](/) that implements this exact structure with authentication, billing, team management, and more built in.

**Related articles:**
- [Server Only Code in Next.js](/blog/tutorials/server-only-code-nextjs)
- [Next.js Server Components Guide](/blog/tutorials/next-server-components)
- [Next.js SaaS Boilerplate Overview](/next-supabase)
- [Navigating the MakerKit Codebase](/docs/next-supabase-turbo/installation/navigating-codebase)
