
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.
npx create-next-app@latest
It may ask you if it can install the latest create-next-app version, just hit yes.
Need to install the following packages:
create-next-app@15.0.0
Ok to proceed? (y)
And then configure your project by hitting yes on everything (TypeScript, Tailwind, app router).
✔ What is your project named? … reactsquad-production
✔ Would you like to use TypeScript? … No / Yes
Yes
✔ Would you like to use ESLint? … No / Yes
Yes
✔ Would you like to use Tailwind CSS? … No / Yes
Yes
✔ Would you like to use `src/` directory? … No / Yes
Yes
✔ Would you like to use App Router? (recommended) … No / Yes
Yes
✔ Would you like to customize the default import alias (@/*)? … No / No
Then change into the directory of your project and open it in your favorite editor.
$ cd nextjs-for-production
~/dev/nextjs-for-production (main) 🤯
$ cursor .
Run The Development Server
You want to verify that your setup worked.
- Run
npm run devto 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.
package.json
"type-check": "tsc -b"
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.
npm install --save-dev prettier prettier-plugin-tailwindcss
Create a prettier.config.js file with your preferred rules.
prettier.config.js
module.exports = {
arrowParens: 'avoid',
bracketSameLine: false,
bracketSpacing: true,
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxSingleQuote: false,
plugins: ['prettier-plugin-tailwindcss'],
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
};
Add a formatting script to your package.json.
package.json
"format": "prettier --write .",
Run the formatter to apply your style.
$ npm run format
> nextjs-for-production@0.1.0 format
> prettier --write .
next.config.mjs 4ms (unchanged)
package-lock.json 54ms (unchanged)
package.json 1ms (unchanged)
postcss.config.mjs 2ms (unchanged)
README.md 20ms (unchanged)
src/app/globals.css 17ms (unchanged)
src/app/layout.tsx 30ms (unchanged)
src/app/page.tsx 11ms (unchanged)
tailwind.config.ts 2ms (unchanged)
tsconfig.json 2ms (unchanged)
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.
npm install --save-dev @typescript-eslint/parser eslint-plugin-unicorn eslint-plugin-import eslint-plugin-playwright eslint-config-prettier eslint-plugin-prettier eslint-plugin-simple-import-sort
Update your .eslintrc.json.
.eslintrc.json
{
"extends": [
"next/core-web-vitals",
"plugin:unicorn/recommended",
"plugin:import/recommended",
"plugin:playwright/recommended",
"plugin:prettier/recommended"
],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/exports": "error",
"simple-import-sort/imports": "error",
"unicorn/no-array-callback-reference": "off",
"unicorn/no-array-for-each": "off",
"unicorn/no-array-reduce": "off",
"unicorn/prevent-abbreviations": [
"error",
{
"allowList": {
"e2e": true
},
"replacements": {
"props": false,
"ref": false,
"params": false
}
}
]
},
"overrides": [
{
"files": ["*.js"],
"rules": {
"unicorn/prefer-module": "off"
}
}
]
}
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.
package.json
"lint:fix": "next lint --fix",
Run it to format all files according to your new rules.
$ npm run lint:fix
> nextjs-for-production@0.1.0 lint:fix
> next lint --fix
✔ No ESLint warnings or errors
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.
npm install --save-dev @commitlint/cli@latest @commitlint/config-conventional@latest husky@latest
Initialize Husky in your project to set up the basic configuration.
npx husky-init && npm install
Add hooks to automate linting and type checking before each commit, and customize your commit message workflow.
npx husky add .husky/pre-commit 'npm run lint && npm run type-check'
npx husky add .husky/prepare-commit-msg 'exec < /dev/tty && npx cz --hook || true'
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.
chmod a+x .husky/pre-commit
chmod a+x .husky/prepare-commit-msg
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.
npm install --save-dev commitizen cz-conventional-changelog
Configure Commitizen in your package.json to use the conventional changelog standard.
package.json
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
},
The conventional changelog 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.
commitlint.config.cjs
const config = {
extends: ['@commitlint/config-conventional'],
rules: {
'references-empty': [1, 'never'],
'footer-max-line-length': [0, 'always'],
'body-max-line-length': [0, 'always'],
},
};
module.exports = config;
Database With Prisma
For database management, use Prisma, which is an open-source ORM.
Install Prisma and initialize it.
npm install --save-dev prisma
npx prisma init
Define your schema in prisma/schema.prisma.
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
}
Add the Prisma client and set up useful scripts for database management.
npm install @prisma/client
package.json
"prisma:push": "prisma db push",
"prisma:studio": "prisma studio",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:wipe": "prisma migrate reset --force --skip-seed && prisma migrate deploy && prisma db seed",
These scripts manage database schema migrations, data visualization through Prisma Studio, and database seeding.
Create a seed file to populate your database with initial data.
prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.user.create({
data: {
email: 'jan@reactsquad.io',
name: 'Jan Hesters',
},
});
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
Update your package.json to point to the seed script.
package.json
"prisma": {
"seed": "tsx prisma/seed.ts"
},
Install tsx for running TypeScript files.
npm install --save-dev tsx
Generate your Prisma client.
npx prisma generate
Create a singleton instance of Prisma client to avoid connection issues.
src/lib/db.server.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
Authentication With Clerk
For authentication, use Clerk. Sign up at clerk.com and create a new application.
Install the Clerk SDK.
npm install @clerk/nextjs
Add your Clerk keys to .env.local.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
Wrap your app with ClerkProvider in your root layout.
src/app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Next.js for Production',
description: 'Production-ready Next.js setup',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Create middleware to protect your routes.
src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect();
});
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
};
Create a dashboard page to test authentication.
src/app/dashboard/page.tsx
import { db } from '@/lib/db.server';
export default async function DashboardPage() {
const user = await db.user.findFirst();
return (
<div>
<h1>Dashboard</h1>
<p>{user?.name}</p>
<p>{user?.email}</p>
</div>
);
}
Testing With Vitest
Add Vitest for unit and integration testing.
Install Vitest and related dependencies.
npm install --save-dev vitest @vitejs/plugin-react vite-tsconfig-paths @vitest/coverage-v8
Create a Vitest configuration file.
vitest.config.ts
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
coverage: {
provider: 'v8',
},
},
});
Add test scripts to your package.json.
package.json
"test": "vitest",
"test:ui": "vitest --ui",
Create a sample test file.
src/lib/db.server.test.ts
import { db } from '@/lib/db.server';
import { expect, test } from 'vitest';
test('db is defined', () => {
expect(db).toBeDefined();
});
Run your tests to verify the setup.
$ npm run test
> reactsquad-production@0.1.0 test
> vitest
DEV v2.1.8 /dev/reactsquad-production
✓ src/lib/db.server.test.ts (1)
✓ db is defined
Test Files 1 passed (1)
Tests 1 passed (1)
Deployment With Vercel
Deploy your application to Vercel.
Install the Vercel CLI.
npm install -g vercel
Link your project to Vercel.
vercel link
Create a production database on Vercel.
- Go to your project on Vercel
- Navigate to Storage tab
- Click Create Database
- Select Postgres
- Choose a region (preferably close to your function region)
- Click Create
Then add POSTGRES_URL_NON_POOLING to the datasource in your Prisma schema.
prisma/schema.prisma
datasource db {
provider = "postgresql"
// Uses connection pooling
url = env("DATABASE_URL")
// Uses direct connection
directUrl = env("POSTGRES_URL_NON_POOLING")
}
Vercel uses connection poolers, which manage a pool of database connections that can be reused by different parts of an application. The directUrl property ensures 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.
vercel env pull .env
Playwright
Use E2E tests with Playwright because they give you the most confidence that your app works as intended.
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? (Y/n) · true
Modify the webServer key in your playwright.config.ts file.
playwright.config.ts
webServer: {
command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',
port: 3000,
},
Add test scripts to your package.json.
package.json
"test:e2e": "npx playwright test",
"test:e2e:ui": "npx playwright test --ui",
The first one runs your Playwright test in headless mode, while the second one runs your test in UI mode with time travel debugging and watch mode.
Create a sample test.
playwright/example.spec.ts
import { expect, test } from '@playwright/test';
test.describe('dashboard page', () => {
test('given any user: shows the test user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Jan Hesters')).toBeVisible();
await expect(page.getByText('jan@reactsquad.io')).toBeVisible();
});
});
Run your tests to verify the setup.
$ npm run test:e2e
> reactsquad-production@0.1.0 test:e2e
> npx playwright test
Running 3 tests using 3 workers
3 passed (3.9s)
GitHub Actions
Set up CI/CD with GitHub Actions.
Add a secret for your database URL to your repository's settings in GitHub.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/testdb"
Create your GitHub Actions workflow configuration.
.github/workflows/pull-request.yml
name: Pull Request
on: [pull_request]
jobs:
lint:
name: ⬣ ESLint
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: 20
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- name: 🔬 Lint
run: npm run lint
type-check:
name: ʦ TypeScript
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: 20
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- name: 🔎 Type check
run: npm run type-check --if-present
commitlint:
name: ⚙️ commitlint
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: ⚙️ commitlint
uses: wagoid/commitlint-github-action@v4
vitest:
name: ⚡ Vitest
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: 20
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- name: 🛠 Setup Database
run: npm run prisma:wipe
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: ⚡ Run vitest
run: npm run test -- --coverage
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
playwright:
name: 🎭 Playwright
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: 20
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
- name: 🌐 Install Playwright Browsers
run: npx playwright install --with-deps
- name: 🛠 Setup Database
run: npm run prisma:wipe
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: 🎭 Playwright Run
run: npx playwright test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: 📸 Playwright Screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Now every time you make a pull request to your app, it automatically runs your tests, type checks, and linting to ensure everyone contributes code with the same formatting and quality standards.
Conclusion
You now have a production-ready Next.js 15 setup with:
- TypeScript for type safety
- Prettier and ESLint for code quality
- Commitlint and Husky for consistent Git workflow
- Prisma for database management
- Clerk for authentication
- Vitest for unit testing
- Playwright for E2E testing
- GitHub Actions for CI/CD
- Vercel for deployment
This foundation will help your application scale smoothly as your team and user base grow.
Want to learn more senior fullstack secrets? Subscribe to my newsletter for weekly updates on new videos, articles, and courses with exclusive bonus content and discounts.
Top comments (0)