How To Set Up Next.js 15 For Production In 2024
I've worked on Next.js applications that scaled to over 100k monthly active users and landing pages with millions of monthly visitors. In this article, I'll share all the lessons I've learned through countless iterations.
Whether you're part of a small team of one, two, or three developers, or you work on a massive Next.js project with multiple teams on the same codebase, it's critical to get the foundation of your app right from the start. Even if you're working on an existing project, you will discover some hard-earned insights that you can apply to your app today.
Let me take you by the hand and show you all the steps and packages that you want to set up and explain to you why they're useful, so your app scales smoothly.
Initialize Your Project
Begin by creating a new Next.js project.
It may ask you if it can install the latest create-next-app
version, just hit yes.
And then configure your project by hitting yes
on everything (TypeScript, Tailwind, app router).
Then change into the directory of your project and open it in your favorite editor.
Run The Development Server
You want to verify that your setup worked.
- Run
npm run dev
to start the development server. - Visit
http://localhost:3000
to view your application.
Type Checks With TypeScript
Your project has TypeScript already configured, but you also want to add an explicit command to your package.json
that checks all your files for type errors.
You're going to use this command later together with other automated static analysis checks.
Code Formatting
When you collaborate with a large team on a project, it's important to unify the way that everyone writes code. Discuss choices like using semicolons, quote styles, and tabs versus spaces with your team.
Then use tools to enforce your style guide and format code automatically.
There are two tools for that: Prettier and ESLint.
Prettier
Prettier is an opinionated code formatter that eliminates style discussions during code reviews.
Install Prettier together with its Tailwind plugin.
Create a prettier.config.js
file with your preferred rules.
Add a formatting script to your package.json
.
Run the formatter to apply your style.
Your files are now "prettier", but you also want to use ESLint.
ESLint
ESLint can scan your code for both stylistic and logical issues. Install ESLint and its plugins, like unicorn, playwright and import sort.
Update your .eslintrc.json
.
This tutorial's plugins offer different functions. For detailed descriptions, visit their respective GitHub pages.
But in short, they enforce coding standards, organize imports, and ensure correct use of modern JavaScript features. Since ESLint and Prettier can conflict, this setup makes them work together smoothly. The plugins also help prevent bugs and keep styles consistent, especially with tools like Vitest and Playwright.
Add a linting script to your package.json
.
Run it to format all files according to your new rules.
If you get a TypeScript version warning, you can ignore that.
Note: As of this article's writing, ESLint 9 is available, but this tutorial uses ESLint 8 because many plugins do not yet support the latest version.
Commitlint
When collaborating with a large team, it's also helpful to enforce consistent commit messages to keep the project history clear. By choosing the right standards, you can automate changelog and release generation with correct semantic versioning.
Install Commitlint and its necessary configurations. This includes Husky, which helps manage Git hooks.
Initialize Husky in your project to set up the basic configuration.
Add hooks to automate linting and type checking before each commit, and customize your commit message workflow.
The pre-commit
hook runs after git commit
, but before the commit message is finalized and runs linting and type-checking on your code.
The prepare-commit-msg
hook runs after git commit
is initiated but before the commit message editor opens. It runs commitizen
CLI to let you craft conventional commit messages. You'll learn how to use this hook in a bit.
Remove the line that says npm test
from .husky/_/pre-commit
.
Make sure these scripts are executable.
Here, chmod
stands for "change mode." This command allows you to change the access permissions or modes of a file or directory in Unix and Unix-like operating systems. The argument a+x
adds execute permissions for all users.
Install Commitizen, which provides a CLI for crafting conventional commit messages.
Configure Commitizen in your package.json
to use the conventional changelog standard.
The conventional changeloge standard is a specification for adding human and machine-readable meaning to commit messages. It is designed to automate the producing of changelogs and releases based on the Git history of your project.
A future article will explain this standard in detail. It's included in this tutorial because getting it right from the start is important. You can use it and benefit from it without needing an in-depth understanding.
Create your commitlint.config.cjs
file with rules that suit your team's needs. This setup ensures your commit messages are consistent and relevant to the changes made.
Run the following command to start crafting your commit messages using a guided CLI.
The cz command asks a series of questions and then writes your commit message for you.
As you answer all questions, the Husky hooks will automatically run TypeScript type checks and linting.
Folder structure
There are usually two popular ways to organize your code: grouping by type or grouping by feature.
Group by type looks like this:
.
├── components
│ ├── todos
│ └── user
├── reducers
│ ├── todos
│ └── user
└── tests
├── todos
└── user
And grouping by feature looks like this:
.
├── todos
│ ├── component
│ ├── reducer
│ └── test
└── user
├── component
├── reducer
└── test
Grouping files by feature in a project organizes all related components, reducers, and tests together, making it easier to manage and modify each feature. It has the following benefits:
- Scalability: Large applications are easier to scale and maintain because each feature acts like a mini-application. You avoid scrolling up and down in your file list to find all the files you need.
- Collaboration: Developers can focus on specific features without disrupting others’ work.
- Onboarding: New developers understand the project structure faster, as all files for a feature are in one place.
- Refactoring: Updating a feature is streamlined since all its elements are grouped together.
- Modularity: Features can be reused, shared, or turned into standalone packages more easily.
- Micro-frontends: If your application ever grows to 30 or more engineers, it is easy to refactor to micro-frontends.
Here is a more concrete example.
src/
├── app/
│ ├── ...
│ ├── (group)/
│ │ ├── about/
│ │ │ └── page.tsx
│ │ ├── settings/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── dashboard/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ...
│ └── header/
│ ├── header-component.tsx
│ ├── header-component.test.ts
│ └── header.module.css
├── features/
│ ├── ...
│ ├── todos/
│ │ ├── ...
│ │ ├── todos-component.tsx
│ │ ├── todos-component.test.ts
│ │ ├── todos-container.ts
│ │ ├── todos-reducer.ts
│ │ ├── todos-reducer.test.ts
│ │ └── todos-styles.ts
│ └── user/
│ ├── ...
│ ├── user-reducer.ts
│ └── user-reducer.test.ts
├── hocs/
│ ├── ...
│ └── with-layout.tsx
├── hooks/
├── redux/
│ ├── root-reducer.ts
│ ├── root-saga.ts
│ └── store.ts
├── ...
└── middleware.ts
This example shows you how you can organize your src/
directory. Generally, everything is grouped by feature. Tests files live next to their respective implementation files. But anything that is shared by multiple features is then grouped in general folders, such as components/
or HOCs/
or hooks/
. Shared setup for state management - in this example for Redux - lives in a redux/
folder.
Some people also like to group by feature within the app/
folder. Here is how that would look like.
src/
├── app/
│ ├── ...
│ ├── (group)/
│ ├── dashboard/
│ │ ├── components/
│ │ │ ├── dashboard-header.tsx
│ │ │ ├── dashboard-header.test.ts
│ │ │ ├── dashboard-widgets.tsx
│ │ │ └── dashboard-widgets.test.ts
│ │ ├── services/
│ │ │ ├── fetch-data.ts
│ │ │ ├── fetch-data.test.ts
│ │ │ ├── auth-service.ts
│ │ │ └── auth-service.test.ts
│ │ ├── page.tsx
│ │ └── layout.ts
│ ├── layout.tsx
│ └── page.tsx
├── ...
└── middleware.ts
In this tutorial, you're going to group files by feature in the first way.
Vitest
If you want to avoid bugs and prevent regressions, you need to write tests. Vitest is a great choice because it has the same API as the most popular framework, which is Jest, but it runs faster.
Install Vitest.
And configure a testing command in your package.json
.
Then create an example.test.ts
file and write a short test to check to check Vitest works.
It should pass.
If you want to learn how you can write better tests, check out this article that teaches you 12 evergreen testing principles that every senior developer should know.
React Testing Library
You also want to write tests for your React components. For that, you can use the React Testing Library, often abbreviated as RTL.
Then, create a vitest.config.ts
file.
It sets up the development server, specifies testing parameters like that the environment is happy-dom
and file paths, and defines coverage report formats.
And create a file called src/tests/setup-test-environment.ts
to set up your test environment.
The first line imports additional assertions, which extend Vitest's built-in assertions, allowing you to more easily test DOM nodes. For example, you can check if an element is visible, has a certain text content, or includes specific attributes.
The global environment flag ensures that tests involving state updates work as expected without timing issues.
Next, you want to set up a custom render method src/tests/react-test-utils.tsx
.
You can use this to wrap your code in providers, for example, when using Redux, or adding layouts and styling.
Make sure your configuration worked by writing a test for a React component.
You import render
and screen
from your test utils file instead of from RTL directly.
toBeInTheDocument()
is one of those special assertions that you configured earlier in your test environment setup file.
It should pass, too.
Styling
Now let's talk about styling. The two most important aspects to get right are accessibility and maintainability. One of the most popular libraries out there that nails both of these aspects is Shadcn. It uses Tailwind for streamlined style management and Radix for enhanced accessibility.
Initialize Shadcn in your project.
Now, if you need a card or any other component, you can easily add it to your project using the Shadcn command line interface.
Internationalization
As your app scales, you want to translate it to multiple languages, so it can reach more users.
And adding internationalization - or i18n - in a later cycle of an app, can be a pain because you'll have to find all hardcoded strings and replace them with translation function calls.
Install negotiator and a locale matcher.
The @formatjs/intl-localematcher
package selects the best language for your app's content based on a user's language preferences. The negotiator
package helps your app to figure out what type of content (like language or format) your user's browser can handle best, based on the information the browser sends.
You'll also need to install the TypeScript types for the negotiator package.
Then, create your i18n config in a new file at src/features/internationalization/i18n-config.ts
.
Use that i18n
config, in your localization-middleware
at
src/features/internationalization/localization-middleware.ts
.
The purpose of this middleware is to automatically detect and redirect your users to the appropriate language version of the website based on their browser language preferences.
Then create a middleware file src/middleware.ts
in the root of your project, and use your localization middleware there.
It's time to add your translations. Create a json
dictionary for your English translations at src/features/internationalization/dictionaries/en-us.json
.
And then, create a file src/features/internationalization/get-dictionaries.ts
for your getDictionary
function.
It takes in a locale and then returns the respective dictionary.
If you want to add a hook that lets your users pick their language, it can look like this.
And then you can use that hook in a component, and use it's return value in a <Link />
like this.
Change your URL structure to support i18n. Create a new folder [lang]
in your app/
folder, which creates a dynamic segment for the language.
Move your page.tsx
and layout.tsx
files into that folder, and modify the layout to set the correct language on the <html />
tag.
Going forward, you can use the getDictionary
function in any server component.
For client components, you'll want to pass in the respective dictionary instead.
Database
This tutorial is going to use Postgres for its database because it is well battle-tested, but you're going to use the Prisma ORM to abstract away the database layer. This gives you the flexibility to use a variety of databases, and simplifies the API that you use to interact with it.
You'll also need to install the Prisma client.
Initialize Prisma.
This auto-generates your prisma/schema.prisma
file.
Add a aUserProfile
model to it, which has an email and a name and a boolean whether they accepted your terms and conditions.
If you ran npx prisma init
, rename your .env
file to .env.local
, otherwise create it and make sure it contains the credentials for your Prisma database.
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
Now create a src/lib/prisma.ts
file, which will contain your Prisma client connection.
The re-assignment ensures that in a non-production environment (like during development or testing), only one instance of the Prisma client is created and reused across the entire application. This approach prevents the overhead of repeatedly initializing new connections to the database every time a module that needs database access is imported.
Modify your package.json
to include the following helper commands for Prisma.
Some of these commands use the run-s
, tsx
and dotenv
packages, which you'll need to install.
Here’s an explanation of each Prisma command:
- "prisma:deploy": Deploys database migrations and generates Prisma Client. It runs migrations on production databases and updates the client API.
- "prisma:migrate": Creates a new migration from changes in the Prisma schema, applying it in the development environment. You must specify a name for the migration after the command.
- "prisma:push": Pushes the schema changes directly to the database and updates the Prisma Client. Useful for prototyping without creating migration files.
- "prisma:reset-dev": Resets the development database by wiping it, re-seeding it, and applying migrations in development mode.
- "prisma:seed": Runs a TypeScript seed script to populate the database with initial data.
- "prisma:setup": Generates the Prisma Client, deploys migrations to the database, and pushes the schema to the database.
- "prisma:studio": Opens Prisma Studio, a GUI for viewing and editing the database records.
- "prisma:wipe": Resets the database by forcefully dropping all data and migrations and then pushes the schema changes.
npm run prisma:setup
If your Prisma doesn't recognize your .env.local
file, set your environment variable manually in your terminal. On Mac, this can be done using the export
command.
Create a prisma/seed.ts
file. You can use it to seed your database with data for development.
If you run it, it'll create your user.
In your server components, you can use prisma
to fetch any data.
Use Facades
It's a good idea to abstract away your database calls using facades. A facade is a design pattern where you provide a simplified interface to a complex subsystem.
Create a file that contains all facades related to your user profile model features/user-profile/user-profile-model.ts
.
There are two main reasons for using facades.
- Increase vendor resistance - You can switch third-party providers easily. For example, you might switch from Firebase to Supabase or vice versa. Instead of updating your whole codebase to reflect the change, you only update the facade.
- Simplify your code - A facade can reduce the code you need to write in your application because it reduces the API to your specific application needs. Simultaneously, it makes your code easier to understand because you can give the facade descriptive names.
Then use your facade in your server component.
Vercel Postgres
You can use Vercel Postgres for your production deployment. They have an easy to follow guide, which you can check out in the Vercel documentation. But here are the steps real quick for your convenience.
To set up a database in your Vercel project, follow these steps:
- Go to the Storage tab and click the Create Database button.
- When the Browse Storage modal opens, choose Postgres and click Continue.
For creating a new database:
- In the dialog box, type
sample_postgres_db
(or a name you prefer) in the Store Name field. Ensure the name has only alphanumeric characters, "_" or "-", and does not exceed 32 characters. - Choose a region. For lower latency, select a region close to your function region, which is US East by default.
- Click Create.
Then you'll need to add POSTGRES_URL_NON_POOLING
to the datasource
in your Prisma schema.
Vercel uses connection poolers, which manage a pool of database connections that can be reused by different parts of an application, rather than establishing a new connection for each database request. The directUrl
property is used to ensure operations requiring direct database access, such as migrations, can bypass the connection pooler for reliable execution.
You can get the environment variables for your Vercel database by pulling them from Vercel.
Playwright
You also want to use E2E tests because E2E tests give you the most confidence that your app works as intended. E2E tests are skipped far too often, but you really should make it a habit to write them. The benefits are compounding.
Initialize Playwright.
$ npm init playwright@latest
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Where to put your end-to-end tests? · playwright
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
If this is your first time using Playwright, you can have a look through the test-examples/
folder that the initialization script creates, and then deleted it because you won't need it.
Modify the webServer
key in your playwright.config.ts
file.
Add two scripts for your E2E tests to your package.json
.
The first one runs your Playwright test in headless mode, while the second one runs your test in UI mode, which gives you time travel debugging, watch mode and more.
Run your tests to check that your Playwright setup worked.
It ran three tests because by default Playwright is configured to run in Chrome, Safari, and Firefox.
GitHub Actions
It's good practice to run your app with CI/CD. CI/CD stands for continuous delivery and continuous deployment.
Add a secret for your database URL to your repository's settings in GitHub.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/testdb"
Then create your GitHub Actions YAML configuration in a .github/workflows/pull-request.yml
file for a comprehensive CI/CD pipeline including linting, type checking, testing, and more.
Now every time you make a pull request to your app, it automatically runs your tests to ensure it works, runs TypeScript type checks and lints it, so that everyone contributes code with the same formatting.