Next.js Reference

20 Next.js Code Patterns Every Developer Should Know

Stop re-inventing the same utilities in every project. This is a bookmark-worthy reference of 20 battle-tested Next.js patterns — each with a clear problem statement, copy-paste code, a short explanation, and production best practices.

✓ Copy-paste snippets✓ App Router✓ Production-ready✓ 20 patterns

Patterns, not theory

Every section follows the same structure: Problem → Code → Explanation → Best practices. Grab what you need and ship.

How to use this guide: Each pattern is self-contained. Copy the snippet into your project, adjust types and routes, and move on. All examples assume Next.js 15 App Router unless noted.

2. Infinite Scroll

Problem

Paginated "Load more" buttons add friction. You want the next page to load automatically when the user scrolls near the bottom — without duplicate requests or layout jumps.

// hooks/use-infinite-scroll.ts
import { useEffect, useRef } from "react";

type Options = {
  onLoadMore: () => void;
  hasMore: boolean;
  isLoading: boolean;
  rootMargin?: string;
};

export function useInfiniteScroll({
  onLoadMore,
  hasMore,
  isLoading,
  rootMargin = "200px",
}: Options) {
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = sentinelRef.current;
    if (!el || !hasMore || isLoading) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) onLoadMore();
      },
      { rootMargin }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [hasMore, isLoading, onLoadMore, rootMargin]);

  return sentinelRef;
}

// components/InfiniteList.tsx
"use client";

import { useCallback, useState } from "react";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";

export function InfiniteList({ initialItems, fetchPage }) {
  const [items, setItems] = useState(initialItems);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return;
    setIsLoading(true);
    const nextPage = page + 1;
    const { data, hasMore: more } = await fetchPage(nextPage);
    setItems((prev) => [...prev, ...data]);
    setPage(nextPage);
    setHasMore(more);
    setIsLoading(false);
  }, [fetchPage, hasMore, isLoading, page]);

  const sentinelRef = useInfiniteScroll({ onLoadMore: loadMore, hasMore, isLoading });

  return (
    <>
      <ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
      <div ref={sentinelRef} aria-hidden className="h-4" />
      {isLoading && <p className="text-center text-sm">Loading more...</p>}
    </>
  );
}

Explanation

An invisible sentinel element at the list bottom triggers onLoadMore when it enters the viewport. The hook guards against duplicate fetches withisLoading and hasMore checks.

Best practices

  • Prefer cursor-based pagination over offset for large datasets.
  • Keep skeleton placeholders at the bottom to prevent layout shift.
  • Disconnect the observer when hasMore is false.
  • For SEO-critical lists, render the first page server-side.

3. Optimistic Updates

Problem

Waiting for a server round-trip before updating the UI makes apps feel sluggish. Users expect instant feedback when they like a post, toggle a setting, or delete an item.

// components/TodoList.tsx
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTodo } from "@/app/actions/todos";

type Todo = { id: string; title: string; done: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, updated: Todo) =>
      state.map((t) => (t.id === updated.id ? updated : t))
  );
  const [isPending, startTransition] = useTransition();

  function handleToggle(todo: Todo) {
    const next = { ...todo, done: !todo.done };
    startTransition(async () => {
      addOptimistic(next);
      await toggleTodo(todo.id, next.done);
    });
  }

  return (
    <ul className={isPending ? "opacity-80" : ""}>
      {optimisticTodos.map((todo) => (
        <li key={todo.id}>
          <button onClick={() => handleToggle(todo)}>
            {todo.done ? "✓" : "○"} {todo.title}
          </button>
        </li>
      ))}
    </ul>
  );
}

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

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

export async function toggleTodo(id: string, done: boolean) {
  await db.todo.update({ where: { id }, data: { done } });
  revalidatePath("/todos");
}

Explanation

useOptimistic applies a temporary UI state inside a transition. React reverts automatically if the Server Action throws. Pair with revalidatePathso the server cache stays in sync after success.

Best practices

  • Only optimistically update reversible actions — show a toast on failure.
  • Dim the UI slightly during pending transitions so users know sync is happening.
  • Never optimistically update financial or irreversible operations without confirmation.
  • Revalidate the smallest path possible — not the entire layout.

4. Pagination

Problem

Client-only pagination breaks shareable URLs and Server Component data fetching. You need page state in the URL, stable links, and server-rendered page data.

