DEV Community

Cover image for Next.js 16 Complete Beginner's Guide
Yogesh Chavan
Yogesh Chavan

Posted on

Next.js 16 Complete Beginner's Guide

In this article, you will learn everything you need to know about Next.js 16. This is a completely beginner-friendly guide.

Table Of Contents


Table Of Contents


Download this complete 110 pages PDF Guide

Section 1: Next.js Fundamentals

1. What is Next.js & Why React Developers Should Care

Next.js is a powerful React framework created by Vercel that enables developers to build full-stack web applications with ease. It extends React's capabilities by providing built-in solutions for routing, rendering, data fetching, and optimization. Unlike plain React which only handles the view layer, Next.js provides a complete framework for building production-ready applications.

Before Next.js, React developers had to manually configure webpack, set up routing with React Router, implement server-side rendering, and handle code splitting. Next.js eliminates this complexity by providing sensible defaults while remaining highly customizable. Version 16 brings significant improvements including enhanced performance, better TypeScript support, and improved developer experience.

Why React Developers Should Learn Next.js

As a React developer, learning Next.js is a natural progression that will significantly enhance your capabilities. Here are the key reasons why Next.js has become the go-to framework for React applications:

1. SEO Optimization - Unlike client-side rendered React apps where content is generated in the browser, Next.js can render pages on the server. This means search engine crawlers see fully rendered HTML, dramatically improving your site's visibility in search results.

2. Performance Out of the Box - Next.js automatically optimizes your application with features like automatic code splitting, image optimization, font optimization, and intelligent prefetching.

3. Multiple Rendering Strategies - Choose the best rendering approach for each page: Static Site Generation (SSG), Server-Side Rendering (SSR), Incremental Static Regeneration (ISR), or Client-Side Rendering (CSR).

4. Full-Stack Capabilities - Build API endpoints directly within your Next.js application using Route Handlers. No need for a separate backend server for simple to moderate API needs.

5. File-Based Routing - Create pages and routes simply by adding files to the app directory. No need to configure a router or maintain route definitions separately.

6. Built-in TypeScript Support - Next.js 16 comes with excellent TypeScript integration out of the box, providing better developer experience and catching errors at compile time.

💡 Tip: Next.js is used by major companies including Netflix, TikTok, Twitch, Hulu, Nike, and many more. Learning it opens doors to enterprise-level opportunities.

Key Features at a Glance

Feature Description Benefit
App Router New routing system with RSC Better performance & DX
Server Components Components render on server Smaller bundles
Streaming Progressive page rendering Faster perceived load
Route Handlers API endpoints in Next.js Full-stack in one project
Image Optimization Auto image resizing Faster loading images
Font Optimization Self-hosted fonts Better Core Web Vitals

2. Next.js vs CRA vs Vite

Comparing React Development Approaches

When starting a new React project, developers typically choose between three popular options: Create React App (CRA), Vite, or Next.js. Each has its strengths and ideal use cases.

Create React App (CRA)

Create React App was the official way to start React projects for years. It provides a zero-configuration setup for client-side React applications. However, CRA is no longer recommended by the React team.

Pros: Simple setup, good for learning React basics, well-documented

Cons: Client-side only (poor SEO), slow build times, no longer actively recommended

Vite

Vite is a modern build tool that offers lightning-fast development experience. It uses native ES modules and provides instant hot module replacement.

Pros: Extremely fast dev server, quick builds, modern architecture, framework-agnostic

Cons: Client-side focused (needs plugins for SSR), less opinionated, requires more configuration for full-stack

Next.js

Next.js is a full-featured React framework that provides everything you need for production applications out of the box.

Pros: Full-stack capabilities, multiple rendering strategies, built-in optimizations, excellent DX, strong community

Cons: Steeper learning curve, Vercel-centric ecosystem, more opinionated

Feature Comparison

Feature CRA Vite Next.js
Server Rendering No Plugin Built-in
Static Generation No Plugin Built-in
API Routes No No Built-in
File-based Routing No No Built-in
Image Optimization No No Built-in
Dev Server Speed Slow Fast Fast
Production Ready Basic Good Excellent

When to Choose Each Option

  • Choose CRA if you're learning React basics (though consider alternatives now)
  • Choose Vite for SPAs, prototypes, or when you need maximum flexibility
  • Choose Next.js for production apps, SEO-critical sites, or full-stack projects

⚠️ Important: The React team now recommends using a framework like Next.js for new projects instead of Create React App.


3. App Router vs Pages Router (Which One & Why)

Understanding Next.js Routing Systems

Next.js has two routing systems: the older Pages Router (pages/ directory) and the newer App Router (app/ directory). Understanding the differences helps you make the right choice for your project.

Pages Router (Legacy)

The Pages Router has been the standard since Next.js began. Files in the pages/ directory automatically become routes.

pages/
├── index.tsx        -> /
├── about.tsx        -> /about
├── blog/
│   └── [slug].tsx   -> /blog/:slug
└── api/
    └── users.ts     -> /api/users
Enter fullscreen mode Exit fullscreen mode

