End-to-end Next.js project setup for 2026.

Bootstrap

npx create-next-app@latest myapp \
  --typescript --tailwind --app --src-dir --eslint --import-alias "@/*"
cd myapp

Dependencies

npm i next-auth@beta @auth/prisma-adapter @prisma/client \
  @tanstack/react-query zod react-hook-form @hookform/resolvers \
  next-intl next-themes \
  zustand sonner clsx tailwind-merge class-variance-authority

npm i -D prisma \
  vitest @vitest/coverage-v8 jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event \
  prettier prettier-plugin-tailwindcss \
  @tanstack/react-query-devtools

Layout

src/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── globals.css
│   ├── providers.tsx
│   ├── (auth)/login/page.tsx
│   ├── (app)/
│   │   ├── layout.tsx
│   │   ├── dashboard/page.tsx
│   │   └── ...
│   └── api/...
├── components/
│   └── ui/
├── lib/
│   ├── db.ts
│   ├── env.ts
│   ├── auth.ts
│   └── cn.ts
├── stores/
└── types/
prisma/schema.prisma

lib/env.ts

import { z } from "zod";

const Env = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  AUTH_GITHUB_ID: z.string(),
  AUTH_GITHUB_SECRET: z.string(),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

export const env = Env.parse(process.env);

lib/db.ts

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { db?: PrismaClient };

export const db = globalForPrisma.db ?? new PrismaClient({
  log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});

if (process.env.NODE_ENV !== "production") globalForPrisma.db = db;

lib/cn.ts

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

app/providers.tsx

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "next-themes";
import { Toaster } from "sonner";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [qc] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 60_000 } },
  }));
  
  return (
    <SessionProvider>
      <QueryClientProvider client={qc}>
        <ThemeProvider attribute="class" defaultTheme="system">
          {children}
          <Toaster />
        </ThemeProvider>
      </QueryClientProvider>
    </SessionProvider>
  );
}

app/layout.tsx

import "./globals.css";
import { Inter } from "next/font/google";
import { Providers } from "./providers";

const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });

export const metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL!),
  title: { default: "MyApp", template: "%s — MyApp" },
  description: "...",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning className={inter.variable}>
      <body className="font-sans">
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

prisma/schema.prisma

generator client { provider = "prisma-client-js" }

datasource db { provider = "postgresql", url = env("DATABASE_URL") }

model User {
  id       Int      @id @default(autoincrement())
  email    String   @unique
  name     String?
  role     Role     @default(USER)
  posts    Post[]
  createdAt DateTime @default(now())
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  body     String
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

enum Role { USER ADMIN }
npx prisma migrate dev --name init
npx prisma generate

next.config.js

/** @type {import('next').NextConfig} */
module.exports = {
  output: "standalone",
  experimental: { reactCompiler: true },
  images: {
    remotePatterns: [{ protocol: "https", hostname: "**.example.com" }],
  },
  async headers() {
    return [{
      source: "/(.*)",
      headers: [
        { key: "X-Frame-Options", value: "DENY" },
        { key: "X-Content-Type-Options", value: "nosniff" },
        { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
      ],
    }];
  },
};

scripts

{
  "scripts": {
    "dev": "next dev",
    "build": "prisma generate && next build",
    "start": "next start",
    "lint": "next lint",
    "format": "prettier --write .",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:run": "vitest run --coverage",
    "db:migrate": "prisma migrate dev",
    "db:studio": "prisma studio"
  }
}

vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./test/setup.ts"],
  },
});

.github/workflows/ci.yml

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npx prisma generate
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run test:run
      - run: npm run build

Dockerfile

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json prisma ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

.gitignore

node_modules/
.next/
out/
.env.local
.env.production.local
coverage/
*.log

Conventions

  • Server components by default; "use client" only when needed.
  • Push client boundary to the leaves.
  • Validate all input at server boundary with Zod.
  • One QueryClient per request on server.
  • Standalone output for Docker, Vercel for managed.

Read this next

That’s 20 Next.js cheatsheets. Next category: Django.

If you want my full Next.js starter (Auth + Prisma + Tailwind + Tests + Docker), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .