Toolmingo
Guides11 min read

Next.js Cheat Sheet: App Router, Data Fetching, Server Actions & More

A complete Next.js cheat sheet — App Router, Server & Client Components, data fetching, dynamic routes, Server Actions, API routes, and metadata. Copy-ready examples for Next.js 14/15.

Next.js is a React framework that adds file-based routing, server-side rendering, static generation, and full-stack capabilities. This reference covers the App Router (Next.js 13+) with practical patterns for everything you need day-to-day.

Quick reference

Task Pattern
Create a page app/dashboard/page.tsx/dashboard
Shared layout app/dashboard/layout.tsx
Dynamic route app/posts/[slug]/page.tsx
Catch-all route app/docs/[...slug]/page.tsx
API route app/api/users/route.ts → GET/POST
Server Component Default — no 'use client' directive
Client Component Add 'use client' at top of file
Fetch with cache fetch(url, { cache: 'force-cache' })
Fetch no cache fetch(url, { cache: 'no-store' })
Revalidate ISR fetch(url, { next: { revalidate: 60 } })
Server Action async function save() { 'use server'; ... }
Navigate <Link href="/about">About</Link>
Programmatic nav const router = useRouter(); router.push('/x')
Redirect (server) import { redirect } from 'next/navigation'
Not found import { notFound } from 'next/navigation'
Metadata export const metadata = { title: '...' }
Loading UI app/dashboard/loading.tsx
Error UI app/dashboard/error.tsx
Optimise image <Image src={img} alt="..." width={800} height={600} />
Env variable process.env.MY_SECRET / NEXT_PUBLIC_* for client
Parallel routes app/@modal/(.)post/[id]/page.tsx

Project structure

my-app/
├── app/                    # App Router root
│   ├── layout.tsx          # Root layout (required)
│   ├── page.tsx            # Homepage /
│   ├── globals.css
│   ├── dashboard/
│   │   ├── layout.tsx      # Nested layout
│   │   ├── page.tsx        # /dashboard
│   │   ├── loading.tsx     # Suspense loading UI
│   │   └── error.tsx       # Error boundary
│   └── api/
│       └── users/
│           └── route.ts    # API route handler
├── components/             # Shared components
├── lib/                    # Utilities, db clients
├── public/                 # Static assets
└── next.config.ts

Routing

File conventions

File Purpose
page.tsx Unique UI for a route — makes it publicly accessible
layout.tsx Shared UI that wraps child pages, persists across navigation
loading.tsx Instant loading state (wraps page in <Suspense>)
error.tsx Error boundary (must be a Client Component)
not-found.tsx Custom 404 for a segment
route.ts API endpoint (no UI)
middleware.ts Runs before every request (auth, redirects)

Dynamic routes

// app/posts/[slug]/page.tsx
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // Next.js 15: params is a Promise
  const post = await getPost(slug);
  if (!post) notFound();
  return <article>{post.title}</article>;
}

// Catch-all: app/docs/[...slug]/page.tsx
// Matches /docs/a, /docs/a/b, /docs/a/b/c
// params.slug = ['a'] | ['a','b'] | ['a','b','c']

// Optional catch-all: app/docs/[[...slug]]/page.tsx
// Also matches /docs itself

generateStaticParams

// Pre-render dynamic routes at build time
export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Layouts

Root layout (required)

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

export const metadata: Metadata = {
  title: { template: '%s | MySite', default: 'MySite' },
  description: 'My awesome site',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Nested layout

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <nav>/* sidebar */</nav>
      <main>{children}</main>
    </div>
  );
}

Server vs Client Components

Server Components (default)

No 'use client' — runs only on the server:

