R
Rishtaara
Knowledge Hub
Technology & IT

Next.js: Zero to Full-Stack — Complete Notes

By Rishtaara Editorial Team140 min read
#Next.js#App Router#Server Components#Auth.js#SSR#Full-Stack

Full Next.js App Router syllabus — foundations, styling, layouts, data fetching, streaming, Server Actions, Auth.js, APIs, SEO, testing, and deploy with copy-paste examples.

What will I learn in this course?

Here’s everything covered in the Rishtaara Next.js: Zero to Full-Stack course — Vercel-style foundations plus routing depth, APIs, Auth.js, SEO, testing, UI practice, and deploy.

You build the mental model of a dashboard app: create the project, style it, optimize fonts and images, add layouts and navigation, connect a database, fetch and mutate data, stream loading UI, then lock routes behind authentication and ship metadata.

Real-life example: This course is like learning to drive an automatic car (Next.js) after you already know the road rules (React). Same steering ideas, but the car handles more work for you.

  • Introduction — build a full-stack web app with Next.js Foundations patterns
  • 1 Getting Started — create-next-app and explore the project
  • 2 CSS Styling — Tailwind and CSS Modules
  • 3 Optimizing Fonts and Images — next/font and next/image
  • 4 Creating Layouts and Pages — shared dashboard layout
  • 5 Navigating Between Pages — the Link component
  • 6 Setting Up Your Database — connect and seed data
  • 7 Fetching Data — Server Components and fetch strategies
  • 8 Static and Dynamic Rendering — SSG, SSR, and cache
  • 9 Streaming — Suspense and loading skeletons
  • 10 Search and Pagination — URL search params APIs
  • 11 Mutating Data — Server Actions and revalidation
  • 12 Handling Errors — error.tsx and notFound
  • 13 Improving Accessibility — server form validation
  • 14 Authentication — Auth.js, Server Actions, and middleware/proxy
  • 15 Metadata — titles, Open Graph, and generateMetadata
  • Extras — APIs, MongoDB, SEO, testing, UI widgets, interviews, deploy
Tip: Know HTML, CSS, JavaScript, and React basics first. Finish the Rishtaara React course if components and JSX still feel new.
Rishtaara Next.js course path

Course map — how lessons are grouped

Lessons are ordered like a learning journey, not a random wiki. Each block builds on the last so you always have a working mental model before the next feature.

Real-life example: Think of four floors in one building — lobby (basics), corridors (routing), kitchen (data), and security desk (auth & ship).

  • Basics (lessons 1–10) — intro, vs React, install, folders, CSS/Tailwind/MUI, fonts/images, dynamic import, Sass, TypeScript & env
  • Routing (11–18) — layouts, Link, nested & dynamic routes, redirects, middleware/proxy, i18n, file conventions
  • Data (19–28) — database, fetch, SSG/SSR, streaming, search/pagination, generateStaticParams, client fetch, mutations, errors, a11y
  • Advanced (29–42) — Auth.js, JWT, API routes, MongoDB, metadata, performance, SEO, testing, practice UI, config, interviews, deploy

What is Next.js?

Next.js is a React framework by Vercel. It adds routing, rendering strategies, and backend features so you build full apps — not only single-page UIs.

With the App Router you get Server Components by default, file-based routes, Route Handlers for APIs, and tools for SSR (server-side rendering) and SSG (static generation).

Real-life example: React alone is a kitchen counter where you cook one dish. Next.js is a full restaurant kitchen — prep stations (SSR/SSG), waiters (routing), and a small bar (API routes) in one building.

  • SSR — HTML built on each request for fresh data
  • SSG — HTML built at build time for fast static pages
  • File-based routing — folders and page.tsx become URLs
  • APIs — Route Handlers and Server Actions live in the same project
Next.js app stack

Why learn Next.js?

Companies hire for Next.js because it ships production features out of the box: image optimization, code splitting, SEO-friendly HTML, and easy deploy on Vercel.

You write one codebase for UI and server logic. That fullstack path is valuable for startups and product teams.

Real-life example: Learning Next.js is like learning both front desk and kitchen in a cafe — you can run the whole shop, not only take orders or only cook.

  • Fullstack React — pages, APIs, and server logic together
  • Built-in performance — fonts, images, bundling, caching
  • Used by many product companies and agencies
  • Vercel hosting makes deploy simple for beginners
Tip: This course uses the App Router (app/), not the older Pages Router (pages/). New projects should start with App Router.
Minimal App Router page (app/page.tsx)
export default function HomePage() {
  return (
    <main>
      <h1>Welcome to Rishtaara Next.js</h1>
      <p>Your first App Router page.</p>
    </main>
  );
}

Clear comparison — React vs Next.js

React is a UI library. Next.js is a framework built on React. You still write components — Next.js decides how they are routed, rendered, and shipped.

Real-life example: React is flour and recipes. Next.js is a bakery brand with ovens, delivery routes, and a storefront already set up.

  • React: UI components only — you add routing and build tools yourself
  • Next.js: routing, SSR/SSG, APIs, and optimizations included
  • React SPA: often one index.html; client fetches data after load
  • Next.js: server can send ready HTML for SEO and first paint
  • React: great for dashboards inside another backend
  • Next.js: great for websites, blogs, SaaS apps, and marketing pages

When to choose each

Choose React (Vite) when you need a pure client app, an embed inside another system, or you already have a separate backend API.

Choose Next.js when you want SEO, file-based routes, server rendering, or one repo for frontend plus lightweight APIs.

Real-life example: Use a bicycle (React SPA) for a short park ride. Use a delivery van (Next.js) when you must carry packages, follow a map, and arrive on a schedule.

  • Pick React + Vite: admin panels, widgets, learning React alone
  • Pick Next.js: public sites, blogs, e-commerce, fullstack starters
  • You already know React? Next.js is the next natural step on Rishtaara
Tip: Next.js is still React. Your JSX, hooks, and component skills transfer — you learn framework conventions on top.

Code — React SPA vs Next.js page

In a Vite React SPA you mount one App and use a client router. In Next.js App Router, each route folder has a page.tsx that can render on the server by default.

Real-life example: The SPA is one big notebook where you flip pages yourself. Next.js is a binder with labeled tabs — open the tab named about and that page is ready.

React SPA (Vite) — client mount
// src/main.tsx
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("root")!).render(<App />);

// src/App.tsx — you wire routes yourself (e.g. React Router)
export default function App() {
  return <h1>Hello from a React SPA</h1>;
}
Next.js App Router — app/page.tsx
// File path = URL. app/page.tsx → "/"
// Server Component by default — no createRoot needed

export default function HomePage() {
  return (
    <main>
      <h1>Hello from Next.js</h1>
      <p>This HTML can be rendered on the server.</p>
    </main>
  );
}

Create a Next.js app

The official way to start is create-next-app. It asks a few questions (TypeScript, Tailwind, App Router, src/) and scaffolds a working project.

You can pick a dashboard or starter template when offered, or start with the default blank App Router app and grow from there.

Real-life example: create-next-app is like buying a furnished studio apartment — walls, lights, and keys included. You move in and decorate instead of pouring concrete.

Tip: Choose App Router when prompted. For this Rishtaara course, TypeScript and Tailwind are recommended from day one.
Create Next.js app (interactive)
npx create-next-app@latest my-next-app
cd my-next-app
npm run dev
Non-interactive example flags
npx create-next-app@latest my-next-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

Run the dev server

npm run dev starts the local development server. Open http://localhost:3000 in your browser. Save a file and the page updates quickly.

Real-life example: The dev server is a practice stage with lights on — you rehearse scenes (pages) before opening night (production deploy).

Common npm scripts
npm run dev      # development server
npm run build    # production build
npm run start    # run the production build locally
npm run lint     # run ESLint

Explore the first project files

After create-next-app you will see package.json, next.config, the app/ folder with layout.tsx and page.tsx, and often a public/ folder for static files.

Open app/page.tsx, change the heading text, save, and watch the browser update. That is your first Next.js edit.

Real-life example: Exploring the project is like walking through a new house — find the kitchen (app/), the closet (public/), and the fuse box (config files) before cooking a big meal.

  • app/page.tsx — home route UI
  • app/layout.tsx — shared shell (html, body, fonts)
  • public/ — images and files served as-is
  • package.json — scripts and dependencies
Edit your home page
// app/page.tsx (or src/app/page.tsx)
export default function HomePage() {
  return (
    <main>
      <h1>My first Rishtaara Next.js app</h1>
      <p>Edit this file and save to see hot reload.</p>
    </main>
  );
}

Main folders you will use

A typical Next.js App Router project keeps routes in app/, static files in public/, reusable UI in components/, and helpers in lib/.

You may put app/ at the project root or under src/app/. Both work — pick one style and stay consistent.

Real-life example: Folders are labeled drawers in a desk. app/ is active paperwork, public/ is the brochure rack, components/ is spare parts, lib/ is your toolbox.

  • app/ — routes, layouts, loading UI, Route Handlers
  • public/ — favicon, images, robots.txt (URL starts at /)
  • components/ — shared React components
  • lib/ — utils, db clients, helpers
Request through App Router

page.tsx and layout.tsx conventions

In the App Router, a folder becomes a URL segment. A file named page.tsx makes that segment a public route. layout.tsx wraps pages and nested routes with shared UI.

Root layout (app/layout.tsx) must include html and body tags. Nested layouts wrap only their branch of the tree.

Real-life example: layout.tsx is the frame and header of every room in a house. page.tsx is the furniture unique to that room.

Tip: Only special file names create routes (page, layout, loading, error, route, and a few more). A random Button.tsx inside app/ is not a URL by itself.
Root layout + home page
// app/layout.tsx
import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header>Rishtaara Learn</header>
        {children}
      </body>
    </html>
  );
}

// app/page.tsx → route "/"
export default function HomePage() {
  return <h1>Home</h1>;
}

// app/about/page.tsx → route "/about"
export default function AboutPage() {
  return <h1>About</h1>;
}

The optional src/ directory

Many teams keep source code under src/ so config files stay at the root and app code stays together: src/app, src/components, src/lib.

create-next-app can enable this with --src-dir. URLs do not change — src/ is only an organization choice.

Real-life example: src/ is like keeping school notebooks in one backpack pouch while leaving the timetable and ID card in the outer pocket (root config).

Example tree with src/
my-next-app/
  package.json
  next.config.ts
  public/
    logo.png
  src/
    app/
      layout.tsx
      page.tsx
      about/
        page.tsx
    components/
      Header.tsx
    lib/
      utils.ts

Three common styling approaches

In Next.js you often combine global CSS for resets, CSS Modules for scoped component styles, and Tailwind utility classes for speed.

Import global CSS only from the root layout. CSS Modules can be imported into any component file.

Real-life example: Global CSS is house paint on all walls. CSS Modules are room-specific wallpaper. Tailwind is stickers you place exactly where you need them.

  • Global CSS — base styles, resets, font defaults
  • CSS Modules — class names scoped to one file
  • Tailwind — utility classes in JSX (className)

Global CSS example

Create app/globals.css and import it in app/layout.tsx. Those rules apply across the whole app.

Real-life example: globals.css is the school dress code — everyone follows the same basic rules before adding personal style.

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

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #f8fafc;
  color: #0f172a;
}

a {
  color: #0369a1;
}
Import in app/layout.tsx
import "./globals.css";
import type { ReactNode } from "react";

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

CSS Modules example

Name a file like Card.module.css. Import styles as an object. Next.js makes class names unique so they do not clash with other components.

Real-life example: CSS Modules are name tags at a conference — two people can both be called "title" without confusion because each badge is unique.

components/Card.module.css
.card {
  padding: 1rem;
  border-radius: 8px;
  background: white;
  border: 1px solid #e2e8f0;
}

.title {
  margin: 0 0 0.5rem;
  font-size: 1.25rem;
}
components/Card.tsx
import styles from "./Card.module.css";

export function Card() {
  return (
    <article className={styles.card}>
      <h2 className={styles.title}>Rishtaara Card</h2>
      <p>Scoped styles with CSS Modules.</p>
    </article>
  );
}

Tailwind utility example

With Tailwind configured, you style directly in className. This is fast for layouts and common UI patterns.

Real-life example: Tailwind classes are like Lego stud sizes printed on each brick — snap padding, color, and flex without opening a separate paint can.

Tip: You can mix approaches — Tailwind for layout speed, CSS Modules for complex unique components, global CSS for resets.
Tailwind in a Server Component
export default function Hero() {
  return (
    <section className="mx-auto max-w-3xl px-4 py-16">
      <h1 className="text-4xl font-bold text-slate-900">
        Learn Next.js on Rishtaara
      </h1>
      <p className="mt-4 text-lg text-slate-600">
        Build fast pages with App Router and Tailwind CSS.
      </p>
      <button className="mt-6 rounded-lg bg-sky-600 px-4 py-2 text-white">
        Start learning
      </button>
    </section>
  );
}

Tailwind CSS setup for Next.js

Modern create-next-app can enable Tailwind for you. If you start without it, install the Tailwind packages and create the config files Next expects.

