Summary: Next.js 14 cemented the App Router as the default path for modern React apps, while the Pages Router remains supported for migrations and specific use cases. This guide shows how to start a project, choose between routers, route correctly, fetch data, use Server Actions, and structure your codebase for long-term velocity.
1) App Router vs Pages Router (which should you pick?)
App Router (/app)
- Server Components by default; use
"use client"to opt into client components. - Layouts:
layout.tsxlets sections fetch once and persist UI across navigation. - Routing: nested, parallel, and intercepting routes;
loading.tsxfor streaming. - Data: built-in caching with
fetch()+revalidate, async components,generateStaticParams(). - Mutations: Server Actions let you change data securely without a separate API call.
Pages Router (/pages)
- Client components by default.
- Data via
getStaticProps,getServerSideProps,getStaticPaths. - No layouts/parallel routes.
Recommendation: Use App Router for new work; keep Pages Router for legacy/migration. You can mix both in one repo.
2) Create a project with sane defaults
npx create-next-app@latest my-app
cd my-app
# Choose: TypeScript, ESLint, Tailwind, src/, App Router, alias @/*
3) Routing mental model (App Router)
Every folder under app/ is a route segment:
page.tsx: the page for that segmentlayout.tsx: persistent wrapper and data for a sectionloading.tsx: instant skeleton while data streamserror.tsx,not-found.tsx: error/404 boundariestemplate.tsx: like layout but re-renders on navigationroute.ts: API endpoint for that segment(group): route group (organize without changing the URL)
src/app/
layout.tsx
page.tsx
about/page.tsx
dashboard/
layout.tsx
page.tsx
users/[id]/page.tsx
users/[id]/loading.tsx
4) Data fetching & caching (SSG/ISR/SSR reimagined)
- Static (default):
fetch()caches; great for content. - ISR:
fetch(url, { next: { revalidate: 600 } })→ re-build at most every 10 minutes. - SSR:
fetch(url, { cache: "no-store" })→ always fresh.
Examples
/* Static (default cache) */
export default async function Page() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return <List posts={posts} />;
}
/* ISR */
export async function PageISR() {
const res = await fetch('https://api.example.com/posts', { next: { revalidate: 600 } });
const posts = await res.json();
return <List posts={posts} />;
}
/* SSR (no cache) */
export async function PageSSR() {
const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
const posts = await res.json();
return <List posts={posts} />;
}
Streaming with loading.tsx
Create a loading.tsx next to the page to show immediate UI while data loads.
5) Dynamic routes & pre-rendering
Create app/blog/[slug]/page.tsx to handle dynamic segments. Use generateStaticParams() to pre-build known slugs.
/* app/blog/[slug]/page.tsx */
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/blog/slugs');
const slugs: string[] = await res.json();
return slugs.map((slug) => ({ slug }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const res = await fetch(`https://api.example.com/blog/${params.slug}`, { next: { revalidate: 300 } });
const post = await res.json();
return <Article post={post} />;
}
6) Server Actions (forms & mutations without API boilerplate)
Mark a server file with "use server" and export functions you call from components. Use revalidatePath or revalidateTag to refresh caches after writes.
/* app/products/actions.ts */
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function createProduct(formData: FormData) {
const name = formData.get('name');
await fetch('https://api.example.com/products', {
method: 'POST',
body: JSON.stringify({ name }),
headers: { 'content-type': 'application/json' }
});
// Choose one:
revalidateTag('products'); // if your fetcher used next: { tags: ['products'] }
// revalidatePath('/dashboard/products'); // if you want to revalidate a route path
}
/* app/products/page.tsx */
export default function ProductsPage() {
return (
<form action={createProduct}>
<input name="name" />
<button type="submit">Create</button>
</form>
);
}
7) API routes in the App Router
Add route.ts under app/api/... to expose endpoints for third parties or public use.
/* app/api/users/[id]/route.ts */
import { NextResponse } from 'next/server';
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const user = await fetch(`https://api.example.com/users/${params.id}`).then(r => r.json());
return NextResponse.json(user);
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
const data = await req.json();
const saved = await fetch(`https://api.example.com/users/${params.id}`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'content-type': 'application/json' }
}).then(r => r.json());
return NextResponse.json(saved, { status: 201 });
}
8) A pragmatic project structure
src/
app/
(marketing)/
layout.tsx
page.tsx
blog/[slug]/page.tsx
dashboard/
layout.tsx
users/[id]/page.tsx
api/users/[id]/route.ts
components/ui/
lib/
db.ts
fetchers.ts
validators.ts
styles/globals.css
- Co-locate
actions.tswith the route that owns the mutation. - Keep client components small; let server components fetch/compute.
- Use route groups to separate concerns without affecting URLs.
9) Cache tags & invalidation
// lib/fetchers.ts
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
});
return res.json();
}
// app/products/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createProduct(data: FormData) {
// ...write to DB or API
revalidateTag('products');
}
10) Env, auth & deployment notes
- Secrets in
.env.local(server-only unless prefixed withNEXT_PUBLIC_). - Protect routes via
middleware.tsand your auth provider. - Pick
runtime = "edge"per route if needed. - Deploy to Vercel or your Node host; monitor logs and cache revalidation.
11) Common pitfalls
- Forgetting
"use client"where state/effects are needed. - Overusing
no-storeand killing caching. - Not using
error.tsx/not-found.tsxfor graceful failures. - Giant client components doing server work.
12) Final checklist
- App Router + TypeScript + Tailwind
- Layouts & loading states per section
- Static-by-default with
revalidatewhere needed - Server Actions for internal writes; API routes for public use
- Cache tags or
revalidatePathon mutation - Clear folder structure with route groups
Bottom line: Choose App Router for new apps, think in sections (layouts), treat data as cached by default, and colocate actions with routes. That mindset keeps your Next.js 14 apps fast, reliable, and easy to evolve.