// app/products/page.tsx
import Link from "next/link";
import { getProducts } from "@/lib/products";

const PAGE_SIZE = 12;

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>;
}) {
  const { page: pageParam } = await searchParams;
  const page = Math.max(1, Number(pageParam) || 1);
  const { items, total } = await getProducts({ page, pageSize: PAGE_SIZE });
  const totalPages = Math.ceil(total / PAGE_SIZE);

  return (
    <>
      <div className="grid grid-cols-3 gap-4">
        {items.map((p) => (
          <article key={p.id}>{p.name}</article>
        ))}
      </div>
      <Pagination page={page} totalPages={totalPages} />
    </>
  );
}

// components/Pagination.tsx
import Link from "next/link";

export function Pagination({
  page,
  totalPages,
  basePath = "/products",
}: {
  page: number;
  totalPages: number;
  basePath?: string;
}) {
  const prev = Math.max(1, page - 1);
  const next = Math.min(totalPages, page + 1);

  return (
    <nav aria-label="Pagination" className="flex gap-2 mt-8">
      <Link
        href={`${basePath}?page=${prev}`}
        aria-disabled={page <= 1}
        className={page <= 1 ? "pointer-events-none opacity-40" : ""}
      >
        Previous
      </Link>
      <span>Page {page} of {totalPages}</span>
      <Link
        href={`${basePath}?page=${next}`}
        aria-disabled={page >= totalPages}
        className={page >= totalPages ? "pointer-events-none opacity-40" : ""}
      >
        Next
      </Link>
    </nav>
  );
}

Explanation

Page number lives in searchParams, so each page is a real URL. The server fetches only the current slice. Pagination links are plain Linkcomponents — no client JavaScript required.

Best practices

  • Clamp page to valid range; redirect invalid pages to page 1.
  • Add rel="prev" and rel="next" in metadata for SEO lists.
  • Combine with debounced search — reset page when filters change.
  • Return total count from the database in a single query when possible.

6. Error Boundary

Problem

One thrown error in a child component should not white-screen the entire app. You need graceful recovery at the route and component level.

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

import { useEffect } from "react";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Send to Sentry, Datadog, etc.
    console.error(error.digest, error);
  }, [error]);

  return (
    <div className="rounded-xl border p-8 text-center">
      <h2>Something went wrong</h2>
      <p className="text-muted-foreground mt-2">
        {error.message || "An unexpected error occurred."}
      </p>
      <button onClick={reset} className="mt-4 btn btn-primary">
        Try again
      </button>
    </div>
  );
}

// components/ErrorBoundary.tsx (client component isolation)
"use client";

import { Component, type ReactNode } from "react";

export class ErrorBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

// Usage: wrap risky widgets
// <ErrorBoundary fallback={<ChartFallback />}>
//   <AnalyticsChart />
// </ErrorBoundary>

Explanation

Next.js error.tsx is a route-level error boundary with a built-inreset() function. For isolated widgets (charts, third-party embeds), use a class-based ErrorBoundary so one failure does not crash the page.

Best practices

  • Log error.digest server-side — it maps to the real stack trace.
  • Place error.tsx at the narrowest route segment that makes sense.
  • Never catch errors silently — always report to your observability tool.
  • Pair with global-error.tsx for root layout failures.

7. Server Actions

Problem

Creating API routes for every form mutation adds boilerplate. Server Actions let you call server functions directly from forms and components with built-in POST handling.

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

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

const schema = z.object({
  title: z.string().min(1).max(120),
  body: z.string().min(1),
});

export type ActionState = { error?: string; success?: boolean };

export async function createPost(
  _prev: ActionState,
  formData: FormData
): Promise<ActionState> {
  const user = await auth();
  if (!user) return { error: "Unauthorized" };

  const parsed = schema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors.title?.[0] ?? "Invalid input" };
  }

  await db.post.create({ data: { ...parsed.data, authorId: user.id } });

  revalidatePath("/posts");
  redirect("/posts");
}

// app/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";
import { SubmitButton } from "@/components/SubmitButton";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="body" required />
      <SubmitButton label="Publish" />
    </form>
  );
}

Explanation

The "use server" directive marks functions callable from the client. Validation and auth run on the server. Use the two-argument form (prevState, formData) with useActionState for inline error messages.

Best practices

  • Always validate with Zod — never trust FormData.
  • Check authentication inside every action, not just the page.
  • Return structured errors for forms; use redirect() on success.
  • Keep actions thin — delegate business logic to service modules.

