Toolmingo
Guides19 min read

Next.js Tutorial for Beginners (2025): Learn Next.js Step by Step

Complete Next.js 14/15 tutorial for absolute beginners. Learn App Router, Server Components, data fetching, routing, and build real projects. Free guide with code examples.

Next.js is the world's most popular React framework — used by Vercel, Netflix, TikTok, Twitch, and over 1 million websites. It adds file-based routing, server-side rendering, static generation, and full-stack capabilities on top of React. This tutorial takes you from zero to building real Next.js applications, step by step.

What you'll learn

Topic What you'll be able to do
Setup Create a Next.js app and run it locally
App Router Build pages with the modern file-based router
Server Components Render components on the server by default
Client Components Add interactivity where needed
Data Fetching Fetch data in Server Components and Client Components
Routing Dynamic routes, layouts, nested routes
API Routes Build backend endpoints in the same project
Deployment Deploy to Vercel in minutes

Why Next.js?

React alone is a UI library — it doesn't include routing, data fetching, or server-side rendering. Next.js adds all of that:

Feature React alone Next.js
Routing Manual (React Router) Built-in file-based
Server-side rendering Manual setup Built-in
Static generation Manual Built-in
API endpoints Needs Express Built-in API routes
Image optimization Manual Built-in <Image>
SEO Hard Easy (metadata API)
Full-stack No Yes
Deployment Any Optimized for Vercel

Setup

Prerequisites

You need:

  • Node.js 18.17 or later
  • Basic JavaScript knowledge
  • Basic React knowledge (components, props, useState)

Create your first Next.js app

npx create-next-app@latest my-app

You'll be asked:

Would you like to use TypeScript? › No
Would you like to use ESLint? › Yes
Would you like to use Tailwind CSS? › No
Would you like your code inside a `src/` directory? › No
Would you like to use App Router? › Yes   ← say Yes
Would you like to use Turbopack? › No
Would you like to customize the default import alias? › No

Then:

cd my-app
npm run dev

Open http://localhost:3000 — you'll see the Next.js welcome page.

Project structure

my-app/
├── app/                  # App Router (pages live here)
│   ├── layout.js         # Root layout (wraps every page)
│   ├── page.js           # Home page (route: /)
│   └── globals.css       # Global styles
├── public/               # Static files (images, fonts)
├── next.config.mjs       # Next.js configuration
└── package.json

App Router basics

Next.js 13+ uses the App Router. Every folder inside app/ maps to a URL route.

File Route Purpose
app/page.js / Home page
app/about/page.js /about About page
app/blog/page.js /blog Blog index
app/blog/[slug]/page.js /blog/any-slug Dynamic blog post
app/layout.js All routes Root layout
app/loading.js All routes Loading UI
app/error.js All routes Error UI
app/not-found.js 404 Not found page

Your first page

app/page.js:

export default function HomePage() {
  return (
    <main>
      <h1>Hello, Next.js!</h1>
      <p>Welcome to my first Next.js app.</p>
    </main>
  );
}

Adding a new page

Create app/about/page.js:

export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <p>This is the about page.</p>
    </main>
  );
}

Now visit /about — it works automatically.


Layouts

Layouts wrap multiple pages and preserve state between navigations (like a shared header and footer).

Root layout

app/layout.js — this wraps every page:

export const metadata = {
  title: 'My App',
  description: 'Built with Next.js',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <a href="/">Home</a>
            <a href="/about">About</a>
            <a href="/blog">Blog</a>
          </nav>
        </header>
        <main>{children}</main>
        <footer>© 2025 My App</footer>
      </body>
    </html>
  );
}

Nested layouts

Create app/blog/layout.js to add a sidebar only on blog pages:

export default function BlogLayout({ children }) {
  return (
    <div style={{ display: 'flex', gap: '2rem' }}>
      <aside>
        <h3>Categories</h3>
        <ul>
          <li><a href="/blog/tech">Tech</a></li>
          <li><a href="/blog/life">Life</a></li>
        </ul>
      </aside>
      <div>{children}</div>
    </div>
  );
}

Server Components vs Client Components

This is the most important concept in Next.js 13+.

Server Component Client Component
Runs on Server Browser
Default Yes No (add 'use client')
Can fetch data Yes (async/await) Yes (useEffect)
Can use hooks No Yes (useState, useEffect)
Can handle events No Yes (onClick, onChange)
Bundle size Zero JS sent JS included in bundle
Access to DB, files Yes (directly) No
Best for Static content, data fetching Interactive UI

Server Component (default)

