Next.js API Routes
A bookmarks app built with Next.js App Router that uses route handlers to expose a REST API (GET, POST, DELETE) and a client component that consumes it with fetch. No server actions — just plain HTTP requests between client and server.
How it works
The page is a server component that renders a client BookmarkManager. On mount the client fetches GET /api/bookmarks to load the list. When the user submits the form, it POSTs JSON to the same endpoint. Clicking the delete button sends DELETE /api/bookmarks/[id]. Each route handler reads or writes a JSON file on disk and returns a JSON response.
nextjs-api-routes/
├── data/bookmarks.json ← JSON "database" (seeded with two entries)
├── app/layout.tsx ← root layout (minimal)
├── app/page.tsx ← server component: renders the client manager
├── app/api/bookmarks/route.ts ← GET all + POST new bookmark
├── app/api/bookmarks/[id]/route.ts ← DELETE a bookmark by id
└── app/bookmark-manager.tsx ← client component: fetch, create, deletedata/bookmarks.json
Seed file with two entries so the list isn’t empty on first load. The route handlers read and write this array.
[
{
"id": 1,
"title": "Next.js Docs",
"url": "https://nextjs.org/docs"
},
{
"id": 2,
"title": "MDN Web Docs",
"url": "https://developer.mozilla.org"
}
]app/layout.tsx
Minimal root layout — just the HTML shell and the system font stack.
import type { ReactNode } from 'react'
export const metadata = { title: 'Next.js API Routes' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 520, margin: '2rem auto' }}>
{children}
</body>
</html>
)
}app/api/bookmarks/route.ts
This is the main route handler. Exporting named GET and POST functions tells Next.js to handle those HTTP methods at /api/bookmarks. GET reads the JSON file and returns the array. POST parses the request body with req.json(), validates it, appends a new entry, writes the file, and returns the created bookmark with a 201 status.
import { NextResponse } from 'next/server'
import { readFileSync, writeFileSync } from 'fs'
import path from 'path'
export type Bookmark = {
id: number
title: string
url: string
}
const DATA_PATH = path.join(process.cwd(), 'data', 'bookmarks.json')
export function readBookmarks(): Bookmark[] {
return JSON.parse(readFileSync(DATA_PATH, 'utf-8'))
}
export function writeBookmarks(bookmarks: Bookmark[]) {
writeFileSync(DATA_PATH, JSON.stringify(bookmarks, null, 2))
}
// GET /api/bookmarks → return all bookmarks
export async function GET() {
const bookmarks = readBookmarks()
return NextResponse.json(bookmarks)
}
// POST /api/bookmarks → create a new bookmark
export async function POST(req: Request) {
const body = await req.json()
const { title, url } = body as { title?: string; url?: string }
if (!title?.trim() || !url?.trim()) {
return NextResponse.json(
{ error: 'Title and URL are required.' },
{ status: 400 }
)
}
const bookmarks = readBookmarks()
const nextId = bookmarks.length > 0
? Math.max(...bookmarks.map((b) => b.id)) + 1
: 1
const bookmark: Bookmark = {
id: nextId,
title: title.trim(),
url: url.trim(),
}
bookmarks.push(bookmark)
writeBookmarks(bookmarks)
return NextResponse.json(bookmark, { status: 201 })
}app/api/bookmarks/[id]/route.ts
Dynamic route segment — Next.js matches /api/bookmarks/42 and passes { id: '42' } through params. The params property is a Promise in Next.js 16, so it needs to be awaited. The handler imports readBookmarks and writeBookmarks from the parent route file so the helpers aren’t duplicated.
import { NextResponse } from 'next/server'
import { readBookmarks, writeBookmarks } from '../route'
interface Ctx {
params: Promise<{ id: string }>
}
// DELETE /api/bookmarks/:id → remove a bookmark
export async function DELETE(_req: Request, ctx: Ctx) {
const { id } = await ctx.params
const bookmarks = readBookmarks()
const index = bookmarks.findIndex((b) => b.id === Number(id))
if (index === -1) {
return NextResponse.json(
{ error: 'Bookmark not found.' },
{ status: 404 }
)
}
bookmarks.splice(index, 1)
writeBookmarks(bookmarks)
return new Response(null, { status: 204 })
}app/page.tsx
Server component that just renders the heading and the client-side BookmarkManager. The data fetching happens in the client via fetch calls to the API routes — this is the key difference from the server-action pattern.
import { BookmarkManager } from './bookmark-manager'
export default function HomePage() {
return (
<main>
<h1>Bookmarks</h1>
<BookmarkManager />
</main>
)
}app/bookmark-manager.tsx
'use client' makes this a client component so it can use hooks and event handlers. On mount, useEffect calls GET /api/bookmarks and populates state. The form submit handler calls POST /api/bookmarks with a JSON body and appends the returned bookmark to the list. The delete handler calls DELETE /api/bookmarks/[id] and removes it from state. Every API call uses the standard fetch API — no special libraries needed.
'use client'
import { useEffect, useState } from 'react'
type Bookmark = {
id: number
title: string
url: string
}
export function BookmarkManager() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
// Fetch all bookmarks on mount
useEffect(() => {
fetch('/api/bookmarks')
.then((res) => {
if (!res.ok) throw new Error('Failed to load')
return res.json()
})
.then((data) => setBookmarks(data))
.catch(() => setError('Failed to load bookmarks.'))
.finally(() => setLoading(false))
}, [])
// Create a new bookmark
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError('')
const form = e.currentTarget
const formData = new FormData(form)
const title = formData.get('title') as string
const url = formData.get('url') as string
const res = await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, url }),
})
if (!res.ok) {
const { error } = await res.json()
setError(error)
return
}
const bookmark: Bookmark = await res.json()
setBookmarks((prev) => [...prev, bookmark])
form.reset()
}
// Delete a bookmark
async function handleDelete(id: number) {
const res = await fetch(`/api/bookmarks/${id}`, { method: 'DELETE' })
if (!res.ok) {
setError('Failed to delete bookmark.')
return
}
setBookmarks((prev) => prev.filter((b) => b.id !== id))
}
if (loading) return <p>Loading...</p>
return (
<>
{bookmarks.length > 0 ? (
<ul style={{ listStyle: 'none', padding: 0 }}>
{bookmarks.map((b) => (
<li key={b.id} style={{ marginBottom: '0.75rem' }}>
<a href={b.url} target="_blank" rel="noopener noreferrer">
{b.title}
</a>
<button
onClick={() => handleDelete(b.id)}
style={{ marginLeft: '0.5rem', cursor: 'pointer' }}
>
Delete
</button>
</li>
))}
</ul>
) : (
<p>No bookmarks yet.</p>
)}
{error && <p style={{ color: 'red' }}>{error}</p>}
<hr style={{ margin: '1.5rem 0' }} />
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '0.75rem' }}>
<label htmlFor="title">Title</label><br />
<input id="title" name="title" required style={{ width: '100%', padding: '0.4rem' }} />
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label htmlFor="url">URL</label><br />
<input id="url" name="url" type="url" required style={{ width: '100%', padding: '0.4rem' }} />
</div>
<button type="submit">Add Bookmark</button>
</form>
</>
)
}Run it
npx create-next-app@latest nextjs-api-routes --ts --app --no-tailwind --no-eslint --no-src-dir
cd nextjs-api-routes
mkdir data
# create the files above
npm run devOpen http://localhost:3000 , add a bookmark, and watch it appear in the list. Click delete to remove it. Check data/bookmarks.json to see changes persisted to disk.