After setup, add the Tailwind directives to your global stylesheet so utilities work everywhere.

Real-life example: Installing Tailwind is stocking a spice rack — once jars are labeled and on the shelf, every recipe (component) can grab salt and chili fast.

Tip: If create-next-app already added Tailwind, skip reinstalling. Just confirm globals.css has the Tailwind import and restart npm run dev.
Install Tailwind (v4-style with PostCSS)
npm install tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;
Add directives in app/globals.css
@import "tailwindcss";

/* Your custom base styles below */
body {
  margin: 0;
}

Verify Tailwind works

Add a utility class on the home page. If the style appears, Tailwind is connected. If not, check the CSS import path and restart the dev server.

Real-life example: Verification is tasting the soup after adding salt — one spoon tells you if the kitchen setup worked.

Quick Tailwind smoke test
export default function Page() {
  return (
    <h1 className="text-3xl font-bold underline text-sky-700">
      Tailwind is working
    </h1>
  );
}

Optional — Material UI (MUI)

Material UI is a React component library with buttons, dialogs, and themes. It is optional — many Next.js apps use Tailwind alone.

MUI components need a Client Component boundary because they use browser APIs and theme context. Install the packages, then wrap interactive UI as needed.

Real-life example: MUI is buying ready-made furniture for one room. Tailwind is painting and arranging yourself. You can use both carefully, but start with one primary system.

Tip: Prefer one main styling system for beginners. Learn Tailwind deeply first; add MUI later if your team already uses it.
Optional MUI install
npm install @mui/material @emotion/react @emotion/styled
Client component using MUI Button
"use client";

import Button from "@mui/material/Button";

export function MuiDemo() {
  return (
    <Button variant="contained" color="primary">
      Rishtaara MUI Button
    </Button>
  );
}

Optimize fonts with next/font

next/font loads fonts in a way that reduces layout shift and avoids extra network round-trips for common Google fonts.

You import a font in the root layout, apply its className to body or a wrapper, and Next hosts the files efficiently.

Real-life example: next/font is like storing school uniforms in the building locker — students dress on site instead of waiting for a delivery truck every morning.

Google font in app/layout.tsx
import { Inter } from "next/font/google";
import type { ReactNode } from "react";
import "./globals.css";

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

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

Custom local fonts

For brand fonts, place files under a folder like app/fonts/ or src/app/fonts/ and use localFont from next/font/local.

Real-life example: A local font is your family recipe book on the shelf — you already own it, so you do not order it from a store each time guests arrive.

Local font with next/font/local
import localFont from "next/font/local";

const brand = localFont({
  src: "./fonts/BrandSans.woff2",
  variable: "--font-brand",
  display: "swap",
});

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

Optimize images with next/image

Use the Image component from next/image instead of a plain img tag when you can. It helps with resizing, lazy loading, and modern formats.

Images in public/ are referenced by path string. Images imported from the file system get width and height automatically.

Real-life example: next/image is a smart photo lab — it prints the right size print for a phone screen or a billboard instead of handing everyone the same huge poster.

Tip: Always set meaningful alt text. For above-the-fold LCP images, consider priority so the browser loads them sooner.
Image from public/ vs imported file
import Image from "next/image";
import heroPic from "@/assets/hero.jpg";

export default function Gallery() {
  return (
    <section>
      {/* From public/logo.png → URL "/logo.png" */}
      <Image
        src="/logo.png"
        alt="Rishtaara logo"
        width={120}
        height={40}
        priority
      />

      {/* Imported image — dimensions inferred */}
      <Image src={heroPic} alt="Course hero" placeholder="blur" />
    </section>
  );
}

Rendering modes reminder

Fonts and images work across static and dynamic pages. Next.js can still pre-render shells and stream the rest for faster first paint.

Real-life example: Optimized assets are pre-chopped vegetables — whether you cook for one guest (SSR) or meal-prep for the week (SSG), prep work saves time.

Rendering strategies in Next.js

Dynamic import with next/dynamic

next/dynamic lets you load a component only when needed. That shrinks the first JavaScript bundle for heavy charts, editors, or maps.

Dynamic imports are useful for Client Components that are large or only shown after a click.

Real-life example: Dynamic import is packing a suitcase for a day trip — leave the winter coat at home and pick it up only if the weather turns cold.

Tip: Do not dynamically import everything. Use it for heavy or rarely shown UI. Keep small Server Components normal imports.
Lazy-load a heavy client chart
import dynamic from "next/dynamic";

const HeavyChart = dynamic(() => import("@/components/HeavyChart"), {
  loading: () => <p>Loading chart...</p>,
  ssr: false,
});

export default function AnalyticsPage() {
  return (
    <main>
      <h1>Course analytics</h1>
      <HeavyChart />
    </main>
  );
}

Lazy loading components

You can also use React.lazy in Client Components, but next/dynamic is the Next.js-friendly helper with loading UI and ssr options.

Real-life example: Lazy loading is a streaming playlist — the next song downloads when you need it, not the entire album at once when you open the app.

Conditional dynamic component
"use client";

import { useState } from "react";
import dynamic from "next/dynamic";

const MarkdownEditor = dynamic(() => import("@/components/MarkdownEditor"), {
  loading: () => <p>Opening editor...</p>,
});

export function NotesPanel() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setOpen(true)}>Write notes</button>
      {open ? <MarkdownEditor /> : null}
    </div>
  );
}

Static files from public/

Anything in public/ is served from the site root. public/avatar.png is available at /avatar.png. Do not import these through the JS bundler unless you need hashed imports.

Use public/ for favicons, downloadable PDFs, robots.txt, and simple static images referenced by string path.

Real-life example: public/ is the open notice board in a school hallway — anyone who knows the path can read the poster without asking the teacher (bundler) to fetch it.

  • Reference as src="/file.png" — leading slash matters
  • Nested folders work: public/icons/home.svg → /icons/home.svg
  • Files are copied as-is — no automatic image optimization unless you use next/image
Linking a static PDF and icon
export default function Resources() {
  return (
    <ul>
      <li>
        <a href="/docs/syllabus.pdf" download>
          Download syllabus
        </a>
      </li>
      <li>
        <img src="/icons/book.svg" alt="" width={24} height={24} />
        Course icon from public/
      </li>
    </ul>
  );
}

Using Sass in Next.js

Next.js supports Sass out of the box after you install the sass package. You can use .scss files globally or as CSS Modules (.module.scss).

Sass gives nested rules, variables, and mixins — helpful when you already know SCSS from older projects.

Real-life example: Sass is a blender for CSS — you put simple ingredients (variables, nesting) in and pour out richer styles without rewriting everything by hand.

Install Sass
npm install sass
components/Panel.module.scss
$border: #e2e8f0;

.panel {
  padding: 1rem;
  border: 1px solid $border;

  .heading {
    margin: 0;
    font-size: 1.125rem;
  }
}
components/Panel.tsx
import styles from "./Panel.module.scss";

export function Panel() {
  return (
    <section className={styles.panel}>
      <h2 className={styles.heading}>Sass module panel</h2>
    </section>
  );
}

CSS-in-JS note for App Router

Many CSS-in-JS libraries need runtime access to the browser. In the App Router, put those styles inside Client Components ("use client") or follow each library's Next.js 15 guide.

Server Components prefer CSS Modules, Tailwind, or Sass without a client-only runtime.

Real-life example: CSS-in-JS that needs the browser is like a lamp that only works with electricity — use it in rooms with power (Client Components), not in a solar-only shed (pure Server Components) unless the library supports it.

Tip: For new Rishtaara projects, Tailwind + CSS Modules cover most needs without CSS-in-JS complexity.
Client component pattern for runtime CSS-in-JS
"use client";

// Example shape — follow your library docs for Next.js 15+
export function StyledBadge({ label }: { label: string }) {
  return (
    <span
      style={{
        display: "inline-block",
        padding: "0.25rem 0.5rem",
        background: "#e0f2fe",
        borderRadius: 6,
      }}
    >
      {label}
    </span>
  );
}

CSS Modules specificity tip

CSS Module classes are unique, but a global CSS rule with higher specificity or !important can still override them. Keep global CSS minimal.

When two module classes fight, prefer composing classes in the component or nesting in Sass/Modules instead of stacking !important.

Real-life example: Specificity is a queue at a ticket window — a VIP pass (!important or a more specific selector) cuts the line. Do not hand out VIP passes unless you must.

  • Prefer one clear class per element from a module
  • Avoid styling tags globally (like button { }) if modules style buttons too
  • Use Tailwind utilities or modules — mixing both on one element is fine if intentional
  • Debug with browser DevTools — see which rule wins and from which file
Compose module classes instead of !important
import styles from "./Alert.module.css";

type Props = { variant?: "info" | "danger" };

export function Alert({ variant = "info" }: Props) {
  const className =
    variant === "danger"
      ? styles.alert + " " + styles.danger
      : styles.alert + " " + styles.info;

  return <div className={className}>Check your Next.js styles</div>;
}

TypeScript setup in Next.js

create-next-app can enable TypeScript automatically. Next adds a tsconfig.json and lets you use .ts and .tsx files right away.

If you started in JavaScript, add a tsconfig.json and rename files gradually — Next guides you when types are missing.

Real-life example: TypeScript is spell-check for your code — it underlines mistakes before you publish the essay (deploy).

Tip: Keep tsconfig paths like "@/*" aligned with your folder root (./src/* if you use src/).
Typed page props example
type CourseCardProps = {
  title: string;
  lessons: number;
};

export function CourseCard({ title, lessons }: CourseCardProps) {
  return (
    <article>
      <h2>{title}</h2>
      <p>{lessons} lessons on Rishtaara</p>
    </article>
  );
}

export default function Page() {
  return <CourseCard title="Next.js Basics" lessons={10} />;
}

Environment variables — .env.local and NEXT_PUBLIC_

Put secrets and config in .env.local (do not commit this file). Restart the dev server after changing env files.

Variables available in the browser must be prefixed with NEXT_PUBLIC_. Server-only variables omit that prefix and stay on the server.

Real-life example: .env.local is a locked diary in your desk. NEXT_PUBLIC_ notes are posters on the classroom wall — anyone in the browser can read them.

.env.local examples
# Server-only — never exposed to the browser
DATABASE_URL=postgres://user:pass@localhost:5432/rishtaara
API_SECRET=super-secret-value

# Exposed to client bundles — only put non-secret config here
NEXT_PUBLIC_APP_NAME=Rishtaara
NEXT_PUBLIC_API_BASE=https://api.example.com
Reading env vars in code
// Server Component or Route Handler — OK for secrets
const dbUrl = process.env.DATABASE_URL;

// Client or shared code — only NEXT_PUBLIC_ values
const appName = process.env.NEXT_PUBLIC_APP_NAME;

export default function EnvDemo() {
  return <p>App: {process.env.NEXT_PUBLIC_APP_NAME}</p>;
}

Security of environment variables

Anything with NEXT_PUBLIC_ is visible in the client JavaScript bundle. Never put API keys, database passwords, or private tokens there.

Use server-only env vars in Server Components, Server Actions, and Route Handlers. Add .env* secrets to .gitignore.

Real-life example: Putting a secret in NEXT_PUBLIC_ is writing your ATM PIN on a sticker on your laptop lid — everyone who opens the lid can see it.

  • Commit .env.example with empty keys — not real values
  • Keep .env.local out of git
  • Rotate keys if you ever commit a secret by mistake
  • Remember: modern Next.js may use proxy.ts as the evolved middleware entry — keep auth checks and secrets on the server side
Auth-related request path (preview)

Using the src/ directory

The src/ directory keeps application code together. Move app/, components/, and lib/ under src/ and update imports if needed — public/ and config files usually stay at the project root.

create-next-app --src-dir does this for you. Either layout is valid; teams pick one for consistency.

Real-life example: src/ is one labeled suitcase for clothes. Root-level config files are your passport and tickets kept in a separate pouch for quick checks at the airport.

Tip: You finished Next.js basics on Rishtaara. Next up: deeper App Router data fetching, Server Actions, and auth patterns.
tsconfig paths with src/
// tsconfig.json (paths section)
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

// Then import like:
// import { CourseCard } from "@/components/CourseCard";

Shared layout — one shell for many pages

In the App Router, folders under app define your URLs. A page.tsx file makes that folder a route. A layout.tsx wraps every page in that folder (and nested folders) with shared UI — nav, footer, sidebar.

The root layout lives at app/layout.tsx. It must include <html> and <body>. Child layouts nest inside it like Russian dolls.

Real-life example: A layout is the building lobby and elevators. Pages are different offices on each floor. You reuse the lobby; you do not rebuild it for every room.

  • app/layout.tsx — root shell (required)
  • app/page.tsx — home route /
  • Nested layout.tsx only wraps that segment
Tip: Put logos, nav, and fonts in the layout. Put page-specific content in page.tsx so navigation feels instant — the shell stays mounted.
Request → layout → page

layout.tsx + page.tsx — first routes

Every route segment can have its own layout and page. Children of a layout arrive as the children prop — that is where Next.js inserts the active page (or nested layout).

Real-life example: children is the stage area in a theater. The curtains and seats (layout) stay; the play (page) changes.

app/layout.tsx — root layout
import type { ReactNode } from "react";
import "./globals.css";

export const metadata = {
  title: "Rishtaara Learn",
  description: "Next.js App Router course",
};

export default function RootLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>
          <strong>Rishtaara</strong>
          <nav>Home · Courses · Blog</nav>
        </header>
        <main>{children}</main>
        <footer>© Rishtaara</footer>
      </body>
    </html>
  );
}
app/page.tsx — home page
export default function HomePage() {
  return (
    <section>
      <h1>Welcome to Rishtaara</h1>
      <p>Learn Next.js with simple English and real projects.</p>
    </section>
  );
}

Nested layouts — dashboard example

Dashboard routes often share a sidebar. Put layout.tsx inside app/dashboard so every dashboard page gets the sidebar automatically.

URLs map to folders: app/dashboard/page.tsx → /dashboard, app/dashboard/settings/page.tsx → /dashboard/settings.

Real-life example: Nested layout is a hospital wing — the wing has its own corridor signs (sidebar), while the whole hospital still shares the main entrance (root layout).

Tip: Layouts do not remount on navigation between their child pages. That is why dashboards feel snappy — only the page content swaps.
app/dashboard/layout.tsx
import type { ReactNode } from "react";
import Link from "next/link";

export default function DashboardLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <div style={{ display: "flex", gap: "1.5rem" }}>
      <aside>
        <h2>Dashboard</h2>
        <ul>
          <li>
            <Link href="/dashboard">Overview</Link>
          </li>
          <li>
            <Link href="/dashboard/settings">Settings</Link>
          </li>
          <li>
            <Link href="/dashboard/courses">Courses</Link>
          </li>
        </ul>
      </aside>
      <section>{children}</section>
    </div>
  );
}
app/dashboard/page.tsx + settings/page.tsx
// app/dashboard/page.tsx
export default function DashboardHome() {
  return <h1>Overview — your Rishtaara progress</h1>;
}

// app/dashboard/settings/page.tsx
export default function SettingsPage() {
  return <h1>Account settings</h1>;
}
Folder tree
app/
  layout.tsx          # root: html, body, site nav
  page.tsx            # /
  dashboard/
    layout.tsx        # sidebar for all /dashboard/*
    page.tsx          # /dashboard
    settings/
      page.tsx        # /dashboard/settings
    courses/
      page.tsx        # /dashboard/courses

next/link — client navigation

Use Link from next/link instead of a plain <a> for internal routes. Link does client-side navigation — no full page reload — and can prefetch the next page in the background.

For external URLs (https://...), a normal <a> is fine. For your own app paths, prefer Link.

Real-life example: Link is taking the metro between stations. A full <a> reload is getting off, walking home, and catching another train from scratch.

Tip: href can be a string or an object: href={{ pathname: '/courses', query: { level: 'beginner' } }} — though searchParams in App Router are often read on the page with useSearchParams.
Nav with Link
import Link from "next/link";

export default function SiteNav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/courses">Courses</Link>
      <Link href="/courses/nextjs">Next.js course</Link>
      <Link href="/dashboard/settings">Settings</Link>
      {/* External — use <a> */}
      <a href="https://rishtaara.com" target="_blank" rel="noreferrer">
        Rishtaara
      </a>
    </nav>
  );
}