8. Dynamic Metadata

Problem

Static metadata exports cannot reflect per-page content like blog titles, product names, or OG images. Dynamic routes need generateMetadata.

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";

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

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return { title: "Post not found" };

  const url = `https://example.com/blog/${slug}`;

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: url },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url,
      type: "article",
      publishedTime: post.publishedAt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) notFound();

  return <article><h1>{post.title}</h1>{post.content}</article>;
}

Explanation

generateMetadata runs on the server before render, populating<head> tags for SEO and social sharing. It can fetch the same data as the page — Next.js deduplicates identical fetch requests automatically.

Best practices

  • Always set canonical URL to prevent duplicate content issues.
  • Provide fallback metadata when the resource is not found.
  • Use absolute URLs for OG images — relative paths break on social crawlers.
  • Export generateStaticParams for popular slugs at build time.

9. Caching

Problem

Hitting the database on every request is expensive. You need predictable caching for reads that do not change every second — without serving stale data forever.

// lib/data.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

// Option A: fetch with cache hints
export async function getPublicStats() {
  const res = await fetch("https://api.example.com/stats", {
    next: { revalidate: 3600, tags: ["stats"] },
  });
  return res.json();
}

// Option B: cache expensive DB queries
export const getFeaturedProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      where: { featured: true },
      take: 8,
      orderBy: { updatedAt: "desc" },
    });
  },
  ["featured-products"],
  { revalidate: 300, tags: ["products"] }
);

// app/page.tsx — force fresh when needed
import { getFeaturedProducts } from "@/lib/data";

export default async function HomePage() {
  const products = await getFeaturedProducts();
  return <ProductGrid products={products} />;
}

// Opt out of cache for user-specific data:
// export const dynamic = "force-dynamic";
// or: cache: "no-store" on fetch

Explanation

Next.js caches fetch results and unstable_cache wrappers across requests. Tags and revalidate TTLs give you control over freshness vs. performance.

Best practices

  • Tag cache entries by entity (products, user:123) for surgical invalidation.
  • Never cache user-specific or auth-gated data with shared keys.
  • Use force-dynamic sparingly — it disables static optimization for the route.
  • Log cache hit rates in staging to tune TTL values.

10. Revalidation

Problem

After a mutation, cached pages still show old data until the TTL expires. You need on-demand invalidation when content changes.

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

import { revalidatePath, revalidateTag } from "next/cache";
import { db } from "@/lib/db";

export async function updateProduct(id: string, data: { name: string; price: number }) {
  await db.product.update({ where: { id }, data });

  // Invalidate tagged fetches across the app
  revalidateTag("products");
  revalidateTag(`product:${id}`);

  // Invalidate specific route segments
  revalidatePath("/products");
  revalidatePath(`/products/${id}`);
}

// app/api/revalidate/route.ts — webhook from CMS
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const secret = req.headers.get("x-revalidate-secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { tag } = await req.json();
  revalidateTag(tag);

  return NextResponse.json({ revalidated: true, tag });
}

Explanation

revalidateTag busts all cache entries with that tag.revalidatePath refreshes a specific route's rendered output. Use webhooks from your CMS or database triggers for external updates.

Best practices

  • Prefer tags over path revalidation when the same data appears on multiple pages.
  • Protect webhook endpoints with a shared secret.
  • Call revalidation after the database transaction commits.
  • Do not revalidate the root layout unless absolutely necessary — it is expensive.

11. Loading Skeleton

Problem

Blank screens while Server Components fetch data feel broken. Skeletons communicate structure and reduce perceived load time.

// app/dashboard/loading.tsx
import { DashboardSkeleton } from "@/components/skeletons/DashboardSkeleton";

export default function Loading() {
  return <DashboardSkeleton />;
}

// components/skeletons/DashboardSkeleton.tsx
export function DashboardSkeleton() {
  return (
    <div className="animate-pulse space-y-6 p-6">
      <div className="h-8 w-48 rounded bg-muted" />
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 3 }).map((_, i) => (
          <div key={i} className="h-28 rounded-xl bg-muted" />
        ))}
      </div>
      <div className="space-y-3">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-12 rounded-lg bg-muted" />
        ))}
      </div>
    </div>
  );
}

