Polish your API for real use — return errors in one consistent shape, page through long lists instead of returning everything, and let clients filter and sort with query parameters.
When something goes wrong, clients need to react in code — show a message, retry, or send the user to log in. That is only possible if every error from your API looks the same. Pick one error shape and use it for all of them: a stable machine-readable code the client can switch on, a human message for logs and developers, and optionally the per-field details. Never leak a raw stack trace or database error to the client — that is both confusing and a security risk.
// Same shape for every error, whatever the status code.
// 400:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "email is not a valid address",
"fields": { "email": "must be a valid email" }
}
}
// 404:
{ "error": { "code": "NOT_FOUND", "message": "No user with id 9999" } }
// A small helper keeps it consistent across every route:
function fail(status: number, code: string, message: string) {
return Response.json({ error: { code, message } }, { status })
}A list endpoint that returns every row works on day one and falls over once the table is large — slow responses, huge payloads, and strained memory. Pagination returns the data in pages. The simplest style is limit/offset: limit is the page size, offset is how many to skip. Always send back the total and the page settings so the client can build "next/previous" controls. Cap the limit (here, 100) so no one can ask for the whole table in one call.
// GET /api/users?limit=20&offset=40 -> page 3 (skip 40, take 20)
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams
const limit = Math.min(Number(sp.get('limit') ?? 20), 100) // hard cap
const offset = Math.max(Number(sp.get('offset') ?? 0), 0)
const [rows, total] = await Promise.all([
db.users.findMany({ take: limit, skip: offset }),
db.users.count(),
])
return Response.json({
data: rows,
page: { total, limit, offset },
})
}Let clients narrow and order a list through the query string instead of inventing a new URL for each case — /api/users?role=admin&sort=-createdAt rather than /api/admins. A common convention for sorting is a field name with an optional leading minus for descending order (-createdAt = newest first). The golden rule: only allow filtering and sorting on fields you explicitly approve. Passing user input straight into a query is how SQL-injection and accidental full-table scans happen.
// GET /api/users?role=admin&sort=-createdAt
const SORTABLE = { createdAt: 'createdAt', name: 'name' } as const
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams
// Filter: only on fields you allow.
const where: Record<string, unknown> = {}
const role = sp.get('role')
if (role) where.role = role
// Sort: "-createdAt" means descending. Reject unknown fields.
const raw = sp.get('sort') ?? 'createdAt'
const desc = raw.startsWith('-')
const field = desc ? raw.slice(1) : raw
if (!(field in SORTABLE)) {
return Response.json({ error: { code: 'BAD_SORT' } }, { status: 400 })
}
const rows = await db.users.findMany({
where,
orderBy: { [field]: desc ? 'desc' : 'asc' },
})
return Response.json({ data: rows })
}