πŸ“ Tutorials
Β· 6 min read

Next.js Complete Guide: From Setup to Production


Next.js is the most popular React framework for production applications. It gives you server-side rendering, static generation, API routes, and a file-based router out of the box. This guide covers the App Router (Next.js 13+), which is the recommended approach for new projects.

For quick reference, keep the Next.js cheat sheet open. If you’re comparing frameworks, check Next.js vs Astro, Next.js vs Remix, or Next.js vs Nuxt.

Getting Started

npx create-next-app@latest my-app
cd my-app
npm run dev

The scaffolder asks about TypeScript, Tailwind, ESLint, and the App Router. Say yes to all of them β€” they’re the standard setup for modern Next.js projects.

Project structure:

my-app/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ layout.tsx      # root layout (wraps all pages)
β”‚   β”œβ”€β”€ page.tsx        # home page (/)
β”‚   β”œβ”€β”€ globals.css
β”‚   └── blog/
β”‚       β”œβ”€β”€ page.tsx    # /blog
β”‚       └── [slug]/
β”‚           └── page.tsx # /blog/my-post
β”œβ”€β”€ public/             # static files
β”œβ”€β”€ next.config.js
└── package.json

Routing

The App Router uses the filesystem for routing. Every folder inside app/ becomes a URL segment, and page.tsx files define the actual pages.

app/
β”œβ”€β”€ page.tsx              β†’ /
β”œβ”€β”€ about/page.tsx        β†’ /about
β”œβ”€β”€ blog/
β”‚   β”œβ”€β”€ page.tsx          β†’ /blog
β”‚   └── [slug]/page.tsx   β†’ /blog/any-slug
β”œβ”€β”€ (marketing)/
β”‚   β”œβ”€β”€ pricing/page.tsx  β†’ /pricing (group doesn't affect URL)
β”‚   └── features/page.tsx β†’ /features
└── api/
    └── users/route.ts    β†’ /api/users

Dynamic Routes

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{post.content}</article>;
}

// Generate static pages at build time
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Catch-All Routes

app/docs/[...slug]/page.tsx  β†’ /docs/a, /docs/a/b, /docs/a/b/c

Layouts

Layouts wrap pages and persist across navigations (they don’t re-render when you navigate between child pages):

// app/layout.tsx β€” root layout (required)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

// app/blog/layout.tsx β€” nested layout for /blog/*
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="max-w-3xl mx-auto">
      <nav>Blog Navigation</nav>
      {children}
    </div>
  );
}

Server Components vs Client Components

By default, all components in the App Router are Server Components. They run on the server, can access databases directly, and send zero JavaScript to the browser.

// Server Component (default) β€” no "use client" directive
export default async function Dashboard() {
  const data = await db.query('SELECT * FROM stats'); // direct DB access
  return <div>{data.totalUsers} users</div>;
}

Add "use client" when you need interactivity:

"use client";

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Rules of thumb:

  • Server Components: data fetching, accessing backend resources, keeping secrets safe, reducing bundle size
  • Client Components: event handlers (onClick, onChange), state (useState, useReducer), effects (useEffect), browser APIs

If you’re getting errors mixing them, see our Server Component client error fix.

Data Fetching

In the App Router, you fetch data directly in Server Components using async/await:

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }, // revalidate every hour
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Caching Strategies

// Static (cached forever until redeployed)
fetch('https://api.example.com/data');

// Revalidate every 60 seconds (ISR)
fetch('https://api.example.com/data', { next: { revalidate: 60 } });

// No cache (always fresh)
fetch('https://api.example.com/data', { cache: 'no-store' });

Server Actions

Mutate data without API routes:

// app/actions.ts
"use server";

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  await db.insert(posts).values({ title });
  revalidatePath('/posts');
}

// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

API Routes

// app/api/users/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return NextResponse.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.insert(users).values(body);
  return NextResponse.json(user, { status: 201 });
}

Middleware

Runs before every request β€” useful for auth, redirects, and headers:

// middleware.ts (in project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session');

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

Metadata and SEO

// app/layout.tsx β€” static metadata
export const metadata = {
  title: { default: 'My Site', template: '%s | My Site' },
  description: 'My awesome site',
  openGraph: { images: ['/og-image.png'] },
};

// app/blog/[slug]/page.tsx β€” dynamic metadata
export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.coverImage] },
  };
}

Image Optimization

Next.js optimizes images automatically with the <Image> component:

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={630}
  priority // load immediately (above the fold)
/>

For external images, configure allowed hostnames. If you’re getting errors, see our Image hostname not configured fix.

Error Handling

// app/error.tsx β€” catches errors in the nearest layout segment
"use client";

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// app/not-found.tsx β€” custom 404 page
export default function NotFound() {
  return <h1>Page not found</h1>;
}

Deployment

Next.js deploys best on Vercel (the company behind it), but works anywhere:

# Build for production
npm run build

# Start production server
npm start

# Or export as static site
# next.config.js: output: 'export'

For Docker:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]

Loading States and Streaming

The App Router supports instant loading states with loading.tsx:

// app/dashboard/loading.tsx β€” shows while page data loads
export default function Loading() {
  return <div className="animate-pulse">Loading dashboard...</div>;
}

For more granular control, use React Suspense:

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading stats...</p>}>
        <Stats />
      </Suspense>
      <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
      </Suspense>
    </div>
  );
}

Each Suspense boundary streams independently β€” the user sees content as soon as each section is ready, rather than waiting for everything.

Parallel and Sequential Data Fetching

// ❌ Sequential β€” slow (each waits for the previous)
const user = await getUser(id);
const posts = await getPosts(id);

// βœ… Parallel β€” fast (both run at the same time)
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
]);

In layouts and pages, Next.js automatically parallelizes data fetching across sibling components. Each Server Component starts fetching as soon as it renders, without waiting for siblings.

Environment Variables

# .env.local (not committed β€” local secrets)
DATABASE_URL=postgres://localhost/mydb
API_SECRET=my-secret-key

# .env (committed β€” shared defaults)
NEXT_PUBLIC_API_URL=https://api.example.com

Variables prefixed with NEXT_PUBLIC_ are exposed to the browser. All others are server-only.

// Server Component β€” can access all env vars
const db = new Database(process.env.DATABASE_URL);

// Client Component β€” only NEXT_PUBLIC_ vars
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Troubleshooting

Common Next.js errors with detailed fixes:

Next Steps