// components/skeletons/ProductCardSkeleton.tsx
export function ProductCardSkeleton() {
  return (
    <div className="animate-pulse rounded-xl border p-4">
      <div className="aspect-square rounded-lg bg-muted" />
      <div className="mt-3 h-4 w-3/4 rounded bg-muted" />
      <div className="mt-2 h-4 w-1/2 rounded bg-muted" />
    </div>
  );
}

Explanation

loading.tsx automatically wraps a route segment in Suspense. Skeleton components mirror the final layout so content does not jump when data arrives.

Best practices

  • Match skeleton dimensions to real content to avoid layout shift (CLS).
  • Use animate-pulse sparingly — one animation per section is enough.
  • Create reusable skeleton primitives (SkeletonLine, SkeletonCard).
  • Colocate loading.tsx at the route segment that actually suspends.

12. Suspense

Problem

One slow query blocks the entire page from rendering. You want the shell to stream immediately while independent sections load in parallel.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { StatsSkeleton, TableSkeleton } from "@/components/skeletons";
import { StatsPanel } from "@/components/StatsPanel";
import { RecentOrders } from "@/components/RecentOrders";

export default function DashboardPage() {
  return (
    <div className="space-y-8 p-6">
      <h1>Dashboard</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// components/RecentOrders.tsx — async Server Component
import { db } from "@/lib/db";

export async function RecentOrders() {
  const orders = await db.order.findMany({
    take: 10,
    orderBy: { createdAt: "desc" },
  });

  return (
    <table>
      <tbody>
        {orders.map((o) => (
          <tr key={o.id}><td>{o.id}</td><td>{o.total}</td></tr>
        ))}
      </tbody>
    </table>
  );
}

Explanation

Each Suspense boundary streams its fallback first, then swaps in the async child when ready. Independent boundaries load in parallel instead of blocking each other.

Best practices

  • Split at natural UI boundaries — stats, sidebar, main content.
  • Do not wrap the entire page in one Suspense — defeats streaming.
  • Combine with loading.tsx for route-level and component-level fallbacks.
  • Prefetch critical above-the-fold data; suspense the rest.

13. Protected Route

Problem

Client-side redirects are trivially bypassed. Authenticated routes must be enforced in middleware and verified again on the server before rendering sensitive data.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";

const PROTECTED = ["/dashboard", "/settings", "/billing"];
const AUTH_ROUTES = ["/login", "/signup"];

export async function middleware(request: NextRequest) {
  const { response, user } = await updateSession(request);
  const { pathname } = request.nextUrl;

  const isProtected = PROTECTED.some((p) => pathname.startsWith(p));
  const isAuthRoute = AUTH_ROUTES.some((p) => pathname.startsWith(p));

  if (isProtected && !user) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("redirect", pathname);
    return NextResponse.redirect(url);
  }

  if (isAuthRoute && user) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

// lib/auth.ts — server-side guard
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function requireUser() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");
  return user;
}

// app/dashboard/page.tsx
import { requireUser } from "@/lib/auth";

export default async function DashboardPage() {
  const user = await requireUser();
  return <h1>Welcome, {user.email}</h1>;
}

Explanation

Middleware handles the fast redirect before render. requireUser() in Server Components is the second line of defense — never trust middleware alone for authorization decisions on data mutations.

Best practices

  • Use getUser(), not getSession(), for auth checks.
  • Preserve the intended URL in a redirect query param after login.
  • Enforce permissions in Server Actions and API routes too.
  • Keep the middleware matcher lean — exclude static assets.

14. API Error Handler

Problem

Every Route Handler reimplements try/catch with inconsistent status codes and error shapes. Clients cannot reliably parse failures.

// lib/api-error.ts
export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
    public code?: string
  ) {
    super(message);
    this.name = "ApiError";
  }
}

export function handleApiError(error: unknown) {
  if (error instanceof ApiError) {
    return Response.json(
      { error: { message: error.message, code: error.code } },
      { status: error.status }
    );
  }

  console.error("[API]", error);
  return Response.json(
    { error: { message: "Internal server error", code: "INTERNAL" } },
    { status: 500 }
  );
}

// app/api/posts/[id]/route.ts
import { NextRequest } from "next/server";
import { ApiError, handleApiError } from "@/lib/api-error";
import { db } from "@/lib/db";
import { requireApiUser } from "@/lib/auth";

export async function GET(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id } = await params;
    const post = await db.post.findUnique({ where: { id } });
    if (!post) throw new ApiError(404, "Post not found", "NOT_FOUND");
    return Response.json(post);
  } catch (error) {
    return handleApiError(error);
  }
}

