Next.js App Router: Project Structure

This guide will help you with the architecture and structure of a production-grade Next.js App Router project.

Next.js is one of the most popular frameworks for building web applications, however, it does not mandate a specific architecture or structure.

In this guide, we will explore how to structure a production-grade Next.js App Router project following Makerkit's proven patterns and best practices.

In this post, we will use:

  1. Turborepo, a monorepo tool built by Vercel (also the creator of Next.js)
  2. PNPM, an alternative package manager to NPM or Yarn

Using Monorepos for your Production-Ready Next.js Project

Why do we suggest using a monorepo for your production-ready Next.js project? Does it not only add more complexity? Let's understand why:

  1. Consistency: Using a monorepo ensures a consistent structure and organization for your project, making it easier to manage and maintain.
  2. Scalability: A monorepo allows you to scale your project by adding new packages or features without breaking existing code.
  3. Caching and performance: By using a monorepo, you can take advantage of caching and performance optimizations provided by Turborepo, which can help improve your Next.js application's build time.
  4. Explicit APIs: By using a monorepo, you can create explicit APIs for your project, making it easier to share code between different parts of your application.

In the next sections, we will explain how to structure a production-ready Next.js project using a monorepo.

The Turborepo Structure

First, let's understand the high-level architecture. A Makerkit project uses Turborepo with two main directories:

/
├── apps/
│ └── web/ # Your main Next.js application
└── packages/ # Shared packages and core functionality
  1. apps/web: The main Next.js application
  2. packages: Shared packages and core functionality

You can add more applications to apps, such as end-to-end testing applications, or more Next.js apps.

The Main Application Structure

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

apps/web/
├── app/ # Next.js App Router routes
├── components/ # Shared components
├── config/ # Application configuration
├── lib/ # Shared utilities and business logic
├── styles/ # Global styles
├── content/ # Static content
└── supabase/ # Database migrations and configurations

