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:
- Explicit APIs: Packages define clear boundaries. When auth logic lives in
@kit/auth, you know exactly where to look. - Atomic changes: Update a shared component and its consumers in one commit. No version mismatches.
- Build caching: Turborepo skips unchanged packages. A type change in your billing package doesn't rebuild your UI components.
- 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 dependencies90% 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| 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 |
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
| 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 clarityauth/: 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.tsIn 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 loadingimport { 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 Componentexport 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?
- 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.
- Reusability: The same business logic might be needed in a webhook handler, a cron job, or an API route.
- 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.tsimport { 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 testspnpm --filter web test:watch # Watch mode during developmentpnpm --filter web test:coverage # Generate coverage reportMakerkit 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" directiveimport { 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 constantsWhy 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 - dangerousconst 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-safeimport { 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 errorsexport 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.tsimport { 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.jsonPackage Export Rules
- Separate server and client code—never mix in one export:
// Don't do thisexport { serverFunction, ClientComponent };// Do this instead// organization/server.tsexport { createOrganization, deleteOrganization };// organization/components.tsexport { OrganizationSwitcher, InviteForm };- 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" }}- Configuration comes from the consumer:
// Don't hardcode in packagesconst API_URL = 'https://api.example.com';// Accept configurationexport 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.tsimport '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.jsimportOrder: [ '/^(?!.*\\.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 configurationUsage 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 packagespnpm typecheck# Lint and auto-fixpnpm lint:fix# Format codepnpm format:fix# Build for productionpnpm build# Database commands (Drizzle)pnpm --filter @kit/database drizzle:generate # Generate migrationspnpm --filter @kit/database drizzle:migrate # Run migrationspnpm --filter @kit/database drizzle:studio # Open Drizzle Studio# Run E2E testspnpm --filter e2e testDevelopment workflow after changes:
pnpm typecheck && pnpm lint:fix && pnpm format:fixBest Practices Summary
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
- Marketing pages in
Component Organization:
- Route-specific:
_components/in route folder - App-wide:
apps/web/components/ - Shared across apps:
packages/ui/
- Route-specific:
Server Code:
- Keep in
_lib/server/or use.server.tssuffix - Use
server-onlypackage for sensitive code - Never import server code in Client Components
- Keep in
Business Logic:
- Keep Server Actions thin (validate → call service → revalidate)
- Extract logic to
*.service.tsfiles for testability - Test services with Vitest, not actions
- Colocate tests:
boards.service.test.tsnext toboards.service.ts
Configuration:
- Validate with Zod schemas
- Keep in
config/directory - Fail fast at build time, not runtime
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?
What's the difference between route groups and regular folders?
Should I use the Pages Router or App Router in Next.js 16?
How do I decide if code belongs in a package or the app?
Why use underscore prefixes for _components and _lib?
How do Server Actions compare to API routes?
Why extract business logic from Server Actions into services?
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: