Skip to main content

Introduction to Next.js

In this chapter, you learn about Next.js, React's full-stack framework.

What you learn in this chapter

  • Next.js's characteristics and rendering strategies (SSR/SSG/CSR)
  • The App Router and Server Components
  • Server Actions and Route Handlers
  • Choosing between a Vite project and Next.js
info

In this chapter, you create a new Next.js project. Separately from the Chapter 3 project, you work in a directory for Next.js.

What is Next.js

Next.js is a React-based full-stack framework developed by Vercel. It is a production-ready React framework that is also recommended in the official React documentation.

Main characteristics

CharacteristicDescription
Hybrid renderingYou can flexibly choose SSR, SSG, and CSR
File-based routingThe file structure becomes the routes directly
Route HandlersBuild backend APIs in the same project too
Automatic optimizationAutomatic optimization of images, fonts, and scripts
Server ComponentsNative support for React's Server Components

Rendering strategies

CSR (Client-Side Rendering)

The UI is generated by running JavaScript in the browser. The Vite + React apps you have learned in this book use this method.

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │ ───> │ Server │ ───> │ Browser │
│ Request │ │ HTML/JS │ │ Render │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
Empty HTML + Build the UI
a large JS with JavaScript

Pros: rich interaction Cons: slow initial display, unfavorable for SEO.

SSR (Server-Side Rendering)

The HTML is generated on the server and sent to the browser.

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │ ───> │ Server │ ───> │ Browser │
│ Request │ │ Generate │ │ Display │
└─────────────┘ │ HTML │ └─────────────┘
└─────────────┘

Generate the complete
HTML on the server

Pros: fast initial display, favorable for SEO Cons: server load, TTFB may become slow.

SSG (Static Site Generation)

The HTML is generated at build time.

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Build Time │ ───> │ Static HTML │ ───> │ CDN │
│ Generate │ │ Files │ │ Serve │
└─────────────┘ └─────────────┘ └─────────────┘

Generate the HTML
at build time

Pros: the fastest delivery, high scalability Cons: not suited for dynamic content.

Creating a project

npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm run dev

Directory structure (App Router)

my-app/
├── app/
│ ├── layout.tsx # the root layout
│ ├── page.tsx # the home page (/)
│ ├── globals.css # global CSS
│ ├── about/
│ │ └── page.tsx # the About page (/about)
│ └── blog/
│ ├── page.tsx # the blog list (/blog)
│ └── [slug]/
│ └── page.tsx # a dynamic route (/blog/xxx)
├── components/
├── public/
├── next.config.ts
└── package.json

App Router

A new routing system introduced in Next.js 13, and this is now the standard. It assumes Server Components and lets you leverage Suspense, Streaming, and Server Actions.

warning

Next.js also has the older Pages Router that uses the pages/ directory, but for new projects, adopt the App Router. The Pages Router is treated as legacy, kept for compatibility with existing projects, and new features ('use cache', Partial Prerendering, etc.) are available only in the App Router.

info

In Next.js 16 (the latest stable version), Turbopack was stabilized as the default bundler. Both development (next dev) and production builds (next build) use Turbopack, which is dramatically faster than the Webpack era. You benefit from it in a new project without any special configuration.

A basic page

// app/page.tsx - the home page
export default function HomePage() {
return (
<main>
<h1>Welcome to Next.js</h1>
</main>
)
}
// app/about/page.tsx - /about
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
</main>
)
}

Layouts

Define UI shared across multiple pages.

// app/layout.tsx
import { ReactNode } from 'react'
import './globals.css'

export const metadata = {
title: 'My App',
description: 'My awesome app'
}

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<header className="bg-gray-800 text-white p-4">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main>{children}</main>
<footer className="bg-gray-100 p-4">
© {new Date().getFullYear()} My App
</footer>
</body>
</html>
)
}

Dynamic routes

A page that uses URL parameters.

// app/blog/[slug]/page.tsx
type BlogPostPageProps = {
params: Promise<{ slug: string }>
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params

// Fetch the data
const post = await getPost(slug)

return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}

// Generate static paths (SSG)
export async function generateStaticParams() {
const posts = await getAllPosts()

return posts.map(post => ({
slug: post.slug
}))
}

Server Components and Client Components

info