// app/users/page.js — Server Component (no 'use client')
async function getUsers() {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers(); // runs on server

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} — {user.email}</li>
        ))}
      </ul>
    </div>
  );
}

No loading spinner needed — data is fetched before the page is sent to the browser.

Client Component

'use client'; // ← required for hooks and interactivity

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>
  );
}

Mixing both

You can use Client Components inside Server Components:

// app/page.js — Server Component
import Counter from '@/components/Counter'; // Client Component

export default function HomePage() {
  return (
    <div>
      <h1>Welcome</h1>
      <Counter /> {/* Client Component embedded in Server Component */}
    </div>
  );
}

Routing

Dynamic routes

Create app/blog/[slug]/page.js for /blog/my-post, /blog/any-slug:

export default function BlogPost({ params }) {
  return (
    <div>
      <h1>Post: {params.slug}</h1>
    </div>
  );
}

Multiple dynamic segments

app/shop/[category]/[product]/page.js/shop/electronics/laptop:

export default function ProductPage({ params }) {
  return (
    <div>
      <p>Category: {params.category}</p>
      <p>Product: {params.product}</p>
    </div>
  );
}

Catch-all routes

app/docs/[...slug]/page.js matches /docs/a, /docs/a/b, /docs/a/b/c:

export default function DocsPage({ params }) {
  // params.slug = ['a', 'b', 'c']
  return <h1>Docs: {params.slug.join('/')}</h1>;
}

Route groups

Use parentheses to group routes without affecting the URL:

app/
├── (marketing)/
│   ├── about/page.js     → /about
│   └── pricing/page.js   → /pricing
└── (app)/
    ├── dashboard/page.js → /dashboard
    └── settings/page.js  → /settings

Different layouts for each group without URL nesting.


Link and Navigation

<Link> component

Use Link instead of <a> for client-side navigation:

import Link from 'next/link';

export default function Navbar() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog">Blog</Link>
    </nav>
  );
}

Programmatic navigation

'use client';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const router = useRouter();

  async function handleLogin() {
    // ... login logic
    router.push('/dashboard'); // redirect
    // router.replace('/dashboard'); // replace history (no back button)
    // router.back();               // go back
  }

  return <button onClick={handleLogin}>Log in</button>;
}

usePathname — get current path

'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';

export default function NavLink({ href, children }) {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <Link
      href={href}
      style={{ fontWeight: isActive ? 'bold' : 'normal' }}
    >
      {children}
    </Link>
  );
}

Data Fetching

Fetch in Server Components (recommended)

// app/posts/page.js
async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Posts</h1>
      {posts.slice(0, 10).map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body.slice(0, 100)}...</p>
        </article>
      ))}
    </div>
  );
}

Fetch with caching options

// Default: cached (like getStaticProps)
const res = await fetch('https://api.example.com/data');

// No cache: fresh every request (like getServerSideProps)
const res = await fetch('https://api.example.com/data', {
  cache: 'no-store'
});

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

Caching behaviour summary

Option Behaviour Old equivalent
Default Cached indefinitely getStaticProps
cache: 'no-store' No cache, fresh each request getServerSideProps
next: { revalidate: N } Cache, refresh every N seconds ISR (Incremental Static Regeneration)

Loading states

Create app/posts/loading.js — shown while the page fetches:

export default function Loading() {
  return <p>Loading posts...</p>;
}

Error handling

Create app/posts/error.js:

'use client';

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

Fetch in Client Components

When you need data on the client (e.g., after user action):

'use client';
import { useState, useEffect } from 'react';

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading...</p>;

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

API Routes

Next.js lets you build backend endpoints inside app/api/:

Simple GET endpoint

app/api/hello/route.js:

import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ message: 'Hello from Next.js API!' });
}

Visit /api/hello — you get JSON back.

Full CRUD example

app/api/posts/route.js:

import { NextResponse } from 'next/server';

// In-memory store (use a real DB in production)
let posts = [
  { id: 1, title: 'First post', body: 'Hello world' },
  { id: 2, title: 'Second post', body: 'More content' },
];

export async function GET() {
  return NextResponse.json(posts);
}

export async function POST(request) {
  const body = await request.json();
  const newPost = { id: Date.now(), ...body };
  posts.push(newPost);
  return NextResponse.json(newPost, { status: 201 });
}

app/api/posts/[id]/route.js:

import { NextResponse } from 'next/server';

let posts = [/* same posts array */];

export async function GET(request, { params }) {
  const post = posts.find(p => p.id === Number(params.id));
  if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  return NextResponse.json(post);
}

