Make HTTP requests with the Fetch API in a Next.js + TypeScript app — typed GET and POST, reading JSON, checking response status, handling errors, cancelling requests, and fetching in Server and Client Components.
Why: fetch is the browser built-in for talking to a server — no library needed. It returns a Promise, so you await it, then call res.json() to read the JSON body. In TypeScript you type the value you expect back so the rest of your code is checked. Note: these examples hit a real network, so try them in a Next.js route or Server Component, not an offline editor.
type User = { id: number; name: string; email: string }
// fetch returns a Promise, so await it inside an async function
async function getUser(id: number): Promise<User> {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
return (await res.json()) as User // res.json() is Promise<any>, so we type it
}
const user = await getUser(1)
console.log(user.name)Note: this trips up everyone — fetch only rejects on a network failure (offline, DNS). A 404 or 500 still counts as a "successful" response, so res.ok is false but nothing is thrown. You have to check it yourself and throw.
type User = { id: number; name: string }
async function getUser(id: number): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`)
if (!res.ok) {
// 404, 500, etc. — fetch will NOT throw on its own
throw new Error(`Request failed: ${res.status}`)
}
return res.json()
}Why: to create or update data, pass a second options argument — the method, a Content-Type header so the server knows the body is JSON, and a body you stringify yourself. Typing the input keeps the call site honest.
type NewPost = { title: string; body: string }
async function createPost(data: NewPost) {
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), // objects must be turned into a string
})
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json()
}
await createPost({ title: 'Hello', body: 'World' })Where: wrap the call in try/catch to catch both network failures and the errors you threw. In TypeScript the caught value is unknown, so narrow it with instanceof Error before reading .name. To cancel a request you no longer need — the user navigated away or typed a new search term — pass an AbortController signal and call abort().
const controller = new AbortController()
async function search(term: string) {
try {
const res = await fetch(`/api/search?q=${term}`, {
signal: controller.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (err) {
// err is 'unknown' in TS — narrow before using it
if (err instanceof Error && err.name === 'AbortError') return // cancelled
console.error('Search failed:', err)
}
}
// later, to cancel the in-flight request:
controller.abort()Note: you rarely call fetch straight from a component. In Next.js, fetch inside a Server Component runs on the server and is cached for you — and the component can be async. In a Client Component, reach for TanStack Query, which wraps fetch with caching, loading and error state, and retries — so you do not hand-roll everything above.
// app/users/[id]/page.tsx — a Next.js Server Component (async is fine here)
type User = { id: number; name: string }
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const res = await fetch(`https://api.example.com/users/${id}`)
const user: User = await res.json()
return <h1>{user.name}</h1>
}
// Client Component — let TanStack Query manage the request:
// const { data } = useQuery({
// queryKey: ['user', id],
// queryFn: (): Promise<User> =>
// fetch(`/api/users/${id}`).then((r) => r.json()),
// })