DEV Community

Cover image for Modern Next.js Essentials: Building Scalable Full-Stack Applications
Preyum Kumar
Preyum Kumar

Posted on

Modern Next.js Essentials: Building Scalable Full-Stack Applications

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

  1. Fundamentals
  2. The Legacy Page Router
  3. The App Router
  4. Client Components and 'use client'
  5. Advanced Routing and Navigation
  6. Layouts and UI Shells
  7. Handling UI States: Loading, Errors, and Not Found
  8. Built-in Optimizations and SEO
  9. UI and Styling with shadcn/ui
  10. Theming with next-themes
  11. Schema Validation with Zod
  12. Form Validation with React Hook Form and Zod
  13. Server-Side Logic: Route Handlers vs. Server Actions
  14. The 'Proxy' Concept (formerly Middleware)
  15. Real-time Data and Communication
  16. Authentication with Better Auth
  17. Toasts with Sonner
  18. Data Fetching, Caching, and Revalidation
  19. 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:

  1. Server side: Next.js pre-renders your React components into static HTML.
  2. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 app directory represents a URL segment.
  • page.tsx: To make a route publicly accessible, you must include a page.tsx file inside that folder.
  • Default Export: Every page.tsx must have an export default function to 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Client-Side Rendering: The component (and all its imported dependencies) is added to the Client-Side JavaScript Bundle.
  2. 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.
  3. Client Boundary: It establishes a "Client Boundary." Any component imported into a Client Component automatically becomes a Client Component as well.
  4. 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, or onSubmit.
    • React Hooks: State (useState), effects (useEffect), or context (useContext).
    • Browser APIs: Access to window, document, or localStorage.
  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.tsx is accessible at /login, not /(auth)/login.
  • Shared Layouts: You can add a layout.tsx inside 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.tsx and error.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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 (.)folder to intercept routes at the same level, or (..)folder to 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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode
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>;
}
Enter fullscreen mode Exit fullscreen mode

Server-Side Example:

'use server';
import { redirect } from 'next/navigation';

export async function updateProfile(formData: FormData) {
  // logic...
  redirect(`/profile/123`);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 init
    

    This command is interactive and will ask you several configuration questions (style, base color, CSS variables, etc.). Upon completion, it creates a components.json file 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/ui folder, 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 ThemeProvider wrapper must be a Client Component because it manages state (using React Context and hooks like useState or useEffect) and interacts with browser APIs like localStorage to persist the user's theme preference.
  • suppressHydrationWarning: You must add suppressHydrationWarning to your root <html> tag in layout.tsx.
    • Reason: On the server, Next.js doesn't know the user's client-side theme preference (e.g., from localStorage). When next-themes updates the class or data-theme on 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.

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 an interface or type that 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());
}
Enter fullscreen mode Exit fullscreen mode

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/resolvers is 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 (like required, 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 errors object automatically.

  • Benefits:

    • Type Safety: Use z.infer to 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 errors object based on your Zod schema's messages.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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.ts runs 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:

  1. Rewriting: Changing the internal destination (the actual proxying) using NextResponse.rewrite().
  2. Redirecting: Sending users to a different URL.
  3. 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*'],
};
Enter fullscreen mode Exit fullscreen mode

Migration Note

If you are moving from an older version of Next.js:

  1. Rename middleware.ts to proxy.ts.
  2. Rename the exported function middleware to proxy.
  3. middleware.ts is 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} />;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Messaging: Use Convex to store messages. Since it's reactive, the chat UI updates instantly for both users.
  2. Calling: When a user clicks "Call," a signaling message is sent via Pusher or Convex to the other user.
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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.tsx is being fetched on the server, the UI defined in loading.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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 fallback UI 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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?
  1. Eliminate Layout Shift: By reserving the exact space a component will occupy, you prevent the page from "jumping" when the data finally arrives.
  2. Visual Continuity: It gives users a clear idea of what kind of content is loading (e.g., an image vs. a list of links).

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.tsx must 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 reset function 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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. A not-found.tsx in app/dashboard only 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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-adjust property 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 metadata object from a layout.tsx or page.tsx.
  • Dynamic Metadata: Export a generateMetadata function 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, and sitemap.ts directly in the app directory.

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],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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 to cache: '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();
}
Enter fullscreen mode Exit fullscreen mode

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'); 
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (like cookies() or headers()) or fetch data with cache: '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)