export async function PUT(request, { params }) {
  const body = await request.json();
  const index = posts.findIndex(p => p.id === Number(params.id));
  if (index === -1) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  posts[index] = { ...posts[index], ...body };
  return NextResponse.json(posts[index]);
}

export async function DELETE(request, { params }) {
  posts = posts.filter(p => p.id !== Number(params.id));
  return new NextResponse(null, { status: 204 });
}

Image optimization

Next.js has a built-in <Image> component that automatically:

  • Resizes images
  • Converts to modern formats (WebP, AVIF)
  • Lazy loads
  • Prevents layout shift
import Image from 'next/image';

export default function ProfileCard() {
  return (
    <div>
      {/* Local image */}
      <Image
        src="/profile.jpg"
        alt="Profile picture"
        width={200}
        height={200}
      />

      {/* Remote image (add hostname to next.config.mjs) */}
      <Image
        src="https://example.com/photo.jpg"
        alt="Remote photo"
        width={800}
        height={600}
      />

      {/* Fill parent container */}
      <div style={{ position: 'relative', width: '100%', height: '400px' }}>
        <Image
          src="/hero.jpg"
          alt="Hero"
          fill
          style={{ objectFit: 'cover' }}
        />
      </div>
    </div>
  );
}

Configure remote hostnames in next.config.mjs:

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'example.com',
      },
    ],
  },
};

export default nextConfig;

Metadata and SEO

Static metadata

// app/about/page.js
export const metadata = {
  title: 'About Us',
  description: 'Learn more about our company.',
  openGraph: {
    title: 'About Us',
    description: 'Learn more about our company.',
    images: ['/og-about.jpg'],
  },
};

export default function AboutPage() {
  return <h1>About</h1>;
}

Dynamic metadata

// app/blog/[slug]/page.js
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return <article>{post.body}</article>;
}

Favicon and app icons

Put these in app/:

app/
├── favicon.ico         # Browser tab icon
├── icon.png            # App icon (iOS, Android)
└── apple-icon.png      # Apple touch icon

Fonts

Next.js has built-in Google Fonts — no network requests at runtime:

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

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

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Environment variables

Create .env.local (never commit to git):

DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
NEXT_PUBLIC_API_URL=https://api.example.com
SECRET_API_KEY=supersecretkey123

Rules:

Variable Available in Use for
NEXT_PUBLIC_* Server + browser Public config (API URLs)
No prefix Server only Secrets (DB passwords, API keys)

Use in code:

// Server Component or API route — both work
const dbUrl = process.env.DATABASE_URL;

// Client Component — NEXT_PUBLIC_ only
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

Project 1: Blog with Markdown

A static blog that reads Markdown files from the filesystem.

npm install gray-matter remark remark-html

File structure:

posts/
├── hello-world.md
└── my-second-post.md
app/
├── blog/
│   ├── page.js
│   └── [slug]/page.js
└── lib/
    └── posts.js

posts/hello-world.md:

---
title: "Hello World"
date: "2025-01-15"
excerpt: "My first blog post"
---

# Hello World

Welcome to my blog! This post is written in Markdown.

app/lib/posts.js:

import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';

const postsDirectory = path.join(process.cwd(), 'posts');

export function getAllPosts() {
  const fileNames = fs.readdirSync(postsDirectory);

  return fileNames
    .filter(name => name.endsWith('.md'))
    .map(fileName => {
      const slug = fileName.replace(/\.md$/, '');
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, 'utf8');
      const { data } = matter(fileContents);
      return { slug, ...data };
    })
    .sort((a, b) => new Date(b.date) - new Date(a.date));
}