useRouter — navigate from Client Components

When a button or form should change routes in code (after login, after save), use useRouter from next/navigation inside a Client Component ("use client").

router.push goes forward (adds history). router.replace swaps the current entry (good after login so Back does not return to the login form). router.back() goes back.

Real-life example: router.push is opening a new browser tab of history. router.replace is editing the current sticky note so the old page is gone from the stack.

  • Import from next/navigation (App Router), not next/router (Pages Router)
  • Only in Client Components — or call redirect() on the server instead
  • router.refresh() revalidates Server Component data without changing the URL
Client navigation after action
"use client";

import { useRouter } from "next/navigation";

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

  async function handleLogin() {
    // pretend auth succeeded
    await fetch("/api/login", { method: "POST" });
    router.replace("/dashboard");
  }

  return (
    <button type="button" onClick={handleLogin}>
      Log in to Rishtaara
    </button>
  );
}

Prefetching — faster next clicks

In production, Link prefetches linked routes when they enter the viewport (when possible). The user clicks and the page often feels instant because the RSC payload was already fetched.

You can disable prefetch with prefetch={false} for rare or heavy pages. In development, prefetch behavior can differ — trust production for real feel.

Real-life example: Prefetch is a waiter bringing the dessert menu before you ask — when you say yes, the order is already half ready.

Tip: Do not put dozens of prefetch Links to huge authenticated pages on every public page. Prefetch public course lists freely; be careful with private dashboards.
Control prefetch
import Link from "next/link";

export default function CourseLinks() {
  return (
    <>
      {/* Default: prefetch when visible (production) */}
      <Link href="/courses/nextjs">Next.js</Link>

      {/* Skip prefetch for a heavy admin page */}
      <Link href="/admin/reports" prefetch={false}>
        Admin reports
      </Link>
    </>
  );
}

Folder nesting = URL nesting

Each folder is one URL segment. Nest folders to nest routes. Only folders that contain page.tsx (or route.js) become navigable URLs.

Shared UI for a branch of the tree goes in that branch's layout.tsx — you already saw this with /dashboard.

Real-life example: Folders are address lines — Country / City / Street. Nested routes are the same idea for URLs.

Nested course routes
app/
  courses/
    page.tsx                 # /courses
    layout.tsx               # shared courses chrome
    nextjs/
      page.tsx               # /courses/nextjs
      routing/
        page.tsx             # /courses/nextjs/routing

Route groups — folders that skip the URL

Wrap a folder name in parentheses: (marketing), (app), (auth). The name organizes files and can hold a layout, but it does not appear in the URL.

Use route groups when marketing pages and app pages need different layouts but both want clean URLs like /pricing and /dashboard.

Real-life example: A route group is a labeled drawer in a filing cabinet. The label helps you organize; the customer never sees the drawer name on the invoice.

Tip: Route groups are for organization and multiple root-level layouts. They are not the same as parallel routes (@slot) — those come later with default.js.
Route groups for two layouts
app/
  (marketing)/
    layout.tsx       # big landing header
    page.tsx         # /   (not /(marketing))
    pricing/
      page.tsx       # /pricing
  (app)/
    layout.tsx       # logged-in shell
    dashboard/
      page.tsx       # /dashboard
Different layouts, same URL style
// app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="marketing">
      <header>Rishtaara — Learn in public</header>
      {children}
    </div>
  );
}

// app/(app)/layout.tsx
export default function AppShellLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="app-shell">
      <aside>App nav</aside>
      {children}
    </div>
  );
}

Shared UI between pages

Three common patterns: (1) layout.tsx for chrome that stays across navigations, (2) shared components imported into several pages, (3) route-group layout for a family of pages that need the same shell.

Prefer layout for nav/sidebar. Prefer components for cards, buttons, and forms you reuse everywhere.

Real-life example: Layout is the store building. Shared components are reusable shelves and price tags you place in any aisle.

  • layout.tsx — persistent shell for a segment
  • components/ — reusable UI pieces
  • (group)/layout.tsx — shell without changing the URL

Dynamic segment — [id]

A folder named [id] matches one segment: /courses/101 → params.id is "101". Use this for blog posts, products, and user profiles.

In App Router, page props receive params (often as a Promise in newer Next.js versions — await params when your version requires it).

Real-life example: [id] is a hotel room number. The hallway shape is the same; the room number changes which door you open.

Tip: Name the bracket after what it means — [slug], [userId], [courseId] — so params are easy to read.
app/courses/[id]/page.tsx
type Props = {
  params: Promise<{ id: string }>;
};

export default async function CoursePage({ params }: Props) {
  const { id } = await params;

  // Example: fetch course by id
  // const course = await getCourse(id);

  return (
    <article>
      <h1>Course #{id}</h1>
      <p>Dynamic route for one Rishtaara course.</p>
    </article>
  );
}
Folder + Link
app/courses/[id]/page.tsx   →  /courses/abc
                             →  /courses/nextjs-basics

# Link example:
# <Link href={`/courses/${course.id}`}>{course.title}</Link>

Catch-all — [...slug]

[...slug] captures one or more segments as an array. /docs/a/b/c → slug = ["a", "b", "c"]. It does not match the parent path alone unless you also add a page there.

Great for docs trees, nested categories, and file-path style URLs.

Real-life example: Catch-all is a suitcase that holds every remaining layer of clothing — one bag for the rest of the trip itinerary.

app/docs/[...slug]/page.tsx
type Props = {
  params: Promise<{ slug: string[] }>;
};

export default async function DocsPage({ params }: Props) {
  const { slug } = await params;
  const path = slug.join("/");

  return (
    <article>
      <h1>Docs: /{path}</h1>
      <ol>
        {slug.map((part) => (
          <li key={part}>{part}</li>
        ))}
      </ol>
    </article>
  );
}

// Matches:
// /docs/getting-started
// /docs/nextjs/routing/dynamic
// Does NOT match /docs alone (add app/docs/page.tsx for that)

Optional catch-all — [[...slug]]

Double brackets [[...slug]] also match the route with zero segments. So app/shop/[[...slug]]/page.tsx can serve /shop and /shop/shoes/red.

When there are no segments, slug may be undefined — always guard for that.

Real-life example: Optional catch-all is a bus that stops at the depot with no passengers and also stops at every town along the route.

Tip: Prefer a simple [id] when you only need one value. Use catch-all only when the depth of the path is unknown.
app/shop/[[...slug]]/page.tsx
type Props = {
  params: Promise<{ slug?: string[] }>;
};

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

  if (!slug || slug.length === 0) {
    return <h1>All products — Rishtaara Shop</h1>;
  }

  return (
    <h1>
      Category: {slug.join(" / ")}
    </h1>
  );
}

// Matches:
// /shop
// /shop/books
// /shop/books/programming
Compare the three
[id]         → exactly one segment
[...slug]    → one or more segments (required)
[[...slug]]  → zero, one, or more segments (optional)

redirect() and permanentRedirect()

In Server Components, Server Actions, and Route Handlers, call redirect("/login") from next/navigation to send the user elsewhere (307 temporary by default in many cases).

permanentRedirect("/new-path") signals a permanent move (308) — good when a URL has moved forever (SEO-friendly).

Real-life example: redirect is a temporary "floor closed, use stairs" sign. permanentRedirect is changing the building address on Google Maps for good.

Tip: redirect() throws a special Next.js control flow — do not wrap it in try/catch expecting a normal error. Put it in the branch where you want to leave.
Server redirect after auth check
import { redirect, permanentRedirect } from "next/navigation";

export default async function BillingPage() {
  const session = await getSession(); // your auth helper

  if (!session) {
    redirect("/login"); // temporary — come back after login
  }

  if (session.plan === "legacy") {
    permanentRedirect("/billing/v2"); // old URL retired
  }

  return <h1>Billing for {session.email}</h1>;
}

usePathname, useParams, useSearchParams

In Client Components, read the current route with hooks from next/navigation. usePathname() returns the path string. useParams() returns dynamic segments. useSearchParams() reads ?query= values.

Wrap components that use useSearchParams in a <Suspense> boundary when Next.js asks — it can opt the page into client rendering of that part.

Real-life example: These hooks are looking at your phone's GPS and address bar — where you are, which room number, and what filters you typed.

Client route awareness
"use client";

import Link from "next/link";
import {
  usePathname,
  useParams,
  useSearchParams,
} from "next/navigation";

export default function CourseToolbar() {
  const pathname = usePathname();
  const params = useParams<{ id: string }>();
  const searchParams = useSearchParams();
  const tab = searchParams.get("tab") ?? "overview";

  return (
    <div>
      <p>Path: {pathname}</p>
      <p>Course id: {params.id}</p>
      <p>Tab: {tab}</p>
      <Link
        href={`/courses/${params.id}?tab=lessons`}
        style={{ fontWeight: tab === "lessons" ? "bold" : "normal" }}
      >
        Lessons
      </Link>
    </div>
  );
}

Redirects in next.config

For static URL moves (old marketing links, renamed paths), configure redirects in next.config — they run before React renders.

Use this for SEO migrations and short links. Use redirect() in code when the decision depends on session, role, or database state.

Real-life example: next.config redirects are printed street signs at the city edge. Code redirects are a receptionist checking your ID before sending you upstairs.

next.config.ts redirects
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async redirects() {
    return [
      {
        source: "/old-blog/:slug",
        destination: "/blog/:slug",
        permanent: true,
      },
      {
        source: "/go/nextjs",
        destination: "/courses/nextjs",
        permanent: false,
      },
    ];
  },
};

export default nextConfig;

middleware.ts and the move toward proxy

For years, Next.js used a root middleware.ts (or src/middleware.ts) to run code before a request finishes routing — auth checks, redirects, rewrites, headers, A/B cookies.