Choosing between Server Components and Client Components is the most important concept when using the Next.js App Router. Remember "the default is Server Component, and only the parts that need interaction are Client Components."

In the Next.js App Router, Server Components are used by default.

Server Components

Components that run only on the server.

// app/users/page.tsx (Server Component)
// Can access the database directly
import { db } from '@/lib/db'

export default async function UsersPage() {
// Runs on the server
const users = await db.user.findMany()

return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
warning

In Server Components, client-side hooks such as useState and useEffect cannot be used. When interactive functionality is needed, separate only that part into a different component with 'use client'.

Characteristics of Server Components:

  • Can access the database directly
  • Safely use sensitive information such as API keys
  • Not included in the bundle size
  • useState and useEffect cannot be used

Client Components

Components that run in the browser. Add the 'use client' directive.

// components/Counter.tsx (Client Component)
'use client'

import { useState } from 'react'

export function Counter() {
const [count, setCount] = useState(0)

const handleClick = () => {
setCount(c => c + 1)
}

return (
<button onClick={handleClick}>
Count: {count}
</button>
)
}

Situations to use Client Components:

  • Using Hooks such as useState and useEffect
  • Using browser APIs (localStorage, window, etc.)
  • When event handlers are needed
  • When a third-party library is client-only

How to choose

// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db'
import { InteractiveChart } from '@/components/InteractiveChart'

export default async function DashboardPage() {
// Fetch data on the server
const data = await db.analytics.getStats()

return (
<div>
<h1>Dashboard</h1>
{/* Fetch data in a Server Component and pass it to a Client Component */}
<InteractiveChart data={data} />
</div>
)
}
// components/InteractiveChart.tsx (Client Component)
'use client'

import { useState } from 'react'

type ChartProps = {
data: number[]
}

export function InteractiveChart({ data }: ChartProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)

const createSelectHandler = (index: number) => () => {
setSelectedIndex(index)
}

return (
<div>
{data.map((value, index) => (
<div
key={index}
onClick={createSelectHandler(index)}
style={{ opacity: selectedIndex === index ? 1 : 0.7 }}
>
{value}
</div>
))}
</div>
)
}

Data fetching

Data fetching in Server Components

// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
// Cache settings
next: { revalidate: 60 } // revalidate every 60 seconds
})

if (!res.ok) {
throw new Error('Failed to fetch posts')
}

return res.json()
}

export default async function PostsPage() {
const posts = await getPosts()

return (
<ul>
{posts.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}

Cache options

Since Next.js 15, fetch is no longer cached by default (equivalent to no-store). When you want to cache, specify the option explicitly.

// Dynamic data (default) - fetched on every request
fetch(url)
fetch(url, { cache: 'no-store' }) // the same meaning (explicit form)

// Persistent cache - cache at build time or on the first fetch, and reuse afterward
fetch(url, { cache: 'force-cache' })

// Time-based revalidation
fetch(url, { next: { revalidate: 60 } }) // revalidate every 60 seconds
warning

In Next.js 14 and earlier, fetch(url) was cached by default. In Next.js 15, the behavior was inverted, and the default is to fetch fresh data every time. If you need caching, specify force-cache explicitly.

The 'use cache' directive (Cache Components, Next.js 16)

The 'use cache' directive, which lets you declare caching per function or Server Component, was added. You can cache not just fetch but also database access and heavy computation results.

// Cache per function
async function getPosts() {
'use cache'
const posts = await db.posts.findMany()
return posts
}

// Cache per Server Component
export default async function Page() {
'use cache'
const data = await fetchHeavyData()
return <Article data={data} />
}
info

'use cache' is a Cache Components feature. In Next.js 16, you can use it by enabling cacheComponents: true in next.config.ts (in Next.js 15 it was treated as experimental). After enabling it, you can cache not just fetch cache control but any asynchronous processing.

Server Actions

You can write server processing such as form submission concisely.

// app/contact/page.tsx
import { redirect } from 'next/navigation'

async function submitForm(formData: FormData) {
'use server'

const name = formData.get('name') as string
const email = formData.get('email') as string

// Save to the database
await db.contact.create({
data: { name, email }
})

// Redirect
redirect('/contact/thanks')
}

export default function ContactPage() {
return (
<form action={submitForm}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Submit</button>
</form>
)
}

Combining with useActionState

info

The latest version of Next.js (15 and later) uses React 19. useActionState is a hook introduced in React 19. Server Components also became stable in React 19.

// components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact } from '@/app/actions'

export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null)

return (
<form action={formAction}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Email" required />

{state?.error && (
<p className="text-red-500">{state.error}</p>
)}

<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
// app/actions.ts
'use server'

import { z } from 'zod'

const schema = z.object({
name: z.string().min(1),
email: z.email()
})

export async function submitContact(prevState: unknown, formData: FormData) {
const validatedFields = schema.safeParse({
name: formData.get('name'),
email: formData.get('email')
})

if (!validatedFields.success) {
return { error: 'Invalid input' }
}

await db.contact.create({
data: validatedFields.data
})

return { success: true }
}

Route Handlers (creating an API)

You can build a backend API in the same project. It is the equivalent of the Pages Router's API Routes, and in the App Router it is called Route Handlers.

// app/api/users/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users)
}

export async function POST(request: Request) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server'

type Params = {
params: Promise<{ id: string }>
}

export async function GET(request: Request, { params }: Params) {
const { id } = await params
const user = await db.user.findUnique({ where: { id } })

if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}

return NextResponse.json(user)
}
import Link from 'next/link'

