1. Debounced Search
Firing an API call on every keystroke tanks performance and floods your backend. You need the input to feel instant while the search request waits until the user pauses typing.
// hooks/use-debounced-value.ts
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// components/SearchInput.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useEffect, useState, useTransition } from "react";
export function SearchInput({ placeholder = "Search..." }) {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const debouncedQuery = useDebouncedValue(query, 350);
const [isPending, startTransition] = useTransition();
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (debouncedQuery) params.set("q", debouncedQuery);
else params.delete("q");
params.delete("page"); // reset pagination on new search
startTransition(() => {
router.replace(`?${params.toString()}`, { scroll: false });
});
}, [debouncedQuery, router, searchParams]);
return (
<div className="relative">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border px-4 py-2"
/>
{isPending && (
<span className="absolute right-3 top-2.5 text-xs text-muted-foreground">
Searching...
</span>
)}
</div>
);
}Explanation
useDebouncedValue delays propagating the input value until the user stops typing. Syncing to URL search params makes searches shareable and lets Server Components read searchParams without client-side fetch loops.useTransition keeps the input responsive while navigation updates.
Best practices
- Use 300–500ms delay for search; shorter for autocomplete, longer for heavy queries.
- Store search state in the URL so back/forward and deep links work.
- Reset page number when the query changes.
- Show a subtle pending indicator — never block the input.
2. Infinite Scroll
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
hasMoreis false. - For SEO-critical lists, render the first page server-side.
3. Optimistic Updates
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
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"andrel="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.
5. Modal Manager
Prop-drilling isOpen and onClose through five components is unmaintainable. You need a single place to open any modal from anywhere in the tree.
// context/modal-context.tsx
"use client";
import { createContext, useCallback, useContext, useState } from "react";
type ModalEntry = { id: string; props?: Record<string, unknown> };
type ModalContextValue = {
open: (id: string, props?: Record<string, unknown>) => void;
close: () => void;
current: ModalEntry | null;
};
const ModalContext = createContext<ModalContextValue | null>(null);
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [stack, setStack] = useState<ModalEntry[]>([]);
const current = stack[stack.length - 1] ?? null;
const open = useCallback((id: string, props?: Record<string, unknown>) => {
setStack((s) => [...s, { id, props }]);
}, []);
const close = useCallback(() => {
setStack((s) => s.slice(0, -1));
}, []);
return (
<ModalContext.Provider value={{ open, close, current }}>
{children}
</ModalContext.Provider>
);
}
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal must be used within ModalProvider");
return ctx;
}
// components/ModalHost.tsx
"use client";
import { useModal } from "@/context/modal-context";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { EditUserModal } from "@/components/modals/EditUserModal";
const REGISTRY = {
confirm: ConfirmModal,
"edit-user": EditUserModal,
} as const;
export function ModalHost() {
const { current, close } = useModal();
if (!current) return null;
const Component = REGISTRY[current.id as keyof typeof REGISTRY];
if (!Component) return null;
return <Component {...current.props} onClose={close} />;
}
// Usage anywhere:
// const { open } = useModal();
// open("confirm", { title: "Delete?", onConfirm: handleDelete });Explanation
A lightweight stack-based modal registry lets any client component open a modal by ID. ModalHost renders the active modal at the layout level, keeping portals and focus traps in one place.
Best practices
- Register modals in a single
REGISTRYobject for type safety. - Trap focus and close on Escape for accessibility.
- Support a stack for nested modals (confirm inside edit).
- Sync URL hash or query for shareable modals when needed (
?modal=edit-user).
6. Error Boundary
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.digestserver-side — it maps to the real stack trace. - Place
error.tsxat the narrowest route segment that makes sense. - Never catch errors silently — always report to your observability tool.
- Pair with
global-error.tsxfor root layout failures.
7. Server Actions
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
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
canonicalURL 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
generateStaticParamsfor popular slugs at build time.
9. Caching
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 fetchExplanation
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-dynamicsparingly — it disables static optimization for the route. - Log cache hit rates in staging to tune TTL values.
10. Revalidation
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
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-pulsesparingly — one animation per section is enough. - Create reusable skeleton primitives (
SkeletonLine,SkeletonCard). - Colocate
loading.tsxat the route segment that actually suspends.
12. Suspense
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.tsxfor route-level and component-level fallbacks. - Prefetch critical above-the-fold data; suspense the rest.
13. Protected Route
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(), notgetSession(), for auth checks. - Preserve the intended URL in a
redirectquery param after login. - Enforce permissions in Server Actions and API routes too.
- Keep the middleware matcher lean — exclude static assets.
14. API Error Handler
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
codestrings 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
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
sonnerif you need promises, actions, and stacking.
16. Reusable Form Hook
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
isSubmittingto prevent double posts.
17. File Upload Hook
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
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
nextCursorfrom the server. - Invalidate with
queryClient.invalidateQueries({ queryKey: ["posts"] })after mutations. - Set
staleTimeto avoid refetching on every mount. - Prefetch the next page when the user is one screen away from the bottom.
19. Role Permissions
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
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
resolvedThemefor the actual applied theme (handles "system"). - Set
darkMode: "class"in Tailwind — notmedia. - Define CSS variables for colors so one class toggle updates everything.
- Test both themes for contrast compliance (WCAG AA minimum).
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.