Loading...
Loading...
Guide for advanced Next.js App Router patterns including Route Handlers, Parallel Routes, Intercepting Routes, Server Actions, error boundaries, draft mode, and streaming with Suspense. CRITICAL for server actions (action.ts, actions.ts files, 'use server' directive), setting cookies from client components, and form handling. Use when requirements involve server actions, form submissions, cookies, mutations, API routes, `route.ts`, parallel routes, intercepting routes, or streaming. Essential for separating server actions from client components.
npx skill4agent add wsimmonds/claude-nextjs-skills nextjs-advanced-routingany@typescript-eslint/no-explicit-anyanyfunction handleSubmit(e: any) { ... }
const data: any[] = [];function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }
// Server actions
async function myAction(formData: FormData) { ... }app/actions.tsapp/action.tsaction.tsactions.tsaction.tsactions.tsapp/lib/utils/app/
├── actions.ts ← Shared actions that support multiple routes
└── dashboard/
└── action.ts ← Route-specific action colocated with a single page// app/action.ts (single-action example)
'use server';
export async function submitForm(formData: FormData) {
const name = formData.get('name') as string;
// Process the form
console.log('Submitted:', name);
}// app/actions.ts (multiple related actions)
'use server';
export async function createPost(formData: FormData) {
// ...
}
export async function deletePost(id: string) {
// ...
}<form action={serverAction}>return undefinedreturn nullexport async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) throw new Error('Name required');
await db.save(name);
return { success: true }; // ❌ BUILD ERROR: Type mismatch
}
// In component:
<form action={saveForm}> {/* ❌ Expects void function */}
<input name="name" />
</form>export async function saveForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
// Validate - throw errors instead of returning them
if (!name) throw new Error('Name required');
await db.save(name);
revalidatePath('/'); // Trigger UI update
// No return statement - returns void implicitly
}
// In component:
<form action={saveForm}>
<input name="name" required />
<button type="submit">Save</button>
</form>export async function saveForm(prevState: any, formData: FormData) {
'use server';
const name = formData.get('name') as string;
if (!name) return { error: 'Name required' };
await db.save(name);
return { success: true, message: 'Saved!' }; // ✅ OK with useActionState
}
// In component:
'use client';
const [state, action] = useActionState(saveForm, null);
return (
<form action={action}>
<input name="name" required />
<button type="submit">Save</button>
{state?.error && <p>{state.error}</p>}
{state?.success && <p>{state.message}</p>}
</form>
);<form action={...}>voiduseActionStateroute.tsroute.js// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello World' });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({
message: 'Data received',
data: body
});
}// app/api/items/route.ts
export async function GET(request: Request) { }
export async function POST(request: Request) { }
export async function PUT(request: Request) { }
export async function PATCH(request: Request) { }
export async function DELETE(request: Request) { }
export async function HEAD(request: Request) { }
export async function OPTIONS(request: Request) { }// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const id = params.id;
const post = await db.posts.findUnique({ where: { id } });
return Response.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.posts.delete({ where: { id: params.id } });
return Response.json({ success: true });
}// app/api/profile/route.ts
import { cookies, headers } from 'next/headers';
export async function GET(request: Request) {
// Access headers
const headersList = await headers();
const authorization = headersList.get('authorization');
// Access cookies
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session-token');
if (!sessionToken) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await fetchUser(sessionToken.value);
return Response.json(user);
}// app/api/login/route.ts
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
const token = await authenticate(email, password);
if (!token) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set('session-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 1 week
path: '/',
});
return Response.json({ success: true });
}// app/api/public/route.ts
export async function GET(request: Request) {
const data = await fetchData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${i}\n\n`));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return statement - Server Actions with forms should return void
}action.tsaction.tsactions.tsapp/action.tsapp/actions.ts// app/actions.ts
'use server'; // At the top - ALL exports are server actions
export async function createPost(formData: FormData) { ... }
export async function updatePost(formData: FormData) { ... }
export async function deletePost(postId: string) { ... }// app/action.ts or any file
export async function createPost(formData: FormData) {
'use server'; // Inside the function - ONLY this function is a server action
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
}// app/actions.ts - Server Actions file
'use server';
import { cookies } from 'next/headers';
export async function updateUserPreference(key: string, value: string) {
const cookieStore = await cookies();
cookieStore.set(key, value);
// Or perform other server-side operations
await db.userSettings.update({ [key]: value });
}
// app/InteractiveButton.tsx - Client Component
'use client';
import { updateUserPreference } from './actions';
export default function InteractiveButton() {
const handleClick = () => {
updateUserPreference('theme', 'dark');
};
return (
<button onClick={handleClick}>
Update Preference
</button>
);
}// app/CookieButton.tsx
'use client'; // This file is a client component
export async function setCookie() {
'use server'; // ERROR! Can't have server actions in client component file
// ...
}action{ success: true }{ success: true }// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || !content) {
throw new Error('Title and content are required');
}
// Save to database
await db.posts.create({ data: { title, content } });
// Revalidate or redirect - no return needed
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}useActionState// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
return { success: false, error: 'Title and content required' };
}
const post = await db.posts.create({ data: { title, content } });
return { success: true, post };
}
// app/posts/new/page.tsx
'use client';
import { createPost } from '@/app/actions';
import { useActionState } from 'react';
export default function NewPost() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Post created!</p>}
</form>
);
}revalidatePathuseActionState'use server';
export async function saveContactMessage(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate all fields - throw if any are missing
if (!name || !email || !message) {
throw new Error('All fields are required');
}
// Save to database
console.log('Saving contact message:', { name, email, message });
// No return - returns void implicitly
}// app/actions.ts
'use server';
export async function updateUsername(userId: string, username: string) {
await db.users.update({
where: { id: userId },
data: { username },
});
return { success: true };
}
// app/components/UsernameForm.tsx
'use client';
import { updateUsername } from '@/app/actions';
import { useState } from 'react';
export default function UsernameForm({ userId }: { userId: string }) {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
await updateUsername(userId, username);
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="New username"
/>
<button type="submit" disabled={loading}>
{loading ? 'Updating...' : 'Update'}
</button>
</form>
);
}// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation - throw error if invalid
if (!title || !content) {
throw new Error('Title and content are required');
}
if (title.length > 100) {
throw new Error('Title must be less than 100 characters');
}
if (content.length < 10) {
throw new Error('Content must be at least 10 characters');
}
// Save to database
const post = await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
// No return - form actions return void
}useActionState// app/actions.ts
'use server';
import { cookies } from 'next/headers';
export async function setTheme(theme: 'light' | 'dark') {
const cookieStore = await cookies();
cookieStore.set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
});
return { success: true };
}// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
await db.posts.delete({ where: { id: postId } });
// Revalidate specific path
revalidatePath('/posts');
// Or revalidate by cache tag
revalidateTag('posts');
// Redirect after deletion
redirect('/posts');
}"Create a [feature-name] with parallel routes for X and Y"
→ Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/"Create an app with parallel routes for X and Y"
→ Structure: app/@x/ and app/@y/Request: "Create a [specific-feature] with sections for X and Y"
app/
├── @x/ # ❌ Created at root - affects entire app!
├── @y/ # ❌ Wrong scope
└── layout.tsx # ❌ Root layout modified unnecessarilyRequest: "Create a [specific-feature] with sections for X and Y"
app/
├── [specific-feature]/
│ ├── @x/ # ✅ Scoped to this feature
│ ├── @y/ # ✅ Only affects this route
│ └── layout.tsx # ✅ Feature-specific layout
└── layout.tsx # Root layout unchangedapp/[that-feature]//featureapp/feature/@slots//app/@slots//parent/featureapp/parent/feature/@slots/Scenario: a user profile page needs tabs for posts and activity
Analysis:
- "user profile page" = specific feature
- Should be at /profile URL
- Only affects profile page
Structure:
app/
├── profile/
│ ├── @posts/
│ │ └── page.tsx
│ ├── @activity/
│ │ └── page.tsx
│ └── layout.tsx # Accepts posts, activity slotsScenario: the overall application layout must expose sidebar and main content slots
Analysis:
- "application layout" = root level
- Affects entire app
- Should be at root
Structure:
app/
├── @sidebar/
│ └── page.tsx
├── @main/
│ └── page.tsx
└── layout.tsx # Root layout with slotsScenario: the admin area adds an analytics view with charts and tables
Analysis:
- "admin panel" = existing section
- "analytics view" = subsection
- Should be at /admin/analytics URL
Structure:
app/
├── admin/
│ ├── analytics/
│ │ ├── @charts/
│ │ │ └── page.tsx
│ │ ├── @tables/
│ │ │ └── page.tsx
│ │ └── layout.tsx # Analytics-specific layout
│ └── layout.tsx # Admin layout (unchanged)| Requirement Pattern | Route Scope | Example Structure |
|---|---|---|
| Feature-specific requirement | | |
| Section inside a parent area | | |
| App-wide layout requirement | | |
| Page with multiple panels | | |
app/
├── [feature-name]/
│ ├── @slot1/
│ │ └── page.tsx
│ ├── @slot2/
│ │ └── page.tsx
│ ├── layout.tsx # Feature layout accepting slot props
│ └── page.tsx # Feature main pageapp/
├── @slot1/
│ └── page.tsx
├── @slot2/
│ └── page.tsx
├── layout.tsx # Root layout with slots
└── page.tsx// app/[feature]/layout.tsx
export default function FeatureLayout({
children,
slot1,
slot2,
}: {
children: React.ReactNode;
slot1: React.ReactNode;
slot2: React.ReactNode;
}) {
return (
<div>
<h1>Feature Page</h1>
<div className="main">{children}</div>
<div className="slots">
<div className="slot1">{slot1}</div>
<div className="slot2">{slot2}</div>
</div>
</div>
);
}// app/layout.tsx
export default function RootLayout({
children,
sidebar,
main,
}: {
children: React.ReactNode;
sidebar: React.ReactNode;
main: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<div className="app-layout">
<aside>{sidebar}</aside>
<main>{main}</main>
{children}
</div>
</body>
</html>
);
}default.tsx// Feature-scoped: app/[feature]/@slot1/default.tsx
export default function Default() {
return null; // Or a default UI
}
// Root-level: app/@sidebar/default.tsx
export default function Default() {
return <div>Default sidebar content</div>;
}// app/[feature]/layout.tsx (or any layout with parallel routes)
export default function Layout({
children,
analytics,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
const showAnalytics = true; // Could be based on user permissions, feature flags, etc.
return (
<div>
<main>{children}</main>
{showAnalytics && <aside>{analytics}</aside>}
</div>
);
}(.)(..)(..)(..)(...)app/
├── photos/
│ ├── [id]/
│ │ └── page.tsx # Full photo page
│ └── page.tsx # Photo gallery
├── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx # Modal photo view
└── layout.tsx// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal}
</div>
);
}// app/@modal/(.)photos/[id]/page.tsx
import Modal from '@/components/Modal';
import PhotoView from '@/components/PhotoView';
export default async function PhotoModal({
params,
}: {
params: { id: string };
}) {
const photo = await getPhoto(params.id);
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
);
}
// app/@modal/default.tsx
export default function Default() {
return null;
}// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
const handleClose = () => {
router.back();
};
return (
<dialog ref={dialogRef} onClose={handleClose}>
<button onClick={handleClose}>Close</button>
{children}
</dialog>
);
}// app/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="dashboard-error">
<h2>Dashboard Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Application Error</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>Page Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
);
}
// Trigger programmatically
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// Check secret
if (secret !== process.env.DRAFT_SECRET) {
return Response.json({ message: 'Invalid token' }, { status: 401 });
}
// Enable Draft Mode
const draft = await draftMode();
draft.enable();
// Redirect to the path from the fetched post
redirect(slug || '/');
}// app/api/draft/disable/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
const draft = await draftMode();
draft.disable();
redirect('/');
}// app/posts/[slug]/page.tsx
import { draftMode } from 'next/headers';
export default async function Post({ params }: { params: { slug: string } }) {
const draft = await draftMode();
const isDraft = draft.isEnabled;
// Fetch draft or published content
const post = await getPost(params.slug, isDraft);
return (
<article>
{isDraft && (
<div className="draft-banner">
<p>Draft Mode Active</p>
<a href="/api/draft/disable">Exit Draft Mode</a>
</div>
)}
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // Slow query
return <div className="stats">{JSON.stringify(stats)}</div>;
}
async function RecentActivity() {
const activity = await fetchRecentActivity();
return (
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
);
}// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
</div>
);
}
async function MainContent() {
const data = await fetchMainData();
return (
<div>
<h2>{data.title}</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={data.id} />
</Suspense>
</div>
);
}
async function Comments({ postId }: { postId: string }) {
const comments = await fetchComments(postId);
return (
<ul>
{comments.map((c) => <li key={c.id}>{c.text}</li>)}
</ul>
);
}// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-body" />
</div>
);
}// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div>
{[1, 2, 3].map((i) => (
<div key={i} className="post-skeleton">
<div className="skeleton-title" />
<div className="skeleton-excerpt" />
</div>
))}
</div>
);
}// app/components/LikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { likePost } from '@/app/actions';
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, amount: number) => state + amount
);
const handleLike = async () => {
addOptimisticLike(1);
await likePost(postId);
};
return (
<button onClick={handleLike}>
Likes: {optimisticLikes}
</button>
);
}// app/posts/new/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export default function NewPost() {
const [state, formAction] = useFormState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
{state?.errors && (
<div className="errors">
{Object.entries(state.errors).map(([field, messages]) => (
<div key={field}>
{messages.map((msg) => <p key={msg}>{msg}</p>)}
</div>
))}
</div>
)}
<SubmitButton />
</form>
);
}route.ts@folder(.)error.tsxglobal-error.tsxrevalidatePathrevalidateTag