Applied Module 12 · AI-Accelerated Government Development

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 this
listResponse(rows, { page, pageSize, total })
// → { data: [...], meta: { page, pageSize, total, totalPages } }
// Every successful single-item response
singleResponse(row)
// → { data: { ... } }
// Every error
errorResponse('VEHICLE_NOT_FOUND', 'No vehicle with that ID', 404)
// → { error: { code: 'VEHICLE_NOT_FOUND', message: '...', timestamp: '...' } }
// Every successful delete
deletedResponse(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.

💬This is not over-engineering

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.

Pagination pattern

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.

Never skip validation 'just for internal routes'

“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 at
src/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

Claude Code — Generating platform-compliant API route
/home/user $ claude
/home/user $

Response helper signatures reference

Keep this reference handy when reviewing generated routes:

HelperArgumentsStatus codeResponse 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: number400/403/404/500{ error: { code, message, timestamp } }
deletedResponse(id)id: number | string200{ 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 use request.input('status', sql.NVarChar, status) with parameterized queries.
  • Missing pagination cap: The AI may accept the client’s pageSize without clamping to 500. Always verify Math.min(pageSize, 500).
  • Raw err.message in error response: Some AI outputs include errorResponse('FAILED', err.message, 500). That leaks internal error details. The message must be a static, human-friendly string.
  • Missing withPermission on one method: The AI scaffolds GET, POST, PUT, DELETE but forgets to wrap DELETE. Every method needs the guard.
💡Prompt specificity prevents mistakes

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.


KNOWLEDGE CHECK

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.