// app/users/page.tsx — Server Component
async function getUsers() {
  const res = await fetch('https://api.example.com/users', {
    cache: 'no-store', // always fresh
  });
  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers(); // direct async/await
  return (
    <ul>
      {users.map((u: { id: number; name: string }) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Can do: database queries, file system, secrets, async/await at component level.
Cannot do: useState, useEffect, event handlers, browser APIs.

Client Components

Add 'use client' at the top:

'use client';

import { useState } from 'react';

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

Can do: hooks, event handlers, browser APIs, useRouter, useSearchParams.
Cannot do: direct database access, async component function.

Mixing them

Pass Server Component data as props to a Client Component:

// Server Component
import { LikeButton } from './LikeButton'; // Client Component

export default async function Post({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={id} initialLikes={post.likes} />
    </article>
  );
}

Data fetching

fetch with caching

// Static — cached indefinitely (default in Next.js 14, opt-in in 15)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});

// Dynamic — never cached, always fresh
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

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

// Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});

// Invalidate from a Server Action or route handler:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');

Database / ORM access (Prisma example)

import { db } from '@/lib/db';

// app/posts/page.tsx — Server Component
export default async function PostsPage() {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });
  return <PostList posts={posts} />;
}

Parallel data fetching

export default async function Dashboard() {
  // Start both requests in parallel
  const [user, orders] = await Promise.all([
    fetchUser(),
    fetchOrders(),
  ]);
  return <DashboardView user={user} orders={orders} />;
}

Navigation

Link component

import Link from 'next/link';

// Basic
<Link href="/about">About</Link>

// Dynamic
<Link href={`/posts/${post.slug}`}>Read post</Link>

// With query params
<Link href={{ pathname: '/search', query: { q: 'next.js' } }}>
  Search
</Link>

// Replace instead of push (no back history entry)
<Link href="/login" replace>Log in</Link>

// Prefetch control (default: true on hover)
<Link href="/heavy-page" prefetch={false}>Heavy page</Link>

Programmatic navigation (Client Component)

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

export function LoginButton() {
  const router = useRouter();

  async function handleLogin() {
    await login();
    router.push('/dashboard');      // navigate
    router.replace('/dashboard');   // replace history
    router.back();                  // go back
    router.refresh();               // re-fetch current route data
  }

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

Redirect (Server)

import { redirect, notFound } from 'next/navigation';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const item = await getItem(id);

  if (!item) notFound();           // renders not-found.tsx
  if (item.archived) redirect('/archive'); // 307 by default

  return <ItemView item={item} />;
}

Server Actions

Server Actions are async functions that run on the server, called from forms or Client Components.

Form-based action

// app/posts/new/page.tsx
export default function NewPostPage() {
  async function createPost(formData: FormData) {
    'use server';
    const title = formData.get('title') as string;
    await db.post.create({ data: { title } });
    redirect('/posts');
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <button type="submit">Create</button>
    </form>
  );
}

Separate actions file

// app/actions/posts.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  if (!title) throw new Error('Title is required');

  await db.post.create({ data: { title } });
  revalidatePath('/posts');      // invalidate cached route
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

Calling a Server Action from a Client Component

'use client';
import { useState } from 'react';
import { createPost } from '@/app/actions/posts';

export function CreatePostForm() {
  const [pending, setPending] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setPending(true);
    const formData = new FormData(e.currentTarget);
    await createPost(formData);
    setPending(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" disabled={pending} />
      <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
    </form>
  );
}

useActionState (Next.js 15 / React 19)

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';

export function PostForm() {
  const [state, action, pending] = useActionState(createPost, null);

  return (
    <form action={action}>
      {state?.error && <p className="error">{state.error}</p>}
      <input name="title" />
      <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
    </form>
  );
}

API routes (Route Handlers)

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const page = Number(searchParams.get('page') ?? '1');
  const posts = await db.post.findMany({ skip: (page - 1) * 10, take: 10 });
  return NextResponse.json({ posts });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  if (!body.title) {
    return NextResponse.json({ error: 'Title required' }, { status: 400 });
  }
  const post = await db.post.create({ data: { title: body.title } });
  return NextResponse.json(post, { status: 201 });
}

// app/api/posts/[id]/route.ts
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

Loading and error states

loading.tsx — instant loading UI

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center h-screen">
      <div className="spinner" />
    </div>
  );
}
// Next.js wraps page.tsx in <Suspense fallback={<Loading />}> automatically