export function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog/hello-world">Article</Link>
</nav>
)
}

Programmatic navigation

'use client'

import { useRouter } from 'next/navigation'

export function LoginButton() {
const router = useRouter()

const handleLogin = async () => {
await login()
router.push('/dashboard')
}

return <button onClick={handleLogin}>Log in</button>
}

Image optimization

import Image from 'next/image'

export function Avatar({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={100}
height={100}
className="rounded-full"
/>
)
}

Characteristics of the Image component:

  • Automatically converts to the optimal size
  • Automatically selects the latest formats such as WebP
  • Lazy loading
  • Prevents layout shift

Metadata

// app/layout.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App'
},
description: 'My awesome application',
openGraph: {
title: 'My App',
description: 'My awesome application',
images: ['/og-image.png']
}
}
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

type Props = {
params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)

return {
title: post.title,
description: post.excerpt
}
}

Comparison with Vite + React

ItemVite + ReactNext.js
RenderingCSR onlySSR/SSG/CSR
RoutingManual (React Router, etc.)File-based
Data fetchingClient onlyServer/client
SEORequires additional setupStandard support
DeploymentStatic hostingVercel/Node.js server
Learning costLowModerate
Use caseSPA, dashboardWebsite, full-stack

When to use Next.js

When Next.js is suitable

  • Websites where SEO matters
  • Content-centric sites (blogs, e-commerce, etc.)
  • Full-stack development (including the API)
  • When initial display speed matters

When Vite + React is suitable

  • Admin panels, dashboards
  • Apps behind authentication (where SEO is unnecessary)
  • Simple SPAs
  • For learning purposes

Try it

Take on the following challenges to deepen your understanding of Next.js.

  1. Create a Next.js project: create a project with npx create-next-app@latest, and add the /about and /contact pages. Place a shared header and footer in layout.tsx.

  2. Choose between a Server Component and a Client Component: display a dummy user list with a Server Component, and implement a "like" button feature with a Client Component. Be conscious of where you place 'use client'.

  3. Create a form with a Server Action: create a contact form (name, email, message), and implement the submit processing with a Server Action. Display a "submission complete" message after submitting.

Hint
  1. Create app/about/page.tsx and app/contact/page.tsx. Place the header/footer before and after {children} in layout.tsx.
  2. Create the user list in page.tsx (Server Component) and the like button in a separate file with 'use client'.
  3. Add 'use server' at the top of the function, or define the Server Action in a separate file (actions.ts). Using useActionState lets you manage the loading state too.
Answer and explanation

Challenge 1: Create a Next.js project

npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
// app/layout.tsx
import { ReactNode } from 'react'
import Link from 'next/link'
import './globals.css'

