Next.js App Router: Server Components, Data Fetching, Caching and Best Practices
The App Router, introduced in Next.js 13 and stabilized in Next.js 14, represents the biggest paradigm shift in React development in years. React Server Components change where code runs, how data is fetched, and what ships to the browser. Understanding the App Router deeply is now a prerequisite for senior frontend roles at most companies.
The Core Shift: Server vs Client Components
In the App Router, every component is a Server Component by default. Server Components run exclusively on the server β they never ship to the browser.
tsx-- app/users/page.tsx -- This is a Server Component by default -- It can be async, access databases, read environment variables async function UsersPage() { -- Direct database access -- no API route needed const users = await db.users.findMany({ take: 20 }); return ( <main> <h1>Users</h1> {users.map(user => ( <UserCard key={user.id} user={user} /> ))} </main> ); } export default UsersPage;
Server Components:
- Run on the server, never on the client
- Can directly access databases, file systems, secrets
- Cannot use hooks (
useState,useEffect) or browser APIs - Their code never ships to the JavaScript bundle β smaller bundles
Client Components are opted in with "use client":
tsx"use client"; import { useState } from "react"; export function SearchBar({ onSearch }: { onSearch: (q: string) => void }) { const [query, setQuery] = useState(""); return ( <input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === "Enter" && onSearch(query)} placeholder="Search..." /> ); }
Client Components:
- Run on the server (for initial HTML) AND the client
- Can use React hooks and browser APIs
- Their code IS included in the JavaScript bundle
File System Routing
codeapp/ βββ layout.tsx -- root layout (wraps everything) βββ page.tsx -- / βββ loading.tsx -- automatic Suspense boundary βββ error.tsx -- error boundary βββ not-found.tsx -- 404 page βββ blog/ β βββ layout.tsx -- layout for /blog/* β βββ page.tsx -- /blog β βββ [slug]/ β βββ page.tsx -- /blog/my-post βββ dashboard/ β βββ (overview)/ -- route group (no URL segment) β β βββ page.tsx -- /dashboard β βββ settings/ β βββ page.tsx -- /dashboard/settings βββ api/ βββ webhooks/ βββ route.ts -- /api/webhooks (Route Handler)
Layouts and Templates
tsx-- app/layout.tsx (root layout -- required) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Header /> <main>{children}</main> <Footer /> </body> </html> ); } -- app/dashboard/layout.tsx (nested layout -- persists across navigation) export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( <div className="dashboard"> <Sidebar /> <div className="content">{children}</div> </div> ); }
Layouts persist across navigation β Sidebar does not remount when navigating between /dashboard and /dashboard/settings.
Data Fetching
Async Server Components
tsx-- app/posts/[slug]/page.tsx interface Props { params: { slug: string }; } async function PostPage({ params }: Props) { const post = await fetchPost(params.slug); if (!post) notFound(); -- renders not-found.tsx return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ); } -- Generate static params for static generation export async function generateStaticParams() { const posts = await fetchAllPosts(); return posts.map(post => ({ slug: post.slug })); } export default PostPage;
Parallel data fetching
tsxasync function Dashboard() { -- Fetch in parallel -- not sequentially const [user, posts, analytics] = await Promise.all([ fetchUser(), fetchPosts(), fetchAnalytics(), ]); return ( <div> <UserCard user={user} /> <PostList posts={posts} /> <AnalyticsChart data={analytics} /> </div> ); }
Streaming with Suspense
tsximport { Suspense } from "react"; async function SlowComponent() { const data = await fetchSlowData(); -- takes 2 seconds return <DataDisplay data={data} />; } export default function Page() { return ( <div> <FastHeader /> -- renders immediately <Suspense fallback={<Skeleton />}> <SlowComponent /> -- streams in when ready </Suspense> </div> ); }
The page sends HTML immediately. The slow component streams in without blocking the fast content.
Caching in the App Router
Next.js has four caching layers:
Request Memoization
Within a single render, identical fetch calls are deduplicated automatically:
tsx-- Both components call fetchUser(1) -- only ONE network request is made async function Avatar() { const user = await fetchUser(1); -- fetched once return <img src={user.avatar} />; } async function Username() { const user = await fetchUser(1); -- returns memoized result return <span>{user.name}</span>; }
Data Cache
fetch responses are cached across requests until explicitly revalidated:
tsx-- Cache forever (static) const data = await fetch(url, { cache: "force-cache" }); -- Cache for 60 seconds (ISR-style) const data = await fetch(url, { next: { revalidate: 60 } }); -- Never cache (dynamic) const data = await fetch(url, { cache: "no-store" });
Route Segment Config
tsx-- Page-level cache control export const revalidate = 3600; -- revalidate every hour export const dynamic = "force-dynamic"; -- always dynamic export const dynamic = "force-static"; -- always static
On-demand revalidation
tsx-- Invalidate cache tags when data changes import { revalidateTag } from "next/cache"; export async function updatePost(id: string, data: PostData) { await db.posts.update(id, data); revalidateTag(`post-${id}`); -- invalidate all fetches tagged with this revalidateTag("posts"); -- invalidate post list caches } -- In your fetch call, add the tag const post = await fetch(`/api/posts/${id}`, { next: { tags: [`post-${id}`, "posts"] } });
Server Actions
Server Actions let you call server-side code directly from components β no API route needed:
tsx-- app/posts/new/page.tsx import { redirect } from "next/navigation"; async function createPost(formData: FormData) { "use server"; -- marks this as a Server Action const title = formData.get("title") as string; const content = formData.get("content") as string; if (!title || title.length < 5) { throw new Error("Title too short"); } const post = await db.posts.create({ title, content }); revalidateTag("posts"); redirect(`/posts/${post.slug}`); } export default function NewPostPage() { return ( <form action={createPost}> <input name="title" required /> <textarea name="content" required /> <button type="submit">Publish</button> </form> ); }
Server Actions with useActionState (React 19)
tsx"use client"; import { useActionState } from "react"; async function loginAction(prevState: any, formData: FormData) { "use server"; const email = formData.get("email") as string; const password = formData.get("password") as string; const result = await authenticate(email, password); if (!result.success) return { error: result.message }; redirect("/dashboard"); } export function LoginForm() { const [state, action, isPending] = useActionState(loginAction, null); return ( <form action={action}> <input name="email" type="email" /> <input name="password" type="password" /> {state?.error && <p className="error">{state.error}</p>} <button type="submit" disabled={isPending}> {isPending ? "Signing in..." : "Sign in"} </button> </form> ); }
Metadata API
tsx-- Static metadata export const metadata = { title: "My Blog", description: "Articles about software development", }; -- Dynamic metadata export async function generateMetadata({ params }: Props) { const post = await fetchPost(params.slug); return { title: post.title, description: post.summary, openGraph: { title: post.title, description: post.summary, images: [{ url: post.coverImage }], }, }; }
Common Interview Questions
Q: What is the difference between a Server Component and a Client Component?
Server Components run only on the server β they have zero JavaScript bundle cost, can access backend resources directly, and cannot use hooks or browser APIs. Client Components run on both server (for SSR) and client β they can use hooks, event handlers, and browser APIs but their code ships to the browser. Default in App Router is Server Component; add "use client" to opt into Client Component.
Q: How does streaming improve perceived performance?
Without streaming, the server waits until all data is fetched before sending HTML. With streaming (via Suspense), Next.js sends the initial HTML shell immediately, then streams HTML chunks as each Suspense boundary resolves. Users see content faster β the fast parts appear right away while slow parts load progressively.
Q: When would you use a Server Action vs a Route Handler?
Server Actions are ideal for form submissions and mutations triggered from components β less boilerplate, no manual fetch call, automatic CSRF protection. Route Handlers (route.ts) are better for webhooks, third-party integrations, or when you need more control over request/response headers. Both run on the server.
Practice on Froquiz
Next.js and React Server Components are increasingly tested at frontend and full-stack interviews. Test your React knowledge on Froquiz β covering hooks, rendering, and performance.
Summary
- Every component is a Server Component by default β add
"use client"only when needed - Server Components can be async, access databases, and never ship JS to the browser
- Layouts persist across navigation;
loading.tsxanderror.tsxare automatic Suspense/error boundaries Promise.allfor parallel data fetching;Suspensefor streaming slow components- Four caching layers: request memoization, Data Cache, Full Route Cache, Router Cache
- Server Actions replace API routes for mutations β
"use server"in an async function revalidateTagandrevalidatePathinvalidate caches on demand after mutations