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:
- Turborepo, a monorepo tool built by Vercel (also the creator of Next.js)
- 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:
- Consistency: Using a monorepo ensures a consistent structure and organization for your project, making it easier to manage and maintain.
- Scalability: A monorepo allows you to scale your project by adding new packages or features without breaking existing code.
- 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.
- 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
- apps/web: The main Next.js application
- 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:
- app: Next.js App Router routes
- components: Shared components specific to the application (therefore, not shared with other applications)
- config: Application configuration files specific to the application (ex. paths, feature flags, etc.)
- lib: Shared utilities and business logic specific to the application (ex. Zod schemas, services)
- styles: Global styles specific to the application
- content: Static content, such as Markdown files (in Makerkit, we use Keystatic's Markdoc files)
- 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 defaultimport { 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:
- Better organize the codebase and make it more maintainable (eg. no need to specify the name of an environment variable twice)
- Provide a single source of truth for configuration settings
- 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 validationconst 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 undefinedMAX_UPLOAD_SIZE
will be a string, not a numberENABLE_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 buildconst 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 typesconsole.log(typeof appConfig.maxUploadSize); // numberconsole.log(typeof appConfig.features.darkMode); // boolean
The benefits are immediate:
- TypeScript knows the exact types of your config values
- Invalid configuration fails at build time, not runtime
- Environment variables are properly transformed into the correct types
- 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 invalidconst 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:
- Route Organization:
- Marketing pages go in
(marketing)
- App pages go in
home/[account]
- Auth pages go in
auth
- Marketing pages go in
- Component Organization:
- Route-specific components go in
_components
- Shared components go in root
components
directory - UI components come from
@kit/ui
package
- Route-specific components go in
- Server Code:
- Server-only code goes in
_lib/server
- Use
'use server'
for Server Actions - Keep data fetching in Server Components
- Server-only code goes in
- 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 appimport { 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 thisexport { serverFunction, ClientComponent };// ✅ Do this instead// auth/server.tsexport { authenticateUser, validateToken };// auth/components.tsexport { 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 packageimport { 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.tsexport function validateEmail() {} // Publicfunction normalizeEmail() {} // Private// src/index.tsexport { 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 thisconst API_URL = 'https://api.example.com';// ✅ Do this insteadexport 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:
- Components: Passing them to the components
- API: Simply providing them as parameters of a function/class
// auth/components.tsexport function LoginForm(config: AuthConfig) { return <Login config={config} />;}
or via a function:
// auth/api.tsexport function createAuthClient(config: AuthConfig) { return new AuthClient(config);}
The application consumer will be responsible for:
- Validating the configuration using Zod and obtained through environment variables
- Passing the configuration to the package
// apps/web/config/auth.config.tsconst 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.tsximport { 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:
- At the very top, we want
.css
files - Then we follow the
server-only
package, which ensures that server-side code is only ever executed on the server - Then we want React and React DOM
- Then we want Next.js and Next.js utils
- Then we want Supabase JS
- Then we want any other third-party modules in the project
- Then we want the monorepo packages (you can change
@kit
with any other prefix relevant to your organization) - Then we want app-specific imports (we use
~
for this) - 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.