In an AI-driven world, we should keep our basics sharp at all times. This is the final post in my next.js learning series where we'll go over the fundamentals of Next.js.
Index
- Fundamentals
- The Legacy Page Router
- The App Router
- Client Components and 'use client'
- Advanced Routing and Navigation
- Layouts and UI Shells
- Handling UI States: Loading, Errors, and Not Found
- Built-in Optimizations and SEO
- UI and Styling with shadcn/ui
- Theming with next-themes
- Schema Validation with Zod
- Form Validation with React Hook Form and Zod
- Server-Side Logic: Route Handlers vs. Server Actions
- The 'Proxy' Concept (formerly Middleware)
- Real-time Data and Communication
- Authentication with Better Auth
- Toasts with Sonner
- Data Fetching, Caching, and Revalidation
- Identifying Static vs. Dynamic Routes
Fundamentals
Next.js is a powerful React framework used to build full-stack web applications. It provides several out-of-the-box features that simplify the development process and improve performance.
- Server-Side Rendering (SSR): Next.js pre-renders pages on the server for each request. This is great for Search Engine Optimization (SEO) as crawlers can see the full content of the page immediately.
- Production Ready: It includes automatic code splitting, optimized image loading, and built-in CSS (Cascading Style Sheets) / SASS (Syntactically Awesome Style Sheets) support.
-
Easy Setup: You can start a new project instantly using:
npx create-next-app@latest
Next.js and TypeScript (Generics)
If you see syntax like GetServerSideProps<PageProps> or useState<string>() in a Next.js project, those are Generics.
- Origin: Generics are a feature of TypeScript, not JavaScript or React.
- Purpose: They allow you to pass a "type" as a variable to another type. This is what makes Next.js so powerful when combined with TypeScript; it ensures that the data you fetch on the server matches the data your component expects on the client.
-
Example:
// The <User> part is a Generic. // It tells the function exactly what shape the response will have. const user = await fetchUser<User>(id);For a deep dive into how they work in React and Next.js, see: Generics in React and Next.js.
Rendering Patterns: SPA, SSR, and Next.js Hybrid
Understanding the difference between Single Page Applications (SPA) and Server-Side Rendering (SSR) is key to mastering Next.js. Next.js is unique because it allows you to use both individually or combine them seamlessly.
1. SPA (Single Page Application) - The "React Way"
In a pure SPA, the server sends a nearly empty HTML file and a large JavaScript bundle. The browser then executes that JS to "build" the entire website.
- Pros: Smooth transitions (no page reloads), feels like a mobile app.
- Cons: Poor SEO (crawlers see empty HTML), slow "First Paint" (user waits for JS to download).
- In Next.js: You get this when you use
'use client'at the top of a page.
2. SSR (Server-Side Rendering) - The "Classic Way"
The server generates the full HTML for a page on every request and sends it to the browser.
- Pros: Great SEO, fast initial load (content is there immediately).
- Cons: Full page reloads on every click, high server load.
- In Next.js: This is the default behavior for Server Components in the App Router.
3. The Next.js Hybrid: SSR + Hydration (Best of Both Worlds)
Next.js doesn't force you to choose. It uses a process called Hydration:
- Server side: Next.js pre-renders your React components into static HTML.
- Browser side: The HTML is displayed immediately (Fast Paint). Then, a small JS bundle "hydrates" the page, attaching event listeners and making it interactive without a reload.
Detailed Example: Mixing Both
Imagine a Product Page. The product details (name, price, description) are static and good for SEO, but the "Add to Cart" button needs interactivity.
// app/products/[id]/page.tsx (Server Component by default)
import AddToCartButton from './AddToCartButton';
export default async function ProductPage({ params }: { params: { id: string } }) {
// 1. SSR: Fetch data on the server
const product = await fetchProduct(params.id);
return (
<main>
{/* This part is rendered as static HTML on the server (Great for SEO) */}
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 2. SPA-like Interactivity: This component handles client-side state */}
<AddToCartButton productId={product.id} />
</main>
);
}
// app/products/[id]/AddToCartButton.tsx
'use client'; // This part is "Hydrated" in the browser
import { useState } from 'react';
export default function AddToCartButton({ productId }: { productId: string }) {
const [isAdded, setIsAdded] = useState(false);
return (
<button onClick={() => setIsAdded(true)}>
{isAdded ? "Added to Cart!" : "Add to Cart"}
</button>
);
}
Why this is powerful:
- The Search Engine sees the
<h1>and<p>tags immediately. - The User sees the content instantly.
- The Browser only downloads the JS needed for the button, keeping the page fast and interactive like an SPA.
The Legacy Page Router
The Page Router was the original way to handle routing in Next.js. In this system, every file created inside the pages directory automatically became a route.
-
pages/index.js->/ -
pages/about.js->/about
While simple, it lacked advanced features like native nested layouts and React Server Components, leading to the introduction of the App Router.
The App Router
Introduced in Next.js 13, the App Router is the modern standard. It is built on React Server Components and uses a file-system-based router where folders define routes.
- Folders as Routes: A folder inside the
appdirectory represents a URL segment. - page.tsx: To make a route publicly accessible, you must include a
page.tsxfile inside that folder. - Default Export: Every
page.tsxmust have anexport default functionto render the UI.
Example:
// app/contact/page.tsx
export default function ContactPage() {
return (
<main>
<h1>Contact Us</h1>
<p>Feel free to reach out via email.</p>
</main>
);
}
Client Components and 'use client'
In the App Router, all components are Server Components by default. To enable interactivity, you must use the 'use client' directive.
What happens when you add 'use client'?
When you mark a file with 'use client', several things happen behind the scenes:
- Client-Side Rendering: The component (and all its imported dependencies) is added to the Client-Side JavaScript Bundle.
- Hydration: The component is still pre-rendered into static HTML on the server for SEO, but it then "hydrates" in the browser to become interactive.
- Client Boundary: It establishes a "Client Boundary." Any component imported into a Client Component automatically becomes a Client Component as well.
- Enables Interactivity: You gain access to event handlers (
onClick), React Hooks (useState,useEffect), and Browser APIs (window,localStorage).
- When to Use It: You need
'use client'whenever your component requires:- Interactivity: Event listeners like
onClick,onChange, oronSubmit. - React Hooks: State (
useState), effects (useEffect), or context (useContext). - Browser APIs: Access to
window,document, orlocalStorage.
- Interactivity: Event listeners like
- Placement: The
'use client'directive must be placed at the very top of the file, before any imports. - The Client Boundary: Once a file is marked with
'use client', it becomes a "Client Boundary." This means that the file and all the components imported into it will be treated as Client Components.
The Client Boundary Effect (Implicit Client Components)
A common misconception is that every interactive component needs its own 'use client' tag. In reality, the directive establishes a Client Boundary that "infects" the entire dependency tree below it.
- Implicit Conversion: If a component is imported into a file that has the
'use client'directive, it is implicitly converted into a Client Component. It will be included in the client-side JavaScript bundle and rendered on the client. - No Directive Needed: Child components imported into a Client Component do not need their own
'use client'tag to use hooks or interactivity, as they are already executing within the client runtime. - Module Dependency: Because the parent needs to render the child in the browser, the build tool must include the child's code in the client bundle.
Example: Parent vs. Child
// app/components/ParentComponent.tsx
'use client'; // This establishes the Client Boundary
import { useState } from 'react';
import ChildComponent from './ChildComponent';
export default function ParentComponent() {
const [count, setCount] = useState(0);
return (
<div className="p-4 border">
<h2>Parent (Client Component)</h2>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
{/* The Child is imported and rendered here */}
<ChildComponent />
</div>
);
}
// app/components/ChildComponent.tsx
// Note: NO 'use client' directive here!
export default function ChildComponent() {
// This will still render on the client because it's imported into ParentComponent.
console.log("I am rendering in the browser!");
return (
<div className="mt-4 p-2 bg-gray-100">
<h3>Child Component</h3>
<p>I don't have 'use client', but I'm part of the client bundle!</p>
</div>
);
}
Why this matters: To keep your client bundle small, you should move 'use client' as far down the component tree as possible (to the "leaf" components) to avoid accidentally pulling static components into the client-side JavaScript.
Advanced Routing and Navigation
Next.js provides a powerful file-system-based router. Beyond basic page creation, it supports advanced routing patterns to handle complex application structures.
Dynamic Routing
Next.js allows you to create routes that match multiple URL patterns using dynamic segments.
Note for Next.js 15+: The params and searchParams props are now Promises and must be awaited (or handled with React's use() hook) before access.
- [id] Paths: By wrapping a folder name in square brackets, you create a dynamic route (e.g.,
app/posts/[id]/page.tsx). - Mixed Combinations: You can nest dynamic segments to create complex paths like
/categories/[category]/[id].
Example (Next.js 15+):
// app/blog/[blogId]/page.tsx
interface BlogIdPageProps {
params: Promise<{ blogId: string }>;
}
export default async function BlogIdPage({ params }: BlogIdPageProps) {
const { blogId } = await params;
return (
<main>
<h1>Hello from the blog id page</h1>
<p>Viewing blog post ID: {blogId}</p>
</main>
);
}
Route Groups
Route Groups allow you to organize your files and group related routes without affecting the URL structure. You can create a route group by wrapping a folder name in parentheses: (folderName).
- URL Impact: The folder name in parentheses is ignored by the router. For example,
app/(auth)/login/page.tsxis accessible at/login, not/(auth)/login. - Shared Layouts: You can add a
layout.tsxinside a route group to apply a specific UI (like an auth header or sidebar) only to the routes within that group. - Organization: They are excellent for organizing large projects by feature, team, or intent (e.g.,
(admin),(marketing)) without cluttering the URL.
Parallel Routes
Parallel Routes allow you to simultaneously or conditionally render one or more pages within the same layout. They are created using named slots, e.g., @folder.
- Usage: Useful for highly dynamic sections like dashboards or social media feeds where you want multiple independent views (e.g., a team view and an analytics view on the same dashboard).
- Independent Error/Loading States: Each parallel route can have its own
loading.tsxanderror.tsx, meaning one slow section won't block the rest of the page. - Slot Props: Slots are passed as props to the shared parent layout.
Example File Structure:
app/dashboard/
├── layout.tsx
├── @analytics/
│ └── page.tsx
└── @team/
└── page.tsx
Example Layout Usage:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children, // The main page.tsx content
analytics, // Content from @analytics
team, // Content from @team
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
<div className="flex gap-4">
{analytics}
{team}
</div>
</div>
);
}
Intercepting Routes
Intercepting Routes allow you to load a route from another part of your application within the current layout, while "masking" the URL. This is most commonly used for Modals.
- Convention: They use a relative path convention like
(.)folderto intercept routes at the same level, or(..)folderto intercept routes one level up. - How it works: If a user clicks a photo in a feed (e.g.,
/feed), the route/photo/[id]can be "intercepted" and shown as a modal overlaying the feed. If they refresh the page or share the link, the actual/photo/[id]page is loaded independently.
Example Structure for a Photo Modal:
app/
├── feed/
│ ├── layout.tsx
│ ├── page.tsx
│ └── (@modal)/
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx <-- Modal version
└── photo/
└── [id]/
└── page.tsx <-- Standalone version
The Link Component
The <Link> component is the primary way to navigate between routes in Next.js.
- Benefits of :
- Client-Side Navigation: It navigates without a full page reload.
- Prefetching: Next.js automatically prefetches the linked page in the background as it enters the viewport.
Example:
import Link from 'next/link';
export default function Home() {
return <Link href="/about">Go to About Page</Link>;
}
Programmatic Navigation: useRouter vs. redirect
| Feature | useRouter |
redirect |
|---|---|---|
| Component Type | Client Components | Server Components, Actions, Route Handlers |
| Import Source | next/navigation |
next/navigation |
| Execution | Browser (Client-side) | Server (Server-side) |
| Usage | Inside event handlers/effects | Top-level logic or after mutations |
Client-Side Example:
'use client';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
return <button onClick={() => router.push('/dashboard')}>Log In</button>;
}
Server-Side Example:
'use server';
import { redirect } from 'next/navigation';
export async function updateProfile(formData: FormData) {
// logic...
redirect(`/profile/123`);
}
Layouts and UI Shells
Layouts allow you to share UI across multiple pages, such as navigation bars or footers.
- layout.tsx: A layout file wraps the
page.tsx(and any child segments). - Layered Shells: Layouts are loaded from top to bottom in layers. A root layout wraps the entire application, while nested layouts wrap specific sub-sections.
- State Preservation: On navigation, layouts preserve their state and do not re-render, making the app feel faster.
Example:
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<section className="dashboard-container">
<aside>Sidebar Navigation</aside>
<div className="content">
{children} {/* This is where the page content is injected */}
</div>
</section>
);
}
UI and Styling with shadcn/ui
shadcn/ui is a collection of beautifully designed, accessible, and customizable components that you can copy and paste into your apps. Unlike a component library, it is a collection of reusable components that you own and can fully customize.
-
Initialization: Start by initializing the CLI in your Next.js project:
npx shadcn@latest initThis command is interactive and will ask you several configuration questions (style, base color, CSS variables, etc.). Upon completion, it creates a
components.jsonfile in your root directory to track your preferences. -
Adding Components: Add only the components you need (e.g., Button, Card, Dialog):
npx shadcn@latest add button Usage: Components are added directly to your
@/components/uifolder, giving you full control over the code.
Example:
import { Button } from "@/components/ui/button"
export default function Home() {
return (
<div>
<Button variant="outline">Click Me</Button>
</div>
)
}
Theming with next-themes
When using shadcn/ui, it's common to use next-themes to handle light, dark, and system theme switching.
- ThemeProvider: This component provides a context for the current theme across your entire application.
- Why 'use client'?: The
ThemeProviderwrapper must be a Client Component because it manages state (using React Context and hooks likeuseStateoruseEffect) and interacts with browser APIs likelocalStorageto persist the user's theme preference. - suppressHydrationWarning: You must add
suppressHydrationWarningto your root<html>tag inlayout.tsx.- Reason: On the server, Next.js doesn't know the user's client-side theme preference (e.g., from
localStorage). Whennext-themesupdates theclassordata-themeon the<html>tag on the client to match the saved preference, it causes a mismatch with the server-rendered HTML. Adding this flag tells React to ignore that specific mismatch during hydration.
- Reason: On the server, Next.js doesn't know the user's client-side theme preference (e.g., from
Example (layout.tsx):
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
Schema Validation with Zod
Zod is a TypeScript-first schema declaration and validation library. It is widely used in Next.js projects to bridge the gap between runtime data validation and compile-time type safety.
- Runtime Validation: Use Zod to define a schema for your data (e.g., API responses, form data, environment variables). This ensures that the data your app receives actually matches what you expect.
- Type Inference with
z.infer: One of Zod's most powerful features is Type Inference. Instead of manually defining aninterfaceortypethat mirrors your validation logic, Zod can automatically generate a TypeScript type from your schema. - Single Source of Truth: This eliminates redundancy and ensures that your static type definitions are always perfectly synchronized with your runtime validation rules.
Example:
import { z } from "zod";
// 1. Define the schema (Runtime validation)
const UserSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
age: z.number().int().positive().optional(),
});
// 2. Infer the type (Compile-time type)
// This 'User' type is automatically kept in sync with the schema above.
type User = z.infer<typeof UserSchema>;
// 3. Use the inferred type in your code
const processUser = (user: User) => {
console.log(`Processing user: ${user.username}`);
};
// 4. Validate data at runtime (e.g., from an API call or form)
const result = UserSchema.safeParse({
id: "550e8400-e29b-41d4-a716-446655440000",
username: "johndoe",
email: "john@example.com",
});
if (result.success) {
// result.data is automatically typed as 'User'
processUser(result.data);
} else {
// Detailed error information if validation fails
console.error(result.error.format());
}
Form Validation with React Hook Form and Zod
React Hook Form (RHF) is the standard for managing form state in React. When combined with Zod via the @hookform/resolvers package, it provides a powerful, type-safe validation layer.
-
Installation:
npm install react-hook-form zod @hookform/resolvers The Validation Bridge:
@hookform/resolversis a library that allows you to use external validation libraries (like Zod, Yup, Joi, or Superstruct) with React Hook Form. By default, RHF uses HTML-standard validation (likerequired,minLength), but the resolvers package allows you to use a single, powerful schema for all your validation logic.zodResolver: This specific resolver function takes your Zod schema and converts it into a format that React Hook Form understands. It handles the mapping of Zod errors to RHF's
errorsobject automatically.-
Benefits:
- Type Safety: Use
z.inferto ensure your form data types are always in sync with your validation rules. - Clean Code: Keeps validation logic out of your JSX and centralized in a reusable schema.
- Automatic Errors: RHF automatically populates the
errorsobject based on your Zod schema's messages.
- Type Safety: Use
Example:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// 1. Define the schema
const schema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
});
// 2. Infer the type from the schema
type FormData = z.infer<typeof schema>;
export default function MyForm() {
// 3. Initialize the form with the Zod resolver
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log("Validated Data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Username</label>
<input {...register("username")} />
{errors.username && <p className="error">{errors.username.message}</p>}
</div>
<div>
<label>Email</label>
<input {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}
Server-Side Logic: Route Handlers vs. Server Actions
Next.js provides two primary ways to run server-side code in response to client interactions: Route Handlers and Server Actions. Understanding when to use each is crucial for building efficient, secure, and maintainable applications.
1. Route Handlers (route.ts)
Route Handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs. They are the App Router equivalent of API Routes from the Page Router.
- When to Use:
- External APIs: When you need to provide an API endpoint for external clients (mobile apps, 3rd party services).
- Webhooks: Handling incoming requests from services like Stripe, GitHub, or Clerk.
- Custom Responses: When you need to return non-HTML content like JSON, XML, or binary data (PDFs, images).
- Specific HTTP Methods: When you need full control over
GET,POST,PUT,DELETE, etc.
Example: A Simple JSON API
// app/api/hello/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ message: 'Hello from the Route Handler!' });
}
export async function POST(request: Request) {
const data = await request.json();
return NextResponse.json({ received: data }, { status: 201 });
}
2. Server Actions
Server Actions are asynchronous functions that are executed on the server. They can be defined in Server Components or called from Client Components. They are the modern standard for handling data mutations (POST, PUT, DELETE) in Next.js.
- When to Use:
- Form Submissions: Handling user input from forms.
- Data Mutations: Creating, updating, or deleting records in a database.
- Seamless Integration: When you want to trigger server-side logic from a button click or form submit without managing a separate API route.
- Progressive Enhancement: They work even if JavaScript is disabled in the browser (when used with
<form action={...}>).
Example: Handling a Form Submission
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
// 1. Validate data (e.g., using Zod)
// 2. Save to database (e.g., Prisma, Drizzle)
console.log(`Creating post: ${title}`);
// 3. Revalidate the cache to show new data
revalidatePath('/blog');
}
Route Handlers vs. Server Actions: A Quick Comparison
| Feature | Route Handlers (route.ts) |
Server Actions |
|---|---|---|
| Primary Use | REST APIs, Webhooks, External clients | Data mutations, Form submissions |
| Call Method | HTTP Request (fetch('/api/...')) |
Direct function call (await action()) |
| HTTP Methods | Full control (GET, POST, etc.) | Always uses POST under the hood |
| Response Type | Any (JSON, XML, PDF, etc.) | Data or Action result |
| Progressive Enhancement | No | Yes (works without JS) |
| Caching | Can be cached (GET requests) | Never cached |
The 'Proxy' Concept (formerly Middleware)
In Next.js 16, the file previously known as middleware.ts has been renamed to proxy.ts. This change more accurately reflects its role as a network boundary and request gateway.
Why the Change to 'Proxy'?
- Clarified Role: It acts as a "Reverse Proxy" for all incoming traffic before it reaches your application logic.
- Node.js Runtime: Unlike the old middleware,
proxy.tsruns on the Node.js runtime by default, giving you access to the full suite of Node.js APIs. - Performance: It remains optimized for the edge but provides a more predictable environment for complex logic.
How it Works
The proxy.ts file must be located at the root of your project. It exports a function named proxy that intercepts every incoming request.
Key Capabilities:
- Rewriting: Changing the internal destination (the actual proxying) using
NextResponse.rewrite(). - Redirecting: Sending users to a different URL.
- Header/Cookie Injection: Modifying requests before they reach your pages or APIs.
Example Implementation (proxy.ts)
// proxy.ts (at the root of your project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// The function MUST be named 'proxy' in Next.js 16+
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('session-token');
// 1. Authentication Boundary
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 2. Reverse Proxying
if (pathname.startsWith('/old-api')) {
// Internally routes to a legacy server while keeping the URL the same
const legacyUrl = new URL(pathname.replace('/old-api', ''), 'https://api.legacy-system.com');
return NextResponse.rewrite(legacyUrl);
}
return NextResponse.next();
}
// Matcher config to limit where the proxy runs
export const config = {
matcher: ['/dashboard/:path*', '/old-api/:path*'],
};
Migration Note
If you are moving from an older version of Next.js:
- Rename
middleware.tstoproxy.ts. - Rename the exported function
middlewaretoproxy. -
middleware.tsis still supported for backwards compatibility but is considered deprecated.
Security Warning: The 'Two-Layer' Auth Pattern
A common mistake is relying only on the proxy.ts (middleware) for authentication. While it is great for User Experience, it is insecure if used as your only line of defense.
1. Why Proxy-only Checks are Insecure
- Surface Level: The proxy usually only checks if a cookie exists. It rarely performs a deep database check (to see if the user is banned or the session is revoked) because doing so would slow down every single request to your site.
- Bypass Risks: Depending on your deployment configuration (e.g., using certain CDNs or split-routing), it is sometimes possible to craft requests that bypass the proxy layer and hit your internal APIs directly.
- Stale Data: If a user is deleted from your database, their browser might still have a "valid-looking" cookie. The proxy will let them in because it doesn't want to hit the DB on every click.
2. The Solution: Two-Layer Validation
Professional Next.js applications use a Defense in Depth strategy.
-
Layer 1: The Proxy (UX Layer)
- Goal: Speed.
- Action: Do a quick check for a session cookie. If it's missing, redirect to
/login. - Result: The user doesn't see a "flash" of protected content before being redirected.
-
Layer 2: The Server Component/Action (Security Layer)
- Goal: Absolute Security.
- Action: Perform a rigorous database check. Verify permissions, roles, and session validity.
- Result: Even if someone bypasses the proxy, they cannot fetch any sensitive data because the actual data-fetching function will block them.
Detailed Example: Two-Layer Check
// 1. THE UX LAYER (proxy.ts)
export function proxy(request: NextRequest) {
const session = request.cookies.get('auth-session');
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// 2. THE SECURITY LAYER (app/dashboard/page.tsx)
export default async function DashboardPage() {
// Authoritative check: Hit the DB to verify the user is actually active
const user = await getAuthenticatedUser();
if (!user) {
// This handles cases where the session was revoked but the cookie still exists
redirect('/login');
}
const data = await db.privateData.findMany({ where: { userId: user.id } });
return <Dashboard data={data} />;
}
Rule of Thumb: Use the Proxy for Redirects (UX), but use Server-side logic for Access Control (Security).
Real-time Data and Communication
Next.js is primarily built on a request-response model (HTTP). While it handles standard data fetching perfectly, it does not natively provide a persistent, bi-directional connection (like WebSockets) out of the box—especially in serverless environments (like Vercel). For real-time requirements, developers use specialized tools and frameworks.
1. Convex: The Reactive Full-Stack Backend
Convex is a modern, reactive backend-as-a-service. It combines a database, server functions, and real-time synchronization into a single tool.
- Reactive Queries: Instead of manual fetching, you use
useQuery. If the data in the database changes, the UI updates automatically across all connected clients. - Why it's powerful: It eliminates the need for complex WebSocket management or state synchronization libraries. It acts as your database and your real-time engine simultaneously.
2. Pusher: Managed WebSockets
Pusher is a hosted WebSocket service. It allows you to "trigger" events from your Next.js server (API routes/Server Actions) and "listen" for them on the client.
- Infrastructure-less: You don't have to maintain a WebSocket server (like Socket.io), which is difficult to scale in serverless environments.
- Bi-directional: It enables instant features like chat notifications, live dashboards, and "typing" indicators.
3. WebRTC and Signaling
For high-performance real-time applications involving Audio and Video calls, we use WebRTC (Web Real-Time Communication).
- P2P Tunnels: WebRTC allows two browsers to connect directly to each other (Peer-to-Peer) to exchange large amounts of data with very low latency.
- The Signaling Problem: Two peers cannot connect "out of the blue." They need to know each other's IP addresses and media capabilities. However, most devices are hidden behind firewalls.
- The Solution (Signaling): Peers need a "handshake" server to exchange information (SDP and ICE Candidates). This is where Convex or Pusher come in:
- Pusher can broadcast the "Offer" and "Answer" messages between peers.
- Convex can store the signaling data in a reactive table that both peers are "watching."
Example: Real-time Messaging & Video App
In a professional app:
- Messaging: Use Convex to store messages. Since it's reactive, the chat UI updates instantly for both users.
- Calling: When a user clicks "Call," a signaling message is sent via Pusher or Convex to the other user.
- Video Stream: Once the "handshake" is complete via the signaling server, WebRTC takes over to create a direct P2P tunnel for the actual video and audio stream.
Authentication with Better Auth
Better Auth is a modern, type-safe authentication framework designed specifically for TypeScript and frameworks like Next.js. It provides a modular architecture where features (like MFA, Organizations, or Passkeys) are added via plugins.
- Modular Architecture: You only add the features you need (e.g., social login, email/password, organizations) through a plugin-based system.
- Database Agnostic: It works with any database using adapters (Drizzle, Prisma, etc.).
- Type Safety: It provides end-to-end type safety for your user sessions and auth state.
Example Setup:
// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
});
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
Toasts with Sonner
Sonner is an opinionated, lightweight toast component for React, created by Emil Kowalski. It is the default recommendation for shadcn/ui due to its sleek design and stacking behavior.
- Small & Fast: Extremely lightweight (less than 1kb gzipped).
- Opinionated Design: Looks great out of the box with minimal configuration.
- Stacking: Toasts elegantly stack on top of each other, allowing users to see multiple notifications at once.
Installation via shadcn:
npx shadcn@latest add sonner
Usage Example:
// app/layout.tsx
import { Toaster } from "@/components/ui/sonner"
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Toaster /> {/* Add this once to your layout */}
</body>
</html>
)
}
// Any Client Component
"use client"
import { toast } from "sonner"
export function MyComponent() {
return (
<button onClick={() => toast("Operation Successful!")}>
Show Toast
</button>
)
}
Handling UI States: Loading, Errors, and Not Found
Next.js provides special file conventions to handle different states of your application's UI gracefully.
Streaming and Suspense (loading.tsx)
Streaming is a powerful data transfer technique that allows you to break down a page's HTML into smaller chunks and progressively send them from the server to the client. This is a game-changer for performance, especially on slow internet connections.
How Streaming Helps:
- Reduces Time to First Byte (TTFB): Instead of waiting for the entire page to be generated on the server, Next.js starts sending the UI as soon as it's ready.
- Partial Data Delivery: Users can see and interact with parts of the page (like a header or sidebar) while slower data-heavy components (like a product list or user profile) are still loading.
- Better Perceived Performance: The app feels "snappy" because the user isn't staring at a blank screen or a full-page spinner.
Method 1: Page-Level Streaming with loading.tsx
The simplest way to implement streaming is by creating a loading.tsx file in your route folder. This automatically wraps the page.tsx and any nested children in a React Suspense boundary.
- Behavior: While the data in
page.tsxis being fetched on the server, the UI defined inloading.tsx(e.g., a skeleton loader) is shown immediately.
Example:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-64 bg-gray-100 rounded"></div>
</div>
);
}
Method 2: Component-Level Streaming with
For more granular control, you can use the <Suspense> component. This allows you to "stream" specific components independently, so one slow API call doesn't block the rest of the page.
- Behavior: The "static" parts of the page render immediately, while the "dynamic" component shows a
fallbackUI until its data is ready.
Example:
import { Suspense } from 'react';
import { SlowProfileComponent, FastWeatherComponent } from './components';
export default function Page() {
return (
<main>
<h1>My Dashboard</h1>
{/* This renders immediately */}
<FastWeatherComponent />
{/* This streams in later without blocking the page */}
<Suspense fallback={<p>Loading profile...</p>}>
<SlowProfileComponent />
</Suspense>
</main>
);
}
Summary: Why it matters for Slow Internet
On a slow 3G or 4G connection, downloading a massive HTML file takes time. Streaming allows the browser to start parsing and rendering the "Shell" of your app immediately. By the time the slow data arrives, the user has already read the title and navigation, making the wait for the main content feel much shorter.
Modern Loading UI: Skeletons
While a simple "Loading..." text works, modern applications use Skeletons to provide a better user experience. Skeletons are placeholder versions of your UI that mimic the final layout (shape, size, and position) using grey blocks and subtle animations (like a "pulse" effect).
Why use Skeletons?
- Eliminate Layout Shift: By reserving the exact space a component will occupy, you prevent the page from "jumping" when the data finally arrives.
- Visual Continuity: It gives users a clear idea of what kind of content is loading (e.g., an image vs. a list of links).
Example: Blog Tiles and Sidebar
Instead of one big spinner, you can stream individual sections of your page with their own custom skeletons.
// components/skeletons.tsx
export function BlogTileSkeleton() {
return (
<div className="border p-4 rounded-lg space-y-3 animate-pulse">
<div className="h-48 bg-gray-200 rounded-md" /> {/* Image placeholder */}
<div className="h-6 bg-gray-200 rounded w-3/4" /> {/* Title placeholder */}
<div className="h-4 bg-gray-200 rounded w-full" /> {/* Description line 1 */}
<div className="h-4 bg-gray-200 rounded w-2/3" /> {/* Description line 2 */}
</div>
);
}
export function SidebarTileSkeleton() {
return (
<div className="flex items-center space-x-4 p-2 animate-pulse">
<div className="h-10 w-10 bg-gray-200 rounded-full" /> {/* Avatar/Icon */}
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
);
}
Usage with Suspense
import { Suspense } from 'react';
import { BlogList, SidebarLinks } from './components';
import { BlogTileSkeleton, SidebarTileSkeleton } from './components/skeletons';
export default function BlogPage() {
return (
<div className="flex gap-8">
{/* Main Content: Blog Tiles */}
<section className="flex-1">
<Suspense fallback={<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => <BlogTileSkeleton key={i} />)}
</div>}>
<BlogList />
</Suspense>
</section>
{/* Sidebar Tiles */}
<aside className="w-64">
<Suspense fallback={<div className="space-y-4">
{[...Array(5)].map((_, i) => <SidebarTileSkeleton key={i} />)}
</div>}>
<SidebarLinks />
</Suspense>
</aside>
</div>
);
}
Error Boundaries (error.tsx)
The error.tsx file convention allows you to gracefully handle runtime errors in nested routes. It automatically wraps a route segment and its nested children in a React Error Boundary.
- Client Component Requirement:
error.tsxmust be a Client Component ('use client'). - Isolation: Errors are isolated to the segment where they occur. The rest of the application (like a navigation bar or sidebar) remains functional.
- Recovery: It provides a
resetfunction to allow users to attempt to recover from the error (e.g., re-trying a failed data fetch). - Global Errors: For catching errors in the root layout or template, use
global-error.tsx.
Example:
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service like Sentry
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center p-8 bg-red-50 text-red-900 rounded-md">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button
onClick={() => reset()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded"
>
Try again
</button>
</div>
);
}
Not Found Pages (not-found.tsx)
The not-found.tsx file is used to render UI when a route cannot be found, or when the notFound() function is explicitly called.
- Triggering: It renders automatically for unmatched URLs, or programmatically when you call
notFound()in a Server Component or Route Handler (e.g., if a database query for an ID returns null). - Scope: Like
error.tsx, it's scoped to its segment. Anot-found.tsxinapp/dashboardonly applies to unmatched routes within the dashboard.
Example:
// app/blog/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function BlogPost({ params }: { params: { id: string } }) {
const post = await db.post.findUnique(params.id);
if (!post) {
// This will trigger the closest not-found.tsx boundary
notFound();
}
return <article>{post.content}</article>;
}
// app/blog/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="text-center py-20">
<h2>Blog Post Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/blog" className="text-blue-500 underline">Return to Blog</Link>
</div>
);
}
Built-in Optimizations and SEO
Next.js provides built-in components and APIs designed to drastically improve performance (Core Web Vitals) and SEO out of the box.
Image Optimization (next/image)
The <Image> component extends the standard HTML <img> element with powerful automatic optimizations.
- Size Optimization: Automatically serves correctly sized images for each device, using modern formats like WebP and AVIF.
- Visual Stability: Prevents Cumulative Layout Shift (CLS) automatically when images load.
- Faster Page Loads: Images are lazily loaded by default (only loaded when they enter the viewport) with optional blur-up placeholders.
- Asset Flexibility: Can optimize remote images (if configured in
next.config.ts) as well as local assets.
Example:
import Image from 'next/image';
import profilePic from './me.png'; // Local image
export default function Profile() {
return (
<div>
{/* Local Image (width/height automatically determined) */}
<Image
src={profilePic}
alt="Picture of the author"
placeholder="blur" // Optional blur-up while loading
/>
{/* Remote Image (requires width, height, and config) */}
<Image
src="https://example.com/hero.jpg"
alt="Hero image"
width={500}
height={500}
priority // Use 'priority' for images above the fold (LCP)
/>
</div>
);
}
Font Optimization (next/font)
next/font automatically optimizes your fonts (including custom fonts) and removes external network requests for improved privacy and performance.
- Self-Hosting: It downloads Google Fonts at build time and serves them locally, so there are no requests to Google servers from the browser.
- No Layout Shift: It uses a CSS
size-adjustproperty fallback to ensure there is zero layout shift when the font swaps.
Example:
// app/layout.tsx
import { Inter } from 'next/font/google';
// Configure the font subset
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// Apply the font class to the body
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
Metadata and SEO API
Next.js has a built-in Metadata API that allows you to easily define SEO metadata (like meta tags, title, description, Open Graph images) for improved search engine rankings and social sharing.
- Static Metadata: Export a
metadataobject from alayout.tsxorpage.tsx. - Dynamic Metadata: Export a
generateMetadatafunction to fetch data and dynamically set titles (e.g., for a blog post). - File-Based Metadata: You can add special files like
favicon.ico,opengraph-image.png,robots.txt, andsitemap.tsdirectly in theappdirectory.
Example (Static & Dynamic):
// app/layout.tsx (Static)
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | My Site',
default: 'My Site - Home',
},
description: 'The best site ever.',
};
// app/products/[id]/page.tsx (Dynamic)
import type { Metadata, ResolvingMetadata } from 'next';
export async function generateMetadata(
{ params }: { params: { id: string } },
parent: ResolvingMetadata
): Promise<Metadata> {
const product = await fetchProduct(params.id);
return {
title: product.title,
description: product.summary,
openGraph: {
images: [product.imageUrl],
},
};
}
Data Fetching, Caching, and Revalidation
In Next.js 16, the caching model has shifted from "cache by default" to an explicit opt-in model. This gives you more control over what data is stored and for how long.
1. Default Caching Behavior (Next.js 16+)
Unlike previous versions (13/14), Next.js 16 does not cache fetch requests or components by default. Every request is considered dynamic unless you explicitly tell Next.js to cache it.
- Standard Fetch:
fetch('...')is equivalent tocache: 'no-store'. - Opt-in Required: You must use the
'use cache'directive to enable caching.
2. The 'use cache' Directive (Next.js 16+)
The 'use cache' directive is the modern way to cache data in Next.js. It can be applied at the file, component, or function level.
- Function Level: Caches the return value of a specific asynchronous function.
- Component Level: Caches the fully rendered HTML of a Server Component.
- File Level: When placed at the top of a file, everything exported from that file is cached.
cacheLife: Controlling Duration
To control how long data stays in the cache, use the cacheLife function inside a 'use cache' scope. Next.js provides built-in profiles:
| Profile | Revalidate (Fresh) | Expire (Stale) | Use Case |
|---|---|---|---|
seconds |
1 second | 1 minute | Stock prices, scores |
minutes |
1 minute | 1 hour | Social media feeds |
hours |
1 hour | 1 day | Inventory, weather |
days |
1 day | 1 week | Blog posts, documentation |
max |
30 days | 1 year | Static assets, legal pages |
Example: Caching with cacheLife
import { cacheLife } from 'next/cache';
async function getStockPrice(symbol: string) {
'use cache';
cacheLife('seconds'); // Revalidate every second
const res = await fetch(`https://api.stocks.com/${symbol}`);
return res.json();
}
cacheTag: On-Demand Invalidation
To purge the cache when data changes (e.g., after a database update), use cacheTag to label your data and revalidateTag to clear it.
Example: Tagging and Updating
// 1. Tag the cached data
import { cacheTag, revalidateTag } from 'next/cache';
async function getPosts() {
'use cache';
cacheTag('posts-list'); // Assign a unique tag
return db.posts.findMany();
}
// 2. Invalidate the tag in a Server Action
async function addPost(formData: FormData) {
'use server';
await db.posts.create({ data: { ... } });
// This immediately purges all caches labeled 'posts-list'
revalidateTag('posts-list');
}
3. Migration and Summary Table
Next.js 16 provides a clear path for moving from the old model to the new one.
| Method | Execution Environment | Use Case |
|---|---|---|
'use cache' |
Server (Component/Function) | Explicitly opt-in to caching |
cacheLife('days') |
Server (within 'use cache') | Set the lifetime of a cache entry |
cacheTag('tag') |
Server (within 'use cache') | Group related cache entries |
revalidateTag('tag') |
Server Only (Actions/Handlers) | Manually purge a group of caches |
revalidatePath('/') |
Server Only (Actions/Handlers) | Manually purge a specific route |
router.refresh() |
Client Only (useRouter) | Refresh the UI without losing state |
4. Configuration
To use these features, enable them in your next.config.ts:
const nextConfig = {
cacheComponents: true, // Enables 'use cache' and related APIs
};
Identifying Static vs. Dynamic Routes
When you build your Next.js application, the framework provides a clear summary of which routes are static and which are dynamic. This is essential for understanding your app's performance and caching strategy.
How to Check:
Run the build command in your terminal:
npm run build
# OR
npx next build
Understanding the Output:
At the end of the build process, you'll see a list of all your routes with a specific symbol next to them:
-
○(Empty Circle) - Static: This route is rendered into a static HTML file at build time. It is extremely fast and can be cached by a CDN (Content Delivery Network). -
ƒ(Lambda/Function) - Dynamic: This route is rendered at request time (SSR). It runs on the server for every user request. This happens when you use dynamic functions (likecookies()orheaders()) or fetch data withcache: 'no-store'.
Why it matters:
- Static routes are served as pre-rendered HTML, providing the best performance and lowest server cost.
- Dynamic routes allow you to show personalized or real-time data but require server resources for every visit.
- If a route you expected to be static (circle) shows up as dynamic (lambda), check if you accidentally opted out of caching or used a dynamic function.
Top comments (0)