Deep dive into advanced Next.js patterns including Server Actions, Edge Middleware, Suspense boundaries, and performance optimization.
Next.js has evolved significantly with the App Router. This article covers advanced patterns that will level up your Next.js applications.
This guide covers Next.js 16 with React 19, including the latest Server Actions patterns and Edge Runtime improvements.
Server Actions allow you to run server code directly from your components, replacing API routes for mutations.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function updateProfile(formData: FormData) {
const userId = formData.get("userId") as string;
const name = formData.get("name") as string;
// Server-side validation
if (!name || name.length < 2) {
return { error: "Name must be at least 2 characters" };
}
// Database update
await db.user.update({
where: { id: userId },
data: { name },
});
// Revalidate cache
revalidatePath("/profile");
return { success: true };
}
Group Server Actions in dedicated files (e.g., app/actions.ts, app/actions/users.ts). Use "use server" at the top of the file rather than individual functions.
import { z } from "zod";
import { revalidatePath } from "next/cache";
const updateProfileSchema = z.object({
userId: z.string().min(1),
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
bio: z.string().max(500, "Bio must be less than 500 characters").optional(),
});
export async function updateProfile(formData: FormData) {
// Parse and validate
const result = updateProfileSchema.safeParse({
userId: formData.get("userId"),
name: formData.get("name"),
email: formData.get("email"),
bio: formData.get("bio"),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
const { userId, name, email, bio } = result.data;
// Update database
await db.user.update({
where: { id: userId },
data: { name, email, bio },
});
revalidatePath("/profile");
revalidatePath("/settings");
return { success: true, data: result.data };
}
"use client";
import { useFormStatus } from "react-dom";
import { updateProfile } from "@/app/actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Changes"}
</button>
);
}
export function ProfileForm({ user }: { user: User }) {
return (
<form action={updateProfile}>
<input name="userId" type="hidden" value={user.id} />
<input name="name" defaultValue={user.name} />
<input name="email" type="email" defaultValue={user.email} />
<textarea name="bio" defaultValue={user.bio} />
<SubmitButton />
</form>
);
}
Middleware runs on the Edge, making it perfect for auth, redirects, and geolocation.
Suspense allows you to show loading UI while data fetches.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { StatsCardSkeleton } from "@/components/ui/loading-state";
async function UserStats() {
const stats = await fetchUserStats(); // Suspends until complete
return <StatsCards data={stats} />;
}
async function RecentActivity() {
const activities = await fetchRecentActivity(); // Suspends until complete
return <ActivityList items={activities} />;
}
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1>Dashboard</h1>
{/* These fetch in parallel */}
<Suspense fallback={<StatsCardSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<div>Loading activity...</div>}>
<RecentActivity />
</Suspense>
</div>
);
}
// app/blog/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
async function BlogPost({ slug }: { slug: string }) {
const post = await fetchBlogPost(slug); // Suspends
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<MDXRenderer content={post.content} />
</article>
);
}
async function RelatedPosts({ slug }: { slug: string }) {
const posts = await fetchRelatedPosts(slug); // Fetches in parallel
return (
<aside>
<h2>Related Posts</h2>
<PostList posts={posts} />
</aside>
);
}
export default function BlogPage({ params }: { params: { slug: string } }) {
return (
<div className="grid lg:grid-cols-[1fr_300px] gap-8">
<Suspense fallback={<div>Loading post...</div>}>
<BlogPost slug={params.slug} />
</Suspense>
<Suspense fallback={<div>Loading related posts...</div>}>
<RelatedPosts slug={params.slug} />
</Suspense>
</div>
);
}
| Pattern | When to Use | Example |
|---|---|---|
| Page-level loading.tsx | Slow pages (2+ seconds) | Dashboard, analytics |
| Inline Suspense | Component-level loading | Comments, recommendations |
| Skeleton screens | Content with predictable layout | Cards, lists, tables |
| Spinner | Indeterminate loading | File uploads, processing |
"use client";
import { useState, Suspense } from "react";
import dynamic from "next/dynamic";
// Dynamically import heavy components
const HeavyChart = dynamic(
() => import("@/components/heavy-chart"),
{
loading: () => <div>Loading chart...</div>,
ssr: false, // Skip SSR for client-only components
}
);
const RichTextEditor = dynamic(
() => import("@/components/rich-text-editor"),
{
loading: () => <div>Loading editor...</div>,
}
);
export function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Analytics</button>
{showChart && (
<Suspense fallback={<div>Loading...</div>}>
<HeavyChart />
</Suspense>
)}
<RichTextEditor />
</div>
);
}
import Image from "next/image";
export function ProductImage({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={800}
height={600}
priority={false} // Set true for above-fold images
placeholder="blur" // Shows blur placeholder while loading
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="rounded-lg"
/>
);
}
Always provide width and height (or use fill with object-fit) to prevent layout shift. Use priority for above-fold images.
import { unstable_cache } from "next/cache";
// Cache expensive database queries
export const getUser = unstable_cache(
async (id: string) => {
return db.user.findUnique({ where: { id } });
},
["user"],
{
revalidate: 3600, // Cache for 1 hour
tags: ["users"], // Tag for manual revalidation
}
);
// Fetch with caching options
async function fetchBlogPosts() {
const res = await fetch("https://api.example.com/posts", {
next: {
revalidate: 3600, // Cache for 1 hour
tags: ["blog-posts"], // Tag for revalidation
},
});
return res.json();
}
// Manual cache revalidation
import { revalidateTag, revalidatePath } from "next/cache";
export async function revalidatePosts() {
revalidateTag("blog-posts"); // Revalidate by tag
revalidatePath("/blog"); // Revalidate by path
}
// app/error.tsx
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Error:", error);
}, [error]);
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Something went wrong!</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{error.message || "An unexpected error occurred"}
</p>
<Button onClick={reset}>Try again</Button>
</CardContent>
</Card>
</div>
);
}
// app/global-error.tsx
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="min-h-screen flex items-center justify-center">
<div>
<h2>Something went seriously wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
</div>
</body>
</html>
);
}
These patterns will help you build faster, more scalable Next.js applications. Server Actions reduce boilerplate, Suspense improves UX, and proper caching ensures performance.