Next.js is evolving the naming and mental model toward proxy (proxy.ts) — same idea: intercept the request at the edge of your app, decide, then continue. Docs and versions may show middleware, proxy, or both. Learn the concept once; follow your installed Next.js docs for the exact filename.

Real-life example: Middleware / proxy is the security desk at a company gate. Every visitor passes the desk before entering an office (page).

  • Runs before layouts and pages for matched paths
  • Great for auth gates, geo redirects, feature flags
  • Keep it fast — no heavy database work if you can avoid it
  • Check your Next.js version: middleware.ts vs proxy.ts naming
Tip: Treat "middleware" and "proxy" as the same gatekeeper pattern. When you upgrade Next.js, rename/migrate only when the official guide says to.

NextRequest and NextResponse

The handler receives a NextRequest (URL, cookies, headers, geo hints). You return a NextResponse — next() to continue, redirect() to send elsewhere, or rewrite() to show another path while keeping the URL.

Real-life example: NextRequest is the visitor badge. NextResponse is the desk officer saying "go in", "go to reception", or "use the side entrance but keep your appointment card".

middleware.ts — protect /dashboard
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get("session")?.value;

  if (pathname.startsWith("/dashboard") && !token) {
    const login = new URL("/login", request.url);
    login.searchParams.set("from", pathname);
    return NextResponse.redirect(login);
  }

  // Continue to the page
  const response = NextResponse.next();
  response.headers.set("x-rishtaara-path", pathname);
  return response;
}

