Build API Routes the Platform Way
What you'll learn
~30 min- Use the platform response helpers to produce consistent API responses
- Enforce the 500-item pagination cap and Zod validation on every route
- Handle errors safely -- structured codes, no leaked stack traces
Why response helpers exist
You could write NextResponse.json({ data: rows }) in every route handler and it would work. For a while. Until one developer returns { results: rows } instead of { data: rows }. Until another forgets to set the status code on a 404. Until a third leaks a SQL error message to the browser because they forgot the try-catch.
The DS platform solves this with four response helpers that enforce a consistent envelope:
// Every successful list response looks like thislistResponse(rows, { page, pageSize, total })// → { data: [...], meta: { page, pageSize, total, totalPages } }
// Every successful single-item responsesingleResponse(row)// → { data: { ... } }
// Every errorerrorResponse('VEHICLE_NOT_FOUND', 'No vehicle with that ID', 404)// → { error: { code: 'VEHICLE_NOT_FOUND', message: '...', timestamp: '...' } }
// Every successful deletedeletedResponse(id)// → { data: { id, deleted: true } }The frontend SWR hooks expect this envelope. Deviate and the hook’s .data accessor returns undefined. The snackbar’s error handler expects .error.message. The pagination component expects .meta.totalPages. Every piece of the platform agrees on this contract because the helpers enforce it.
On a team of 6 developers building 15+ modules over 18 months, response format inconsistency is the number one source of subtle frontend bugs. The helpers exist because the team learned this the hard way. When your state audit requires API response documentation, the helpers mean you document once.
The 500-item pagination cap
Every GET endpoint that returns a list must enforce pagination. The platform cap is 500 items per page. This is not configurable at the route level — it is a platform rule.
const MAX_PAGE_SIZE = 500;const pageSize = Math.min(requestedPageSize || 25, MAX_PAGE_SIZE);Why 500? Because Azure SQL with a managed identity connection has a practical query timeout. Returning 10,000 rows through the Next.js API route, through the SWR deserializer, through the MUI DataGrid renderer will either time out or freeze the browser tab. The cap prevents both problems.
The platform uses offset-based pagination (OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY) rather than cursor-based. This works for most government datasets — administrative records, fleet inventories, case logs. If you are dealing with high-volume streaming data (telemetry, audit logs), cursor-based pagination is a separate pattern covered in Lane 4.
Zod validation on every request body
Every POST and PUT request body goes through Zod validation before it touches the database. No exceptions.
export async function POST(req: NextRequest) { const body = await req.json(); const parsed = VehicleFleetCreateSchema.safeParse(body);
if (!parsed.success) { return errorResponse( 'VALIDATION_ERROR', parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '), 400 ); }
// Only parsed.data reaches the DB -- never the raw body const result = await createVehicle(parsed.data); return singleResponse(result, 201);}The key line: only parsed.data reaches the DB. The raw body object is never passed to a SQL query. Zod strips unknown fields, coerces types, and validates constraints. If someone sends { "vehicleId": "A123", "sqlInjection": "'; DROP TABLE --" }, the extra field is silently dropped.
“This route is only called by our own frontend” is not a reason to skip Zod validation. A browser extension, a misconfigured proxy, or a developer testing with curl can all send unexpected payloads. Validate everything. The Zod parse adds microseconds. The security incident costs months.
Safe error handling
The platform rule: never leak internal details to the client. That means:
- No stack traces in responses
- No SQL error messages
- No file paths
- No environment variable names
try { const result = await db.query(sql, params); return singleResponse(result.recordset[0], 201);} catch (err) { // Log the real error server-side (App Insights picks this up) console.error('[vehicle-fleet:POST]', err);
// Return a safe, structured error to the client return errorResponse( 'CREATE_FAILED', 'Unable to create vehicle record. Please try again or contact support.', 500 );}The client gets a structured error code (CREATE_FAILED) and a human-readable message. The actual SQL error (Violation of UNIQUE KEY constraint 'UQ_vehicle_fleet_vehicle_id') goes to the server log where App Insights indexes it. An auditor reviewing the API responses sees no internal details. A developer debugging uses App Insights to find the real error.
The prompt
This prompt generates a complete API route handler for the vehicle-fleet module, using all platform conventions:
Build the API route handler for the vehicle-fleet module atsrc/app/api/vehicle-fleet/route.ts
Use these exact platform conventions:
IMPORTS:- { NextRequest } from 'next/server'- { withPermission } from '@/lib/auth/with-permission'- { getDbPool } from '@/lib/db'- { listResponse, singleResponse, errorResponse, deletedResponse } from '@/lib/response-helpers'- { VehicleFleetCreateSchema, VehicleFleetUpdateSchema } from '@/types/vehicle-fleet'
GET handler:- Query params: page (default 1), pageSize (default 25, max 500), status (optional filter)- SQL: SELECT with OFFSET/FETCH NEXT pagination- Count query for total rows (filtered if status param present)- Return listResponse(rows, { page, pageSize, total })- Wrap in withPermission('vehicle-fleet', 'read')
POST handler:- Parse body with VehicleFleetCreateSchema.safeParse()- If validation fails: errorResponse('VALIDATION_ERROR', formatted issues, 400)- INSERT into vehicle_fleet, return singleResponse(inserted, 201)- Set created_by from session.user.email- Wrap in withPermission('vehicle-fleet', 'create')
PUT handler:- Parse body with VehicleFleetUpdateSchema.safeParse()- UPDATE vehicle_fleet WHERE id = parsed.data.id- Set updated_by from session.user.email, updated_at = GETDATE()- Return singleResponse(updated)- Wrap in withPermission('vehicle-fleet', 'update')
DELETE handler:- Soft delete: UPDATE vehicle_fleet SET status = 'inactive' WHERE id = ?- Return deletedResponse(id)- Wrap in withPermission('vehicle-fleet', 'delete')
ERROR HANDLING:- Every handler in a try/catch- catch: console.error with '[vehicle-fleet:METHOD]' prefix, then errorResponse()- NEVER include err.message in the response body- Use structured error codes: VALIDATION_ERROR, NOT_FOUND, CREATE_FAILED, UPDATE_FAILED, DELETE_FAILED, LIST_FAILED
Use parameterized queries (request.input()) for all SQL -- never string interpolation.Watch it work
Response helper signatures reference
Keep this reference handy when reviewing generated routes:
| Helper | Arguments | Status code | Response shape |
|---|---|---|---|
listResponse(rows, meta) | rows: T[], meta: { page, pageSize, total } | 200 | { data: T[], meta: { page, pageSize, total, totalPages } } |
singleResponse(item, status?) | item: T, status?: number (default 200) | 200 or 201 | { data: T } |
errorResponse(code, message, status) | code: string, message: string, status: number | 400/403/404/500 | { error: { code, message, timestamp } } |
deletedResponse(id) | id: number | string | 200 | { data: { id, deleted: true } } |
If the AI generates a route that returns NextResponse.json() directly instead of using a helper, that is a review finding. Ask the AI to refactor it to use the platform helpers.
What to watch for in generated routes
AI-generated API routes usually get the structure right but occasionally miss platform-specific details:
- String concatenation in SQL: If you see
`SELECT * FROM vehicle_fleet WHERE status = '${status}'`, that is a SQL injection vulnerability. The AI must userequest.input('status', sql.NVarChar, status)with parameterized queries. - Missing pagination cap: The AI may accept the client’s
pageSizewithout clamping to 500. Always verifyMath.min(pageSize, 500). - Raw
err.messagein error response: Some AI outputs includeerrorResponse('FAILED', err.message, 500). That leaks internal error details. The message must be a static, human-friendly string. - Missing
withPermissionon one method: The AI scaffolds GET, POST, PUT, DELETE but forgets to wrap DELETE. Every method needs the guard.
Notice how the prompt explicitly says “NEVER include err.message in the response body” and “Use parameterized queries — never string interpolation.” These are not generic best practices you hope the AI remembers. They are explicit instructions that directly prevent the most common generated-code bugs.
A GET request to /api/vehicle-fleet?page=1&pageSize=2000 returns 500 rows instead of 2000. There is no error. What is happening?
What’s next
Your API route talks to the database through getDbPool(). But how does that connection actually work in Azure Gov? The next lesson covers Managed Identity authentication for Azure SQL — no passwords in code, no connection strings in environment variables, and a clear understanding of why the mssql driver cannot run in Next.js edge middleware.