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.

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.

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.

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.

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.

When to Use This Structure


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

Turborepo orchestrates builds across packages, handles caching, and runs tasks in parallel. It's built by Vercel—the same team behind Next.js.

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 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
DirectoryPurposeWhen to Add Code Here
app/Routes, layouts, pagesAdding new pages or API routes
components/App-specific componentsComponents used across multiple routes
config/Configuration filesAdding feature flags, paths, settings
lib/Utilities, services, loadersBusiness logic specific to this app
styles/Global styles, themesModifying design system
content/CMS contentAdding blog posts, documentation

Components in apps/web/components/ are specific to this application. Reusable components across multiple apps belong in packages/ui/.


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

PatternExampleURL ResultPurpose
(name)(public)No URL segmentOrganize routes without affecting path
[param][locale]Dynamic segmentURL parameters like /en/dashboard
[...slug][...slug]Catch-allMatch multiple segments like /docs/a/b/c
_folder_componentsExcluded from routingPrivate 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

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.

Loader and Action Pattern

Makerkit uses a consistent pattern for data fetching and mutations:

// _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} />;
}
// _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.

If your Server Action is longer than 20 lines, you're probably doing too much. Extract the business logic to a service.

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

// _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();
// _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:

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

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

Makerkit uses PGlite 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.


Server vs Client Components

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

// 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} />;
}
// _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 and Server Only Code in Next.js.


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:

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

// 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:
// Don't do this
export { serverFunction, ClientComponent };
// Do this instead
// organization/server.ts
export { createOrganization, deleteOrganization };
// organization/components.ts
export { OrganizationSwitcher, InviteForm };
  1. Structure exports for tree shaking:
{
"exports": {
"./server": "./src/server/index.ts",
"./components": "./src/components/index.ts",
"./hooks": "./src/hooks/index.ts",
"./schemas": "./src/schemas/index.ts"
}
}
  1. Configuration comes from the consumer:
// 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:

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

Then import in your code:

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

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

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

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:

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

Before:

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

After:

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:

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

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

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

Next.js Project Structure FAQ

Why use a monorepo instead of a single Next.js app?
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.
What's the difference between route groups and regular folders?
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.
Should I use the Pages Router or App Router in Next.js 16?
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.
How do I decide if code belongs in a package or the app?
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.
Why use underscore prefixes for _components and _lib?
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.
How do Server Actions compare to API routes?
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.
Why extract business logic from Server Actions into services?
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:

Some other posts you might like...