export const metadata = {
title: 'My App',
description: 'Next.js practice app'
}

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<header className="bg-blue-600 text-white p-4">
<nav className="max-w-4xl mx-auto flex gap-6">
<Link href="/" className="hover:underline">Home</Link>
<Link href="/about" className="hover:underline">About</Link>
<Link href="/contact" className="hover:underline">Contact</Link>
</nav>
</header>
<main className="max-w-4xl mx-auto p-4">{children}</main>
<footer className="bg-gray-100 p-4 text-center text-gray-600">
© {new Date().getFullYear()} My App
</footer>
</body>
</html>
)
}
// app/about/page.tsx
export default function AboutPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">About</h1>
<p>This app was created for practicing Next.js.</p>
</div>
)
}
// app/contact/page.tsx
export default function ContactPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Contact</h1>
<p>Feel free to contact us here.</p>
</div>
)
}

The header and footer defined in layout.tsx are shared across all pages. Using the Link component performs client-side navigation, making page transitions fast.


Challenge 2: Choose between a Server Component and a Client Component

// app/users/page.tsx (Server Component)
import { LikeButton } from './LikeButton'

// Dummy user data (actually fetched from a DB)
const users = [
{ id: '1', name: 'Taro Tanaka', email: 'tanaka@example.com' },
{ id: '2', name: 'Hanako Suzuki', email: 'suzuki@example.com' },
{ id: '3', name: 'Jiro Sato', email: 'sato@example.com' }
]

export default function UsersPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">User list</h1>
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="p-4 border rounded flex justify-between items-center">
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-gray-600 text-sm">{user.email}</p>
</div>
<LikeButton userId={user.id} />
</li>
))}
</ul>
</div>
)
}
// app/users/LikeButton.tsx (Client Component)
'use client'

import { useState } from 'react'

type LikeButtonProps = {
userId: string
}

export function LikeButton({ userId }: LikeButtonProps) {
const [liked, setLiked] = useState(false)
const [count, setCount] = useState(0)

const handleClick = () => {
setLiked(!liked)
setCount(liked ? count - 1 : count + 1)
}

return (
<button
onClick={handleClick}
className={`px-4 py-2 rounded ${
liked ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}

You fetch and display data in the Server Component (page.tsx), and separate the interactive "like" feature into the Client Component (LikeButton.tsx). 'use client' must be placed at the top of the file.


Challenge 3: Create a form with a Server Action

// app/contact/actions.ts
'use server'

type ContactState = {
success?: boolean
error?: string
}

export async function submitContact(
prevState: ContactState | null,
formData: FormData
): Promise<ContactState> {
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string

// Validation
if (!name || !email || !message) {
return { error: 'Please fill in all fields' }
}

// In reality, you save to a DB or send an email here
console.log('Contact received:', { name, email, message })

// A pseudo delay
await new Promise((resolve) => setTimeout(resolve, 1000))

return { success: true }
}
// app/contact/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact } from './actions'

export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null)

if (state?.success) {
return (
<div className="p-4 bg-green-100 text-green-700 rounded">
Your message has been sent. Thank you for contacting us.
</div>
)
}

return (
<form action={formAction} className="space-y-4">
{state?.error && (
<div className="p-4 bg-red-100 text-red-700 rounded">{state.error}</div>
)}

<div>
<label htmlFor="name" className="block font-medium mb-1">Your name</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label htmlFor="email" className="block font-medium mb-1">Email address</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label htmlFor="message" className="block font-medium mb-1">Message</label>
<textarea
id="message"
name="message"
rows={4}
required
className="w-full px-3 py-2 border rounded"
/>
</div>

<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
// app/contact/page.tsx
import { ContactForm } from './ContactForm'

export default function ContactPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Contact</h1>
<ContactForm />
</div>
)
}

Because the Server Action (actions.ts) runs on the server side, you can safely handle database operations and sensitive information. Using useActionState lets you easily manage the submitting state (isPending) and the response from the server (state).

Summary

  • Next.js is React's full-stack framework
  • You can flexibly choose SSR/SSG/CSR
  • File-based routing with the App Router
  • Choosing between Server Components and Client Components
  • Server Actions make form processing concise
  • Automatic optimization of images and fonts

The React knowledge you learned in this book can be used as-is in Next.js. After solidifying the React fundamentals, consider adopting Next.js according to your project's requirements.

In the next chapter, you will work on a practical project that brings together all the knowledge you have learned.

We move on to a comprehensive exercise. As a comprehensive exercise, you complete one practical project.