export async function DELETE(
  _req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const user = await requireApiUser();
    const { id } = await params;
    const post = await db.post.findUnique({ where: { id } });
    if (!post) throw new ApiError(404, "Post not found", "NOT_FOUND");
    if (post.authorId !== user.id) throw new ApiError(403, "Forbidden", "FORBIDDEN");
    await db.post.delete({ where: { id } });
    return new Response(null, { status: 204 });
  } catch (error) {
    return handleApiError(error);
  }
}

Explanation

A custom ApiError class carries HTTP status and machine-readable codes.handleApiError normalizes all responses so the client always receives{ error: { message, code } }.

Best practices

  • Never leak stack traces or SQL errors to clients in production.
  • Use stable code strings for client-side error handling.
  • Return 204 with no body for successful deletes.
  • Log the full error server-side before returning the sanitized response.

15. Toast Hook

Problem

Scattered alert() calls and one-off notification components make feedback inconsistent. You need a global toast API callable from any client component.

// context/toast-context.tsx
"use client";

import { createContext, useCallback, useContext, useState } from "react";

type Toast = { id: string; message: string; type: "success" | "error" | "info" };
type ToastContextValue = {
  toast: (message: string, type?: Toast["type"]) => void;
  toasts: Toast[];
  dismiss: (id: string) => void;
};

const ToastContext = createContext<ToastContextValue | null>(null);

export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const dismiss = useCallback((id: string) => {
    setToasts((t) => t.filter((x) => x.id !== id));
  }, []);

  const toast = useCallback((message: string, type: Toast["type"] = "info") => {
    const id = crypto.randomUUID();
    setToasts((t) => [...t, { id, message, type }]);
    setTimeout(() => dismiss(id), 4000);
  }, [dismiss]);

  return (
    <ToastContext.Provider value={{ toast, toasts, dismiss }}>
      {children}
      <div className="fixed bottom-4 right-4 z-50 space-y-2">
        {toasts.map((t) => (
          <div
            key={t.id}
            role="status"
            className={`rounded-lg px-4 py-3 shadow-lg text-white ${
              t.type === "success" ? "bg-green-600" :
              t.type === "error" ? "bg-red-600" : "bg-slate-800"
            }`}
          >
            {t.message}
          </div>
        ))}
      </div>
    </ToastContext.Provider>
  );
}

export function useToast() {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error("useToast must be used within ToastProvider");
  return ctx;
}

// Usage:
// const { toast } = useToast();
// toast("Saved successfully", "success");

Explanation

A context-backed toast queue lets any client component fire notifications without prop drilling. Auto-dismiss after 4 seconds keeps the UI clean. Swap the renderer for Sonner or Radix Toast in production.

Best practices

  • Limit visible toasts to 3 — queue or replace older ones.
  • Use role="status" for screen reader announcements.
  • Pair error toasts with retry actions for failed mutations.
  • Consider sonner if you need promises, actions, and stacking.

16. Reusable Form Hook

Problem

Repetitive form state, validation, and error display code in every form. You need a typed hook that works with Zod and Server Actions.

// hooks/use-zod-form.ts
"use client";

import { useState } from "react";
import { z, type ZodSchema } from "zod";

export function useZodForm<T extends ZodSchema>(schema: T) {
  type Values = z.infer<T>;
  type Errors = Partial<Record<keyof Values, string>>;

  const [values, setValues] = useState<Partial<Values>>({});
  const [errors, setErrors] = useState<Errors>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  function setField<K extends keyof Values>(key: K, value: Values[K]) {
    setValues((v) => ({ ...v, [key]: value }));
    setErrors((e) => ({ ...e, [key]: undefined }));
  }

  async function handleSubmit(onValid: (data: Values) => Promise<void>) {
    const parsed = schema.safeParse(values);
    if (!parsed.success) {
      const fieldErrors: Errors = {};
      parsed.error.issues.forEach((issue) => {
        const key = issue.path[0] as keyof Values;
        fieldErrors[key] = issue.message;
      });
      setErrors(fieldErrors);
      return;
    }

    setIsSubmitting(true);
    try {
      await onValid(parsed.data);
    } finally {
      setIsSubmitting(false);
    }
  }

  return { values, errors, setField, handleSubmit, isSubmitting };
}

