
Nextjs Core
- 238 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
Implement Next.js App Router pages, layouts, server components, and data fetching for SaaS dashboards, marketing sites, and ecommerce storefronts with SSR and streaming.
About
Covers Next.js core development with App Router: file-based routing, React Server Components, streaming SSR, Server Actions, caching semantics, and production patterns for full-stack web apps on Vercel or self-hosted Node runtimes.
- App Router layouts and route groups
- Server vs client component boundaries
- Server Actions and data fetching
- Metadata, caching, and revalidation
- Deployment and performance defaults
Nextjs Core by the numbers
- 238 all-time installs (skills.sh)
- Ranked #813 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill nextjs-coreAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 238 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Implement Next.js App Router pages, layouts, server components, and data fetching for SaaS dashboards, marketing sites, and ecommerce storefronts with SSR and streaming.
Files
Next.js Core (App Router)
- Server Components by default; minimal
"use client". - Mutations in Server Actions (validate/authz; revalidate tags/paths).
- Route handlers for APIs/webhooks; add loading/error boundaries.
Anti-patterns:
- ❌ Fetch initial data in
useEffect. - ❌ Cache or revalidate too broadly.
- ❌ Client-only authz.
References: see references/ (server actions, fetching, caching, routing, auth, testing).
{
"name": "nextjs-core",
"version": "1.1.0",
"category": "toolchain",
"toolchain": "nextjs",
"framework": "nextjs",
"tags": [
"nextjs",
"react",
"app-router",
"server-components",
"server-actions",
"route-handlers",
"routing",
"data-fetching",
"caching",
"authentication",
"testing"
],
"entry_point_tokens": 188,
"full_tokens": 12236,
"requires": [],
"related_skills": [
"nextjs-v16",
"typescript-core",
"react",
"api-design-patterns",
"security-scanning",
"opentelemetry",
"playwright",
"cypress",
"webapp-testing"
],
"author": "claude-mpm-skills",
"updated": "2025-12-17",
"source_path": "toolchains/nextjs/core/SKILL.md",
"license": "MIT",
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
Authentication in Next.js
Auth.js (NextAuth.js v5) integration, protected routes, and session handling.
Auth.js Setup
Installation
npm install next-auth@betaConfiguration
// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
const user = await db.users.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
session: { strategy: 'jwt' },
pages: {
signIn: '/login',
error: '/auth/error',
},
callbacks: {
authorized: async ({ auth }) => {
return !!auth;
},
jwt: async ({ token, user }) => {
if (user) {
token.id = user.id;
}
return token;
},
session: async ({ session, token }) => {
if (token) {
session.user.id = token.id as string;
}
return session;
},
},
});Route Handler
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;Session Access
Server Components
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect('/login');
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</div>
);
}Server Actions
// actions/profile.ts
'use server';
import { auth } from '@/auth';
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session) {
throw new Error('Unauthorized');
}
await db.users.update({
where: { id: session.user.id },
data: {
name: formData.get('name') as string,
},
});
}Client Components
'use client';
import { useSession } from 'next-auth/react';
export function UserMenu() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <Skeleton />;
}
if (!session) {
return <LoginButton />;
}
return (
<div>
<img src={session.user.image} alt={session.user.name} />
<span>{session.user.name}</span>
<SignOutButton />
</div>
);
}Session Provider
// app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Protected Routes
Middleware Protection
// proxy.ts (middleware.ts in Next.js 15)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith('/login');
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};Layout-Level Protection
// app/(protected)/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect('/login');
}
return <>{children}</>;
}Component-Level Protection
// components/protected.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export async function Protected({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session) {
redirect('/login');
}
return <>{children}</>;
}
// Usage
export default function AdminPage() {
return (
<Protected>
<AdminDashboard />
</Protected>
);
}Sign In / Sign Out
Sign In Form
// app/login/page.tsx
import { signIn } from '@/auth';
import { redirect } from 'next/navigation';
export default function LoginPage() {
return (
<div className="login-container">
<h1>Sign In</h1>
{/* OAuth Providers */}
<form
action={async () => {
'use server';
await signIn('github');
}}
>
<button type="submit">Sign in with GitHub</button>
</form>
<form
action={async () => {
'use server';
await signIn('google');
}}
>
<button type="submit">Sign in with Google</button>
</form>
{/* Credentials Form */}
<form
action={async (formData) => {
'use server';
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false,
});
if (result?.error) {
// Handle error
return;
}
redirect('/dashboard');
}}
>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Sign In</button>
</form>
</div>
);
}Sign Out Button
// components/sign-out-button.tsx
import { signOut } from '@/auth';
export function SignOutButton() {
return (
<form
action={async () => {
'use server';
await signOut();
}}
>
<button type="submit">Sign Out</button>
</form>
);
}
// Client-side alternative
'use client';
import { signOut } from 'next-auth/react';
export function ClientSignOutButton() {
return (
<button onClick={() => signOut({ callbackUrl: '/' })}>
Sign Out
</button>
);
}Role-Based Access Control
Extended Session Type
// types/next-auth.d.ts
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'user' | 'admin' | 'moderator';
} & DefaultSession['user'];
}
interface User {
role: 'user' | 'admin' | 'moderator';
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: 'user' | 'admin' | 'moderator';
}
}Role Callbacks
// auth.ts
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
session: async ({ session, token }) => {
if (token) {
session.user.id = token.id;
session.user.role = token.role;
}
return session;
},
}Role-Based Component
// components/require-role.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
type Role = 'user' | 'admin' | 'moderator';
export async function RequireRole({
children,
role,
}: {
children: React.ReactNode;
role: Role | Role[];
}) {
const session = await auth();
if (!session) {
redirect('/login');
}
const allowedRoles = Array.isArray(role) ? role : [role];
if (!allowedRoles.includes(session.user.role)) {
redirect('/unauthorized');
}
return <>{children}</>;
}
// Usage
export default function AdminPage() {
return (
<RequireRole role="admin">
<AdminDashboard />
</RequireRole>
);
}Database Session Strategy
Prisma Schema
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role String @default("user")
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}Database Session Config
// auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: 'database' }, // Use database sessions
// ... rest of config
});Best Practices
1. Use JWT for serverless - Database sessions don't scale well 2. Extend types properly - Use module augmentation for custom fields 3. Protect at multiple levels - Middleware + component checks 4. Handle loading states - Show skeletons during auth checks 5. Secure credentials - Hash passwords, validate input 6. Implement RBAC - Role-based access for admin features 7. Use secure cookies - Auth.js handles this by default 8. Log auth events - Monitor sign-ins, failures, and suspicious activity
Caching and Rendering Strategy (App Router)
Use caching intentionally. Incorrect cache boundaries create stale UI, data leaks (user A sees user B), or unnecessary load.
Mental Model
- Request memoization: identical calls can be deduped within a single render pass.
- Data cache:
fetchcan cache across requests when configured. - Revalidation: invalidate by path (
revalidatePath) or tag (revalidateTag) after mutations.
If using Next.js 16 Cache Components ("use cache"), see nextjs-v16 for the new opt-in model.
Static vs Dynamic Rendering
Prefer static for shared, cache-safe content; prefer dynamic for per-user or rapidly changing content.
Common triggers for dynamic rendering:
- per-user data (sessions, cookies)
- request headers/cookies access
- real-time requirements
Tag-Based Revalidation (Preferred)
Tags scale better than path-based invalidation when many routes depend on the same data.
// Read with tags
async function getPosts() {
return fetch("https://example.com/api/posts", {
next: { tags: ["posts"], revalidate: 3600 },
}).then((r) => r.json());
}
// After a mutation
import { revalidateTag } from "next/cache";
export async function createPost(data: unknown) {
await db.posts.create({ data });
revalidateTag("posts");
}Path-Based Revalidation
Use when only a small number of routes depend on the data, or when tags are not available.
import { revalidatePath } from "next/cache";
export async function updatePost(id: string, data: unknown) {
await db.posts.update({ where: { id }, data });
revalidatePath(`/posts/${id}`);
}Personalized Data: Avoid Shared Caches
Cache boundaries must not mix users.
✅ Correct patterns:
// ✅ Keep per-user reads dynamic (no shared cache)
import { auth } from "@/auth";
export async function getMe() {
const session = await auth();
if (!session) return null;
return db.users.findUnique({ where: { id: session.user.id } });
}
// ✅ Cache shared data and combine with dynamic per-user overlays
export async function getCatalog() {
return fetch("https://example.com/api/catalog", {
next: { tags: ["catalog"], revalidate: 3600 },
}).then((r) => r.json());
}❌ Anti-pattern: caching a per-user response
// ❌ Do not cache personalized data under a shared key/tag
fetch("/api/me", { next: { tags: ["me"] } });Decision Checklist
- Does the response include user-specific data? → keep it dynamic.
- Is it safe for another user to see this? → if not, do not share-cache it.
- Do many pages depend on the same data? → prefer tag revalidation.
- Do only one or two routes depend on it? → path revalidation is fine.
Common Pitfalls
- ❌
revalidatePath("/")everywhere → ✅ revalidate the smallest affected tag/path. - ❌ caching auth/session-derived data → ✅ keep auth-derived reads dynamic.
- ❌ mixing static and dynamic in a way that disables caching unintentionally → ✅ isolate dynamic reads.
Data Fetching Patterns
Advanced caching, parallel fetching, streaming, and database integration patterns.
Fetch Patterns
Parallel vs Sequential
// ❌ Sequential - slow
async function Page() {
const user = await getUser(); // Wait
const posts = await getPosts(); // Then wait
const comments = await getComments(); // Then wait
return <Dashboard user={user} posts={posts} comments={comments} />;
}
// ✅ Parallel - fast
async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return <Dashboard user={user} posts={posts} comments={comments} />;
}Dependent Fetches
// When data depends on previous fetch
async function Page({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;
// Must be sequential - posts depend on user
const user = await getUser(userId);
const posts = await getPostsByAuthor(user.id);
// But these can be parallel
const [comments, likes] = await Promise.all([
getCommentsForPosts(posts.map(p => p.id)),
getLikesForPosts(posts.map(p => p.id)),
]);
return <UserProfile user={user} posts={posts} comments={comments} likes={likes} />;
}Streaming with Suspense
Basic Streaming
import { Suspense } from 'react';
export default async function Page() {
// Fast data loads immediately
const user = await getUser();
return (
<div>
<Header user={user} />
{/* Slow components stream in */}
<Suspense fallback={<PostsSkeleton />}>
<SlowPostsList />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<SlowRecommendations />
</Suspense>
</div>
);
}
async function SlowPostsList() {
const posts = await getPostsSlowly(); // 3+ seconds
return <PostList posts={posts} />;
}Nested Suspense
export default function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
);
}
async function MainContent() {
const data = await getMainData();
return (
<div>
<PrimaryContent data={data} />
{/* Secondary content streams independently */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</div>
);
}Loading UI
// app/posts/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded" />
))}
</div>
</div>
);
}Caching Strategies
Request Memoization
// Automatically deduplicated within single render pass
async function getUser(id: string) {
console.log('Fetching user:', id); // Only logs once
return db.users.findUnique({ where: { id } });
}
// Multiple components can call same function
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId);
return <header>{user.name}</header>;
}
async function UserSidebar({ userId }: { userId: string }) {
const user = await getUser(userId); // Same call, deduplicated
return <aside>{user.bio}</aside>;
}React Cache
import { cache } from 'react';
// Explicit memoization across components
export const getUser = cache(async (id: string) => {
return db.users.findUnique({ where: { id } });
});
// Preload pattern
export const preloadUser = (id: string) => {
void getUser(id); // Fire request early
};
// Usage in layout
export default async function Layout({ children, params }) {
const { userId } = await params;
preloadUser(userId); // Start fetch before child renders
return <div>{children}</div>;
}Data Cache with Tags
// Fetch with cache tags
async function getPosts(categoryId: string) {
const posts = await fetch(`/api/posts?category=${categoryId}`, {
next: {
tags: ['posts', `category-${categoryId}`],
revalidate: 3600, // 1 hour
},
}).then(r => r.json());
return posts;
}
// Invalidate by tag
import { revalidateTag } from 'next/cache';
export async function createPost(data: PostData) {
await db.posts.create({ data });
revalidateTag('posts');
revalidateTag(`category-${data.categoryId}`);
}Time-Based Revalidation
// Page-level revalidation
export const revalidate = 3600; // Revalidate every hour
export default async function Page() {
const data = await getData();
return <Content data={data} />;
}
// Segment-level in layout
// app/blog/layout.tsx
export const revalidate = 60; // All /blog/* pages revalidate every minuteOn-Demand Revalidation
// API route for webhook
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { secret, path, tag } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
if (path) {
revalidatePath(path);
}
if (tag) {
revalidateTag(tag);
}
return Response.json({ revalidated: true });
}Database Integration
Prisma Patterns
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}Drizzle Patterns
// lib/db.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
// Usage with relations
const postsWithAuthor = await db.query.posts.findMany({
with: {
author: true,
comments: {
with: { author: true },
limit: 5,
},
},
});Connection Pooling
// For serverless (Vercel, AWS Lambda)
import { Pool } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);
// Or with Prisma Accelerate
// In schema.prisma:
// datasource db {
// provider = "postgresql"
// url = env("DATABASE_URL")
// directUrl = env("DIRECT_URL")
// }Pagination Patterns
Offset Pagination
async function getPosts(page: number, pageSize: number = 10) {
const [posts, total] = await Promise.all([
db.posts.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
db.posts.count(),
]);
return {
posts,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}Cursor Pagination
async function getPosts(cursor?: string, limit: number = 10) {
const posts = await db.posts.findMany({
take: limit + 1, // Fetch one extra to check if more exist
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasMore = posts.length > limit;
const items = hasMore ? posts.slice(0, -1) : posts;
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
}Infinite Scroll Component
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
export function InfinitePostList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page) =>
page.items.map((post) => <PostCard key={post.id} post={post} />)
)}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}Error Handling
Error Boundaries
// app/posts/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to error reporting service
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Not Found
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }) {
const { id } = await params;
const post = await getPost(id);
if (!post) {
notFound(); // Renders not-found.tsx
}
return <Post post={post} />;
}
// app/posts/[id]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Post Not Found</h2>
<p>Could not find the requested post.</p>
</div>
);
}Best Practices
1. Fetch in Server Components - Avoid useEffect for initial data 2. Parallelize independent fetches - Use Promise.all 3. Stream slow content - Wrap with Suspense 4. Use appropriate caching - Match strategy to data volatility 5. Preload data - Use React cache() for waterfall prevention 6. Handle errors gracefully - Use error.tsx boundaries 7. Paginate large datasets - Cursor pagination for infinite scroll 8. Pool connections - Essential for serverless
Next.js Routing Patterns
Dynamic routes, parallel routes, intercepting routes, route groups, and middleware.
Route Fundamentals
File-System Routing
app/
├── page.tsx → /
├── about/page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/page.tsx → /blog/:slug
└── api/
└── users/route.ts → /api/usersDynamic Segments
// app/posts/[slug]/page.tsx
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return <Post post={post} />;
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}Catch-All Segments
// app/docs/[...slug]/page.tsx
// Matches /docs/a, /docs/a/b, /docs/a/b/c, etc.
type Props = {
params: Promise<{ slug: string[] }>;
};
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// slug = ['a', 'b', 'c'] for /docs/a/b/c
const path = slug.join('/');
return <Documentation path={path} />;
}
// Optional catch-all: [[...slug]]
// Also matches /docs (slug = undefined)Route Groups
Organizing Without URL Impact
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ ├── page.tsx → /
│ └── about/page.tsx → /about
├── (shop)/
│ ├── layout.tsx # Shop layout
│ ├── products/page.tsx → /products
│ └── cart/page.tsx → /cart
└── (auth)/
├── layout.tsx # Auth layout
├── login/page.tsx → /login
└── signup/page.tsx → /signupMultiple Root Layouts
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
return (
<html>
<body>
<MarketingHeader />
{children}
<MarketingFooter />
</body>
</html>
);
}
// app/(shop)/layout.tsx
export default function ShopLayout({ children }) {
return (
<html>
<body>
<ShopHeader />
<CartProvider>
{children}
</CartProvider>
<ShopFooter />
</body>
</html>
);
}Parallel Routes
Basic Setup
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ └── page.tsx
└── @sidebar/
├── page.tsx
└── default.tsx # Required fallback// app/layout.tsx
export default function Layout({
children,
analytics,
sidebar,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
sidebar: React.ReactNode;
}) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{children}</main>
<div className="analytics">{analytics}</div>
</div>
);
}Conditional Rendering
// app/@auth/page.tsx
import { auth } from '@/lib/auth';
export default async function AuthSlot() {
const session = await auth();
if (!session) {
return <LoginPrompt />;
}
return <UserMenu user={session.user} />;
}Default Fallback (Required in Next.js 16)
// app/@sidebar/default.tsx
export default function Default() {
return null; // Or a loading skeleton
}Intercepting Routes
Modal Pattern
app/
├── feed/
│ └── page.tsx # Feed page
├── photo/
│ └── [id]/
│ └── page.tsx # Full photo page
└── @modal/
├── default.tsx
└── (.)photo/
└── [id]/
└── page.tsx # Photo modal// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal';
export default async function PhotoModal({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<PhotoView photo={photo} />
</Modal>
);
}Interception Conventions
(.) - Same level
(..) - One level up
(..)(..) - Two levels up
(...) - Root levelModal Component
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useRef, useEffect } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
const onDismiss = useCallback(() => {
router.back();
}, [router]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onDismiss]);
return (
<div
ref={overlayRef}
className="modal-overlay"
onClick={(e) => {
if (e.target === overlayRef.current) onDismiss();
}}
>
<div className="modal-content">
<button onClick={onDismiss} className="modal-close">×</button>
{children}
</div>
</div>
);
}Route Handlers
HTTP Methods
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') ?? '1';
const posts = await getPosts(parseInt(page));
return NextResponse.json(posts);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await createPost(body);
return NextResponse.json(post, { status: 201 });
}Dynamic Route Handlers
// app/api/posts/[id]/route.ts
type Context = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, context: Context) {
const { id } = await context.params;
const post = await getPost(id);
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(post);
}
export async function PUT(request: NextRequest, context: Context) {
const { id } = await context.params;
const body = await request.json();
const post = await updatePost(id, body);
return NextResponse.json(post);
}
export async function DELETE(request: NextRequest, context: Context) {
const { id } = await context.params;
await deletePost(id);
return new NextResponse(null, { status: 204 });
}CORS Headers
// app/api/public/route.ts
export async function GET(request: NextRequest) {
const data = await getData();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
export async function OPTIONS() {
return new NextResponse(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}Middleware (proxy.ts in Next.js 16)
Basic Middleware
// proxy.ts (middleware.ts in Next.js 15)
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Redirect
if (pathname === '/old-page') {
return NextResponse.redirect(new URL('/new-page', request.url));
}
// Rewrite (internal)
if (pathname.startsWith('/api/v1')) {
return NextResponse.rewrite(new URL(pathname.replace('/v1', '/v2'), request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};Authentication Middleware
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
const authRoutes = ['/login', '/signup'];
export async function proxy(request: NextRequest) {
const token = await getToken({ req: request });
const { pathname } = request.nextUrl;
// Redirect authenticated users away from auth pages
if (token && authRoutes.some(route => pathname.startsWith(route))) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Protect routes
if (!token && protectedRoutes.some(route => pathname.startsWith(route))) {
const url = new URL('/login', request.url);
url.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}Geolocation & Headers
export function proxy(request: NextRequest) {
const country = request.geo?.country ?? 'US';
const city = request.geo?.city ?? 'Unknown';
const response = NextResponse.next();
// Add custom headers
response.headers.set('x-country', country);
response.headers.set('x-city', city);
return response;
}Navigation
Link Component
import Link from 'next/link';
// Basic
<Link href="/about">About</Link>
// Dynamic
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
// With query params
<Link href={{ pathname: '/search', query: { q: 'next.js' } }}>
Search
</Link>
// Prefetch (default: true)
<Link href="/heavy-page" prefetch={false}>Heavy Page</Link>
// Replace history (no back)
<Link href="/login" replace>Login</Link>useRouter
'use client';
import { useRouter } from 'next/navigation';
export function NavigationButtons() {
const router = useRouter();
return (
<>
<button onClick={() => router.push('/dashboard')}>Dashboard</button>
<button onClick={() => router.replace('/home')}>Home (replace)</button>
<button onClick={() => router.back()}>Back</button>
<button onClick={() => router.forward()}>Forward</button>
<button onClick={() => router.refresh()}>Refresh</button>
<button onClick={() => router.prefetch('/heavy-page')}>Prefetch</button>
</>
);
}usePathname & useSearchParams
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
export function CurrentRoute() {
const pathname = usePathname();
const searchParams = useSearchParams();
const query = searchParams.get('q');
return (
<div>
<p>Path: {pathname}</p>
<p>Search: {query}</p>
</div>
);
}Best Practices
1. Use route groups for layout organization without URL impact 2. Implement default.tsx for all parallel routes (required in Next.js 16) 3. Prefer Server Components for data fetching in pages 4. Use intercepting routes for modal patterns 5. Keep middleware lean - heavy logic belongs in route handlers 6. Match middleware carefully - exclude static assets 7. Prefetch strategically - disable for rarely visited pages
Anti-patterns
- ❌ Put heavy auth and business logic in middleware; ✅ keep middleware as routing glue and enforce authz in server code.
- ❌ Use
Access-Control-Allow-Origin: *for authenticated endpoints; ✅ scope CORS to trusted origins. - ❌ Forget
default.tsxfor parallel routes; ✅ add defaults to avoid slot rendering errors.
Type-Safe Server Actions
Complete patterns for Server Actions with validation, middleware, and optimistic updates.
next-safe-action Setup
Installation
npm install next-safe-action zodAction Client Configuration
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient({
// Global error handler
handleServerError(e) {
console.error('Action error:', e.message);
return 'Something went wrong';
},
});
// With authentication middleware
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
return next({ ctx: { session, userId: session.user.id } });
});
// With rate limiting
export const rateLimitedClient = authActionClient.use(async ({ next, ctx }) => {
const { success } = await rateLimit.check(ctx.userId);
if (!success) {
throw new Error('Rate limit exceeded');
}
return next({ ctx });
});Action Definitions
Basic Action
// actions/create-post.ts
'use server';
import { z } from 'zod';
import { authActionClient } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(10000),
published: z.boolean().default(false),
});
export const createPost = authActionClient
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
const post = await db.posts.create({
data: {
...parsedInput,
authorId: ctx.userId,
},
});
revalidatePath('/posts');
return { post };
});Action with Bind Args
// Partial application for dynamic params
const updatePostSchema = z.object({
title: z.string().min(1),
content: z.string(),
});
export const updatePost = authActionClient
.schema(updatePostSchema)
.bindArgsSchemas<[postId: z.ZodString]>([z.string().uuid()])
.action(async ({ parsedInput, bindArgsParsedInputs: [postId], ctx }) => {
// Verify ownership
const post = await db.posts.findUnique({ where: { id: postId } });
if (post?.authorId !== ctx.userId) {
throw new Error('Forbidden');
}
return db.posts.update({
where: { id: postId },
data: parsedInput,
});
});
// Usage with bound postId
const boundAction = updatePost.bind(null, postId);File Upload Action
const uploadSchema = z.object({
file: z.instanceof(File).refine(
(file) => file.size <= 5 * 1024 * 1024,
'File must be less than 5MB'
),
folder: z.string().optional(),
});
export const uploadFile = authActionClient
.schema(uploadSchema)
.action(async ({ parsedInput, ctx }) => {
const { file, folder } = parsedInput;
const buffer = await file.arrayBuffer();
const key = `${folder ?? 'uploads'}/${ctx.userId}/${file.name}`;
await s3.putObject({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: Buffer.from(buffer),
ContentType: file.type,
});
return { url: `https://cdn.example.com/${key}` };
});Client Integration
useAction Hook
'use client';
import { useAction } from 'next-safe-action/hooks';
import { createPost } from '@/actions/create-post';
export function CreatePostForm() {
const { execute, result, status, reset } = useAction(createPost);
const handleSubmit = async (formData: FormData) => {
await execute({
title: formData.get('title') as string,
content: formData.get('content') as string,
published: formData.get('published') === 'on',
});
};
return (
<form action={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<label>
<input name="published" type="checkbox" />
Publish immediately
</label>
<button type="submit" disabled={status === 'executing'}>
{status === 'executing' ? 'Creating...' : 'Create Post'}
</button>
{result.validationErrors && (
<ul className="errors">
{Object.entries(result.validationErrors).map(([field, errors]) => (
<li key={field}>{field}: {errors?.join(', ')}</li>
))}
</ul>
)}
{result.serverError && (
<p className="error">{result.serverError}</p>
)}
{result.data?.post && (
<p className="success">Post created: {result.data.post.title}</p>
)}
</form>
);
}useOptimisticAction Hook
'use client';
import { useOptimisticAction } from 'next-safe-action/hooks';
import { toggleLike } from '@/actions/toggle-like';
export function LikeButton({ postId, initialLiked, initialCount }) {
const { execute, optimisticState } = useOptimisticAction(toggleLike, {
currentState: { liked: initialLiked, count: initialCount },
updateFn: (state, input) => ({
liked: !state.liked,
count: state.liked ? state.count - 1 : state.count + 1,
}),
});
return (
<button onClick={() => execute({ postId })}>
{optimisticState.liked ? '❤️' : '🤍'} {optimisticState.count}
</button>
);
}Form with React Hook Form
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { createPost, createPostSchema } from '@/actions/create-post';
type FormData = z.infer<typeof createPostSchema>;
export function PostForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(createPostSchema),
});
const { execute, status } = useAction(createPost, {
onSuccess: () => {
// Handle success
},
onError: (error) => {
// Handle error
},
});
return (
<form onSubmit={handleSubmit((data) => execute(data))}>
<input {...register('title')} />
{errors.title && <span>{errors.title.message}</span>}
<textarea {...register('content')} />
{errors.content && <span>{errors.content.message}</span>}
<button type="submit" disabled={status === 'executing'}>
Submit
</button>
</form>
);
}Native Server Actions (Without Library)
Basic Pattern
// actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const schema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContact(prevState: any, formData: FormData) {
const result = schema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: 'Validation failed',
};
}
try {
await sendEmail(result.data);
revalidatePath('/contact');
return { message: 'Message sent successfully' };
} catch (error) {
return { message: 'Failed to send message' };
}
}With useActionState
'use client';
import { useActionState } from 'react';
import { submitContact } from './actions';
export function ContactForm() {
const [state, action, pending] = useActionState(submitContact, {});
return (
<form action={action}>
<input name="email" type="email" disabled={pending} />
{state.errors?.email && <span>{state.errors.email}</span>}
<textarea name="message" disabled={pending} />
{state.errors?.message && <span>{state.errors.message}</span>}
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}Error Handling Patterns
Typed Error Responses
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; code: string };
export const createUser = authActionClient
.schema(userSchema)
.action(async ({ parsedInput }): Promise<ActionResult<User>> => {
try {
const user = await db.users.create({ data: parsedInput });
return { success: true, data: user };
} catch (error) {
if (error.code === 'P2002') {
return {
success: false,
error: 'Email already exists',
code: 'DUPLICATE_EMAIL'
};
}
throw error; // Re-throw unexpected errors
}
});Global Error Boundary
// lib/safe-action.ts
export const actionClient = createSafeActionClient({
handleServerError(e) {
// Log to monitoring service
Sentry.captureException(e);
// Return user-friendly message
if (e instanceof AuthError) {
return 'Please sign in to continue';
}
if (e instanceof RateLimitError) {
return 'Too many requests. Please wait a moment.';
}
return 'An unexpected error occurred';
},
});Testing Server Actions
// __tests__/actions/create-post.test.ts
import { createPost } from '@/actions/create-post';
// Mock auth
jest.mock('@/lib/auth', () => ({
auth: jest.fn(() => Promise.resolve({ user: { id: 'user-1' } })),
}));
describe('createPost', () => {
it('creates a post with valid input', async () => {
const result = await createPost({
title: 'Test Post',
content: 'This is test content that is long enough.',
published: true,
});
expect(result.data?.post).toBeDefined();
expect(result.data?.post.title).toBe('Test Post');
});
it('returns validation errors for invalid input', async () => {
const result = await createPost({
title: '',
content: 'short',
published: false,
});
expect(result.validationErrors).toBeDefined();
expect(result.validationErrors?.title).toBeDefined();
});
});Best Practices
1. Always validate input - Use Zod schemas for all actions 2. Type the return value - Define explicit return types 3. Handle errors gracefully - Return structured errors, don't expose internals 4. Revalidate appropriately - Use revalidatePath/revalidateTag after mutations 5. Use middleware - Extract auth, rate limiting, logging into reusable middleware 6. Prefer next-safe-action - Better DX than raw Server Actions for complex apps 7. Test actions - Unit test business logic, integration test with database
Testing Next.js App Router Apps
Strategy
Use a layered approach:
- Unit tests for pure logic (parsing, validation, policies).
- Component tests for Client Components.
- Integration/E2E tests for critical user flows.
Server Code: Make It Testable
Make server code testable by extracting business logic into pure functions and keeping handlers/actions as thin wrappers.
✅ Pattern: thin wrapper + pure core
// lib/posts.ts
export async function createPostCore(input: { title: string }, userId: string) {
return db.posts.create({ data: { title: input.title, authorId: userId } });
}
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
const post = await createPostCore(body, "test-user");
return NextResponse.json(post, { status: 201 });
}❌ Anti-pattern: monolithic handler logic
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
if (!body?.title) {
return NextResponse.json({ error: "invalid" }, { status: 400 });
}
const post = await db.posts.create({
data: { title: body.title, authorId: "test-user" },
});
await fetch("https://example.com/webhook", {
method: "POST",
body: JSON.stringify(post),
});
return NextResponse.json(post, { status: 201 });
}Client Components
Test Client Components with a DOM-focused runner:
- validate rendering and UI state transitions
- mock network boundaries at the edges
Avoid over-testing framework wiring.
E2E (Recommended for Critical Flows)
Run one E2E suite for:
- auth/login
- checkout/payment
- tenant switching / admin actions
Keep E2E stable:
- use
data-testidselectors - avoid fixed sleeps; wait on visible state or network aliases
- seed deterministic test data
Pair with playwright or cypress skills for tooling patterns.
Anti-patterns
- ❌ Assert on implementation details (internal component state); ✅ assert on user-visible behavior.
- ❌ Flaky waits (
wait(1000)); ✅ wait on conditions or network. - ❌ E2E everything; ✅ reserve E2E for critical flows and keep the rest unit/component tests.