Key Characteristics:

  • Uses getServerSideProps, getStaticProps for data fetching
  • All components are Client Components by default
  • Layouts require workarounds (_app.tsx, _document.tsx)
  • Simpler mental model

App Router (Recommended)

The App Router, introduced in Next.js 13 and now stable, uses the app/ directory with React Server Components as the foundation.

app/
├── page.tsx           -> /
├── layout.tsx         -> Shared layout
├── about/
│   └── page.tsx       -> /about
├── blog/
│   └── [slug]/
│       └── page.tsx   -> /blog/:slug
└── api/
    └── users/
        └── route.ts   -> /api/users
Enter fullscreen mode Exit fullscreen mode

Key Characteristics:

  • React Server Components by default
  • Native async/await for data fetching
  • Nested layouts, loading states, error boundaries
  • Streaming and Suspense support

Key Differences

Feature Pages Router App Router
Default Components Client Server
Data Fetching getServerSideProps async/await
Layouts _app.tsx workaround Native nested
Loading States Manual loading.tsx
Error Handling _error.tsx error.tsx (nested)
Streaming Limited Full support

Why Choose the App Router?

For new projects, the App Router is recommended because:

  1. Better Performance - Server Components reduce JavaScript sent to client
  2. Simpler Data Fetching - Use async/await directly in components
  3. Nested Layouts - Share UI between routes without re-rendering
  4. Built-in Loading/Error States - Automatic handling with special files
  5. Future-Proof - All new Next.js features target the App Router

💡 Tip: If you're starting a new project, use the App Router. Only use Pages Router for legacy projects or specific compatibility needs.


4. Setting Up Your First Next.js Project

Prerequisites

Before creating a Next.js project, ensure you have:

  • Node.js 18.17 or later - Download from nodejs.org
  • npm, yarn, or pnpm - Package manager (npm comes with Node.js)
  • Code editor - VS Code recommended with ESLint and Prettier extensions

Creating a New Project

Open your terminal and run:

npx create-next-app@latest my-nextjs-app
Enter fullscreen mode Exit fullscreen mode

You'll be prompted with configuration options:

Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? No
Would you like to use App Router? Yes
Would you like to customize the default import alias? No
Enter fullscreen mode Exit fullscreen mode

Recommended choices for beginners:

  • TypeScript: Yes (better DX and error catching)
  • ESLint: Yes (code quality)
  • Tailwind CSS: Yes (rapid styling)
  • src/ directory: No (simpler structure)
  • App Router: Yes (modern approach)

Running the Development Server

Navigate to your project and start the development server:

cd my-nextjs-app
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 in your browser. You should see the Next.js welcome page!

Available Scripts

