API Design for SPA Clients
Key Points
- ProblemDetails (RFC 9457) for errors. Consistent shape; SPAs parse uniformly.
- Pagination: cursor-based for big or live data; offset for small/static.
- Idempotency keys for non-idempotent POST.
- OpenAPI drives type generation. Keep spec accurate.
- ETags / If-Match for optimistic concurrency in updates.
See API Versioning & Contracts and ProblemDetails.
Concepts (deep dive)
ProblemDetails
{
"type": "https://errors.contoso.com/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Account balance $5; required $20",
"instance": "/api/transactions/abc",
"accountId": "u-123",
"currentBalance": 5,
"required": 20
}
return TypedResults.Problem(
title: "Insufficient funds",
detail: $"Balance: {balance}; required: {amount}",
statusCode: 422,
extensions: new Dictionary<string, object?>
{
["accountId"] = userId,
["currentBalance"] = balance,
["required"] = amount
});
SPA error handler:
async function fetchJson<T>(url: string): Promise<T> {
const r = await fetch(url);
if (!r.ok) {
const problem = await r.json();
throw new ApiError(problem);
}
return r.json();
}
Pagination
Cursor-based: stable across inserts; can't jump. Offset is simpler but breaks with concurrent inserts.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['orders'],
queryFn: ({ pageParam }) => fetch(`/api/orders?cursor=${pageParam ?? ''}`),
initialPageParam: '',
getNextPageParam: last => last.nextCursor
});
Idempotency
Server stores IdempotencyKey → Response for a TTL (24h). Repeat = cached response. Critical for retried POSTs.
ETags / If-Match
GET /api/users/1
ETag: "abc123"
PUT /api/users/1
If-Match: "abc123"
→ 200 OK or 412 Precondition Failed
Optimistic concurrency. Browser/server tracks; SPA passes through.
OpenAPI-driven types
# Generate TS types from OpenAPI spec
npx openapi-typescript-codegen --input http://localhost:5000/swagger/v1/swagger.json --output ./src/api
Or Microsoft Kiota:
Generated client + types.
Errors as data
type Result<T, E = ApiError> = { ok: true; data: T } | { ok: false; error: E };
async function getUser(id: string): Promise<Result<User>> {
try { return { ok: true, data: await api.users.get(id) }; }
catch (e) { return { ok: false, error: e as ApiError }; }
}
Validation errors
ASP.NET ProblemDetails for validation:
{
"title": "Validation failed",
"status": 400,
"errors": {
"Email": ["Required"],
"Age": ["Must be positive"]
}
}
SPA renders per-field:
Date / time conventions
- ISO 8601 in JSON:
2026-04-27T12:34:56Z. - Server returns UTC. SPA converts to local for display.
- Use libraries (date-fns, Day.js, Luxon).
Currency
Or store as integer cents ({ "amountCents": 123 }) to avoid floating-point.
Caching headers
For read-mostly resources. SPAs/CDN cache automatically.
Rate limit responses
SPA can show "Slow down; try again in 30s".
Response shape conventions
// REST list
{ items: [...], totalCount, page, pageSize } // offset
{ items: [...], nextCursor, hasMore } // cursor
// Single
{ id, name, ... }
// Error
ProblemDetails
Consistent across endpoints simplifies SPA.
Versioning
URL: /v1/orders. Or header: Api-Version: 1. Plan v2 from day one.
CSRF (cookie auth)
If cookie auth, all state-changing requests need anti-forgery token:
const token = await fetch('/antiforgery/token').then(r => r.json());
await fetch('/api/orders', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
Compression
API responds with Content-Encoding: gzip or br. Browser decompresses transparently. Configure in ASP.NET response compression middleware.
Long-running operations
Async operation pattern:
POST /api/exports
202 Accepted
Location: /api/exports/abc/status
Operation-Location: /api/exports/abc/status
Polling or SignalR for completion notification.
Streaming responses
IAsyncEnumerable<T> returned from minimal API streams as application/x-ndjson or text/event-stream.
const r = await fetch('/api/stream');
const reader = r.body!.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// process chunk
}
Code: correct vs wrong
❌ Wrong: ad-hoc error shapes
✅ Correct: ProblemDetails
❌ Wrong: offset pagination on inserting data
Page 2 of /orders?offset=50&limit=50 skips/duplicates with concurrent writes.
✅ Correct: cursor
❌ Wrong: no ETag on update
✅ Correct: If-Match
Design patterns for this topic
Pattern 1 — "ProblemDetails everywhere"
- Intent: consistent error handling.
Pattern 2 — "Cursor pagination for live data"
- Intent: stable across inserts.
Pattern 3 — "Idempotency keys"
- Intent: safe retry of POSTs.
Pattern 4 — "OpenAPI → TS types"
- Intent: end-to-end type safety.
Pattern 5 — "ETags for optimistic concurrency"
- Intent: lost-update detection.
Pros & cons / trade-offs
| Pattern | Pros | Cons |
|---|---|---|
| Cursor pagination | Stable | Can't jump pages |
| Offset | Random access | Inserts shift |
| ETags | Concurrency | Server overhead |
| ProblemDetails | Consistent | Overkill for tiny APIs |
When to use / when to avoid
- Use ProblemDetails for any non-trivial API.
- Use cursor for live / large data.
- Use idempotency keys for charges / orders.
- Avoid custom error shapes.
Interview Q&A
Q1. ProblemDetails advantages? Standard error shape; clients parse uniformly; extensible via custom fields.
Q2. Cursor vs offset pagination? Cursor: stable across inserts; can't jump. Offset: random access; breaks with writes.
Q3. Idempotency keys? Header Idempotency-Key. Server caches response per key. Safe retries.
Q4. ETags? Versioning per resource. PUT with If-Match → 412 if stale.
Q5. OpenAPI codegen? Auto-generate TS types/clients from spec. Consistent backend↔SPA.
Q6. Validation errors shape? ProblemDetails with errors dict per field.
Q7. CSRF with cookie auth? Anti-forgery token in custom header.
Q8. Rate limit response? 429 + Retry-After. SPA respects.
Q9. Long-running operation pattern? 202 + Operation-Location for status; poll or notify via SignalR.
Q10. Streaming response? ndjson / SSE; client reads chunks.
Q11. Date format? ISO 8601 UTC; SPA converts.
Q12. Currency precision? Integer cents; avoid floats.
Gotchas / common mistakes
- ⚠️ Custom error shapes per endpoint.
- ⚠️ Offset for live data — pagination jitter.
- ⚠️ No idempotency for retried charges.
- ⚠️ Last-write-wins without ETag.
- ⚠️ Floats for money.