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 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 or useState() 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 part is a Generic. // It tells the function exactly what shape the response will have. const user = await fetchUser(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 ( {/* This part is rendered as static HTML on the server (Great for SEO) */} {product.name} {product.description} {/* 2. SPA-like Interactivity: This component handles client-side state */} ); } // 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 ( setIsAdded(true)}> {isAdded ? "Added to Cart!" : "Add to Cart"} ); } Why this is powerful: The Search Engine sees the and 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 ( Contact Us Feel free to reach out via email. ); } 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, 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 ( Parent (Client Component) setCount(count + 1)}>Count: {count} {/* The Child is imported and rendered here */} ); } // 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 ( Child Component I don't have 'use client', but I'm part of the client bundle! ); } 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; } export default async function BlogIdPage({ params }: BlogIdPageProps) { const { blogId } = await params; return ( Hello from the blog id page Viewing blog post ID: {blogId} ); } 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 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 ( {children} {analytics} {team} ); } 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 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 Go to About Page; } 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 router.push('/dashboard')}>Log In; } 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 ( Sidebar Navigation {children} {/* This is where the page content is injected */} ); } 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 ( Click Me ) } 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 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 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 ( {children} ) } 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; // 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: "[email protected]", }); 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/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; export default function MyForm() { // 3. Initialize the form with the Zod resolver const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(schema), }); const onSubmit = (data: FormData) => { console.log("Validated Data:", data); }; return ( Username {errors.username && {errors.username.message}} Email {errors.email && {errors.email.message}} Submit ); } 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 ). 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.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: 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.ts to proxy.ts. Rename the exported function middleware to proxy. 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 ; } 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 ( {children} {/* Add this once to your layout */} ) } // Any Client Component "use client" import { toast } from "sonner" export function MyComponent() { return ( toast("Operation Successful!")}> Show Toast ) } 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.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 ( ); } Method 2: Component-Level Streaming with For more granular control, you can use the 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 ( My Dashboard {/* This renders immediately */} {/* This streams in later without blocking the page */} Loading profile...}> ); } 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 ( {/* Image placeholder */} {/* Title placeholder */} {/* Description line 1 */} {/* Description line 2 */} ); } export function SidebarTileSkeleton() { return ( {/* Avatar/Icon */} ); } Usage with Suspense import { Suspense } from 'react'; import { BlogList, SidebarLinks } from './components'; import { BlogTileSkeleton, SidebarTileSkeleton } from './components/skeletons'; export default function BlogPage() { return ( {/* Main Content: Blog Tiles */} {[...Array(6)].map((_, i) => )} }> {/* Sidebar Tiles */} {[...Array(5)].map((_, i) => )} }> ); } 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 ( Something went wrong! {error.message} reset()} className="mt-4 px-4 py-2 bg-red-600 text-white rounded" > Try again ); } 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 {post.content}; } // app/blog/not-found.tsx import Link from 'next/link'; export default function NotFound() { return ( Blog Post Not Found Could not find requested resource Return to Blog ); } 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 component extends the standard HTML 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 ( {/* Local Image (width/height automatically determined) */} {/* Remote Image (requires width, height, and config) */} ); } 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 {children} ); } 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 { 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 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(); } 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 (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.
