Next.js Framework Guide¶
Framework: Next.js 14+ Language: TypeScript/JavaScript Use Cases: Full-Stack Web Apps, SSR/SSG, E-commerce, Blogs, Dashboards
Overview¶
Next.js is a React framework that enables server-side rendering, static site generation, API routes, and full-stack development with a single codebase. Version 14+ uses the App Router as the default.
Project Setup¶
Create New Project¶
# Create new Next.js app
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
# Options explained:
# --typescript: Use TypeScript
# --tailwind: Include Tailwind CSS
# --eslint: Include ESLint
# --app: Use App Router (default in 14+)
cd my-app
npm run dev
Project Structure (App Router)¶
my-app/
├── app/
│ ├── (auth)/ # Route group (no URL segment)
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── dashboard/
│ │ ├── page.tsx # /dashboard
│ │ ├── loading.tsx # Loading UI
│ │ ├── error.tsx # Error UI
│ │ └── layout.tsx # Dashboard layout
│ ├── api/
│ │ └── users/
│ │ └── route.ts # API route
│ ├── globals.css
│ ├── layout.tsx # Root layout
│ └── page.tsx # Home page
├── components/
│ ├── ui/ # Reusable UI components
│ └── features/ # Feature components
├── lib/
│ ├── db.ts # Database client
│ └── utils.ts # Utility functions
├── hooks/ # Custom hooks
├── types/ # TypeScript types
├── public/ # Static assets
├── next.config.js
├── tailwind.config.ts
└── package.json
Routing¶
File-Based Routing¶
app/
├── page.tsx # / (home)
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/my-post (dynamic)
├── shop/
│ └── [...categories]/
│ └── page.tsx # /shop/a/b/c (catch-all)
└── (marketing)/
├── pricing/
│ └── page.tsx # /pricing (grouped)
└── features/
└── page.tsx # /features (grouped)
Page Component¶
// app/blog/[slug]/page.tsx
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default function BlogPost({ params, searchParams }: PageProps) {
return (
<article>
<h1>Post: {params.slug}</h1>
</article>
);
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// Metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
}
Layouts¶
// app/layout.tsx (Root Layout)
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'My awesome application',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx (Nested Layout)
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<div className="flex-1">{children}</div>
</div>
);
}
Server Components vs Client Components¶
Server Components (Default)¶
// app/users/page.tsx
// Server Component - runs on server only
import { db } from '@/lib/db';
export default async function UsersPage() {
// Direct database access (no API needed)
const users = await db.user.findMany();
return (
<div>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Client Components¶
// components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Composition Pattern¶
// app/dashboard/page.tsx (Server Component)
import { ClientSidebar } from '@/components/ClientSidebar';
import { db } from '@/lib/db';
export default async function Dashboard() {
// Fetch data on server
const stats = await db.stats.get();
return (
<div>
{/* Pass server data to client component */}
<ClientSidebar initialStats={stats} />
{/* Server-rendered content */}
<DashboardContent stats={stats} />
</div>
);
}
Data Fetching¶
Server Component Fetching¶
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // Revalidate every hour
});
if (!res.ok) {
throw new Error('Failed to fetch products');
}
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Parallel Data Fetching¶
// app/dashboard/page.tsx
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function getOrders(userId: string) {
const res = await fetch(`/api/users/${userId}/orders`);
return res.json();
}
export default async function Dashboard({ params }: { params: { id: string } }) {
// Fetch in parallel
const [user, orders] = await Promise.all([
getUser(params.id),
getOrders(params.id),
]);
return (
<div>
<UserProfile user={user} />
<OrderList orders={orders} />
</div>
);
}
Streaming with Suspense¶
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Immediate render */}
<WelcomeMessage />
{/* Stream in when ready */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // Slow query
return <StatsDisplay stats={stats} />;
}
API Routes¶
Route Handlers¶
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = userSchema.parse(body);
const user = await db.user.create({
data: validated,
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ errors: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: params.id },
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await db.user.delete({
where: { id: params.id },
});
return new NextResponse(null, { status: 204 });
}
Server Actions¶
Form Actions¶
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/lib/db';
const createPostSchema = z.object({
title: z.string().min(1),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
const validated = createPostSchema.parse({
title: formData.get('title'),
content: formData.get('content'),
});
const post = await db.post.create({
data: validated,
});
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
export async function deletePost(id: string) {
await db.post.delete({
where: { id },
});
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '../actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
With useFormState¶
// app/actions.ts
'use server';
type ActionState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
};
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const validatedFields = createPostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
await db.post.create({ data: validatedFields.data });
revalidatePath('/posts');
return { message: 'Post created successfully' };
} catch {
return { message: 'Failed to create post' };
}
}
// components/PostForm.tsx
'use client';
import { useFormState, 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 function PostForm() {
const [state, formAction] = useFormState(createPost, {});
return (
<form action={formAction}>
<div>
<input name="title" placeholder="Title" />
{state.errors?.title && <p className="error">{state.errors.title}</p>}
</div>
<div>
<textarea name="content" placeholder="Content" />
{state.errors?.content && <p className="error">{state.errors.content}</p>}
</div>
<SubmitButton />
{state.message && <p>{state.message}</p>}
</form>
);
}
Authentication¶
NextAuth.js Setup¶
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
// lib/auth.ts
import { NextAuthOptions } from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import GitHubProvider from 'next-auth/providers/github';
import CredentialsProvider from 'next-auth/providers/credentials';
import { db } from './db';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db),
providers: [
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Validate credentials
const user = await validateUser(credentials);
return user;
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
},
};
// lib/auth-utils.ts
import { getServerSession } from 'next-auth';
import { authOptions } from './auth';
export async function getSession() {
return await getServerSession(authOptions);
}
export async function getCurrentUser() {
const session = await getSession();
return session?.user;
}
// Protect server component
export default async function ProtectedPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return <div>Welcome, {user.name}</div>;
}
Middleware¶
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
if (isAuthPage) {
if (token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
Error Handling¶
Error Boundaries¶
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
// app/global-error.tsx (for root layout errors)
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
Not Found¶
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<Link href="/">Return Home</Link>
</div>
);
}
// Trigger programmatically
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const item = await getItem(params.id);
if (!item) {
notFound();
}
return <div>{item.name}</div>;
}
Best Practices¶
Guardrails¶
- ✓ Use Server Components by default
- ✓ Add 'use client' only when needed (interactivity, hooks)
- ✓ Colocate data fetching with components
- ✓ Use parallel data fetching with Promise.all
- ✓ Implement loading.tsx and error.tsx for each route
- ✓ Use Server Actions for mutations
- ✓ Cache and revalidate appropriately
- ✓ Use proper metadata for SEO
- ✓ Implement proper error boundaries
- ✓ Use TypeScript for type safety
Performance Tips¶
- Use
next/imagefor optimized images - Use
next/fontfor optimized fonts - Implement proper caching strategies
- Use streaming with Suspense
- Minimize client-side JavaScript
Configuration¶
next.config.js¶
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.example.com',
},
],
},
experimental: {
serverActions: true,
},
async redirects() {
return [
{
source: '/old-path',
destination: '/new-path',
permanent: true,
},
];
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
],
},
];
},
};
module.exports = nextConfig;