export async function getPostBySlug(slug) {
  const fullPath = path.join(postsDirectory, `${slug}.md`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  const processedContent = await remark().use(html).process(content);
  const contentHtml = processedContent.toString();

  return { slug, contentHtml, ...data };
}

app/blog/page.js:

import Link from 'next/link';
import { getAllPosts } from '@/app/lib/posts';

export const metadata = {
  title: 'Blog',
  description: 'All blog posts',
};

export default function BlogIndex() {
  const posts = getAllPosts();

  return (
    <div>
      <h1>Blog</h1>
      {posts.map(post => (
        <article key={post.slug}>
          <Link href={`/blog/${post.slug}`}>
            <h2>{post.title}</h2>
          </Link>
          <time>{post.date}</time>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

app/blog/[slug]/page.js:

import { getAllPosts, getPostBySlug } from '@/app/lib/posts';

export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map(post => ({ slug: post.slug }));
}

export async function generateMetadata({ params }) {
  const post = await getPostBySlug(params.slug);
  return { title: post.title, description: post.excerpt };
}

export default async function BlogPost({ params }) {
  const post = await getPostBySlug(params.slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  );
}

Project 2: Todo App with API Routes

Full-stack todo app — frontend + backend in the same Next.js project.

app/api/todos/route.js:

import { NextResponse } from 'next/server';

// Simple in-memory store
let todos = [];
let nextId = 1;

export async function GET() {
  return NextResponse.json(todos);
}

export async function POST(request) {
  const { text } = await request.json();
  if (!text?.trim()) {
    return NextResponse.json({ error: 'Text is required' }, { status: 400 });
  }
  const todo = { id: nextId++, text: text.trim(), done: false };
  todos.push(todo);
  return NextResponse.json(todo, { status: 201 });
}

app/api/todos/[id]/route.js:

import { NextResponse } from 'next/server';

// Same todos array (in a real app, use a DB)
export async function PATCH(request, { params }) {
  const { done } = await request.json();
  const todo = todos.find(t => t.id === Number(params.id));
  if (!todo) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  todo.done = done;
  return NextResponse.json(todo);
}

export async function DELETE(request, { params }) {
  todos = todos.filter(t => t.id !== Number(params.id));
  return new NextResponse(null, { status: 204 });
}

app/page.js:

'use client';
import { useState, useEffect } from 'react';

export default function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  useEffect(() => {
    fetch('/api/todos').then(r => r.json()).then(setTodos);
  }, []);

  async function addTodo(e) {
    e.preventDefault();
    if (!input.trim()) return;
    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: input }),
    });
    const todo = await res.json();
    setTodos(prev => [...prev, todo]);
    setInput('');
  }

  async function toggleTodo(id, done) {
    await fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ done: !done }),
    });
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, done: !done } : t)
    );
  }

  async function deleteTodo(id) {
    await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    setTodos(prev => prev.filter(t => t.id !== id));
  }

  return (
    <div style={{ maxWidth: 500, margin: '2rem auto', padding: '0 1rem' }}>
      <h1>Todo App</h1>
      <form onSubmit={addTodo} style={{ display: 'flex', gap: '0.5rem' }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Add a todo..."
          style={{ flex: 1, padding: '0.5rem' }}
        />
        <button type="submit">Add</button>
      </form>
      <ul style={{ listStyle: 'none', padding: 0, marginTop: '1rem' }}>
        {todos.map(todo => (
          <li key={todo.id} style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleTodo(todo.id, todo.done)}
            />
            <span style={{ flex: 1, textDecoration: todo.done ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Project 3: E-Commerce Product Listing

Static product pages with ISR (Incremental Static Regeneration).

app/products/page.js:

async function getProducts() {
  const res = await fetch('https://fakestoreapi.com/products', {
    next: { revalidate: 3600 }, // refresh every hour
  });
  return res.json();
}

export const metadata = {
  title: 'Products',
  description: 'Browse all products',
};

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

  return (
    <div>
      <h1>Products</h1>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
        {products.map(product => (
          <a key={product.id} href={`/products/${product.id}`} style={{ textDecoration: 'none', color: 'inherit', border: '1px solid #eee', padding: '1rem', borderRadius: 8 }}>
            <img src={product.image} alt={product.title} style={{ width: '100%', height: 200, objectFit: 'contain' }} />
            <h3 style={{ fontSize: '0.9rem' }}>{product.title}</h3>
            <p style={{ fontWeight: 'bold' }}>${product.price}</p>
          </a>
        ))}
      </div>
    </div>
  );
}

app/products/[id]/page.js:

export async function generateStaticParams() {
  const res = await fetch('https://fakestoreapi.com/products');
  const products = await res.json();
  return products.map(p => ({ id: String(p.id) }));
}

async function getProduct(id) {
  const res = await fetch(`https://fakestoreapi.com/products/${id}`, {
    next: { revalidate: 3600 },
  });
  if (!res.ok) return null;
  return res.json();
}

export async function generateMetadata({ params }) {
  const product = await getProduct(params.id);
  return {
    title: product?.title ?? 'Product',
    description: product?.description,
  };
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  if (!product) return <h1>Product not found</h1>;

  return (
    <div style={{ maxWidth: 600, margin: '2rem auto' }}>
      <img src={product.image} alt={product.title} style={{ width: '100%', height: 300, objectFit: 'contain' }} />
      <h1>{product.title}</h1>
      <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>${product.price}</p>
      <p>{product.description}</p>
      <button style={{ padding: '0.75rem 1.5rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
        Add to Cart
      </button>
    </div>
  );
}

Deployment to Vercel

Vercel builds and deploys Next.js with zero configuration.

npm install -g vercel
vercel

Or connect your GitHub repo at vercel.com — every push to main auto-deploys.

Other deployment options

Platform Command Notes
Vercel vercel Best Next.js support
Netlify netlify deploy Good support
AWS Amplify Git push Auto-deploys
Docker (self-hosted) docker build Full control
Node.js server npm start next build && next start

Docker deployment

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

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Add to next.config.mjs:

const nextConfig = {
  output: 'standalone',
};

Learning path

Stage Topics Time
1. React basics Components, props, useState, useEffect 1-2 weeks
2. Next.js setup App Router, pages, layouts 1 week
3. Server Components Async components, data fetching, caching 1 week
4. Routing Dynamic routes, route groups, redirects 3 days
5. API Routes GET/POST/PUT/DELETE, middleware 3 days
6. Database Prisma or Drizzle with PostgreSQL 1-2 weeks
7. Auth NextAuth.js or Clerk 1 week
8. Deployment Vercel, environment variables 1 day
9. Advanced Middleware, ISR, caching strategies 2 weeks

Common mistakes

Mistake Problem Fix
Missing 'use client' Hook used in Server Component throws error Add 'use client' at the top of the file
useRouter from next/router Only works in Pages Router Import from next/navigation
Fetching in useEffect instead of Server Component Slower, needs loading state Use async Server Component instead
No alt on <Image> Accessibility failure + Next.js error Always provide meaningful alt text
Hardcoded secrets in code Security vulnerability Use .env.local, never commit secrets
Importing server-only code in Client Component Build error Move to Server Component or API route
Not using <Link> for navigation Full page reload Use <Link> for internal links
cache: 'no-store' everywhere No caching = slow site Only use where fresh data is needed

Next.js vs related frameworks

Next.js Create React App Gatsby Remix Astro
Rendering SSR/SSG/ISR/CSR CSR only SSG/SSR SSR/CSR SSG/SSR
Routing File-based React Router File-based File-based File-based
Full-stack Yes No Partial Yes Yes
Data fetching Server Components useEffect GraphQL loaders fetch
Best for General web apps SPAs Content sites Web apps Content sites
Bundle size Moderate Large Moderate Small Tiny
Learning curve Medium Low Medium Medium Low

Quick reference

// New page
export default function Page() { return <h1>Hello</h1>; }

// Server Component with data
export default async function Page() {
  const data = await fetch('...').then(r => r.json());
  return <div>{data.name}</div>;
}

// Client Component
'use client';
import { useState } from 'react';
export default function Client() { const [n, setN] = useState(0); return <button onClick={() => setN(n+1)}>{n}</button>; }

// Dynamic route params
export default function Page({ params }) { return <h1>{params.id}</h1>; }

// Static params (SSG)
export function generateStaticParams() { return [{ id: '1' }, { id: '2' }]; }

// Metadata
export const metadata = { title: 'Page Title' };

// Dynamic metadata
export async function generateMetadata({ params }) { return { title: params.slug }; }

// API route
import { NextResponse } from 'next/server';
export async function GET() { return NextResponse.json({ ok: true }); }
export async function POST(req) { const body = await req.json(); return NextResponse.json(body, { status: 201 }); }

// Redirect
import { redirect } from 'next/navigation';
redirect('/login');

// Not found
import { notFound } from 'next/navigation';
if (!data) notFound();

FAQ

Do I need to know React before learning Next.js? Yes — Next.js builds on React. Learn components, props, useState, and useEffect first.

What's the difference between Pages Router and App Router? Pages Router is the old approach (pages/ directory). App Router is the modern approach (app/ directory) with Server Components. Use App Router for new projects.

When should I use a Server Component vs Client Component? Default to Server Components. Only add 'use client' when you need hooks (useState, useEffect) or browser events (onClick, onChange).

Is Next.js only for React? Yes — Next.js is specifically a React framework. For Vue, use Nuxt.js. For Svelte, use SvelteKit.

Do I need Vercel to deploy Next.js? No. Vercel is the creator of Next.js and offers the best hosting experience, but you can deploy to any platform that runs Node.js (AWS, GCP, Railway, Render, Docker, etc.).

What database should I use with Next.js? PostgreSQL with Prisma or Drizzle is the most popular choice. SQLite works great for small projects. For hosted databases, try Neon (serverless Postgres) or PlanetScale (MySQL).

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools