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.

kader
Reading Time :11minutes
hand-drawn, sketch-like art style with text : Master Next.js 15 Production Setup in 2025: The Ultimate Guide

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 like msg, temp, etc.—except we allow e2e 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:

  1. Go to Vercel Dashboard → Storage → Create Database
  2. Choose Postgres
  3. Name: sample_postgres_db
  4. 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."
Work With Me

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.