// Usage with Server Action
const schema = z.object({ email: z.string().email(), name: z.string().min(2) });

function ProfileForm() {
  const { values, errors, setField, handleSubmit, isSubmitting } = useZodForm(schema);

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      handleSubmit(async (data) => {
        await updateProfile(data);
      });
    }}>
      <input
        value={values.name ?? ""}
        onChange={(e) => setField("name", e.target.value)}
      />
      {errors.name && <p className="text-red-500">{errors.name}</p>}
      <button disabled={isSubmitting}>Save</button>
    </form>
  );
}

Explanation

The hook centralizes Zod validation, field-level errors, and submit loading state. It works with Server Actions, fetch calls, or any async handler passed tohandleSubmit.

Best practices

  • Share Zod schemas between client hook and Server Actions.
  • Clear field errors on change for immediate feedback.
  • For complex forms, use react-hook-form + @hookform/resolvers/zod.
  • Disable submit while isSubmitting to prevent double posts.

17. File Upload Hook

Problem

File uploads need progress tracking, validation, error handling, and abort support — too much logic to duplicate in every dropzone.

// hooks/use-upload.ts
"use client";

import { useCallback, useRef, useState } from "react";

type UploadState = {
  progress: number;
  error: string | null;
  url: string | null;
  isUploading: boolean;
};

export function useUpload(endpoint = "/api/upload") {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    error: null,
    url: null,
    isUploading: false,
  });
  const xhrRef = useRef<XMLHttpRequest | null>(null);

  const upload = useCallback((file: File) => {
    const allowed = ["image/jpeg", "image/png", "application/pdf"];
    const maxSize = 10 * 1024 * 1024; // 10 MB

    if (!allowed.includes(file.type)) {
      setState((s) => ({ ...s, error: "Unsupported file type" }));
      return;
    }
    if (file.size > maxSize) {
      setState((s) => ({ ...s, error: "File exceeds 10 MB limit" }));
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    const xhr = new XMLHttpRequest();
    xhrRef.current = xhr;

    setState({ progress: 0, error: null, url: null, isUploading: true });

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        setState((s) => ({ ...s, progress: Math.round((e.loaded / e.total) * 100) }));
      }
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        const { url } = JSON.parse(xhr.responseText);
        setState({ progress: 100, error: null, url, isUploading: false });
      } else {
        setState((s) => ({ ...s, error: "Upload failed", isUploading: false }));
      }
    };

    xhr.onerror = () => setState((s) => ({ ...s, error: "Network error", isUploading: false }));
    xhr.open("POST", endpoint);
    xhr.send(formData);
  }, [endpoint]);

  const cancel = useCallback(() => {
    xhrRef.current?.abort();
    setState({ progress: 0, error: null, url: null, isUploading: false });
  }, []);

  const reset = useCallback(() => {
    setState({ progress: 0, error: null, url: null, isUploading: false });
  }, []);

  return { ...state, upload, cancel, reset };
}

Explanation

XMLHttpRequest exposes upload progress events that fetch lacks. The hook validates client-side before sending, tracks progress, and supports cancellation viaabort().

Best practices

  • Always re-validate file type and size on the server — client checks are not security.
  • Use presigned URLs for large files to bypass your server's body size limits.
  • Show progress bar and cancel button for uploads over 1 MB.
  • Return the final CDN/storage URL from the API, not the raw file buffer.

18. Infinite Query

Problem

Manual infinite scroll state management gets messy with caching, refetching, and error retry. TanStack Query's useInfiniteQuery handles the hard parts.

// app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

// hooks/use-infinite-posts.ts
"use client";

import { useInfiniteQuery } from "@tanstack/react-query";

type Page = { data: Post[]; nextCursor: string | null };

async function fetchPosts({ pageParam }: { pageParam?: string }): Promise<Page> {
  const url = pageParam ? `/api/posts?cursor=${pageParam}` : "/api/posts";
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch");
  return res.json();
}

export function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (last) => last.nextCursor ?? undefined,
  });
}

// components/PostFeed.tsx
"use client";

import { useInfinitePosts } from "@/hooks/use-infinite-posts";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";