// Only run on matching paths (keeps middleware lean)
export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*"],
};
Rewrite example (same URL, different page)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const country = request.headers.get("x-vercel-ip-country") ?? "US";

  if (request.nextUrl.pathname === "/" && country === "IN") {
    // Browser still shows "/" but serves /in content
    return NextResponse.rewrite(new URL("/in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/"],
};

Protecting routes — redirect vs rewrite

Redirect changes the browser URL — user sees /login. Rewrite keeps the URL but serves another route — useful for localization or maintenance pages without exposing internal paths.

For auth, prefer redirect to /login so the address bar matches reality. Store the original path in a query (?from=) so you can send them back after login.

Real-life example: Redirect is telling someone "go to the ticket counter" and walking them there. Rewrite is seating them in hall B while their ticket still says hall A.

Tip: Cookies set in middleware must follow your host's secure/sameSite rules. Test auth redirects in both local and production-like HTTPS.
Matcher tips
# Typical matcher ideas:
# /dashboard/:path*   — dashboard and nested
# /((?!_next|api|favicon.ico).*)  — exclude static/api (advanced)

# Keep matcher narrow. Running on every image request wastes edge time.

Locale as a URL segment

A simple i18n pattern puts the language first: /en/courses, /hi/courses, /es/courses. The locale folder is a dynamic segment [locale] (or fixed folders en, hi).

Root redirects can send / to /en or detect Accept-Language in middleware/proxy and rewrite or redirect.

Real-life example: Locale routing is airport gates labeled EN, HI, ES — same flight (page), different language channel.

Folder structure with [locale]
app/
  [locale]/
    layout.tsx       # lang attribute, locale nav
    page.tsx         # /en  /hi
    courses/
      page.tsx       # /en/courses  /hi/courses

Basic i18n routing pattern

Validate the locale. If someone visits /xx/courses with an unknown code, redirect to a default language. Pass locale into dictionaries or next-intl / similar libraries later — start with folders and a simple messages map.

Real-life example: Validating locale is checking the passport language stamp before handing out the correct guidebook.

Tip: For production apps, consider a library (next-intl, next-i18next patterns adapted to App Router). The folder + middleware pattern above is the foundation those libraries build on.
app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import type { ReactNode } from "react";

const locales = ["en", "hi", "es"] as const;

type Props = {
  children: ReactNode;
  params: Promise<{ locale: string }>;
};

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;

  if (!locales.includes(locale as (typeof locales)[number])) {
    notFound();
  }

  return (
    <div lang={locale}>
      <header>Rishtaara · {locale.toUpperCase()}</header>
      {children}
    </div>
  );
}
Middleware — prefix default locale
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const locales = ["en", "hi", "es"];
const defaultLocale = "en";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = locales.some(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );

  if (hasLocale) return NextResponse.next();

  // /courses → /en/courses
  const url = request.nextUrl.clone();
  url.pathname = `/${defaultLocale}${pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  matcher: ["/((?!_next|api|favicon.ico).*)"],
};
Tiny dictionary helper
const messages = {
  en: { hello: "Hello learner" },
  hi: { hello: "नमस्ते learner" },
  es: { hello: "Hola learner" },
} as const;

export function t(locale: keyof typeof messages, key: "hello") {
  return messages[locale][key];
}

loading, not-found, and error files

Special files next to page.tsx add UX without wiring everything by hand. loading.tsx shows an instant fallback while Server Components load (Suspense boundary). not-found.tsx renders when notFound() is called or no route matches. error.tsx catches errors in that segment (must be a Client Component).

Real-life example: loading is the "please wait" screen at a ticket window. not-found is "counter closed". error is "system down — try again" with a reset button.

Tip: error.tsx must start with "use client" because it uses hooks and interactive reset. loading.tsx and not-found.tsx can be Server Components.
app/courses/loading.tsx
export default function Loading() {
  return <p>Loading Rishtaara courses…</p>;
}
app/courses/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div>
      <h1>Course not found</h1>
      <Link href="/courses">Back to courses</Link>
    </div>
  );
}
app/courses/error.tsx
"use client";

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

template.tsx vs layout.tsx

layout.tsx persists across navigations inside its segment — state and DOM can be reused. template.tsx creates a new instance for each navigation — useful for enter animations or resetting client state per page visit.

Most apps need layout. Reach for template when you explicitly want a fresh mount every time.

Real-life example: Layout is your desk that stays messy between meetings. Template is a cleaned whiteboard brought in for every new meeting.

app/dashboard/template.tsx
import type { ReactNode } from "react";

export default function DashboardTemplate({
  children,
}: {
  children: ReactNode;
}) {
  // Remounts on each dashboard page navigation
  return <div className="page-enter">{children}</div>;
}

default.js and route.js — previews

Parallel routes use named slots like @analytics beside page.tsx. default.tsx (or default.js) is the fallback when Next.js cannot recover a slot on soft navigation — you will use it when you build multi-slot dashboards.

route.ts (or route.js) defines a Route Handler — an API endpoint at that path (GET, POST, …). Full API patterns come in a later lesson; for now know it lives next to pages as a file convention.

Real-life example: default.js is a spare tire for a dashboard panel. route.js is a service window that speaks JSON instead of HTML.

Parallel routes sketch + default
app/dashboard/
  layout.tsx
  page.tsx
  @analytics/
    page.tsx
    default.tsx    # fallback for @analytics slot
  @team/
    page.tsx
    default.tsx
app/api/hello/route.ts — API preview
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({
    message: "Hello from Rishtaara API",
  });
}

// POST, PUT, DELETE work the same way — export named functions.
// Details in the Route Handlers lesson later.

Metadata files — brief tour

App Router also supports special metadata files: favicon.ico, icon.png, apple-icon.png, opengraph-image.tsx, twitter-image.tsx, robots.txt, sitemap.ts. Place them in app/ (or a route segment) and Next.js wires them into <head> or well-known URLs.

You can also export a metadata object or generateMetadata function from layout.tsx / page.tsx for titles and descriptions — you already saw a small example in the root layout lesson.

Real-life example: Metadata files are the shop sign, logo sticker, and visiting card — people see them before they read the menu (page body).

  • favicon.ico / icon — browser tab icon
  • opengraph-image — social share preview
  • sitemap.ts / robots.txt — SEO crawlers
  • metadata export — title, description, Open Graph fields
Tip: Master layout, page, loading, and error first. Add template, parallel default, route handlers, and metadata files as your app grows — conventions stay next to the route they affect.

Your app needs a place to store data

So far your Next.js pages can render UI. Real apps also need a database — users, invoices, products. Think of the database as a filing cabinet in the office basement, and your Server Components as staff who walk downstairs, pull a folder, and bring it back to the front desk.

In the App Router you almost always talk to the database from the server (Server Components, Server Actions, or Route Handlers) — never from the browser with your secret connection string.

Never put DATABASE_URL or API secrets in client components. Env vars without NEXT_PUBLIC_ stay on the server only.

Postgres or SQLite — pick what fits

Postgres is the usual choice for production (Vercel Postgres, Neon, Supabase, Railway). SQLite (often via Prisma + file DB or Turso) is great for local learning and small projects.

Real-life example: Postgres is a shared warehouse for a growing shop. SQLite is a single notebook on your desk — perfect for practice, limited when many cashiers write at once.

  • Postgres — cloud-friendly, great for multi-user apps
  • SQLite — zero setup locally, one file
  • Prisma, Drizzle, or raw SQL drivers all work with App Router
.env.local — connection string
# Never commit this file
DATABASE_URL="postgresql://user:password@host:5432/mydb?sslmode=require"

# Local SQLite example (Prisma)
# DATABASE_URL="file:./dev.db"
Query with @vercel/postgres
import { sql } from "@vercel/postgres";

export async function getInvoices() {
  const { rows } = await sql`
    SELECT id, customer_name, amount, status
    FROM invoices
    ORDER BY created_at DESC
  `;
  return rows;
}
Prisma-style client (same idea)
// lib/db.ts
import { PrismaClient } from "@prisma/client";

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

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

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

// Usage in a Server Component
// const invoices = await prisma.invoice.findMany();

Seed scripts — fill the cabinet before opening day

A seed script inserts starter rows so your dashboard is not empty on first run. You run it once (or when resetting local data): npm run seed.

Real-life example: Seeding is stocking shelves before a shop opens — empty shelves make demos boring and tests flaky.

Add a package.json script like "seed": "tsx scripts/seed.ts" and run it after migrations. Keep seed data boring and safe — no real customer emails.
scripts/seed.ts (simplified)
import { sql } from "@vercel/postgres";

async function seed() {
  await sql`
    CREATE TABLE IF NOT EXISTS invoices (
      id SERIAL PRIMARY KEY,
      customer_name TEXT NOT NULL,
      amount INT NOT NULL,
      status TEXT NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `;

  await sql`
    INSERT INTO invoices (customer_name, amount, status)
    VALUES
      ('Asha Traders', 2500, 'paid'),
      ('Ravi Motors', 1800, 'pending'),
      ('Knowvora Labs', 4200, 'paid')
    ON CONFLICT DO NOTHING
  `;

  console.log("Seed complete");
}

seed().catch((err) => {
  console.error(err);
  process.exit(1);
});

async Server Components — fetch where the HTML is built

In the App Router, page.tsx and most components are Server Components by default. They can be async: await a database or fetch, then return JSX. The browser never sees your query — only the finished HTML (plus the RSC payload).

Real-life example: A Server Component is a chef who cooks in the kitchen and brings a plated dish to the table. Guests do not walk into the kitchen and grab raw ingredients.

app/dashboard/page.tsx
import { sql } from "@vercel/postgres";

async function getDashboardStats() {
  const { rows } = await sql`
    SELECT
      COUNT(*)::int AS total,
      COALESCE(SUM(amount), 0)::int AS revenue
    FROM invoices
    WHERE status = 'paid'
  `;
  return rows[0];
}

export default async function DashboardPage() {
  const stats = await getDashboardStats();

  return (
    <main>
      <h1>Dashboard</h1>
      <p>Paid invoices: {stats.total}</p>
      <p>Revenue: ₹{stats.revenue}</p>
    </main>
  );
}

fetch in Next.js — caching notes

Next.js extends fetch. By default (depending on version and route settings), results can be cached and reused across requests. That is great for a blog, wrong for a live bank balance.

You control the behavior with options and route segment config.

  • fetch(url, { cache: 'force-cache' }) — prefer cached (static-friendly)
  • fetch(url, { cache: 'no-store' }) — always fresh (dynamic)
  • fetch(url, { next: { revalidate: 60 } }) — ISR-style: refresh at most every 60s
  • fetch(url, { next: { tags: ['invoices'] } }) — tag for on-demand revalidation
Prefer Server Components + await for first paint. Reach for client fetch only when the data truly depends on browser-only state (see later lessons).
Cached vs fresh fetch
// Good for a public course catalog (can be slightly stale)
const courses = await fetch("https://api.example.com/courses", {
  next: { revalidate: 3600, tags: ["courses"] },
}).then((r) => r.json());

// Good for the signed-in user's notifications
const notes = await fetch("https://api.example.com/notifications", {
  cache: "no-store",
  headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());

Dashboard data example — parallel awaits

If two queries do not depend on each other, start them together with Promise.all so the page waits once, not twice in a row.

Real-life example: Asking two assistants to get coffee and printouts at the same time beats sending one, waiting, then sending the other.

Parallel data loading
async function getRecentInvoices() {
  const res = await fetch("https://api.example.com/invoices?limit=5", {
    next: { tags: ["invoices"] },
  });
  return res.json();
}

async function getCustomers() {
  const res = await fetch("https://api.example.com/customers", {
    next: { revalidate: 300 },
  });
  return res.json();
}

export default async function DashboardPage() {
  const [invoices, customers] = await Promise.all([
    getRecentInvoices(),
    getCustomers(),
  ]);

  return (
    <section>
      <h2>Recent invoices ({invoices.length})</h2>
      <p>Customers on file: {customers.length}</p>
    </section>
  );
}

Static vs dynamic — when is HTML built?

Static (SSG): HTML is ready at build time (or after revalidation). Fast CDN delivery. Great for docs, marketing, course lists that change slowly.

Dynamic (SSR): HTML is built per request. Great for personalized dashboards, carts, anything that must be fresh or user-specific.

Real-life example: Static is a printed menu at a food stall — same for everyone, updated occasionally. Dynamic is a kitchen order ticket printed when you order — unique each time.

Next.js rendering paths

force-dynamic and revalidate

You can hint Next.js how a route should behave. Segment config and fetch options work together.

  • dynamic = 'force-static' — try to stay static
  • dynamic = 'force-dynamic' — render on every request
  • revalidate = number — time-based refresh for static pages
  • Using cookies() or headers() often forces dynamic rendering
Static with timed revalidation (ISR-style)
// app/blog/page.tsx
export const revalidate = 60; // regenerate at most every 60 seconds

export default async function BlogPage() {
  const posts = await fetch("https://api.example.com/posts", {
    next: { revalidate: 60 },
  }).then((r) => r.json());

  return (
    <ul>
      {posts.map((p: { id: string; title: string }) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
Always dynamic
// app/account/page.tsx
export const dynamic = "force-dynamic";

export default async function AccountPage() {
  // cookies(), headers(), or no-store fetch also opt into dynamic
  const res = await fetch("https://api.example.com/me", { cache: "no-store" });
  const user = await res.json();
  return <h1>Hello, {user.name}</h1>;
}

When to use each

Default to static or revalidated when content is shared. Go dynamic when correctness matters more than cache hits.

  • Static / revalidate — blog, docs, product catalog, marketing
  • Dynamic — auth dashboards, checkout, admin panels, live scores
  • Mix them — static shell + dynamic islands via Suspense (next lesson)
You do not pick SSG or SSR with a special file type anymore. App Router decides from your fetch options, segment config, and dynamic APIs.

Why stream?

Without streaming, the user waits until every slow query finishes before seeing anything. With streaming, Next.js sends a shell first, then fills in slow parts as they resolve.

Real-life example: Streaming is a restaurant bringing water and bread while the main course cooks — you are not staring at an empty table the whole time.

Shell first, then streamed chunks

Suspense boundaries

Wrap a slow async Server Component in <Suspense fallback={...}>. The fallback shows immediately; the child streams in when ready.

Suspense around a slow panel
import { Suspense } from "react";

async function RevenueChart() {
  const data = await fetch("https://api.example.com/revenue", {
    cache: "no-store",
  }).then((r) => r.json());
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

function ChartSkeleton() {
  return <div className="h-40 animate-pulse rounded bg-zinc-200" />;
}

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </main>
  );
}

loading.tsx — route-level skeleton

A loading.tsx file next to page.tsx automatically wraps that page in Suspense. Instant navigation feels snappy because the skeleton shows while the page loads.

Use loading.tsx for whole-page waits. Use nested Suspense for independent panels so a slow chart does not block a fast invoice list.
app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4 p-6">
      <div className="h-8 w-48 animate-pulse rounded bg-zinc-200" />
      <div className="h-32 animate-pulse rounded bg-zinc-200" />
      <div className="grid gap-3 sm:grid-cols-3">
        <div className="h-24 animate-pulse rounded bg-zinc-200" />
        <div className="h-24 animate-pulse rounded bg-zinc-200" />
        <div className="h-24 animate-pulse rounded bg-zinc-200" />
      </div>
    </div>
  );
}

URL as state — searchable, shareable, refresh-safe

Search text and page numbers belong in the URL (?q=react&page=2), not only in useState. Users can bookmark, share, and use the back button. Server Components can read searchParams and filter on the server.

Real-life example: URL state is like writing the aisle and shelf number on a shopping list — anyone with the note finds the same spot.

searchParams in page.tsx

In the App Router, the page receives searchParams. Treat them as the source of truth for filters.

A plain <form method="GET"> without action submits to the same URL and updates query params — no client JS required for basic search.
app/invoices/page.tsx
import Link from "next/link";
import { sql } from "@vercel/postgres";

type Props = {
  searchParams: Promise<{ q?: string; page?: string }>;
};

const PAGE_SIZE = 10;

export default async function InvoicesPage({ searchParams }: Props) {
  const { q = "", page = "1" } = await searchParams;
  const current = Math.max(1, Number(page) || 1);
  const offset = (current - 1) * PAGE_SIZE;

  const { rows } = await sql`
    SELECT id, customer_name, amount, status
    FROM invoices
    WHERE customer_name ILIKE ${"%" + q + "%"}
    ORDER BY id DESC
    LIMIT ${PAGE_SIZE} OFFSET ${offset}
  `;

  return (
    <main>
      <form>
        <label htmlFor="q">Search customers</label>
        <input id="q" name="q" defaultValue={q} placeholder="Asha..." />
        <button type="submit">Search</button>
      </form>

      <ul>
        {rows.map((row) => (
          <li key={row.id}>
            {row.customer_name} — ₹{row.amount} ({row.status})
          </li>
        ))}
      </ul>

      <nav aria-label="Pagination">
        {current > 1 && (
          <Link href={`/invoices?q=${encodeURIComponent(q)}&page=${current - 1}`}>
            Previous
          </Link>
        )}
        <span>Page {current}</span>
        <Link href={`/invoices?q=${encodeURIComponent(q)}&page=${current + 1}`}>
          Next
        </Link>
      </nav>
    </main>
  );
}

Pagination with Link

Prefer <Link> for page changes so Next.js can prefetch. Keep q in every page link so filters survive when you flip pages.

Real-life example: Pagination links are page numbers in a book index — each link must remember which chapter (filter) you were reading.

  • Encode query strings with encodeURIComponent
  • Clamp page to >= 1
  • Disable or hide Next when the last page has fewer than PAGE_SIZE rows

generateStaticParams — prebuild known paths

For dynamic routes like app/blog/[slug]/page.tsx, generateStaticParams tells Next.js which slugs to build as static HTML at build time.

Real-life example: It is printing popular metro maps the night before — tomorrow riders get them instantly; rare stations can still be printed on demand.

app/blog/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }> };

export async function generateStaticParams() {
  const posts = await fetch("https://api.example.com/posts").then((r) =>
    r.json()
  );

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 },
  }).then((r) => r.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </article>
  );
}

Comparing SSG, SSR, and client fetch

Same UI, different places and times for the network call. Pick the leftmost option that still meets your freshness and privacy needs.

  • SSG / generateStaticParams — build-time HTML, CDN fast, shared content
  • SSR / no-store / force-dynamic — per-request HTML, personalized or fresh
  • Client fetch — after JS loads; use for polls, search-as-you-type, browser-only APIs
For public blog posts, generateStaticParams + revalidate is usually enough. Save client fetching for interactive filters that change every keystroke.
Mental model cheat sheet
// 1) Static path list at build
export async function generateStaticParams() { /* ... */ }

// 2) Server fetch with revalidate (shared, slightly stale OK)
await fetch(url, { next: { revalidate: 60 } });

// 3) Server fetch always fresh
await fetch(url, { cache: "no-store" });

// 4) Client — only when server cannot do the job
"use client";
useEffect(() => { fetch("/api/...").then(/* setState */); }, []);

When the browser must fetch

Most list and detail pages should stay on the server. Client fetching shines when data depends on browser events: live search typing, infinite scroll, WebSocket dashboards, or third-party widgets that need window.

Real-life example: Server fetch is the hotel printing your room bill at checkout. Client fetch is refreshing the lobby screen every few seconds while guests walk by.

useEffect fetch pattern

Classic pattern: loading flag, error flag, cleanup so a slow response does not update an unmounted component.

Client component fetch
"use client";

import { useEffect, useState } from "react";

type Tip = { id: string; text: string };

export function LiveTips() {
  const [tips, setTips] = useState<Tip[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function load() {
      try {
        const res = await fetch("/api/tips");
        if (!res.ok) throw new Error("Failed to load tips");
        const data = (await res.json()) as Tip[];
        if (!cancelled) setTips(data);
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "Unknown error");
        }
      }
    }

    load();
    return () => {
      cancelled = true;
    };
  }, []);

  if (error) return <p role="alert">{error}</p>;
  if (!tips.length) return <p>Loading tips…</p>;

  return (
    <ul>
      {tips.map((t) => (
        <li key={t.id}>{t.text}</li>
      ))}
    </ul>
  );
}

SWR and React Query — brief mention

Libraries like SWR and TanStack Query add caching, revalidation, deduping, and retries so you do not hand-roll that in every useEffect.

Use them when you have many client-driven endpoints. For one simple poll, a small useEffect may be enough.

When NOT to client-fetch: first paint of SEO pages, secret DB access, and anything that can be an async Server Component. Client fetch after hydration is slower and exposes more network chatter.

"use server" — mutations that stay on the server

Server Actions are async functions that run on the server. Forms can call them with action={fn}. You get progressive enhancement: the form still works if JS is slow or disabled.

Real-life example: A Server Action is handing a sealed envelope to the receptionist — the guest never walks into the vault to update the ledger.

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

import { revalidatePath, revalidateTag } from "next/cache";
import { sql } from "@vercel/postgres";
import { redirect } from "next/navigation";

export async function createInvoice(formData: FormData) {
  const customerName = String(formData.get("customerName") ?? "").trim();
  const amount = Number(formData.get("amount"));

  if (!customerName || Number.isNaN(amount) || amount <= 0) {
    throw new Error("Invalid invoice data");
  }

  await sql`
    INSERT INTO invoices (customer_name, amount, status)
    VALUES (${customerName}, ${amount}, 'pending')
  `;

  revalidatePath("/invoices");
  revalidateTag("invoices");
  redirect("/invoices");
}

Forms with action=

Wire the action directly on the form. Prefer name attributes so FormData picks up fields automatically.

app/invoices/new/page.tsx
import { createInvoice } from "../actions";

export default function NewInvoicePage() {
  return (
    <form action={createInvoice}>
      <label htmlFor="customerName">Customer</label>
      <input id="customerName" name="customerName" required />

      <label htmlFor="amount">Amount (₹)</label>
      <input id="amount" name="amount" type="number" min={1} required />

      <button type="submit">Create invoice</button>
    </form>
  );
}

revalidatePath and revalidateTag

After a write, cached reads are stale. revalidatePath('/invoices') refreshes that route. revalidateTag('invoices') refreshes every fetch tagged with invoices.

Real-life example: Revalidation is telling the library to reprint the catalog after you add a new book — otherwise readers keep seeing yesterday's list.

  • revalidatePath — good for one page or layout
  • revalidateTag — good when many pages share the same tagged fetch
  • redirect after success so the browser lands on a fresh list
Keep validation and auth checks inside the Server Action. Never trust the client alone — anyone can POST form fields.

error.tsx — catch unexpected failures

If a Server Component throws, the nearest error.tsx shows a recovery UI. It must be a Client Component so the reset button can re-render the segment.

Real-life example: error.tsx is the fire exit sign — when the kitchen catches fire (crash), guests get a clear path instead of a blank wall (white screen).

app/dashboard/error.tsx
"use client";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <main role="alert">
      <h2>Something went wrong on the dashboard</h2>
      <p>{error.message}</p>
      <button type="button" onClick={() => reset()}>
        Try again
      </button>
    </main>
  );
}

notFound() — missing resources

When an id or slug does not exist, call notFound(). Next.js renders the closest not-found.tsx (or the default 404).

Use notFound for expected missing data. Use throw / error.tsx for unexpected failures (DB down, bug, 500). Custom global UI lives in app/global-error.tsx for root layout crashes.
Triggering and customizing 404
import { notFound } from "next/navigation";

export default async function InvoicePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/invoices/${id}`, {
    cache: "no-store",
  });

  if (res.status === 404) notFound();
  if (!res.ok) throw new Error("Failed to load invoice");

  const invoice = await res.json();
  return <h1>Invoice #{invoice.id}</h1>;
}

// app/invoices/[id]/not-found.tsx
export default function InvoiceNotFound() {
  return (
    <main>
      <h2>Invoice not found</h2>
      <p>Check the link or go back to the invoice list.</p>
    </main>
  );
}

Accessibility is part of the product

Labels, focus order, and clear errors help everyone — keyboard users, screen reader users, and tired users on mobile. In forms, accessibility and validation go together.

Real-life example: A good form is a well-marked train station — platforms labeled, announcements clear, and staff who stop you before you board the wrong train (server validation).

  • Every input needs a visible <label htmlFor=...>
  • Use aria-invalid and aria-describedby when showing errors
  • Keep focus management in mind after failed submit (error summary first)
  • Do not rely on color alone — pair red text with words or icons

Server-side validation with Server Actions

Validate on the server even if the browser has required and type=email. Return field errors instead of throwing opaque failures when input is wrong.

