Next.js Advanced Routing Overview
Provide comprehensive guidance for advanced Next.js App Router features including Route Handlers (API routes), Parallel Routes, Intercepting Routes, Server Actions, error handling, draft mode, and streaming with Suspense.
TypeScript: NEVER Use any Type
CRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleSubmit(e: any) { ... } const data: any[] = [];
✅ CORRECT:
function handleSubmit(e: React.FormEvent
Common Next.js Type Patterns // Page props function Page({ params }: { params: { slug: string } }) { ... } function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }
// Form events
const handleSubmit = (e: React.FormEvent
// Server actions async function myAction(formData: FormData) { ... }
When to Use This Skill
Use this skill when:
Creating API endpoints with Route Handlers Implementing parallel or intercepting routes Building forms with Server Actions Setting cookies or handling mutations Creating error boundaries Implementing draft mode for CMS previews Setting up streaming and Suspense boundaries Building complex routing patterns (modals, drawers) ⚠️ CRITICAL: Server Action File Naming and Location
When work requirements mention a specific filename, follow that instruction exactly. If no name is given, pick the option that best matches the project conventions—app/actions.ts is a safe default for collections of actions, while app/action.ts works for a single form handler.
Choosing between action.ts and actions.ts Match existing patterns: Check whether the project already has an actions file and extend it if appropriate. Single vs multiple exports: Prefer action.ts for a single action, and actions.ts for a group of related actions. Explicit requirement: If stakeholders call out a specific name, do not change it.
Location guidelines
Server actions belong under the app/ directory so they can participate in the App Router tree. Keep the file alongside the UI that invokes it unless shared across multiple routes. Avoid placing actions in lib/ or utils/ unless they are triggered from multiple distant routes and remain server-only utilities.
Example placement
app/ ├── actions.ts ← Shared actions that support multiple routes └── dashboard/ └── action.ts ← Route-specific action colocated with a single page
Example: Creating action.ts // 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); }
Example: Creating actions.ts // app/actions.ts (multiple related actions) 'use server';
export async function createPost(formData: FormData) { // ... }
export async function deletePost(id: string) { // ... }
Remember: When a project requirement spells out an exact filename, mirror it precisely.
⚠️ CRITICAL: Server Actions Return Types - Form Actions MUST Return Void
This is a TypeScript requirement, not optional. Even if you see code that returns data from form actions, that code is WRONG.
When using form action attribute:
✅ CORRECT - Option 1 (Simple form action, no response):
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:
✅ CORRECT - Option 2 (With useActionState for feedback):
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 (
);
The key rule:
); }Pattern 2: Form with useActionState (Returns data)
When you need to display success/error messages, use 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 (
); }Key difference:
Pattern 1: Form action only, Server Action returns void, use revalidatePath Pattern 2: With useActionState, Server Action returns data for display Form Validation Example - Multiple Fields
When validating multiple required fields, check them all together and throw if any are missing:
'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 }
This will:
✅ Pass TypeScript checks (returns void) ✅ Validate all inputs before processing ✅ Throw error if validation fails (prevents database save) ✅ Not return an error object (which would break form action typing) Server Actions with Client Components // 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 (
); }Server Actions with Validation (Throw Errors)
When using form actions directly, throw errors for validation failures (don't return error objects):
// 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 }
For returning validation state: If you need to return validation errors or show them in the UI, use useActionState (Pattern 2 above) instead.
Setting Cookies in Server Actions // 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 }; }
Revalidation and Redirection // 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'); }
Step 0: Determine Parallel Route Scope
Before implementing parallel routes, identify WHERE they should live in your route structure.
Analyzing Route Context
Key Question: Is this feature for a specific page/section, or for the entire application?
Specific page/section → Create under that route directory Entire application → Create at root level Route Scope Decision Process
When the requirement mentions a specific feature or page:
"Create a [feature-name] with parallel routes for X and Y" → Structure: app/[feature-name]/@x/ and app/[feature-name]/@y/
When the requirement covers app-wide layout:
"Create an app with parallel routes for X and Y" → Structure: app/@x/ and app/@y/
Common Scope Mistake
❌ WRONG - Parallel routes at incorrect scope:
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 unnecessarily
This makes the parallel routes global when they should be feature-specific.
✅ CORRECT - Parallel routes properly scoped:
Request: "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 unchanged
Decision Criteria
Analyze the requirements - Look for specific feature/page names
Mentions a specific noun/feature? → Create under app/[that-feature]/ General app-level description? → Determine if root or new route
Consider URL structure - What URL should this live at?
/feature path → Use app/feature/@slots/ Root / path → Use app/@slots/ Nested /parent/feature → Use app/parent/feature/@slots/
Think about scope impact - How much of the app is affected?
One feature/page only? → Scope to feature directory Multiple related pages? → Scope to parent directory Entire application? → Use root level Practical Examples
Example 1: Feature-specific parallel routes
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 slots
Example 2: App-wide parallel routes
Scenario: 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 slots
Example 3: Nested section parallel routes
Scenario: 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)
Quick Reference Requirement Pattern Route Scope Example Structure Feature-specific requirement app/[feature]/ app/profile/@tab/ Section inside a parent area app/[parent]/[section]/ app/admin/analytics/@view/ App-wide layout requirement app/ app/@sidebar/ Page with multiple panels app/[page]/ app/settings/@panel/
CRITICAL RULE: Always analyze the requirement for scope indicators before defaulting to root-level parallel routes.
Parallel Routes
Parallel Routes allow rendering multiple pages in the same layout simultaneously.
⚠️ IMPORTANT: Understand Route Scope First
Before creating parallel routes, review "Step 0: Determine Parallel Route Scope" above to identify the correct directory level.
Don't default to creating parallel routes at root level - scope them appropriately to the feature/page mentioned in the requirements.
Creating Parallel Routes (Feature-Scoped)
For feature-specific parallel routes (most common):
app/ ├── [feature-name]/ │ ├── @slot1/ │ │ └── page.tsx │ ├── @slot2/ │ │ └── page.tsx │ ├── layout.tsx # Feature layout accepting slot props │ └── page.tsx # Feature main page
Creating Parallel Routes (Root-Level)
For app-wide parallel routes (less common):
app/ ├── @slot1/ │ └── page.tsx ├── @slot2/ │ └── page.tsx ├── layout.tsx # Root layout with slots └── page.tsx
Layout with Parallel Routes (Feature-Scoped Example)
For a feature with parallel routes:
// app/[feature]/layout.tsx export default function FeatureLayout({ children, slot1, slot2, }: { children: React.ReactNode; slot1: React.ReactNode; slot2: React.ReactNode; }) { return (
Feature Page
Layout with Parallel Routes (Root-Level Example)
For app-wide parallel routes:
// app/layout.tsx export default function RootLayout({ children, sidebar, main, }: { children: React.ReactNode; sidebar: React.ReactNode; main: React.ReactNode; }) { return (
Default Parallel Route
Create a default.tsx to handle unmatched routes or provide fallback UI:
// 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
Conditional Parallel Routes
Parallel routes can be conditionally rendered based on runtime conditions:
// 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 (
Note: This pattern works at any layout level (root or feature-scoped).
Intercepting Routes
Intercepting Routes allow you to load a route within the current layout while keeping the context of the current page.
Intercepting Route Conventions (.) - Match segments on the same level (..) - Match segments one level above (..)(..) - Match segments two levels above (...) - Match segments from the root Modal Pattern with Intercepting Routes app/ ├── photos/ │ ├── [id]/ │ │ └── page.tsx # Full photo page │ └── page.tsx # Photo gallery ├── @modal/ │ └── (.)photos/ │ └── [id]/ │ └── page.tsx # Modal photo view └── layout.tsx
Layout for Modal Pattern // app/layout.tsx export default function Layout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return (
Modal Component // 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 (
// app/@modal/default.tsx export default function Default() { return null; }
Client-Side Modal Component // 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
useEffect(() => { dialogRef.current?.showModal(); }, []);
const handleClose = () => { router.back(); };
return ( ); }
Error Boundaries Basic Error Boundary // app/error.tsx 'use client';
export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return (
Something went wrong!
{error.message}
Nested Error Boundaries // app/dashboard/error.tsx 'use client';
export default function DashboardError({ error, reset, }: { error: Error; reset: () => void; }) { return (
Dashboard Error
{error.message}
Global Error Boundary // app/global-error.tsx 'use client';
export default function GlobalError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return (
Application Error
{error.message}
); }Not Found Boundary // app/not-found.tsx import Link from 'next/link';
export default function NotFound() { return (
Page Not Found
Could not find requested resource
Return Home// 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
Draft Mode
Draft Mode allows you to preview draft content from a headless CMS.
Enabling Draft Mode // 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 || '/'); }
Disabling Draft Mode // 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('/'); }
Using Draft Mode in Pages // 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 (
Draft Mode Active{post.title}
Streaming and Suspense Basic Streaming with Suspense // app/dashboard/page.tsx import { Suspense } from 'react';
export default function Dashboard() { return (
Dashboard
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
); }
async function Stats() { const stats = await fetchStats(); // Slow query return
async function RecentActivity() { const activity = await fetchRecentActivity(); return (
-
{activity.map((item) => (
- {item.description} ))}
Nested Suspense Boundaries // app/page.tsx import { Suspense } from 'react';
export default function Page() { return (
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
</div>
); }
async function MainContent() { const data = await fetchMainData();
return (
{data.title}
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={data.id} />
</Suspense>
</div>
); }
async function Comments({ postId }: { postId: string }) { const comments = await fetchComments(postId); return (
-
{comments.map((c) =>
- {c.text} )}
Loading States with loading.tsx // app/dashboard/loading.tsx export default function Loading() { return (
Streaming with Loading UI // app/posts/loading.tsx export default function PostsLoading() { return (
Advanced Patterns Optimistic Updates with Server Actions // 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 ( ); }
Progressive Enhancement with Forms // app/posts/new/page.tsx 'use client';
import { useFormState, useFormStatus } from 'react-dom'; import { createPost } from '@/app/actions';
function SubmitButton() { const { pending } = useFormStatus();
return ( ); }
export default function NewPost() { const [state, formAction] = useFormState(createPost, null);
return (