{
  "scripts": {
    "dev": "next dev",        // Start development server
    "build": "next build",    // Create production build
    "start": "next start",    // Run production server
    "lint": "next lint"       // Run ESLint
  }
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Use npm run dev -- -p 3001 to run on a different port if 3000 is already in use.


5. Understanding the Project Structure

Project Directory Overview

After creating a new Next.js project, you'll see this structure:

my-nextjs-app/
+-- app/
|   +-- favicon.ico
|   +-- globals.css
|   +-- layout.tsx
|   +-- page.tsx
+-- public/
|   +-- (static files)
+-- node_modules/
+-- .eslintrc.json
+-- .gitignore
+-- next.config.ts
+-- package.json
+-- postcss.config.mjs
+-- tailwind.config.ts
+-- tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Key Files and Directories

app/ - The main directory for your application code using the App Router.

app/layout.tsx - The root layout component that wraps all pages. Define global UI elements like headers and footers here.

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

app/page.tsx - The home page component, rendered at the root URL (/).

// app/page.tsx
export default function Home() {
  return (
    <main>
      <h1>Welcome to Next.js!</h1>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

public/ - Static assets like images, fonts, and files. Accessible at the root URL.

next.config.ts - Next.js configuration file for customizing build and runtime behavior.

tailwind.config.ts - Tailwind CSS configuration for customizing your design system.

tsconfig.json - TypeScript configuration with Next.js-specific settings.

📝 Note: The globals.css file contains your global styles. When using Tailwind CSS, this file includes the Tailwind directives.


Section 2: Styling & Assets

1. CSS Modules vs Global CSS

Next.js supports multiple styling approaches out of the box: Global CSS, CSS Modules, Tailwind CSS, and CSS-in-JS libraries. Understanding when to use each helps you build maintainable styles.

Global CSS

Global styles apply to your entire application. Import them in your root layout:

/* app/globals.css */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
}

a {
    color: #0070f3;
    text-decoration: none;
}

a:hover {
    text-decoration: underline;
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
import './globals.css'

export default function RootLayout({ children }) {
    return (
        <html lang="en">
            <body>{children}</body>
        </html>
    )
}
Enter fullscreen mode Exit fullscreen mode

CSS Modules

CSS Modules scope styles to a component, preventing naming conflicts. Create files with .module.css extension:

/* app/components/Button.module.css */
.button {
    padding: 12px 24px;
    border: none;
    border-radius: 8px;
    font-size: 16px;
    cursor: pointer;
    transition: all 0.2s;
}

.primary {
    background: #0070f3;
    color: white;
}

.primary:hover {
    background: #0051a8;
}

.secondary {
    background: #eaeaea;
    color: #333;
}
Enter fullscreen mode Exit fullscreen mode
// app/components/Button.tsx
import styles from './Button.module.css'

type ButtonProps = {
    variant?: 'primary' | 'secondary'
    children: React.ReactNode
}

export function Button({ variant = 'primary', children }: ButtonProps) {
    return (
        <button className={styles.button + ' ' + styles[variant]}>
            {children}
        </button>
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: CSS Modules automatically generate unique class names like 'Button_primary__x7f3a', ensuring styles never leak between components.


2. Tailwind CSS v4 Setup & Best Practices

What's New in Tailwind CSS v4

Tailwind CSS v4 is a major rewrite with significant improvements: it's up to 10x faster, uses native CSS cascade layers, and has a simplified configuration using CSS instead of JavaScript.

Installing Tailwind CSS v4

# Install Tailwind CSS v4
npm install tailwindcss @tailwindcss/postcss postcss
Enter fullscreen mode Exit fullscreen mode

Configuration with CSS (New in v4)

Tailwind v4 uses CSS-based configuration instead of tailwind.config.js:

/* app/globals.css */
@import "tailwindcss";

/* Custom theme configuration using CSS */
@theme {
    --color-primary: #0070f3;
    --color-secondary: #7928ca;
    --font-sans: "Inter", system-ui, sans-serif;
    --spacing-18: 4.5rem;
}

/* Custom utilities */
@utility container-narrow {
    max-width: 42rem;
    margin-inline: auto;
    padding-inline: 1rem;
}
Enter fullscreen mode Exit fullscreen mode
// postcss.config.mjs
export default {
    plugins: {
        '@tailwindcss/postcss': {}
    }
}
Enter fullscreen mode Exit fullscreen mode

Using Tailwind in Components

// app/components/Card.tsx
export function Card({ title, description, children }) {
    return (
        <div className="rounded-xl border border-gray-200 bg-white p-6
                        shadow-sm hover:shadow-md transition-shadow">
            <h2 className="text-xl font-semibold text-gray-900 mb-2">
                {title}
            </h2>
            <p className="text-gray-600 mb-4">
                {description}
            </p>
            {children}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  • Use consistent spacing scale (p-4, p-6, p-8 not p-5, p-7)
  • Extract repeated patterns into components, not @apply
  • Use CSS variables for brand colors via @theme
  • Leverage the new container queries in v4

⚠️ Important: Tailwind v4 no longer requires a tailwind.config.js file. All configuration happens in your CSS using @theme and other directives.


3. Fonts with next/font

Why Use next/font?

next/font automatically optimizes fonts and removes external network requests for improved privacy and performance. It self-hosts font files and eliminates layout shift with automatic font fallbacks.

Using Google Fonts

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'

const inter = Inter({
    subsets: ['latin'],
    display: 'swap',
    variable: '--font-inter',
})

const robotoMono = Roboto_Mono({
    subsets: ['latin'],
    display: 'swap',
    variable: '--font-roboto-mono',
})

export default function RootLayout({ children }) {
    return (
        <html lang="en" className={inter.variable + ' ' + robotoMono.variable}>
            <body className={inter.className}>
                {children}
            </body>
        </html>
    )
}
Enter fullscreen mode Exit fullscreen mode

Using Local Fonts

// app/layout.tsx
import localFont from 'next/font/local'

const myFont = localFont({
    src: [
        {
            path: '../fonts/MyFont-Regular.woff2',
            weight: '400',
            style: 'normal',
        },
        {
            path: '../fonts/MyFont-Bold.woff2',
            weight: '700',
            style: 'normal',
        },
    ],
    variable: '--font-my-font',
})

export default function RootLayout({ children }) {
    return (
        <html lang="en" className={myFont.variable}>
            <body>{children}</body>
        </html>
    )
}
Enter fullscreen mode Exit fullscreen mode

Using with Tailwind CSS

/* app/globals.css - Tailwind v4 */
@import "tailwindcss";

@theme {
    --font-sans: var(--font-inter), system-ui, sans-serif;
    --font-mono: var(--font-roboto-mono), monospace;
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Always use display: 'swap' to prevent invisible text while fonts load. This improves Core Web Vitals scores.


4. Images with next/image

Automatic Image Optimization

The next/image component automatically optimizes images: resizing, converting to modern formats (WebP/AVIF), and lazy loading. It prevents layout shift with automatic dimension handling.

Basic Usage

import Image from 'next/image'

// Local image (automatically gets dimensions)
import profilePic from './profile.jpg'

export function Avatar() {
    return (
        <Image
            src={profilePic}
            alt="Profile picture"
            placeholder="blur" // Shows blurred version while loading
        />
    )
}

// Remote image (must specify dimensions)
export function ProductImage({ src, name }) {
    return (
        <Image
            src={src}
            alt={name}
            width={400}
            height={300}
            priority // Load immediately (above the fold)
        />
    )
}
Enter fullscreen mode Exit fullscreen mode

Responsive Images

// Fill container (responsive)
export function HeroImage() {
    return (
        <div className="relative h-[400px] w-full">
            <Image
                src="/hero.jpg"
                alt="Hero image"
                fill
                style={{ objectFit: 'cover' }}
                sizes="100vw"
                priority
            />
        </div>
    )
}

// Responsive with sizes hint
export function BlogImage({ src, alt }) {
    return (
        <Image
            src={src}
            alt={alt}
            width={800}
            height={400}
            sizes="(max-width: 768px) 100vw, 800px"
            className="rounded-lg"
        />
    )
}
Enter fullscreen mode Exit fullscreen mode

Configuring Remote Images

By default, Next.js blocks remote images for security reasons. The next/image component optimizes images on-demand, which means your server fetches, processes, and caches external images. Without restrictions, malicious users could abuse your server to process images from any URL, potentially causing security vulnerabilities and unexpected costs.

To use remote images, you must explicitly whitelist the domains in your next.config.ts file using the remotePatterns configuration:

// next.config.ts
import type { NextConfig } from 'next'

const config: NextConfig = {
    images: {
        remotePatterns: [
            {
                protocol: 'https',
                hostname: 'images.unsplash.com',
            },
            {
                protocol: 'https',
                hostname: '*.cloudinary.com',
            },
        ],
    },
}

export default config
Enter fullscreen mode Exit fullscreen mode

Why This Configuration Matters:

  • Security - Prevents your server from being used to fetch and process arbitrary external images
  • Cost Control - Image optimization uses server resources; whitelisting prevents abuse
  • Performance - Only trusted CDNs and image sources are optimized
  • Wildcards - Use '.' prefix to allow all subdomains (e.g., '.cloudinary.com')

📝 Note: Use priority prop for Largest Contentful Paint (LCP) images like hero images. This preloads them for better performance.


5. Metadata, SEO & Open Graph Images

Static Metadata

Define metadata in your layout or page files to improve SEO. Next.js automatically generates the appropriate meta tags.

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
    title: {
        default: 'My App',
        template: '%s | My App', // For child pages
    },
    description: 'A Next.js application',
    keywords: ['Next.js', 'React', 'JavaScript'],
    authors: [{ name: 'Your Name' }],
    openGraph: {
        title: 'My App',
        description: 'A Next.js application',
        url: 'https://myapp.com',
        siteName: 'My App',
        images: [
            {
                url: '/og-image.png',
                width: 1200,
                height: 630,
            },
        ],
        locale: 'en_US',
        type: 'website',
    },
    twitter: {
        card: 'summary_large_image',
        title: 'My App',
        description: 'A Next.js application',
        images: ['/og-image.png'],
    },
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Metadata

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = {
    params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
    const { slug } = await params
    const post = await getPost(slug)

    return {
        title: post.title,
        description: post.excerpt,
        openGraph: {
            title: post.title,
            description: post.excerpt,
            images: [post.coverImage],
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

Dynamic OG Images

// app/og/route.tsx
import { ImageResponse } from 'next/og'

export async function GET(request: Request) {
    const { searchParams } = new URL(request.url)
    const title = searchParams.get('title') || 'My App'

    return new ImageResponse(
        (
            <div style={{
                display: 'flex',
                fontSize: 60,
                background: 'linear-gradient(to bottom, #1a1a2e, #16213e)',
                color: 'white',
                width: '100%',
                height: '100%',
                alignItems: 'center',
                justifyContent: 'center',
            }}>
                {title}
            </div>
        ),
        { width: 1200, height: 630 }
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Use the template pattern in title metadata so child pages automatically append your site name: 'Blog Post Title | My App'


Section 3: Routing in Depth

1. File-Based Routing Explained

Next.js uses a file-system based router where folders define routes. This means the structure of your app/ directory directly maps to your URL structure.

app/
+-- page.tsx           -> /
+-- about/
|   +-- page.tsx       -> /about
+-- blog/
|   +-- page.tsx       -> /blog
+-- contact/
    +-- page.tsx       -> /contact
Enter fullscreen mode Exit fullscreen mode

Creating Your First Routes

Each route requires a page.tsx file inside its folder. The page file exports a React component that renders when users visit that route.

// app/page.tsx - Home page (/)
export default function HomePage() {
    return (
        <main>
            <h1>Welcome to My App</h1>
            <p>This is the home page.</p>
        </main>
    )
}
Enter fullscreen mode Exit fullscreen mode
// app/about/page.tsx - About page (/about)
export default function AboutPage() {
    return (
        <main>
            <h1>About Us</h1>
            <p>Learn more about our company.</p>
        </main>
    )
}
Enter fullscreen mode Exit fullscreen mode

Nested Routes

Create nested folders for nested routes:

app/
+-- blog/
    +-- page.tsx           -> /blog
    +-- posts/
        +-- page.tsx       -> /blog/posts
Enter fullscreen mode Exit fullscreen mode
// app/blog/page.tsx
export default function BlogPage() {
    return <h1>Blog</h1>
}
Enter fullscreen mode Exit fullscreen mode
// app/blog/posts/page.tsx
export default function PostsPage() {
    return <h1>All Blog Posts</h1>
}
Enter fullscreen mode Exit fullscreen mode

Navigation Between Pages

Use the Link component for client-side navigation:

import Link from 'next/link'

export default function Navigation() {
    return (
        <nav>
            <Link href="/">Home</Link>
            <Link href="/about">About</Link>
            <Link href="/blog">Blog</Link>
            <Link href="/contact">Contact</Link>
        </nav>
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Always use the Link component instead of anchor tags for internal navigation. Link enables client-side navigation and automatic prefetching for faster page loads.


2. Pages, Layouts & Nested Routes

Understanding Layouts

Layouts wrap pages and preserve state across navigations. They're perfect for shared UI like headers, sidebars, and footers.

// app/layout.tsx - Root Layout (required)
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({
    children,
}: {
    children: React.ReactNode
}) {
    return (
        <html lang="en">
            <body className={inter.className}>
                <header>
                    <nav>My App</nav>
                </header>
                <main>{children}</main>
                <footer>© 2024</footer>
            </body>
        </html>
    )
}
Enter fullscreen mode Exit fullscreen mode

Nested Layouts

Each route segment can have its own layout that nests inside parent layouts:

app/
+-- layout.tsx              -> Root layout
+-- page.tsx                -> Home page
+-- dashboard/
    +-- layout.tsx          -> Dashboard layout (nested)
    +-- page.tsx            -> /dashboard
    +-- settings/
        +-- page.tsx        -> /dashboard/settings
Enter fullscreen mode Exit fullscreen mode
// app/dashboard/layout.tsx
export default function DashboardLayout({
    children,
}: {
    children: React.ReactNode
}) {
    return (
        <div className="dashboard">
            <aside>
                <nav>
                    <a href="/dashboard">Overview</a>
                    <a href="/dashboard/settings">Settings</a>
                </nav>
            </aside>
            <section>{children}</section>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Layout Benefits

  • Preserved State - Layouts don't re-render when navigating between pages they wrap
  • No Prop Drilling - Fetch data once in layout, available to all children
  • Partial Rendering - Only the page content updates, not the entire layout

⚠️ Important: The root layout (app/layout.tsx) is required and must contain html and body tags. Nested layouts should NOT include these tags.


3. Dynamic Routes & Catch-All Routes

Dynamic Segments

Use square brackets to create dynamic routes that match variable paths:

app/
+-- blog/
    +-- [slug]/
        +-- page.tsx       -> /blog/hello-world, /blog/my-post
Enter fullscreen mode Exit fullscreen mode
// app/blog/[slug]/page.tsx
type Props = {
    params: Promise<{ slug: string }>
}

export default async function BlogPost({ params }: Props) {
    const { slug } = await params

    return (
        <article>
            <h1>Post: {slug}</h1>
        </article>
    )
}
Enter fullscreen mode Exit fullscreen mode

Multiple Dynamic Segments

app/
+-- shop/
    +-- [category]/
        +-- [product]/
            +-- page.tsx   -> /shop/electronics/iphone
Enter fullscreen mode Exit fullscreen mode
// app/shop/[category]/[product]/page.tsx
type Props = {
    params: Promise<{ category: string; product: string }>
}

export default async function ProductPage({ params }: Props) {
    const { category, product } = await params

    return (
        <div>
            <p>Category: {category}</p>
            <p>Product: {product}</p>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Catch-All Segments

Use [...slug] to match any number of segments:

app/
+-- docs/
    +-- [...slug]/
        +-- page.tsx       -> /docs/a, /docs/a/b, /docs/a/b/c
Enter fullscreen mode Exit fullscreen mode
// app/docs/[...slug]/page.tsx
type Props = {
    params: Promise<{ slug: string[] }>
}

export default async function DocsPage({ params }: Props) {
    const { slug } = await params
    // slug = ['a', 'b', 'c'] for /docs/a/b/c

    return (
        <div>
            <p>Path: {slug.join(' / ')}</p>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Use [...slug] for optional catch-all routes that also match the parent path (/docs).


4. Route Groups & Folder Conventions

What Are Route Groups?

Route groups organize routes without affecting the URL structure. Wrap folder names in parentheses:

app/
+-- (marketing)/
|   +-- layout.tsx         -> Marketing layout
|   +-- page.tsx           -> / (home page)
|   +-- about/
|   |   +-- page.tsx       -> /about
|   +-- pricing/
|       +-- page.tsx       -> /pricing
+-- (app)/
    +-- layout.tsx         -> App layout
    +-- dashboard/
    |   +-- page.tsx       -> /dashboard
    +-- settings/
        +-- page.tsx       -> /settings
Enter fullscreen mode Exit fullscreen mode

Use Cases for Route Groups

1. Different layouts for different sections:

// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
    return (
        <div className="marketing">
            <header>Marketing Header</header>
            {children}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode
// app/(app)/layout.tsx
export default function AppLayout({ children }) {
    return (
        <div className="app">
            <Sidebar />
            {children}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

2. Organizing by feature:

app/
+-- (auth)/
|   +-- login/
|   +-- register/
+-- (dashboard)/
    +-- overview/
    +-- analytics/
Enter fullscreen mode Exit fullscreen mode

Private Folders

Prefix with underscore to exclude from routing:

app/
+-- _components/     -> Not a route, just organization
+-- _lib/            -> Not a route, utilities
+-- page.tsx         -> Actual route
Enter fullscreen mode Exit fullscreen mode

📝 Note: Route groups are purely organizational. The parentheses folder name is completely removed from the URL path.


5. Not Found & Error Routes

Custom 404 Page

Create a not-found.tsx file to show when a page doesn't exist:

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
    return (
        <div className="not-found">
            <h1>404 - Page Not Found</h1>
            <p>Sorry, we couldn't find the page you're looking for.</p>
            <Link href="/">Return Home</Link>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Triggering Not Found

Use the notFound() function to programmatically show the 404 page:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
    const { slug } = await params
    const post = await getPost(slug)

    if (!post) {
        notFound() // Shows the not-found.tsx page
    }

    return <article>{post.content}</article>
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

Create error.tsx to handle runtime errors gracefully:

// app/error.tsx
'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function Error({
    error,
    reset,
}: {
    error: Error & { digest?: string }
    reset: () => void
}) {
    useEffect(() => {
        console.error(error)
    }, [error])

    return (
        <div className="error">
            <h2>Something went wrong!</h2>
            <button onClick={() => reset()}>Try again</button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Nested Error Boundaries

Error files create boundaries at that route segment level. Errors bubble up to the nearest error boundary:

app/
+-- error.tsx              -> Catches all errors
+-- dashboard/
    +-- error.tsx          -> Catches dashboard errors only
    +-- page.tsx
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: error.tsx must be a Client Component (add 'use client' at the top). The reset() function attempts to re-render the segment.


6. Loading UI & Suspense Boundaries

Automatic Loading States

Create a loading.tsx file to show loading UI while a route segment loads:

// app/dashboard/loading.tsx
export default function Loading() {
    return (
        <div className="loading">
            <div className="spinner"></div>
            <p>Loading dashboard...</p>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

How It Works

Next.js automatically wraps your page in a Suspense boundary:

// What Next.js does internally:
<Suspense fallback={<Loading />}>
    <Page />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Skeleton Components

Create better loading experiences with skeletons:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
    return (
        <div className="dashboard-skeleton">
            <div className="skeleton-header" />
            <div className="skeleton-cards">
                <div className="skeleton-card" />
                <div className="skeleton-card" />
                <div className="skeleton-card" />
            </div>
            <div className="skeleton-table" />
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode
/* Skeleton animation */
.skeleton-card {
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}
Enter fullscreen mode Exit fullscreen mode

Manual Suspense Boundaries

Use Suspense directly for more granular control:

import { Suspense } from 'react'

export default function Dashboard() {
    return (
        <div>
            <h1>Dashboard</h1>

            <Suspense fallback={<CardsSkeleton />}>
                <DashboardCards />
            </Suspense>

            <Suspense fallback={<ChartSkeleton />}>
                <RevenueChart />
            </Suspense>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Use multiple Suspense boundaries to stream different parts of the page independently. This allows faster content to appear first.


Section 4: Rendering & Data Fetching

1. Rendering Models: CSR, SSR, SSG, ISR

Next.js supports multiple rendering strategies. Choosing the right one depends on your content type and update frequency.

Client-Side Rendering (CSR)

Content is rendered in the browser using JavaScript. The server sends an empty HTML shell.

'use client'

import { useState, useEffect } from 'react'

export default function ClientPage() {
    const [data, setData] = useState(null)

    useEffect(() => {
        fetch('/api/data')
            .then(res => res.json())
            .then(setData)
    }, [])

    if (!data) return <p>Loading...</p>

    return <div>{data.content}</div>
}
Enter fullscreen mode Exit fullscreen mode

Use for: User dashboards, real-time data, personalized content

Server-Side Rendering (SSR)

Content is rendered on the server for each request. Fresh data every time.

// app/products/page.tsx
export const dynamic = 'force-dynamic' // Opt into SSR

export default async function ProductsPage() {
    const products = await fetch('https://api.example.com/products', {
        cache: 'no-store' // Don't cache, always fresh
    }).then(res => res.json())

    return (
        <ul>
            {products.map(p => <li key={p.id}>{p.name}</li>)}
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

Use for: Frequently changing data, user-specific content

Static Site Generation (SSG)

Content is rendered at build time. Fastest performance, cached globally.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
    const posts = await getPosts()
    return posts.map(post => ({ slug: post.slug }))
}

export default async function BlogPost({ params }) {
    const { slug } = await params
    const post = await getPost(slug)
    return <article>{post.content}</article>
}
Enter fullscreen mode Exit fullscreen mode

Use for: Blog posts, documentation, marketing pages

Incremental Static Regeneration (ISR)

Static pages that revalidate after a time period. Best of both worlds.

// app/products/page.tsx
export const revalidate = 3600 // Revalidate every hour

export default async function ProductsPage() {
    const products = await fetch('https://api.example.com/products')
        .then(res => res.json())

    return (
        <ul>
            {products.map(p => <li key={p.id}>{p.name}</li>)}
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

Use for: E-commerce products, news articles, content that changes periodically

Comparison Table

Strategy Build Time Request Time SEO Fresh Data
CSR Fast Slow Poor Yes
SSR Fast Slower Good Yes
SSG Slower Fast Good No
ISR Slower Fast Good Periodic

2. Server Components vs Client Components

Understanding the Difference

In Next.js App Router, components are Server Components by default. They render on the server and send HTML to the client with zero JavaScript.

When to Use Server Components

Server Components Can:

  • Fetch data directly from databases
  • Access backend resources and APIs
  • Keep sensitive data on the server (API keys)

Server Components Cannot:

  • Use React hooks (useState, useEffect)
  • Add event listeners (onClick, onChange)
  • Access browser APIs (localStorage, window)
// Server Component (default) - app/dashboard/page.tsx
async function getData() {
    const res = await fetch('https://api.example.com/data', {
        headers: { 'API-Key': process.env.API_KEY }
    })
    return res.json()
}

export default async function Dashboard() {
    const data = await getData()

    return (
        <div>
            <h1>Dashboard</h1>
            <p>Total users: {data.userCount}</p>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

When to Use Client Components

Use Client Components when you need:

  • State and lifecycle effects (useState, useEffect)
  • Event handlers (onClick, onSubmit)
  • Browser APIs (localStorage, geolocation)

Understanding Hydration

Hydration is the process where React attaches event listeners and makes server-rendered HTML interactive in the browser. When a page loads, the server sends pre-rendered HTML so users see content immediately. Then React 'hydrates' this HTML by attaching JavaScript functionality to make buttons clickable, forms submittable, and state manageable.

How Hydration Works:

  1. Server renders HTML and sends it to the browser (fast initial display)
  2. Browser displays the static HTML immediately (users see content)
  3. JavaScript bundle loads in the background
  4. React hydrates the HTML by attaching event handlers and state
  5. Page becomes fully interactive

Hydration Errors and How to Fix Them

Hydration errors occur when the server-rendered HTML doesn't match what React expects to render on the client. This mismatch confuses React and can cause visual glitches or broken functionality.

Common Causes of Hydration Errors:

  • Using browser-only APIs like window, localStorage, or document during render
  • Rendering dates/times that differ between server and client
  • Using random values or Math.random() during render
  • Invalid HTML nesting (e.g., div inside p, or p inside p)
  • Browser extensions modifying the HTML

How to Fix Hydration Errors:

// BAD: Using window during render causes hydration error
export default function BadComponent() {
    const width = window.innerWidth // Error: window is undefined on server
    return <div>Width: {width}</div>
}

// GOOD: Use useEffect for browser-only code
"use client"
import { useState, useEffect } from 'react'

export default function GoodComponent() {
    const [width, setWidth] = useState(0)

    useEffect(() => {
        setWidth(window.innerWidth)
    }, [])

    return <div>Width: {width}</div>
}
Enter fullscreen mode Exit fullscreen mode
// GOOD: Use suppressHydrationWarning for intentional mismatches
export default function TimeDisplay() {
    return (
        <time suppressHydrationWarning>
            {new Date().toLocaleTimeString()}
        </time>
    )
}

// GOOD: Use dynamic import with ssr: false for client-only components
import dynamic from 'next/dynamic'

const ClientOnlyChart = dynamic(() => import('./Chart'), {
    ssr: false,
    loading: () => <p>Loading chart...</p>
})
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Server Components don't hydrate at all - they render only on the server and send pure HTML. This is why they're more performant and can't use hooks or browser APIs.

⚠️ Important: Keep Client Components as leaf nodes in your component tree. Push interactivity down and keep data fetching in Server Components.


3. 'use client' - When & Why to Use It

The Client Boundary

The 'use client' directive marks a file as a Client Component. Place it at the very top of the file, before any imports.

"use client"

import { useState } from 'react'

export default function Counter() {
    const [count, setCount] = useState(0)

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Common Patterns

Make small, focused Client Components and compose them within Server Components:

// app/products/page.tsx (Server Component)
import { AddToCartButton } from './AddToCartButton'

async function getProducts() {
    return fetch('https://api.example.com/products').then(r => r.json())
}

export default async function ProductsPage() {
    const products = await getProducts()

    return (
        <div className="products-grid">
            {products.map(product => (
                <div key={product.id}>
                    <h2>{product.name}</h2>
                    <p>${product.price}</p>
                    <AddToCartButton productId={product.id} />
                </div>
            ))}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode
// app/products/AddToCartButton.tsx (Client Component)
"use client"

import { useState } from 'react'

export function AddToCartButton({ productId }: { productId: string }) {
    const [added, setAdded] = useState(false)

    return (
        <button
            onClick={() => {
                addToCart(productId)
                setAdded(true)
            }}
        >
            {added ? 'Added!' : 'Add to Cart'}
        </button>
    )
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: The boundary effect means all components imported into a Client Component also become client components. Design your component tree to minimize the client boundary.


4. Fetching Data in Server Components

Direct Data Fetching

Server Components can fetch data directly using async/await:

// app/posts/page.tsx
async function getPosts() {
    const res = await fetch('https://api.example.com/posts')
    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>
    )
}
Enter fullscreen mode Exit fullscreen mode

Database Queries

Query your database directly - no API layer needed:

// app/users/page.tsx
import { db } from '@/lib/db'

export default async function UsersPage() {
    const users = await db.user.findMany({
        orderBy: { createdAt: 'desc' },
        take: 10
    })

    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

Caching Behavior

Next.js automatically caches fetch requests:

// Cached indefinitely (default)
const data = await fetch('https://api.example.com/data')

// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }
})

// No caching - always fresh
const data = await fetch('https://api.example.com/data', {
    cache: 'no-store'
})
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: Sensitive operations like database queries should only happen in Server Components where the code never reaches the client.


5. Parallel & Sequential Data Fetching

Sequential Fetching (Waterfall)

When one fetch depends on another:

// Each fetch waits for the previous one
async function getUser(id: string) {
    const res = await fetch('/api/users/' + id)
    return res.json()
}

async function getUserPosts(userId: string) {
    const res = await fetch('/api/users/' + userId + '/posts')
    return res.json()
}

export default async function UserProfile({ params }) {
    const { id } = await params
    const user = await getUser(id)         // First, get user
    const posts = await getUserPosts(user.id)  // Then, get posts

    return (
        <div>
            <h1>{user.name}</h1>
            <PostsList posts={posts} />
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Parallel Fetching

When fetches are independent, run them simultaneously:

// All fetches start at the same time
async function getUser(id: string) {
    const res = await fetch('/api/users/' + id)
    return res.json()
}

async function getPosts() {
    const res = await fetch('/api/posts')
    return res.json()
}

async function getComments() {
    const res = await fetch('/api/comments')
    return res.json()
}

export default async function Dashboard({ params }) {
    const { id } = await params

    // Start all fetches simultaneously
    const [user, posts, comments] = await Promise.all([
        getUser(id),
        getPosts(),
        getComments()
    ])

    return { user, posts, comments }
}
Enter fullscreen mode Exit fullscreen mode

Practical Example

// app/dashboard/page.tsx
async function getStats() {
    return fetch('https://api.example.com/stats').then(r => r.json())
}

async function getRecentOrders() {
    return fetch('https://api.example.com/orders?limit=5').then(r => r.json())
}

async function getNotifications() {
    return fetch('https://api.example.com/notifications').then(r => r.json())
}

export default async function Dashboard() {
    const [stats, orders, notifications] = await Promise.all([
        getStats(),
        getRecentOrders(),
        getNotifications()
    ])

    return (
        <div>
            <StatsCards stats={stats} />
            <OrdersTable orders={orders} />
            <NotificationsList notifications={notifications} />
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: Always use Promise.all() / Promise.allSettled() / Promise.race() depending on your requirement when fetching independent data. This can dramatically improve page load times.

Understanding Promise Methods

Promise.all() - Waits for ALL promises to resolve. If any promise rejects, the entire operation fails immediately. Use when you need all data and can't proceed without it.

Promise.allSettled() - Waits for ALL promises to complete (resolve or reject). Never fails early; returns status and value/reason for each. Use when you want partial results even if some requests fail.

Promise.race() - Returns as soon as the FIRST promise settles (resolves or rejects). Use for timeouts or when you only need the fastest response.

// Promise.all - fails if any request fails
const [users, posts] = await Promise.all([getUsers(), getPosts()])

// Promise.allSettled - returns results even if some fail
const results = await Promise.allSettled([getUsers(), getPosts()])
results.forEach(result => {
    if (result.status === 'fulfilled') console.log(result.value)
    if (result.status === 'rejected') console.log(result.reason)
})

// Promise.race - returns first settled promise
const fastest = await Promise.race([fetchFromCDN1(), fetchFromCDN2()])
Enter fullscreen mode Exit fullscreen mode

This article covers the first half of the Next.js 16 Complete Beginner's Guide. For the complete guide including Server Actions, Authentication, Backend APIs, and more, download the full 110-page PDF for FREE.


🚀 Ready to Master Next.js?

Take your skills to the next level by building large-scale, production-ready applications with my comprehensive video course.

👉 Enroll Now.

About Me

I'm a freelancer, mentor, and full-stack developer with 12+ years of experience, working primarily with React, Next.js, and Node.js.

Alongside building real-world web applications, I'm also an Industry/Corporate Trainer, training developers and teams in modern JavaScript, Next.js, and MERN stack technologies with a focus on practical, production-ready skills.

I've also created various courses with 3000+ students enrolled.

My Portfolio: https://yogeshchavan.dev/


Top comments (0)