Skip to content

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

GET /orders?cursor=abc&limit=50
→ { items: [...], nextCursor: "xyz", hasMore: true }

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

POST /api/orders
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000

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:

kiota generate -l typescript -d openapi.json -o ./src/api

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:

const errors = problem?.errors ?? {};
{errors.Email && <span>{errors.Email[0]}</span>}

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

{ "amount": 1.23, "currency": "USD" }

Or store as integer cents ({ "amountCents": 123 }) to avoid floating-point.

Caching headers

Cache-Control: public, max-age=60
ETag: "abc"

For read-mostly resources. SPAs/CDN cache automatically.

Rate limit responses

429 Too Many Requests
Retry-After: 30
X-RateLimit-Remaining: 0

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.

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
GET /api/exports/abc/status
{ "status": "running", "percentComplete": 45 }
GET /api/exports/abc/status
{ "status": "completed", "resultUrl": "/api/exports/abc/result" }

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

{ "error": "bad request" }
{ "message": "not found" }

✅ Correct: ProblemDetails

{ "type": "...", "title": "...", "status": 400, "detail": "..." }

❌ Wrong: offset pagination on inserting data

Page 2 of /orders?offset=50&limit=50 skips/duplicates with concurrent writes.

✅ Correct: cursor

?cursor=abc&limit=50

❌ Wrong: no ETag on update

await fetch('/api/users/1', { method: 'PUT', body: ... });   // last write wins

✅ Correct: If-Match

await fetch('/api/users/1', { method: 'PUT', body: ..., headers: { 'If-Match': version } });

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.

Further reading