Browser checks are a courtesy; server validation is the gate. Pair them with labels, role="alert" for errors, and role="status" for success so assistive tech announces results.
Validation + accessible form
// app/contact/actions.ts
"use server";

export type ContactState = {
  ok: boolean;
  errors: { email?: string; message?: string };
};

export async function sendContact(
  _prev: ContactState,
  formData: FormData
): Promise<ContactState> {
  const email = String(formData.get("email") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim();
  const errors: ContactState["errors"] = {};

  if (!email.includes("@")) {
    errors.email = "Enter a valid email address.";
  }
  if (message.length < 10) {
    errors.message = "Message must be at least 10 characters.";
  }

  if (Object.keys(errors).length) {
    return { ok: false, errors };
  }

  // await saveToDb({ email, message });
  return { ok: true, errors: {} };
}

// app/contact/page.tsx (client wrapper for useActionState)
"use client";

import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";

const initial: ContactState = { ok: false, errors: {} };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendContact, initial);

  return (
    <form action={action} noValidate>
      {state.ok && <p role="status">Thanks — we got your message.</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          autoComplete="email"
          aria-invalid={Boolean(state.errors.email)}
          aria-describedby={state.errors.email ? "email-error" : undefined}
        />
        {state.errors.email && (
          <p id="email-error" role="alert">
            {state.errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          rows={4}
          aria-invalid={Boolean(state.errors.message)}
          aria-describedby={state.errors.message ? "message-error" : undefined}
        />
        {state.errors.message && (
          <p id="message-error" role="alert">
            {state.errors.message}
          </p>
        )}
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

Auth.js / NextAuth — what it does

Auth.js (formerly NextAuth.js) handles sign-in, sessions, and provider logins (Google, GitHub, credentials) so you do not build password security from scratch.

In the App Router you add a route handler at app/api/auth/[...nextauth]/route.ts, a shared auth config, and optional proxy/middleware to protect pages.

Real-life example: Auth.js is the reception desk at an office. It checks your ID (Google/GitHub), gives you a visitor badge (session), and decides which floors you may enter (protected routes).

  • Install: npm install next-auth@beta (Auth.js v5 for App Router)
  • Set AUTH_SECRET and provider keys in .env.local
  • Export handlers, auth, signIn, signOut from one auth.ts file
Tip: Never commit AUTH_SECRET or OAuth client secrets. Generate AUTH_SECRET with openssl rand -base64 32.
Auth.js sign-in to protected dashboard

Minimal Auth.js setup

Create auth.ts at the project root (or src/). Wire Google (or GitHub) as a provider. Re-export the route handlers for the catch-all API route.

Real-life example: Setting up Auth.js is like printing visitor badges once, then using the same badge printer at every entrance.

auth.ts — Auth.js v5 config
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
  ],
  pages: {
    signIn: "/login",
  },
});
app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;
.env.local (never commit)
AUTH_SECRET=your-long-random-secret
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret

Protect dashboard routes

In a Server Component, call auth(). If there is no session, redirect to /login. This keeps protection on the server — clients cannot skip it by editing React state.

Real-life example: Checking auth() on the dashboard page is like a guard at the VIP lounge door — no badge, no entry, even if you walk confidently.

Tip: Prefer auth() in Server Components and Server Actions. Use useSession only when a Client Component must react live to session changes.
app/dashboard/page.tsx — server-side guard
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user) {
    redirect("/login");
  }

  return (
    <main>
      <h1>Welcome, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
    </main>
  );
}
Login button (Server Action)
import { signIn } from "@/auth";

export function SignInGoogle() {
  return (
    <form
      action={async () => {
        "use server";
        await signIn("google", { redirectTo: "/dashboard" });
      }}
    >
      <button type="submit">Continue with Google</button>
    </form>
  );
}

Server Actions + session

Inside a Server Action, call auth() again before mutating data. Never trust a userId sent from the client — always read it from the session.

Real-life example: Checking the session inside a Server Action is confirming the signature on a cheque before cashing it — not just reading the name printed on the form.

Server Action that requires login
"use server";

import { auth } from "@/auth";
import { revalidatePath } from "next/cache";

export async function saveCourseNote(note: string) {
  const session = await auth();
  if (!session?.user?.email) {
    throw new Error("You must be signed in.");
  }

  // save note for session.user.email only
  await db.notes.create({
    data: { email: session.user.email, body: note },
  });

  revalidatePath("/dashboard");
}

Proxy / middleware protection

For many routes at once, use middleware.ts (or proxy.ts in newer Next.js) to check the session cookie and redirect before the page runs. Match only the paths you care about.

Real-life example: Middleware is the building security gate. One check covers every office on floors 3–5, instead of putting a guard in each room.

Tip: Combine middleware for quick redirects with auth() inside pages and actions for real authorization. Middleware alone is not enough for every security check.
middleware.ts — protect /dashboard/*
import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isDashboard = req.nextUrl.pathname.startsWith("/dashboard");

  if (isDashboard && !isLoggedIn) {
    const login = new URL("/login", req.nextUrl.origin);
    login.searchParams.set("callbackUrl", req.nextUrl.pathname);
    return NextResponse.redirect(login);
  }

  return NextResponse.next();
});

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

JWT vs database sessions

A JWT (JSON Web Token) is a signed string that carries user claims (id, email, expiry). The server verifies the signature without looking up a session row every time.

A database session stores a session id in a cookie and the full session data in the DB. You can revoke it instantly by deleting the row.

Auth.js supports both strategies. JWT is simpler to start; database sessions are better when you need instant logout everywhere or admin revoke.

Real-life example: A JWT is a stamped festival wristband — security checks the stamp, not a guest list. A DB session is a hotel key card that the front desk can deactivate in one click.

  • JWT — no DB round-trip; harder to revoke early unless you keep a denylist
  • Database session — easy revoke; needs storage and cleanup of old rows
  • Always set a short expiry and refresh wisely
Tip: Do not put secrets or roles you cannot change in a long-lived JWT. Prefer short-lived tokens and re-check permissions on sensitive actions.
Auth.js session strategy (concept)
export const { handlers, auth } = NextAuth({
  session: {
    strategy: "jwt", // or "database" with an adapter
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
  providers: [/* ... */],
});

Cookies overview

Session cookies travel with every request to your domain. Important flags: HttpOnly (JS cannot read it), Secure (HTTPS only), SameSite (limits cross-site sending).

Auth.js sets these flags for you when configured correctly in production.

Real-life example: HttpOnly cookies are a locker key locked inside the hotel safe — your JavaScript hands cannot pick it up, so XSS thieves struggle more.

  • HttpOnly — blocks document.cookie access
  • Secure — cookie only on HTTPS
  • SameSite=Lax or Strict — reduces CSRF risk
  • Path and Domain — control where the cookie is sent
Reading a cookie in a Route Handler
import { cookies } from "next/headers";

export async function GET() {
  const jar = await cookies();
  const theme = jar.get("theme")?.value ?? "light";
  return Response.json({ theme });
}

Security headers intro

Security headers tell the browser how to treat your site: block embedding (clickjacking), control scripts, force HTTPS.

You can set them in next.config or in middleware. Start with a few safe defaults, then tighten Content-Security-Policy as you learn.

Real-life example: Security headers are house rules on the front door — no framing our windows into another building (X-Frame-Options), prefer the locked entrance (HSTS).

Tip: Add Content-Security-Policy after you know every script and font host you need. A too-strict CSP can break analytics or embeds — tighten step by step.
next.config.ts — starter security headers
import type { NextConfig } from "next";

const securityHeaders = [
  { key: "X-Frame-Options", value: "DENY" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=()",
  },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/:path*", headers: securityHeaders }];
  },
};

export default nextConfig;

app/api/.../route.ts

In the App Router, API endpoints are Route Handlers. Create a folder under app/api and export functions named GET, POST, PUT, PATCH, DELETE from route.ts.

Each file maps to a URL path. app/api/hello/route.ts → /api/hello.

Real-life example: A Route Handler is a small service window at the back of the shop — browsers and mobile apps knock on /api/... and get JSON, not a full HTML page.

  • Export named HTTP methods from route.ts
  • Use Request and Response (Web standard APIs)
  • Prefer Server Actions for form mutations from your own UI; use Route Handlers for public APIs and webhooks
Tip: Keep Route Handlers thin — validate input, call a lib function, return JSON. Fat business logic belongs in shared modules.

GET and POST examples

GET reads data. POST creates or triggers work. Always return the right status codes: 200/201 success, 400 bad input, 401 unauthorized, 404 missing, 500 server error.

Real-life example: GET is asking the library for a book list. POST is submitting a new book donation form.

app/api/courses/route.ts — GET + POST
import { NextRequest, NextResponse } from "next/server";

const courses = [
  { id: "1", title: "Next.js Zero to Full-Stack" },
  { id: "2", title: "React Hooks Deep Dive" },
];

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

export async function POST(req: NextRequest) {
  const body = await req.json();
  const title = String(body.title ?? "").trim();

  if (!title) {
    return NextResponse.json(
      { error: "title is required" },
      { status: 400 }
    );
  }

  const created = { id: String(courses.length + 1), title };
  courses.push(created);

  return NextResponse.json(created, { status: 201 });
}
Calling the API from a Client Component
"use client";

import { useState } from "react";

export function AddCourseForm() {
  const [title, setTitle] = useState("");
  const [message, setMessage] = useState("");

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const res = await fetch("/api/courses", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
    const data = await res.json();
    setMessage(res.ok ? `Saved: ${data.title}` : data.error);
  }

  return (
    <form onSubmit={onSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Course title"
      />
      <button type="submit">Add</button>
      {message && <p>{message}</p>}
    </form>
  );
}

Building and managing API routes

Group related endpoints under folders. Share helpers for auth checks and validation. Document expected JSON shapes for teammates.

For webhooks (Stripe, GitHub), verify signatures before trusting the body.

Real-life example: Managing API routes is organizing a filing cabinet — one drawer per topic (users, courses, webhooks), labeled clearly so nobody digs in the wrong place.

  • app/api/users/route.ts — collection
  • app/api/users/[id]/route.ts — one item
  • app/api/webhooks/stripe/route.ts — external events
Shared JSON error helper
import { NextResponse } from "next/server";

export function jsonError(message: string, status = 400) {
  return NextResponse.json({ error: message }, { status });
}

// usage: return jsonError("Unauthorized", 401);

Dynamic route handlers

Use [id] folders for dynamic segments. In Route Handlers, read params from the second argument — in recent Next.js, params is a Promise you must await.

Real-life example: /api/courses/42 is like asking for locker number 42 — the [id] folder is the locker number slot.

Tip: Validate that id looks like a MongoDB ObjectId (24 hex chars) before querying — bad ids should return 400, not crash the driver.
app/api/courses/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

type Ctx = { params: Promise<{ id: string }> };

export async function GET(_req: NextRequest, context: Ctx) {
  const { id } = await context.params;
  // fetch one course by id...
  return NextResponse.json({ id, title: "Sample course" });
}

export async function DELETE(_req: NextRequest, context: Ctx) {
  const { id } = await context.params;
  // delete course...
  return NextResponse.json({ deleted: id });
}

MongoDB connect example

Reuse one connection across hot reloads in development. Store MONGODB_URI in .env.local. Below uses the native MongoDB driver; Mongoose works the same idea with mongoose.connect.

Real-life example: Caching the Mongo client is keeping one taxi running for the whole office day, not calling a new cab for every short errand.

lib/mongodb.ts — cached connection
import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("Missing MONGODB_URI");

declare global {
  // eslint-disable-next-line no-var
  var _mongoClientPromise: Promise<MongoClient> | undefined;
}

const client = new MongoClient(uri);
const clientPromise =
  global._mongoClientPromise ?? client.connect();

if (process.env.NODE_ENV !== "production") {
  global._mongoClientPromise = clientPromise;
}

export default clientPromise;
Mongoose alternative (sketch)
import mongoose from "mongoose";

export async function dbConnect() {
  if (mongoose.connection.readyState >= 1) return;
  await mongoose.connect(process.env.MONGODB_URI!);
}

CRUD sketch

Create, Read, Update, Delete against a courses collection. Keep ObjectId parsing and error handling tidy.

Real-life example: CRUD is managing a notebook — add a page, read it, edit a line, tear a page out.

Tip: Never expose your MongoDB URI to the browser. Only talk to Mongo from Server Components, Server Actions, or Route Handlers.
CRUD helpers sketch
import { ObjectId } from "mongodb";
import clientPromise from "@/lib/mongodb";

async function courses() {
  const client = await clientPromise;
  return client.db("knowvora").collection("courses");
}

export async function listCourses() {
  return (await courses()).find({}).toArray();
}

export async function createCourse(title: string) {
  const result = await (await courses()).insertOne({
    title,
    createdAt: new Date(),
  });
  return result.insertedId;
}

export async function updateCourse(id: string, title: string) {
  await (await courses()).updateOne(
    { _id: new ObjectId(id) },
    { $set: { title, updatedAt: new Date() } }
  );
}

export async function deleteCourse(id: string) {
  await (await courses()).deleteOne({ _id: new ObjectId(id) });
}

export const metadata

In a Server Component layout or page, export a metadata object. Next.js turns it into <title>, <meta>, and Open Graph tags in the document head.

Static metadata is perfect when the title never depends on URL params.

Real-life example: Static metadata is the printed cover of a textbook — same title for every copy of that edition.

app/blog/page.tsx — static metadata
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Blog | Rishtaara",
  description: "Practical web development lessons and guides.",
  openGraph: {
    title: "Rishtaara Blog",
    description: "Learn Next.js with real-life examples.",
    type: "website",
  },
};

