Building Next.js 13+ applications
Scope: App Router conventions, layouts, loading/error states, Server vs Client Components Lines: ~310 Last Updated: 2025-10-18
Activate this skill when:
Pages Router (Legacy):
pages/
index.tsx # /
about.tsx # /about
blog/
[slug].tsx # /blog/:slug
App Router (New, recommended):
app/
page.tsx # /
about/
page.tsx # /about
blog/
[slug]/
page.tsx # /blog/:slug
Key differences:
app/
layout.tsx # Root layout (required)
page.tsx # Route page
loading.tsx # Loading UI (Suspense boundary)
error.tsx # Error UI (Error boundary)
not-found.tsx # 404 UI
template.tsx # Re-rendered layout
route.ts # API route handler
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Must include:
<html> and <body> tags// app/page.tsx
export default function HomePage() {
return <h1>Home Page</h1>;
}
Each route requires page.tsx to be accessible.
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>;
}
Automatic Suspense boundary - wraps page in <Suspense>.
// app/dashboard/error.tsx
'use client'; // Must be Client Component
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>
);
}
Automatic Error boundary - catches errors in page and nested segments.
// app/page.tsx (Server Component by default)
import { prisma } from '@/lib/prisma';
export default async function HomePage() {
// Fetch data directly in component
const users = await prisma.user.findMany();
return (
<div>
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Benefits:
Limitations:
// app/components/Counter.tsx
'use client'; // Opt-in to Client Component
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
When to use:
Add 'use client' at top of file.
// app/page.tsx (Server Component)
import { prisma } from '@/lib/prisma';
import Counter from './components/Counter'; // Client Component
export default async function HomePage() {
const users = await prisma.user.findMany();
return (
<div>
<h1>Users: {users.length}</h1>
{/* Server Component data passed to Client Component */}
<Counter initialCount={users.length} />
</div>
);
}
Rules:
// app/components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
);
}
// app/page.tsx (Server Component)
import ClientWrapper from './components/ClientWrapper';
import ServerData from './components/ServerData'; // Server Component
export default function Page() {
return (
<ClientWrapper>
{/* ServerData rendered on server, passed as children */}
<ServerData />
</ClientWrapper>
);
}
Pass Server Components as children to Client Components.
// app/layout.tsx (Root Layout)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<nav>Global Nav</nav>
{children}
</body>
</html>
);
}
// app/dashboard/layout.tsx (Dashboard Layout)
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<aside>Dashboard Sidebar</aside>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <h1>Dashboard</h1>;
}
Result:
<html>
<body>
<nav>Global Nav</nav>
<div>
<aside>Dashboard Sidebar</aside>
<main>
<h1>Dashboard</h1>
</main>
</div>
</body>
</html>
Layouts persist across navigation - no re-render when switching pages in same layout.
// app/dashboard/template.tsx
export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
return (
<div className="fade-in">
{children}
</div>
);
}
Difference from Layout:
Use templates for:
app/
blog/
[slug]/
page.tsx # /blog/:slug
// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
return <h1>Post: {params.slug}</h1>;
}
app/
docs/
[...slug]/
page.tsx # /docs/a, /docs/a/b, /docs/a/b/c
// app/docs/[...slug]/page.tsx
export default function DocsPage({ params }: { params: { slug: string[] } }) {
// /docs/a/b/c -> params.slug = ['a', 'b', 'c']
return <h1>Docs: {params.slug.join(' > ')}</h1>;
}
app/
shop/
[[...slug]]/
page.tsx # /shop, /shop/a, /shop/a/b
app/
(marketing)/ # Group, not in URL
page.tsx # /
about/
page.tsx # /about
(shop)/
products/
page.tsx # /products
(dashboard)/
settings/
page.tsx # /settings
Parentheses exclude folder from URL path.
Use for:
app/
@analytics/ # Parallel slot
page.tsx
@team/ # Parallel slot
page.tsx
layout.tsx
page.tsx
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</div>
);
}
Use for:
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
{posts.map((post: Post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
Fetch options:
// Static (cached forever, revalidate on build)
fetch(url, { cache: 'force-cache' })
// Dynamic (no cache, fetch on every request)
fetch(url, { cache: 'no-store' })
// Revalidate (cache, revalidate after N seconds)
fetch(url, { next: { revalidate: 60 } })
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function Analytics() {
const data = await fetchAnalytics(); // Slow
return <div>Analytics: {data.value}</div>;
}
async function RecentSales() {
const sales = await fetchSales(); // Fast
return <div>Sales: {sales.length}</div>;
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* RecentSales loads first, Analytics streams in */}
<Suspense fallback={<div>Loading analytics...</div>}>
<Analytics />
</Suspense>
<Suspense fallback={<div>Loading sales...</div>}>
<RecentSales />
</Suspense>
</div>
);
}
Benefits:
// 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;
// Database insert
await prisma.post.create({
data: { title, content }
});
// Revalidate cache
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}
Progressive enhancement - works without JavaScript.
// app/components/PostForm.tsx
'use client';
import { 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 PostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<SubmitButton />
</form>
);
}
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(id: string, data: any) {
await prisma.post.update({ where: { id }, data });
// Revalidate specific path
revalidatePath('/posts');
revalidatePath(`/posts/${id}`);
// Or revalidate by tag
revalidateTag('posts');
}
// app/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Home Page',
description: 'Welcome to our site',
openGraph: {
title: 'Home Page',
description: 'Welcome to our site',
images: ['/og-image.png'],
},
};
export default function HomePage() {
return <h1>Home</h1>;
}
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image],
},
};
}
export default function PostPage({ params }: { params: { slug: string } }) {
return <div>Post</div>;
}
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const users = await prisma.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const data = await request.json();
const user = await prisma.user.create({ data });
return NextResponse.json(user, { status: 201 });
}
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: '/dashboard/:path*',
};
page.tsx # Route UI
layout.tsx # Persistent layout
template.tsx # Re-rendered layout
loading.tsx # Loading UI (Suspense)
error.tsx # Error UI (Error boundary)
not-found.tsx # 404 UI
route.ts # API endpoint
Need interactivity (state, events)? → Client Component
Need browser APIs? → Client Component
Need server-only code? → Server Component
Need data fetching? → Server Component (default)
Unsure? → Start with Server Component, convert if needed
// Static (build time)
fetch(url, { cache: 'force-cache' })
// Dynamic (every request)
fetch(url, { cache: 'no-store' })
// Revalidate (time-based)
fetch(url, { next: { revalidate: 60 } })
// Revalidate (tag-based)
fetch(url, { next: { tags: ['posts'] } })
react-component-patterns.md - Component composition, custom hooksreact-state-management.md - Client-side state managementreact-data-fetching.md - SWR, React Query for Client Componentsnextjs-seo.md - Metadata API, structured data, sitemapsfrontend-performance.md - Bundle optimization, image optimizationLast Updated: 2025-10-18 Format Version: 1.0 (Atomic)