error.tsx — error boundary

// app/dashboard/error.tsx
'use client'; // Error components must be Client Components

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

Streaming with Suspense

import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading stats…</p>}>
        <SlowStats />     {/* fetches its own data */}
      </Suspense>
    </>
  );
}

Metadata

Static metadata

// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our team.',
  openGraph: {
    title: 'About Us',
    description: 'Learn more about our team.',
    images: ['/og-about.png'],
  },
};

Dynamic metadata

// app/posts/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.coverImage] },
  };
}

Image optimisation

import Image from 'next/image';

// Local image — Next.js auto-detects width/height
import profilePic from '@/public/profile.jpg';
<Image src={profilePic} alt="Profile" />

// Remote image — explicit width+height required
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  quality={85}
  priority        // load eagerly (above the fold)
/>

// Fill parent container
<div style={{ position: 'relative', height: 400 }}>
  <Image src="/banner.jpg" alt="Banner" fill objectFit="cover" />
</div>

Allow remote image domains in next.config.ts:

const config: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.example.com' },
    ],
  },
};

Environment variables

Variable Available on
MY_SECRET Server only (never sent to browser)
NEXT_PUBLIC_API_URL Server and client (bundled into JS)
# .env.local  (gitignored)
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com
// Server Component / Server Action / API route
const dbUrl = process.env.DATABASE_URL;      // ✓

// Client Component
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✓ (NEXT_PUBLIC_ prefix)
const secret = process.env.MY_SECRET;           // ✗ undefined on client

Middleware

// middleware.ts (project root, not inside app/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

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

  return NextResponse.next();
}

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

Common mistakes

Mistake What happens Fix
Using useState/useEffect in a Server Component Build error: hooks not supported Add 'use client' to that component
Accessing process.env.SECRET in a Client Component undefined at runtime Prefix with NEXT_PUBLIC_ or fetch it server-side
params not awaited (Next.js 15) TypeScript error / runtime warning const { id } = await params
Importing a Server Component from a Client Component Build error Move Server Component data fetching up, pass data as props
Using fetch without cache options in dynamic routes Stale data or unnecessary requests Set cache: 'no-store' for dynamic, revalidate for ISR
Forgetting 'use server' in action files Function runs on client — security leak Add 'use server' at the top of every action file
Putting secrets in NEXT_PUBLIC_ vars Secret exposed in browser bundle Use plain MY_SECRET for server-only values
revalidatePath / revalidateTag called inside a try/catch that swallows errors Cache not invalidated silently Call them after successful mutation, outside catch

FAQ

App Router vs Pages Router — which should I use?
App Router (the app/ directory, Next.js 13+) is the current default and recommended for all new projects. It enables Server Components, nested layouts, and Server Actions. Pages Router (pages/) still works and is supported, but won't receive new features.

How do I fetch data on the client?
Use a Client Component with useEffect + fetch, or a library like SWR or TanStack Query. For most reads, prefer a Server Component so the fetch happens on the server — faster, SEO-friendly, and secrets stay safe.

What's the difference between revalidatePath and revalidateTag?
revalidatePath('/posts') purges the cache for a specific URL. revalidateTag('posts') purges all fetch() calls tagged with { next: { tags: ['posts'] } }, regardless of which page they're on. Tags are more precise for shared data.

How do I share state between pages?
For UI state: React Context in a Client Component provider (put it in layout.tsx). For server-side data: re-fetch in each Server Component — it's cached automatically. For global app state: Zustand or Jotai work well, both support React 19.

When should I use a Server Action vs an API route?
Server Actions are simpler for form submissions and mutations within your Next.js app. API routes (route.ts) are better when you need an endpoint consumed by external clients, mobile apps, or third-party webhooks.

How do I handle authentication?
NextAuth.js / Auth.js is the most popular solution — it handles OAuth, credentials, and JWT/database sessions. For fine-grained control, use Middleware to protect routes, and read the session in Server Components or Server Actions via a getSession() helper.

Related tools

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