The main structure is:

  1. app: Next.js App Router routes
  2. components: Shared components specific to the application (therefore, not shared with other applications)
  3. config: Application configuration files specific to the application (ex. paths, feature flags, etc.)
  4. lib: Shared utilities and business logic specific to the application (ex. Zod schemas, services)
  5. styles: Global styles specific to the application
  6. content: Static content, such as Markdown files (in Makerkit, we use Keystatic's Markdoc files)
  7. supabase: Database migrations and configurations

It's important to notice that all these directories are not shared with other applications - but instead, are only specific to the main application.

For example, the application logo may be specific to the current application, but not shared with other applications. In such case, it wouldn't make sense to export this component from the packages directory.

The same applies to utilities, configurations, styles, etc.

Route Organization

The app directory in Makerkit follows specific conventions for different types of routes:

app/
├── (marketing)/ # Marketing pages (landing, pricing, etc)
├── auth/ # Authentication routes
├── home/ # Application routes
│ ├── (user)/ # Personal account pages
│ └── [account] # Team account pages
├── join/ # Team invitation pages
└── admin/ # Admin panel routes

Notice the use of:

  • Parentheses () for route grouping without a path
  • Square brackets [] for dynamic routes with a path
  • Clear separation between marketing and app pages

Just as a reminder, the route groups sorrounded by parenthesis (such as (marketing)) are pathless routes, i.e. they don't show up in the URL.

For example, the path for the (marketing)/page.tsx route is /, but the path for the home/[account]/page.tsx route is /home/[account].

Marketing Pages

Within the (marketing) directory, you can create landing, pricing, and other marketing pages. It's good to group them outside of the main layout as it's likely you will have quite a few of them.

You will have the index page at (marketing)/page.tsx: this page is the home page of your website (since (marketing) is a pathless route).

Application Routes

The application routes are grouped under the home directory. Here you will find all the internal dashboard pages of your application. It's a good idea to create a path that separates the internal dashboard pages from the marketing pages, so you can use the prefix in the middleware to protect internal dashboard pages from being accessed by non-authenticated users.

Under home we will place the application routes that make up the internal dashboard/home of the application.

Authentication Routes

The auth directory contains all the authentication routes, such as the login, signup, and forgot password pages.

The other pages may look like:

app/auth/
├── sign-in
├── page.tsx
├── sign-up
├── page.tsx
└── forgot-password
├── page.tsx

Feature Organization

Within each route, Makerkit uses these conventions:

home/[account]/tickets/
├── _components/ # Route-specific components
│ ├── ticket-list.tsx
│ └── ticket-form.tsx
├── _lib/ # Route-specific logic
│ ├── server/ # Server-side code
│ │ └── server-actions.ts
│ └── schema/ # Zod schemas
│ └── ticket.schema.ts
└── page.tsx # The page component

💡 Pro tip: The underscore prefix (_components, _lib) indicates these directories are not routes.

Server vs Client Components

Makerkit is strategic about Server and Client Components:

// page.tsx - Server Component by default
import { TicketList } from './_components/ticket-list';
export default function TicketsPage() {
return <TicketList />;
}
// _components/ticket-form.tsx - Explicitly client
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function TicketForm() {
// Client-side form logic
}

Configuration Files

Configuration lives in the config directory:

config/
├── app.config.ts # Application configuration
├── auth.config.ts # Authentication settings
├── billing.config.ts # Billing configuration
├── feature-flags.config.ts
└── paths.config.ts # Application routes

We use configuration files to:

  1. Better organize the codebase and make it more maintainable (eg. no need to specify the name of an environment variable twice)
  2. Provide a single source of truth for configuration settings
  3. Validate configuration at build time

Configuration validation is crucial in a production application. Let's understand why we use Zod and how it helps prevent runtime errors.

Why Use Zod for Configuration?

Consider this common scenario without validation:

// ❌ Without validation
const config = {
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
maxUploadSize: process.env.MAX_UPLOAD_SIZE,
features: {
darkMode: process.env.ENABLE_DARK_MODE,
}
};

This seems fine, but can lead to several issues:

  • NEXT_PUBLIC_PRODUCT_NAME might be undefined
  • MAX_UPLOAD_SIZE will be a string, not a number
  • ENABLE_DARK_MODE will be a string 'true'/'false', not a boolean

Here's how Makerkit handles this using Zod:

import { z } from 'zod';
const AppConfigSchema = z.object({
name: z.string({
required_error: 'Please provide NEXT_PUBLIC_PRODUCT_NAME in your env',
}).min(1),
maxUploadSize: z.preprocess(
// Convert string to number
(val) => Number(val),
z.number({
required_error: 'MAX_UPLOAD_SIZE is required',
}).min(1000000, 'Upload size must be at least 1MB')
),
features: z.object({
darkMode: z.preprocess(
// Convert string 'true'/'false' to boolean
(val) => val === 'true',
z.boolean().default(false)
)
})
});
// This will throw if validation fails during build
const appConfig = AppConfigSchema.parse({
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
maxUploadSize: process.env.MAX_UPLOAD_SIZE,
features: {
darkMode: process.env.ENABLE_DARK_MODE,
}
});
// Now you have properly typed config with correct types
console.log(typeof appConfig.maxUploadSize); // number
console.log(typeof appConfig.features.darkMode); // boolean

The benefits are immediate:

  1. TypeScript knows the exact types of your config values
  2. Invalid configuration fails at build time, not runtime
  3. Environment variables are properly transformed into the correct types
  4. Missing required variables are caught early with helpful error messages

Real-World Example: Feature Flags Configuration

Here's a more complex example showing how we validate feature flags:

const FeatureFlagsSchema = z.object({
auth: z.object({
passwordAuth: z.boolean().default(true),
magicLink: z.boolean().default(false),
mfa: z.boolean().default(false),
}),
billing: z.object({
enableTeamBilling: z.boolean().default(false),
enablePersonalBilling: z.boolean().default(true),
provider: z.enum(['stripe', 'lemonsqueezy']),
trialDays: z.number().min(0).default(14),
}),
teams: z.object({
maxMembers: z.number().min(2).default(5),
enableTeamCreation: z.boolean().default(true),
})
}).refine(
// Custom validation: can't enable both billing types
(data) => !(data.billing.enableTeamBilling && data.billing.enablePersonalBilling),
{
message: "Cannot enable both team and personal billing simultaneously"
}
);
// This will throw with a helpful error if any values are invalid
const featureFlags = FeatureFlagsSchema.parse({
auth: {
passwordAuth: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
mfa: process.env.NEXT_PUBLIC_AUTH_MFA === 'true',
},
billing: {
enableTeamBilling: process.env.NEXT_PUBLIC_ENABLE_TEAM_BILLING === 'true',
enablePersonalBilling: process.env.NEXT_PUBLIC_ENABLE_PERSONAL_BILLING === 'true',
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
trialDays: Number(process.env.NEXT_PUBLIC_TRIAL_DAYS),
},
teams: {
maxMembers: Number(process.env.NEXT_PUBLIC_MAX_TEAM_MEMBERS),
enableTeamCreation: process.env.NEXT_PUBLIC_ENABLE_TEAM_CREATION === 'true',
}
});

By validating configuration this way, you prevent many common issues and make your application more maintainable:

  • Configuration errors are caught at build time
  • Complex validation rules can be enforced
  • TypeScript gets perfect type information
  • Environment variables are properly converted to their intended types
  • Default values are handled consistently

Remember: configuration errors are among the most common causes of production issues. Using Zod for validation helps catch these issues before they reach your users.

Shared Logic and Services

Business logic and services go in the lib directory:

lib/
├── server/ # Server-side only code
│ └── tickets/
│ └── tickets.service.ts
├── schema/ # Zod schemas
│ └── ticket.schema.ts
├── hooks/ # React hooks
└── utils/ # Shared utilities

Best Practices from Makerkit

To summarize, Makerkit uses these patterns:

  1. Route Organization:
    • Marketing pages go in (marketing)
    • App pages go in home/[account]
    • Auth pages go in auth
  2. Component Organization:
    • Route-specific components go in _components
    • Shared components go in root components directory
    • UI components come from @kit/ui package
  3. Server Code:
    • Server-only code goes in _lib/server
    • Use 'use server' for Server Actions
    • Keep data fetching in Server Components
  4. Configuration:
    • Use Zod for config validation
    • Keep environment variables in .env (except secrets)
    • Never commit secrets to version control

Example Page Structure

Here's a complete example of a feature in Makerkit:

home/[account]/tickets/
├── _components/
│ ├── ticket-list.tsx
│ ├── ticket-form.tsx
│ └── ticket-filters.tsx
├── _lib/
│ ├── server/
│ │ └── tickets.service.ts
│ └── schema/
│ └── ticket.schema.ts
├── page.tsx
└── layout.tsx

Splitting Features into Packages

Sometimes you'll build functionality that could be useful across multiple applications. Instead of duplicating code, you can extract these features into reusable packages in your Turborepo monorepo.

Let's see how Makerkit splits core features into packages. Take authentication as an example - rather than implementing auth logic in each app, we have a dedicated @kit/auth package:

packages/
├── auth/
│ ├── src/
│ │ ├── components/ # Auth UI components
│ │ ├── hooks/ # Auth-related hooks
│ │ └── api.ts # Auth API functions
│ ├── package.json
│ └── tsconfig.json
└── ...

Your app then imports what it needs:

// In your Next.js app
import { useAuth } from '@kit/auth/hooks';
import { LoginForm } from '@kit/auth/components';
function LoginPage() {
return <LoginForm />;
}

The key benefit? Write once, use everywhere. Your auth logic stays consistent across apps, and updates only need to happen in one place.

When should you create a package? Ask yourself:

  • Is this functionality used by multiple apps?
  • Does it have clear boundaries and responsibilities?
  • Could other developers benefit from this code?

If yes to these questions, it might be time to split your feature into a package. Just remember - packages should be stable and well-tested since multiple apps depend on them.

Installing a Package into Your App

To install a package into your app, you can use the following command using pnpm. We assume the package is named @kit/auth and the app is named web:

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

This command will add the package @kit/auth as a dependency in the web app. You can now use the package in your app! Yay.

Rules for Creating Packages

When creating packages in your application, following these guidelines will help you build maintainable and efficient shared code:

1. Separate Server and Client Code

Never mix server and client code in a single export - this can lead to code being bundled incorrectly. Instead, create separate entry points:

// ❌ Don't do this
export { serverFunction, ClientComponent };
// ✅ Do this instead
// auth/server.ts
export { authenticateUser, validateToken };
// auth/components.ts
export { LoginForm, SignUpForm };

Then, export the required files from different entry points:

{
"exports": {
"./server": "./auth/server.ts",
"./components": "./auth/components.ts"
}
}

2. Split Exports for Tree Shaking

Structure your exports to allow proper tree shaking. This ensures applications only bundle the code they actually use:

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

3. Create a Clear API Surface

Organize exports semantically based on their purpose:

// Using the package
import { useAuth } from '@kit/auth/hooks';
import { LoginForm } from '@kit/auth/components';
import { validateSession } from '@kit/auth/server';

4. Follow the lib/components Pattern

Keep your package internals organized:

src/
├── components/ # React components
├── lib/ # Core logic
│ ├── server
│ │ └── server-actions.ts
│ ├── types.ts
│ └── utils.ts
└── index.ts # Public API

Be meticolous about your Server Side code

As the boundary between client and server code is blurry, be over cautious about where you put your server-side code: my suggestion is to always create a lib/server directory for server-side code, whether that's in a package or in the main app directory.

💡 Good to know: use the server-only package to prevent server-side code getting bundled in client-side packages. If you want to know more, check out our article Server Only code in Next.js.

5. Export Only What's Necessary

Be intentional about your public API - only export what consumers need:

// src/lib/utils.ts
export function validateEmail() {} // Public
function normalizeEmail() {} // Private
// src/index.ts
export { validateEmail } from './lib/utils';
// normalizeEmail stays internal

6. Configuration Comes From the Consumer

Always receive configuration from the consuming application rather than hardcoding it:

// ❌ Don't do this
const API_URL = 'https://api.example.com';
// ✅ Do this instead
export function createAuthClient(config: AuthConfig) {
return new AuthClient(config);
}

The apps/web/config directory will parse and validate your environment variables: use these values to configure your package, and pass them on to your packages.

Passing Configuration to a Package

There are a few ways you can pass configuration to a package:

  1. Components: Passing them to the components
  2. API: Simply providing them as parameters of a function/class
// auth/components.ts
export function LoginForm(config: AuthConfig) {
return <Login config={config} />;
}

or via a function:

// auth/api.ts
export function createAuthClient(config: AuthConfig) {
return new AuthClient(config);
}

The application consumer will be responsible for:

  1. Validating the configuration using Zod and obtained through environment variables
  2. Passing the configuration to the package
// apps/web/config/auth.config.ts
const authConfig = AuthConfigSchema.parse({
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_TOKEN_SITE_KEY,
providers: {
password: {
enabled: process.env.NEXT_PUBLIC_PASSWORD_AUTH_ENABLED === 'true',
},
magicLink: {
enabled: process.env.NEXT_PUBLIC_MAGIC_LINK_AUTH_ENABLED === 'true',
},
oAuth: {
enabled: process.env.NEXT_PUBLIC_OAUTH_AUTH_ENABLED === 'true',
},
}
});

Then in your app:

// apps/web/auth/sign-in/page.tsx
import { SignInForm } from '@kit/auth/components';
import { authConfig } from '~/config/auth.config';
export default function SignInPage() {
return (
<SignInForm
config={authConfig}
/>
);
}

Sorting Imports

In your app, make sure to sort imports alphabetically. This can help keep your code consistent and readable, and make it easier to identify dependencies.

You can use the Trivago's Prettier plugin to do this automatically for you.

Makerkit's own configuration is the following one:

importOrder: [
'/^(?!.*\\.css).*/',
'^server-only$',
'^react$',
'^react-dom$',
'^next$',
'^next/(.*)$',
'^@supabase/supabase-js$',
'<THIRD_PARTY_MODULES>',
'^@kit/(.*)$', // package imports
'^~/(.*)$', // app-specific imports
'^[./]', // relative imports
],

Let's explain each part of this configuration:

  1. At the very top, we want .css files
  2. Then we follow the server-only package, which ensures that server-side code is only ever executed on the server
  3. Then we want React and React DOM
  4. Then we want Next.js and Next.js utils
  5. Then we want Supabase JS
  6. Then we want any other third-party modules in the project
  7. Then we want the monorepo packages (you can change @kit with any other prefix relevant to your organization)
  8. Then we want app-specific imports (we use ~ for this)
  9. Then we want relative imports

This organization ensures your import order is consistent and easy to read.

Conclusion

Following Makerkit's project structure helps you:

  • Keep your code organized and maintainable
  • Separate concerns effectively
  • Scale your application with confidence
  • Maintain consistency across features

Remember: This structure is battle-tested and used in production by many SaaS applications. While you can adapt it to your needs, following these patterns will help you build robust applications faster.

Makerkit - A trusted SaaS Starter Kit 💡

Do you like this structure? It's based on our Next.js SaaS Boilerplate! If you're looking for a Next.js SaaS Starter Kit, Makerkit can help you build a SaaS from scratch starting from this production-grade structure.