export function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    error,
  } = useInfinitePosts();

  const sentinelRef = useInfiniteScroll({
    onLoadMore: () => fetchNextPage(),
    hasMore: !!hasNextPage,
    isLoading: isFetchingNextPage,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Failed to load posts</p>;

  const posts = data?.pages.flatMap((p) => p.data) ?? [];

  return (
    <>
      {posts.map((post) => <article key={post.id}>{post.title}</article>)}
      <div ref={sentinelRef} />
      {isFetchingNextPage && <p>Loading more...</p>}
    </>
  );
}

Explanation

useInfiniteQuery caches pages, deduplicates requests, and exposesfetchNextPage / hasNextPage. Pair with the infinite scroll sentinel hook for automatic loading.

Best practices

  • Use cursor-based APIs — pass nextCursor from the server.
  • Invalidate with queryClient.invalidateQueries({ queryKey: ["posts"] }) after mutations.
  • Set staleTime to avoid refetching on every mount.
  • Prefetch the next page when the user is one screen away from the bottom.

19. Role Permissions

Problem

Scattered if (user.role === "admin") checks are error-prone. You need a single permission matrix enforced on the server and reflected in the UI.

// lib/permissions.ts
export const ROLES = ["viewer", "editor", "admin"] as const;
export type Role = (typeof ROLES)[number];

const PERMISSIONS = {
  "post:read":   ["viewer", "editor", "admin"],
  "post:write":  ["editor", "admin"],
  "post:delete": ["admin"],
  "billing:manage": ["admin"],
} as const;

export type Permission = keyof typeof PERMISSIONS;

export function hasPermission(role: Role, permission: Permission): boolean {
  return (PERMISSIONS[permission] as readonly Role[]).includes(role);
}

export function requirePermission(role: Role, permission: Permission) {
  if (!hasPermission(role, permission)) {
    throw new Error(`Missing permission: ${permission}`);
  }
}

// components/Can.tsx
"use client";

import { hasPermission, type Permission, type Role } from "@/lib/permissions";

export function Can({
  role,
  permission,
  children,
  fallback = null,
}: {
  role: Role;
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  return hasPermission(role, permission) ? children : fallback;
}

// Server Action enforcement
"use server";

import { requireUser } from "@/lib/auth";
import { requirePermission } from "@/lib/permissions";

export async function deletePost(id: string) {
  const user = await requireUser();
  requirePermission(user.role, "post:delete");
  await db.post.delete({ where: { id } });
}

Explanation

Permissions are defined as capabilities (post:delete), not role checks scattered through the codebase. The Can component hides UI elements; Server Actions throw if the user lacks permission.

Best practices

  • Store roles in the database, not JWT custom claims alone.
  • UI hiding is UX only — always enforce on the server.
  • Use string union types for permissions to catch typos at compile time.
  • Mirror permissions in database RLS policies when using Supabase/Postgres.

20. Theme Toggle

Problem

Dark mode with zero flash-of-wrong-theme on load requires blocking script injection, system preference detection, and persistent user choice.

// npm install next-themes

// app/providers.tsx
"use client";

import { ThemeProvider } from "next-themes";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}

// app/layout.tsx
import { Providers } from "./providers";

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

// components/ThemeToggle.tsx
"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export function ThemeToggle() {
  const { theme, setTheme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);
  if (!mounted) return <button aria-label="Toggle theme" className="w-9 h-9" />;

  const isDark = resolvedTheme === "dark";

  return (
    <button
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
      onClick={() => setTheme(isDark ? "light" : "dark")}
      className="rounded-lg border p-2"
    >
      {isDark ? "☀️" : "🌙"}
    </button>
  );
}

// tailwind.config.ts
// darkMode: "class"

Explanation

next-themes injects a blocking script that sets theclass="dark" on <html> before paint.suppressHydrationWarning on <html> prevents React mismatch warnings. The mounted guard avoids hydration flicker on the toggle button.

Best practices

  • Use resolvedTheme for the actual applied theme (handles "system").
  • Set darkMode: "class" in Tailwind — not media.
  • Define CSS variables for colors so one class toggle updates everything.
  • Test both themes for contrast compliance (WCAG AA minimum).
Bookmark this page. These 20 patterns cover the interactions you rebuild in every Next.js project. Copy the snippet, adapt the types, and ship — then come back when you need the next one.

Building a Next.js product and want it done right?

We help startups and enterprises ship production-grade Next.js applications — from architecture and patterns to deployment and performance. Tell us what you're building.

Request Next.js Consultation

Shares
Get Quote
Let's build something powerful

Have a project idea? Let’s turn it into a scalable product.

Book Free Consultation

© 2026 Endurance Softwares. All rights reserved.