Master Next.js 15 Production Setup in 2025: The Ultimate Guide
Learn to set up a scalable, production-ready Next.js 15 app with TypeScript, ESLint, Prettier, Vitest, Playwright, Prisma & CI/CD. Perfect for teams.

Ultimate Guide to Setting Up Next.js 15 for Production in 2025
Meta Description: Learn how to set up a scalable, production-ready Next.js 15 app with TypeScript, ESLint, Prettier, Vitest, Playwright, Prisma, and more. Perfect for teams of all sizes.
SEO summary: Primary keyword: Next.js 15 production setup • Secondary keywords: TypeScript, ESLint, Prettier, Vitest, Playwright, Prisma ORM, internationalization, CI/CD
Imagine launching a high-performance, maintainable, and scalable web application that handles 100k+ monthly users without breaking a sweat. That’s the power of getting your Next.js 15 production setup right from day one.
Whether you're a solo developer or part of a large team, this guide distills years of real-world experience into a step-by-step blueprint. We’ll walk through every critical tool and configuration—TypeScript, ESLint, Prettier, Commitlint, Vitest, Playwright, Prisma, i18n, and GitHub Actions—to build a robust foundation that scales effortlessly.
By the end, you'll have a battle-tested stack ready for any project size. Let's dive in.
Ultimate Guide to Setting Up Next.js 15 for Production in 2025
Initialize Your Next.js 15 Project
The first step in creating a production-ready app is setting up your project with the right defaults. You want TypeScript, ESLint, Tailwind CSS, and the App Router—all modern best practices baked into your workflow.
Start by generating a new Next.js app:
npx create-next-app@latest
When prompted, select:
- ✅ TypeScript
- ✅ ESLint
- ✅ Tailwind CSS
- ✅
src/
directory - ✅ App Router (recommended)
This gives you a clean, modern base with essential tools pre-configured.
After installation, navigate into your project:
cd your-project-name
cursor .
💡 Pro Tip: Replace
cursor
with your preferred code editor (e.g.,code .
for VS Code).
Run the Development Server
Verify everything works by starting the dev server:
npm run dev
Visit http://localhost:3000
. If you see the default Next.js welcome page, success! Your foundation is solid.
Why TypeScript Is Non-Negotiable for Production
You’ve already opted into TypeScript during setup—but did you know that merely having it configured isn’t enough? In large-scale applications, catching type errors early prevents costly bugs down the line.
Let’s make type checking part of your automated pipeline.
Add this script to your package.json
:
"type-check": "tsc -b"
Now run:
npm run type-check
This command performs a full type check across your entire codebase using TypeScript’s incremental build mode (-b
). It ensures no loose types slip through during development or CI/CD.
🎯 Best Practice: Always run type-check
in your CI pipeline before deployment.
Enforce Code Consistency With Prettier & ESLint
In team environments, inconsistent formatting leads to unnecessary friction. Tools like Prettier and ESLint eliminate style debates and enforce uniformity.
Step 1: Install and Configure Prettier
Install Prettier and its Tailwind plugin:
npm install --save-dev prettier prettier-plugin-tailwindcss
Create 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,
};
🔍 Explanation:
semi: true
→ Always include semicolons.singleQuote: true
→ Use single quotes instead of double.trailingComma: 'all'
→ Add commas after last item in arrays/objects for cleaner diffs.plugins: ['prettier-plugin-tailwindcss']
→ Automatically sort Tailwind classes alphabetically.
Add a format script:
"format": "prettier --write ."
Run it:
npm run format
All files are now uniformly formatted.
Step 2: Supercharge Linting With ESLint
While Prettier handles formatting, ESLint catches logical issues and enforces coding standards.
Install required packages:
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 .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"
}
}
]
}
🔍 Explanation:
"extends": [...]
→ Combines rules from multiple sources: Next.js best practices, Unicorn (modern JS), import sorting, Playwright testing, and Prettier compatibility."simple-import-sort"
→ Automatically sorts imports alphabetically and groups them logically."unicorn/prevent-abbreviations"
→ Prevents confusing shorthand likemsg
,temp
, etc.—except we allowe2e
since it's standard.
Add a lint-fix script:
"lint:fix": "next lint --fix"
Run it:
npm run lint:fix
✅ No warnings or errors? Perfect. You now have an intelligent guardrail system for code quality.
⚠️ Note: As of writing, many plugins don't support ESLint 9 yet, so stick with ESLint 8 unless confirmed compatible.
Automate Git Workflows With Husky + Commitlint
Ever seen messy commit messages like “fixed bug” or “update file”? They clutter history and make debugging harder.
Enter Commitlint + Husky + Commitizen—a trio that automates clean, semantic commits.
Install dependencies:
npm install --save-dev @commitlint/cli@latest @commitlint/config-conventional@latest husky@latest
Initialize Husky:
npx husky-init && npm install
Set up pre-commit and prepare-commit-msg hooks:
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'
Make them executable:
chmod a+x .husky/pre-commit
chmod a+x .husky/prepare-commit-msg
Install Commitizen:
npm install --save-dev commitizen cz-conventional-changelog
Configure in package.json
:
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
Create 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;
Now when you commit:
git add --all
npx cz
You’ll get a guided CLI asking:
- Type of change (
feat
,fix
,docs
, etc.) - Scope (optional)
- Short description
- Long description
- Breaking changes?
Example output:
[main eb69ccd] feat(set up static analysis checks): set up TS type checks, ESLint, Prettier, Commitlint and Husky
🧠 Why This Matters: Semantic commits enable automatic changelogs, version bumps, and better release management.
Optimize Folder Structure for Scalability
How you organize files determines how easily your app scales.
Two common patterns:
- ❌ Group by type:
components/
,reducers/
,tests/
- ✅ Group by feature: All related files live together
Here’s what a scalable structure looks like:
src/
├── app/
│ ├── dashboard/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ └── (auth)/
│ ├── login/
│ └── signup/
├── components/
│ └── header/
│ ├── header-component.tsx
│ └── header.module.css
├── features/
│ ├── todos/
│ │ ├── todos-component.tsx
│ │ ├── todos-reducer.ts
│ │ └── todos.test.ts
│ └── user/
│ └── user-model.ts
├── hooks/
├── lib/
│ └── prisma.ts
└── middleware.ts
🔑 Benefits:
- Easier refactoring
- Faster onboarding
- Better modularity
- Supports micro-frontends later
Stick with feature-based organization. Shared utilities go in components
, hooks
, or lib
.
Test Everything With Vitest & React Testing Library
Bugs cost time and money. Automated tests prevent regressions and give confidence.
Set Up Vitest
Install Vitest:
npm install -D vitest
Add test script:
"test": "vitest --reporter=verbose"
Create a basic test:
// src/example.test.ts
import { describe, expect, test } from 'vitest';
describe('example', () => {
test('given a passing test: passes', () => {
expect(1).toStrictEqual(1);
});
});
Run:
npm test
🎉 Should pass.
Add React Testing Library
For component testing:
npm install --save-dev @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event happy-dom @vitejs/plugin-react vite-tsconfig-paths
Create 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()],
server: {
port: 3000,
},
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/tests/setup-test-environment.ts'],
include: ['./src/**/*.{spec,test}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
watch: {
ignored: [
String.raw`.*\/node_modules\/.*`,
String.raw`.*\/build\/.*`,
String.raw`.*\/postgres-data\/.*`,
],
},
coverage: {
reporter: ['text', 'json', 'html'],
},
},
});
🔍 Explanation:
environment: 'happy-dom'
→ Lightweight DOM implementation.setupFiles
→ Runs before each test.coverage.reporter
→ Generates coverage reports in multiple formats.
Create src/tests/setup-test-environment.ts
:
import '@testing-library/jest-dom/vitest';
// See https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment.
// @ts-ignore
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
And src/tests/react-test-utils.tsx
:
/* eslint-disable import/export */
import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'queries'>,
) =>
render(ui, {
wrapper: ({ children }) => <>{children}</>,
...options,
});
// re-export everything
export * from '@testing-library/react';
// override render method
export { customRender as render };
export { default as userEvent } from '@testing-library/user-event';
Now write a real component test:
// src/features/example.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@/tests/react-test-utils';
function MyReactComponent() {
return <div>My React Component</div>;
}
describe('MyReactComponent', () => {
test('given no props: renders a text', () => {
render(<MyReactComponent />);
expect(screen.getByText('My React Component')).toBeInTheDocument();
});
});
Passing tests mean you’re ready to scale safely.
Style Like a Pro With Shadcn UI
Styling should be accessible, consistent, and fast.
Shadcn UI combines:
- 🎨 Tailwind CSS – Utility-first design
- 🔐 Radix UI – Accessible primitives
Initialize:
npx shadcn@latest init
Choose:
- Style: New York
- Base Color: Slate
- CSS Variables: Yes
Add components as needed:
npx shadcn@latest add card
No more guessing class names or accessibility attributes—everything is pre-built and tested.
Prepare for Global Growth With Internationalization (i18n)
Don’t wait until launch to add translations. Hardcoded strings become technical debt.
Install dependencies:
npm install negotiator @formatjs/intl-localematcher
npm install --save-dev @types/negotiator
Create src/features/internationalization/i18n-config.ts
:
export const i18n = {
defaultLocale: 'en-US',
locales: ['en-US'],
} as const;
export type Locale = (typeof i18n)['locales'][number];
Create middleware:
// localization-middleware.ts
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { type NextRequest, NextResponse } from 'next/server';
import { i18n } from './i18n-config';
function getLocale(request: NextRequest) {
const headers = {
'accept-language': request.headers.get('accept-language') ?? '',
};
const languages = new Negotiator({ headers }).languages();
return match(languages, i18n.locales, i18n.defaultLocale);
}
export function localizationMiddleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathnameHasLocale = i18n.locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
Use in middleware.ts
:
import { NextRequest } from 'next/server';
import { localizationMiddleware } from './features/internationalization/localization-middleware';
export const config = { matcher: ['/((?!api|_next|.*.svg$).*)'] };
export function middleware(request: NextRequest) {
return localizationMiddleware(request);
}
Set up dictionary:
// en-us.json
{
"counter": {
"decrement": "Decrement",
"increment": "Increment"
},
"landing": {
"welcome": "Welcome"
}
}
Create get-dictionaries.ts
:
import "server-only";
import type { Locale } from "./i18n-config";
const dictionaries = {
"en-US": () => import("./dictionaries/en-US.json").then((module) => module.default),
};
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries["en-US"]();
Use in server components:
// page.tsx
export default async function IndexPage({ params: { lang } }) {
const dictionary = await getDictionary(lang);
return (
<p>{dictionary.landing.welcome}</p>
);
}
For client components, pass the dictionary as a prop.
🌍 Future-proof your app today.
Connect to PostgreSQL Using Prisma ORM
Your backend needs persistence. We’ll use PostgreSQL with Prisma ORM for flexibility and type safety.
Install:
npm install prisma --save-dev
npm install @prisma/client
Initialize:
npx prisma init
Update schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model UserProfile {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
name String @default("")
acceptedTermsAndConditions Boolean @default(false)
}
Create .env.local
:
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
Create src/lib/prisma.ts
:
import { PrismaClient } from '@prisma/client';
declare global {
var __database__: PrismaClient;
}
let prisma: PrismaClient;
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!global.__database__) {
global.__database__ = new PrismaClient();
}
prisma = global.__database__;
}
export default prisma;
Add helper scripts:
"prisma:deploy": "npx prisma migrate deploy && npx prisma generate",
"prisma:migrate": "npx prisma migrate dev --name",
"prisma:push": "npx prisma db push && npx prisma generate",
"prisma:reset-dev": "run-s prisma:wipe prisma:seed dev",
"prisma:seed": "tsx ./prisma/seed.ts",
"prisma:setup": "prisma generate && prisma migrate deploy && prisma db push",
"prisma:studio": "npx prisma studio",
"prisma:wipe": "npx prisma migrate reset --force && npx prisma db push"
Install helpers:
npm install --save-dev npm-run-all tsx dotenv
Seed data:
// prisma/seed.ts
import { exit } from 'node:process';
import { PrismaClient } from '@prisma/client';
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
const prisma = new PrismaClient();
async function seed() {
const user = await prisma.userProfile.create({
data: {
email: 'jan@reactsquad.io',
name: 'Jan Hesters',
acceptedTermsAndConditions: true,
},
});
console.log('User created:', user);
}
seed()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
exit(1);
});
Run:
npm run prisma:seed
You now have a fully typed, scalable database layer.
Use Facades to Decouple Logic
Avoid spreading Prisma calls throughout your app. Instead, use facades:
// src/features/user-profiles/user-profiles-model.ts
import { UserProfile } from '@prisma/client';
import prisma from '@/lib/prisma';
export async function retrieveUserProfileFromDatabaseByEmail(email: UserProfile['email']) {
return await prisma.userProfile.findUnique({ where: { email } });
}
Use in components:
const user = await retrieveUserProfileFromDatabaseByEmail('jan@reactsquad.io');
✅ Benefits:
- Swap databases easily
- Reduce API surface
- Improve readability
Deploy Database on Vercel Postgres
For production, use Vercel Postgres—fully managed and integrated.
Steps:
- Go to Vercel Dashboard → Storage → Create Database
- Choose Postgres
- Name:
sample_postgres_db
- Pick region near your function (US East recommended)
Update schema.prisma
:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING") // For migrations
}
Pull env vars:
vercel env pull .env
Now your production DB is secure and performant.
Catch Real User Issues With Playwright E2E Tests
Unit tests aren’t enough. End-to-end (E2E) tests simulate real user behavior.
Install Playwright:
npm init playwright@latest
Select:
- Folder:
playwright
- GitHub Actions: No
- Install browsers: Yes
Update playwright.config.ts
:
webServer: {
command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',
port: 3000,
},
Add scripts:
"test:e2e": "npx playwright test",
"test:e2e:ui": "npx playwright test --ui"
Write a 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:
npm run test:e2e
🎯 Catches integration bugs early.
Automate Everything With GitHub Actions
CI/CD ensures every PR is checked automatically.
Create .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
🔐 Add DATABASE_URL
as a secret in GitHub Settings.
Now every PR runs:
- Linting
- Type checking
- Commit validation
- Unit tests
- E2E tests
No more broken builds.
Conclusion: Your Next.js 15 Production Setup Is Ready
You’ve just built a world-class Next.js 15 production setup—complete with TypeScript, automated testing, semantic commits, i18n, Prisma ORM, and CI/CD.
This isn’t just a tutorial—it’s a proven architecture used in apps serving millions of users.
🚀 Your Next Step: Apply these patterns today, even in existing projects. Start small: add type checking, then ESLint, then tests. Each improvement compounds over time.
Want to master advanced patterns like micro-frontends, edge functions, or AI-powered testing? Subscribe below—I’ll cover them soon.
Ready to ship faster and safer?
👉 CTA: Clone the starter template on GitHub → github.com/reactsquad/nextjs-15-starter
Suggested internal anchors
Read also: 12 Keys to Writing Senior-Level Tests
Read also: How to Scale React Apps to 100k Users
Read also: Mastering Prisma Migrations
Read also: Zero-Downtime Deployments with Vercel
Suggested titles
- How to Set Up Next.js 15 for Production in 2025 (Complete Guide)
- The Ultimate Next.js 15 Starter Kit for Teams in 2025
- Build Scalable Apps: Next.js 15 Production Setup From Scratch
- From Zero to Production: Next.js 15, TypeScript & Prisma Workflow
- Senior Dev Secrets: Setting Up Next.js 15 Like a Pro in 2025
Suggested image ideas
- Thumbnail idea: A futuristic city skyline labeled "Next.js 15" with tools (hammer, wrench, shield) floating around it. Caption: "Build bulletproof apps with the ultimate Next.js 15 toolkit."
- Feature image: Split-screen showing a messy codebase vs. a clean, organized folder structure with icons for TypeScript, ESLint, Vitest, etc. Caption: "Upgrade your workflow: From chaos to clarity in 7 steps."
- Infographic: Flowchart of the complete CI/CD pipeline with GitHub Actions, including linting, testing, and deployment stages. Caption: "Automate quality: Every PR runs 5 layers of checks."
Let's Build Something Great Together
Looking for a developer to bring your idea to life, support your team, or tackle a tough challenge? I’m available for freelance projects, collaborations, and new opportunities.