export default function BlogPage() {
  return <h1>Blog</h1>;
}

generateMetadata()

When the title depends on a slug or id, export async function generateMetadata. It receives the same params as the page.

Real-life example: generateMetadata is printing a name badge for each guest — the template is fixed, the name changes per person.

Tip: Set a default title template in the root layout: title: { default: 'Rishtaara', template: '%s | Rishtaara' }.
Dynamic metadata for a course page
import type { Metadata } from "next";

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

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const course = await getCourse(slug); // your DB/fetch helper

  if (!course) {
    return { title: "Course not found" };
  }

  return {
    title: `${course.title} | Rishtaara`,
    description: course.excerpt,
    openGraph: {
      title: course.title,
      description: course.excerpt,
      images: [{ url: course.coverImage }],
    },
  };
}

export default async function CoursePage({ params }: Props) {
  const { slug } = await params;
  const course = await getCourse(slug);
  return <article><h1>{course?.title}</h1></article>;
}

generateImageMetadata & Open Graph

generateImageMetadata helps when one route serves multiple images (for example OG variants). For most apps, openGraph.images in metadata is enough.

Open Graph tags control how links look when shared on WhatsApp, LinkedIn, or Twitter/X.

Real-life example: Open Graph is the shop window photo — people share the link, and the preview image sells the click.

Open Graph essentials
export const metadata: Metadata = {
  metadataBase: new URL("https://rishtaara.com"),
  title: "Next.js Course",
  openGraph: {
    title: "Next.js: Zero to Full-Stack",
    description: "App Router, Auth.js, SEO, and deploy.",
    url: "https://rishtaara.com/learn/web-development/nextjs-advanced-patterns",
    siteName: "Rishtaara",
    images: [{ url: "/og/nextjs-course.png", width: 1200, height: 630 }],
    locale: "en_IN",
    type: "website",
  },
  twitter: {
    card: "summary_large_image",
    title: "Next.js: Zero to Full-Stack",
    images: ["/og/nextjs-course.png"],
  },
};

Image optimization

Use next/image instead of <img>. It serves modern formats, resizes for the device, and can lazy-load below the fold.

Always set width/height or fill with a sized parent to avoid layout shift.

Real-life example: next/image is a smart photo lab — it prints the right size print for phone vs billboard, so you do not mail a giant poster to a pocket.

Optimized Image
import Image from "next/image";

export function HeroPhoto() {
  return (
    <Image
      src="/images/classroom.jpg"
      alt="Students learning Next.js"
      width={1200}
      height={630}
      priority // above-the-fold hero
    />
  );
}

Code splitting & lazy loading

Next.js splits code by route automatically. For heavy client-only widgets (charts, editors), use next/dynamic with ssr: false or a loading placeholder.

Real-life example: Lazy loading is unpacking the camping stove only when you reach the campsite — not carrying a lit stove on the whole train ride.

Dynamic import of a heavy chart
import dynamic from "next/dynamic";

const EnrollmentChart = dynamic(
  () => import("@/components/EnrollmentChart"),
  {
    loading: () => <p>Loading chart…</p>,
    ssr: false,
  }
);

export function AnalyticsPanel() {
  return <EnrollmentChart />;
}

Caching strategies

Understand three layers: browser cache, Next.js Data Cache / Full Route Cache, and CDN cache on Vercel.

fetch options and revalidatePath / revalidateTag control freshness after Server Actions.

Real-life example: Caching is a fridge leftover plan — static salad lasts days (long revalidate); hot soup needs remaking often (no-store or short revalidate).

  • fetch(url, { next: { revalidate: 3600 } }) — ISR-style refresh each hour
  • fetch(url, { cache: 'no-store' }) — always fresh (dashboard-like)
  • revalidateTag('courses') after mutations
Cached vs fresh fetch
// Cached for 1 hour
const catalog = await fetch("https://api.example.com/courses", {
  next: { revalidate: 3600, tags: ["courses"] },
});

// Always fresh
const me = await fetch("https://api.example.com/me", {
  cache: "no-store",
  headers: { Cookie: "..." },
});

Bundle tips & Google Analytics note

Prefer Server Components by default. Mark "use client" only when you need state, effects, or browser APIs. Check the bundle with @next/bundle-analyzer when pages feel heavy.

For Google Analytics (or any third-party script), load it with next/script after interactive so it does not block first paint. Respect consent laws where you publish.

Real-life example: Third-party scripts are guests at a dinner — invite them after the main course (page content) is served, not before the cook starts.

Tip: Put analytics in the root layout only after you understand privacy/consent for your audience. A blocked cookie banner is better than silent tracking.
Google Analytics with next/script
import Script from "next/script";

export function GoogleAnalytics({ gaId }: { gaId: string }) {
  return (
    <>
      <Script
        src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
        strategy="afterInteractive"
      />
      <Script id="ga-init" strategy="afterInteractive">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', '${gaId}');
        `}
      </Script>
    </>
  );
}

Semantics for SEO

Search engines love clear structure: one h1, logical headings, meaningful alt text, descriptive links, and fast LCP.

Server-rendered HTML from the App Router helps crawlers see content without waiting for client JS.

Real-life example: Good semantics is a well-labeled supermarket — crawlers find milk in Dairy, not by wandering every aisle blindly.

  • Use real <h1>–<h3>, not only styled divs
  • Descriptive titles and meta descriptions (unique per page)
  • Internal links with clear anchor text
  • Mobile-friendly layout and Core Web Vitals

sitemap.ts

Export a default function from app/sitemap.ts. Next.js serves /sitemap.xml automatically. Include static routes and dynamic course/blog URLs from your CMS or DB.

Real-life example: A sitemap is the mall directory map — "you are here" plus every shop address for visitors (and Google).

app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = "https://rishtaara.com";
  const courses = await getAllCourseSlugs(); // your data helper

  return [
    { url: base, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
    { url: `${base}/learn`, changeFrequency: "weekly", priority: 0.9 },
    ...courses.map((slug) => ({
      url: `${base}/learn/web-development/${slug}`,
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: 0.8,
    })),
  ];
}

robots.ts & dynamic metadata for SEO

app/robots.ts controls what bots may crawl. Point sitemap to your absolute URL. Disallow private areas like /dashboard and /api.

Combine robots + sitemap + generateMetadata for a solid SEO baseline.

Real-life example: robots.txt is a polite sign on a private driveway — "deliveries welcome at the front; please skip the backyard."

Tip: After deploy, use Google Search Console to submit the sitemap and watch coverage errors — broken titles and soft 404s show up there.
app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/dashboard/", "/api/"],
    },
    sitemap: "https://rishtaara.com/sitemap.xml",
  };
}

Unit testing overview

Unit tests check small pieces: a price formatter, a Zod schema, a pure helper. They run fast and catch regressions early.

Integration tests check a few units together. End-to-end (e2e) tests drive a real browser like a user.

Real-life example: Unit tests are tasting the sauce alone; e2e is eating the full plate at the restaurant table.

  • Unit — functions and components in isolation
  • Integration — page + mocked API
  • E2E — full browser flows (login, checkout)
Tip: Start with pure helpers and critical Server Action validation. Do not chase 100% coverage on every presentational component.

Jest and Vitest

Vitest is popular with Vite-style speed and Jest-compatible APIs. Jest remains common in many Next.js templates. Both work with React Testing Library.

Real-life example: Jest and Vitest are two brands of kitchen scale — same job (measure), slightly different buttons.

Pure function unit test (Vitest/Jest style)
// lib/formatDuration.ts
export function formatDuration(mins: number) {
  if (mins < 60) return `${mins} min`;
  const h = Math.floor(mins / 60);
  const m = mins % 60;
  return m ? `${h}h ${m}m` : `${h}h`;
}

// formatDuration.test.ts
import { describe, it, expect } from "vitest";
import { formatDuration } from "./formatDuration";

describe("formatDuration", () => {
  it("formats under one hour", () => {
    expect(formatDuration(18)).toBe("18 min");
  });
  it("formats hours and minutes", () => {
    expect(formatDuration(90)).toBe("1h 30m");
  });
});
Component test with Testing Library
import { render, screen } from "@testing-library/react";
import { CourseCard } from "./CourseCard";

it("shows the course title", () => {
  render(<CourseCard title="Next.js Auth" duration="20 min" />);
  expect(screen.getByText("Next.js Auth")).toBeInTheDocument();
});

Cypress and Playwright (e2e)

Playwright and Cypress click through your running app. Prefer Playwright for modern multi-browser CI; Cypress is still widely taught.

Test happy paths first: home loads, login redirects, lesson page shows heading.

Real-life example: E2E tests are a dress rehearsal — lights, costume, full stage — before opening night (production).

Playwright smoke test
import { test, expect } from "@playwright/test";

test("home page shows brand", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});

test("learn page lists courses", async ({ page }) => {
  await page.goto("/learn");
  await expect(page.getByText(/Next\.js/i)).toBeVisible();
});
Cypress smoke (brief)
describe("home", () => {
  it("loads", () => {
    cy.visit("/");
    cy.get("h1").should("be.visible");
  });
});

Image carousel

Build a small client carousel with local state. Keep controls keyboard-friendly. Prefer next/image for slides.

Real-life example: A carousel is a photo album you flip — one picture visible, next/prev buttons on the sides.

Simple carousel
"use client";

import { useState } from "react";
import Image from "next/image";

const slides = [
  { src: "/slides/1.jpg", alt: "App Router diagram" },
  { src: "/slides/2.jpg", alt: "Auth flow" },
  { src: "/slides/3.jpg", alt: "Deploy checklist" },
];

export function LessonCarousel() {
  const [i, setI] = useState(0);
  const slide = slides[i];

  return (
    <div>
      <Image src={slide.src} alt={slide.alt} width={800} height={450} />
      <div>
        <button
          type="button"
          onClick={() => setI((n) => (n === 0 ? slides.length - 1 : n - 1))}
        >
          Prev
        </button>
        <span>
          {i + 1} / {slides.length}
        </span>
        <button
          type="button"
          onClick={() => setI((n) => (n + 1) % slides.length)}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Video player & form validation

Use the HTML5 video element for self-hosted files. For YouTube, prefer a privacy-friendly embed (next lesson).

Validate forms on the client for UX and again on the server for safety.

Real-life example: Client validation is spell-check before you send an email; server validation is the post office rejecting a blank address.

Video element
export function LessonVideo() {
  return (
    <video controls preload="metadata" poster="/video/poster.jpg">
      <source src="/video/intro.mp4" type="video/mp4" />
      Your browser does not support video.
    </video>
  );
}
Form with basic validation
"use client";

import { useState } from "react";

export function ContactForm() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email.includes("@")) {
      setError("Enter a valid email.");
      return;
    }
    setError("");
    // call Server Action or /api/contact
  }

  return (
    <form onSubmit={onSubmit}>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </label>
      {error && <p role="alert">{error}</p>}
      <button type="submit">Send</button>
    </form>
  );
}

Skeleton, popup, spinner, slider, date picker

Skeletons reduce layout jump while data loads. Modals need focus trap basics and Escape to close. Spinners need aria-busy or aria-live for accessibility.

A native range input is enough for many sliders. Prefer <input type="date"> before pulling a heavy date library.

Real-life example: A skeleton screen is the chalk outline of furniture while the real sofa is still on the delivery truck.

Tip: Reach for native HTML controls first. Add a UI library only when you need complex a11y patterns you cannot maintain yourself.
Skeleton + spinner
export function CourseSkeleton() {
  return (
    <div aria-hidden className="animate-pulse">
      <div className="h-6 w-2/3 rounded bg-neutral-200" />
      <div className="mt-3 h-4 w-full rounded bg-neutral-200" />
      <div className="mt-2 h-4 w-5/6 rounded bg-neutral-200" />
    </div>
  );
}

export function Spinner() {
  return (
    <p role="status" aria-live="polite">
      Loading…
    </p>
  );
}
Minimal modal popup
"use client";

import { useEffect, useState } from "react";

export function ConfirmModal() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!open) return;
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Delete lesson
      </button>
      {open && (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="confirm-title"
        >
          <h2 id="confirm-title">Delete this lesson?</h2>
          <button type="button" onClick={() => setOpen(false)}>
            Cancel
          </button>
          <button type="button">Confirm</button>
        </div>
      )}
    </>
  );
}
Slider + date input
"use client";

import { useState } from "react";

export function PaceControls() {
  const [speed, setSpeed] = useState(1);
  const [day, setDay] = useState("");

  return (
    <div>
      <label>
        Playback speed: {speed.toFixed(1)}x
        <input
          type="range"
          min={0.5}
          max={2}
          step={0.1}
          value={speed}
          onChange={(e) => setSpeed(Number(e.target.value))}
        />
      </label>
      <label>
        Study date
        <input
          type="date"
          value={day}
          onChange={(e) => setDay(e.target.value)}
        />
      </label>
    </div>
  );
}

Custom error page

app/not-found.tsx handles missing routes. app/error.tsx is a Client Component error boundary for unexpected failures. app/global-error.tsx covers root layout failures.

Real-life example: not-found is a friendly "wrong floor" sign. error.tsx is the building fire warden telling you calmly what to do next.

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

export default function NotFound() {
  return (
    <main>
      <h1>Page not found</h1>
      <p>That lesson may have moved. Try the course home.</p>
      <Link href="/learn">Back to Learn</Link>
    </main>
  );
}
app/error.tsx
"use client";

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

Custom local fonts recap

Use next/font/local for self-hosted files. Fonts are optimized and avoid layout shift when configured with display: swap.

Real-life example: Self-hosting fonts is stocking your own kitchen spices — you are not waiting for a neighbor to lend garam masala on every cook.

Tip: Prefer WOFF2. Limit weights you actually use — each file costs bytes on first visit.
Local font in root layout
import localFont from "next/font/local";

const brandSans = localFont({
  src: [
    { path: "../fonts/BrandSans-Regular.woff2", weight: "400" },
    { path: "../fonts/BrandSans-Bold.woff2", weight: "700" },
  ],
  variable: "--font-brand",
  display: "swap",
});

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

YouTube embed — safe pattern

Do not paste raw iframes with autoplay spam. Use youtube-nocookie.com, a title for accessibility, and lazy loading. Wrap in an aspect-ratio box.

Real-life example: A careful YouTube embed is inviting a guest speaker with a scheduled slot — not letting them rearrange your whole living room.

Privacy-friendlier YouTube embed
type Props = { videoId: string; title: string };

export function YouTubeEmbed({ videoId, title }: Props) {
  return (
    <div style={{ aspectRatio: "16 / 9", width: "100%" }}>
      <iframe
        src={`https://www.youtube-nocookie.com/embed/${videoId}`}
        title={title}
        loading="lazy"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowFullScreen
        style={{ width: "100%", height: "100%", border: 0 }}
      />
    </div>
  );
}

