Put a route.ts file in any folder under app/ and that folder's path becomes an endpoint, following the rules of Routing with the App Router: app/api/products/route.ts answers /api/products. The one hard restriction is that route.ts and page.tsx cannot share a folder, because both claim the same URL. Export one function per HTTP method — Next.js 10,514 recognizes GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS, and nothing else.
import { type NextRequest } from 'next/server'
import { products, addProduct } from '@/lib/products'
export async function GET(request: NextRequest) {
const tag = request.nextUrl.searchParams.get('tag')
const list = tag ? products.filter((p) => p.tag === tag) : products
return Response.json({ count: list.length, products: list })
}
export async function POST(request: NextRequest) {
const body = await request.json()
if (typeof body.name !== 'string' || typeof body.price !== 'number') {
return Response.json({ error: 'name and price are required' }, { status: 422 })
}
const created = addProduct(body.name, body.price, body.tag ?? 'misc')
const headers = { Location: `/api/products/${created.id}` }
return Response.json(created, { status: 201, headers })
}Here products is an in-memory array in lib/products.ts — fine for a demonstration, wrong for anything real, since every serverless instance would get its own copy (MongoDB). A query string narrows the list, and a POST returns 201 with the Location header the handler set:
$ curl -s "http://localhost:3000/api/products?tag=adapter"
{"count":1,"products":[{"id":"p2","name":"USB-C hub","price":45,"tag":"adapter"}]}
$ curl -s -i -X POST http://localhost:3000/api/products \
-H "Content-Type: application/json" -d '{"name":"Desk mat","price":25,"tag":"surface"}'
HTTP/1.1 201 Created
location: /api/products/p4Two responses come free. A method with no matching export returns 405 Method Not Allowed, and an unimplemented OPTIONS returns 204 No Content with a header built from the exports that exist — allow: GET, HEAD, OPTIONS, POST here. HEAD is in that list without being written, because Next.js derives it from GET. Export OPTIONS only to control a preflight (CORS and Cross-Origin Requests).
One default changed under you. Through Next.js 14 a GET handler with no dynamic input was prerendered at build time and served as a static file — a reliable source of "why is my API returning yesterday's data" reports. Version 15 reversed it: in this project's build output every route file is listed with ƒ, the dynamic marker, even one whose handler takes no request argument. Opt back in with export const revalidate = 60 or "use cache" (Caching and Revalidation).