// <YouTubeEmbed videoId="dQw4w9WgXcQ" title="Intro to App Router" />

MDX in Next.js

MDX lets you write Markdown with React components inside. Great for docs and lesson content. Use @next/mdx or a content layer that compiles MDX at build time.

Real-life example: MDX is a recipe card that can also include a working timer widget — words plus interactive pieces on one page.

next.config.mdx sketch
import createMDX from "@next/mdx";

const withMDX = createMDX({
  extension: /\.mdx?$/,
});

const nextConfig = {
  pageExtensions: ["ts", "tsx", "md", "mdx"],
};

export default withMDX(nextConfig);

Draft Mode

Draft Mode lets CMS editors preview unpublished content. You enable it from a Route Handler that calls draftMode().enable(), then fetch draft data when isEnabled is true.

Real-life example: Draft Mode is a backstage pass — editors see the unfinished show; the public ticket holders wait for opening night.

Enable draft mode + read it
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");

  if (secret !== process.env.DRAFT_SECRET || !slug) {
    return new Response("Invalid", { status: 401 });
  }

  const draft = await draftMode();
  draft.enable();
  redirect(`/blog/${slug}`);
}

// in a page:
import { draftMode } from "next/headers";

export default async function PostPage() {
  const { isEnabled } = await draftMode();
  const post = await getPost({ draft: isEnabled });
  return <article>{/* ... */}</article>;
}

Security headers, ESLint, static files

You already saw security headers in the JWT lesson — keep them in next.config for every response.

ESLint with eslint-config-next catches App Router mistakes (missing keys, bad hooks). Run next lint in CI.

Static files live in public/ and are served from /. public/logo.svg → /logo.svg. Do not put secrets there.

Real-life example: public/ is the pamphlet rack in the lobby — anyone can take a brochure; do not leave the office safe combination on that rack.

  • Headers — clickjacking, MIME sniffing, referrer, permissions
  • ESLint — fail CI on errors for shared repos
  • public/ — images, favicons, robots manual files if needed
Tip: Prefer app/robots.ts and app/sitemap.ts over hand-written files in public/ so URLs stay generated from your data.
ESLint check
npx next lint
# or
npm run lint

Built-in components overview

Next.js ships special components that beat hand-rolled HTML for common jobs: Image, Link, Script, and font helpers.

Real-life example: These components are power tools from the same brand as your framework — sized for the job, with safety guards included.

  • next/image — resize, lazy load, modern formats
  • next/link — client navigation + prefetch
  • next/script — control third-party script timing
  • next/font — self-host Google or local fonts with zero layout shift goal
Link + Script together
import Link from "next/link";
import Script from "next/script";

export function DocsNav() {
  return (
    <nav>
      <Link href="/learn">Courses</Link>
      <Link href="/knowledge">Guides</Link>
      <Script src="https://example.com/widget.js" strategy="lazyOnload" />
    </nav>
  );
}

Fonts best practices

Load fonts with next/font/google or next/font/local in the root layout. Apply via className or CSS variables. Avoid linking Google Fonts CSS in raw <link> tags when next/font can self-host.

Limit families and weights. Match body and heading roles clearly.

Real-life example: Font practice is packing for a weekend trip — two outfits that work, not your entire wardrobe in the cabin bag.

Tip: Set font-family in CSS with var(--font-sans) so components stay consistent without importing the font object everywhere.
next/font/google example
import { Source_Sans_3, Source_Serif_4 } from "next/font/google";

const sans = Source_Sans_3({
  subsets: ["latin"],
  variable: "--font-sans",
  display: "swap",
});

const serif = Source_Serif_4({
  subsets: ["latin"],
  variable: "--font-serif",
  display: "swap",
});

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

Common interview questions (with short answers)

Interviewers want clear trade-offs, not buzzwords. Practice saying why you chose Server Components, Auth.js, or ISR in a real project.

Real-life example: Interview answers are like explaining a cricket shot — not only "I hit a six," but footwork, timing, and why that shot vs a single.

  • What is the App Router? — File-system routing under app/ with layouts, Server Components by default, and nested UI.
  • Server vs Client Components? — Server Components fetch and render on the server with no client JS bundle cost; Client Components handle state, effects, and browser APIs after "use client".
  • What are Server Actions? — Async functions marked "use server" that run on the server, often from forms, without hand-writing a REST endpoint.
  • SSR vs SSG vs ISR? — SSR renders per request; SSG at build time; ISR revalidates static pages on a timer or tag after deploy.
  • How does next/image help? — Automatic sizing, lazy loading, and modern formats to improve LCP and reduce bytes.
  • How do you protect routes? — middleware/proxy redirects plus auth() checks in pages and Server Actions; never trust the client alone.
  • JWT vs database session? — JWT is stateless and hard to revoke early; DB sessions are revocable and need storage.
  • What is caching in Next fetch? — Default caching/revalidate options control Data Cache; use no-store for personalized data.
  • How do you do SEO in Next? — metadata / generateMetadata, semantic HTML, sitemap.ts, robots.ts, fast Core Web Vitals.
  • Middleware limitations? — Edge runtime limits; good for redirects and cookie checks, not heavy DB work or large dependencies.
Tip: For each answer, add one sentence from a project you built — "In my dashboard I used auth() in the layout and revalidateTag after updates."

Project ideas to practice

Ship small but complete apps. Deploy them. Write a README with screenshots and decisions.

Real-life example: Portfolio projects are tasting menus — three strong dishes beat a fridge full of unfinished leftovers.

  • Blog — MDX or CMS, generateMetadata, sitemap, tags, RSS optional
  • Dashboard — auth-protected routes, charts (lazy), Server Actions for settings
  • E-commerce mini — product list, cart in cookies/DB, checkout stub, image optimization
  • Auth app — Auth.js Google + credentials optional, role-based UI, middleware matcher
README checklist for a portfolio Next app
## Stack
- Next.js App Router, TypeScript, Tailwind
- Auth.js, MongoDB (or Postgres)

## Features
- [ ] Login + protected /dashboard
- [ ] CRUD for main resource
- [ ] Metadata + sitemap
- [ ] Deployed on Vercel

## What I learned
- When I used Server Actions vs Route Handlers
- How I handled caching and revalidation

Deploy to Vercel

Push your repo to GitHub/GitLab/Bitbucket and import it in Vercel. Set environment variables (AUTH_SECRET, database URLs) in the project settings.

Every push to main can production-deploy; preview URLs appear on pull requests.

Real-life example: Vercel deploy is a moving company that already knows your apartment layout — connect the repo, hand them the keys (env vars), and furniture appears online.

Tip: Run npm run build locally before your first deploy. Most Vercel failures are the same errors you would see at build time on your laptop.
Pre-deploy checklist
npm run build
# fix any TypeScript or ESLint errors first

# required env on Vercel (examples)
# AUTH_SECRET
# AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET
# MONGODB_URI

Static HTML export

If you need pure static hosting (no Node server), set output: 'export' in next.config. You lose features that need a server: Route Handlers at runtime, ISR, some dynamic server APIs.

Good for marketing sites and docs. Not ideal for auth-heavy dashboards.

Real-life example: Static export is printing a brochure PDF — beautiful and fast to hand out, but it cannot check today's stock levels live.

Static export config
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  images: { unoptimized: true }, // or use a custom loader
};

export default nextConfig;

// then: npm run build → out/ folder ready for any static host

Migrating from Create React App

Move pages into app/ routes. Replace react-router with file-based routing and next/link. Move index.html meta into metadata exports. Convert client-only pages gradually; introduce Server Components where data fetching is simple.

Environment variables change from REACT_APP_ to NEXT_PUBLIC_ for browser-exposed values.

Real-life example: CRA to Next is moving from a studio apartment (client-only) to a house with a kitchen upstairs (server) — same furniture (React), new rooms and plumbing.

  • Create Next app → copy components into src/components
  • Map each React Router route to an app/.../page.tsx
  • Replace react-helmet with metadata / generateMetadata
  • Move API calls from useEffect to Server Components when possible

Course conclusion, next steps & cache tip

You covered App Router foundations through Auth.js, APIs, MongoDB sketches, metadata, performance, SEO, testing, UI practice, config, interviews, and deploy.

Next steps: build one portfolio project from lesson 41, add tests for your critical paths, and read the official Next.js docs when APIs change — the framework moves fast.

When local behavior looks "stuck" after config changes, reset the Next.js cache.

Real-life example: Clearing .next is shaking crumbs out of the toaster — old crumbs (cached builds) stop burning the next slice.

  • Build and deploy a small authenticated app
  • Practice interview answers out loud
  • Follow Next.js release notes for Auth.js and caching changes
  • Keep learning TypeScript, SQL/NoSQL, and testing
Tip: Bookmark nextjs.org/docs — when something fails after an upgrade, the docs and the migration guide beat random Stack Overflow answers.
Reset Next.js dev cache
# Stop the dev server first (Ctrl+C), then:
rm -rf .next

# Start again
npm run dev

Key Takeaways

  • Next.js is a React framework — App Router gives file-based routes, Server Components by default, and fullstack APIs in one repo.
  • Style with CSS Modules, Tailwind, or Sass; optimize fonts and images with next/font and next/image.
  • Layouts share UI across pages; Link navigates client-side; dynamic segments and catch-alls map folders to URLs.
  • Fetch in Server Components; choose static, dynamic, or streamed rendering; mutate with Server Actions and revalidate cache.
  • Protect dashboards with Auth.js, middleware/proxy, and server-side checks — never trust the client alone.
  • Ship SEO with metadata, sitemap, and robots; measure performance with images, splitting, and caching.
  • Practice UI patterns, write tests, then deploy to Vercel (or static export when you need pure HTML).

Frequently Asked Questions

React vs Next.js — what's the difference?
React is the UI library. Next.js is a framework on top of React with routing, Server Components, Route Handlers, Server Actions, and production optimizations.
App Router ya Pages Router?
New projects should use the App Router (app/). Pages Router (pages/) still works for legacy apps, but App Router is the default path for layouts, streaming, and Server Components.
When do I need "use client"?
Only when the component needs browser APIs, useState/useEffect, or event handlers. Keep data fetching in Server Components when you can.
SSR, SSG, and streaming mein kya farak hai?
SSG builds HTML at build time. SSR builds HTML per request. Streaming sends HTML in chunks with Suspense so users see a shell while slow parts load.
Server Actions vs Route Handlers?
Server Actions are great for form mutations from React. Route Handlers (app/api/.../route.ts) are better for public HTTP APIs, webhooks, and non-form clients.
How do I protect a dashboard route?
Use Auth.js for sessions, check auth() in Server Components and Server Actions, and redirect unauthenticated users in middleware/proxy for matched paths.