sdxc

Type to search, or start from one of these:

[ HTTP APIs ]

Build a JSON API with problem details

A small resource API that validates bodies, answers failures as RFC 9457 problems, pages its lists and states its rate limit.

Last updated 2026-09-29

This guide builds a books API in a Remix v3 app: list the books, create one, and read one back. Every failure answers as an RFC 9457 problem document, lists page by cursor with Link headers, and every response states how much of the caller's rate limit is left.

Five packages do the work. @sdxc/validate checks the body against a remix/data-schema schema, @sdxc/http names the success statuses, @sdxc/problem names the failures, @sdxc/pagination pages the list, and @sdxc/rate-limit counts the calls.

npm add @sdxc/validate @sdxc/http @sdxc/problem @sdxc/pagination \
	@sdxc/rate-limit @sdxc/result

The handlers read the database as ctx.db, published by middleware as described in Wire the router, and hang on three routes in the app's route table:

routes/web.ts
import { get, post, route } from "remix/routes";

export default route({
	api: {
		books: {
			index: get("/api/books"),
			create: post("/api/books"),
			show: get("/api/books/:bookId"),
		},
	},
});

Declare the problem catalog

A client branches on a problem's type, so every failure the API can answer with deserves a stable URI. defineProblems writes each one once: the slug, the status and the title. Every entry becomes a builder, so a handler supplies only what differs per occurrence.

app/services/problems.ts
import { defineProblems, ISSUES_SCHEMA } from "@sdxc/problem";
import * as s from "remix/data-schema";

export const problems = defineProblems("https://books.example.com/docs/errors/", {
	badRequest: {
		slug: "bad-request",
		status: 400,
		title: "The request is malformed",
	},
	notFound: {
		slug: "not-found",
		status: 404,
		title: "The resource does not exist",
	},
	validationFailed: {
		slug: "validation-failed",
		status: 422,
		title: "The request failed validation",
		extensions: s.object({ errors: ISSUES_SCHEMA }),
	},
	rateLimited: { slug: "rate-limited", status: 429, title: "Too many requests" },
	internal: {
		slug: "internal",
		status: 500,
		title: "The request failed on the server",
	},
});

The base URL must end in /, and each type should resolve to a page describing the error: problems.entries() lists every entry with its resolved type, which is what an error reference page renders from. The extensions schema types the builder's argument, so validationFailed will not compile without its errors.

Validate the body and create

The body and the path params are described as remix/data-schema schemas, in a module of their own so every handler and the later guides import the same values. A path param schema that only accepts the shape your ids have lets a malformed id answer 404 without a query, since no book can have it.

app/http/schemas/books.ts
import * as s from "remix/data-schema";
import { max, maxLength, min, minLength } from "remix/data-schema/checks";

export const BOOK_INPUT = s.object({
	title: s.string().pipe(minLength(1), maxLength(200)),
	author: s.string().pipe(minLength(1), maxLength(200)),
	year: s.number().pipe(min(1450), max(2100)),
	summary: s.optional(s.string().pipe(maxLength(2000))),
});

export const BOOK_PARAMS = s.object({
	bookId: s.string().pipe(minLength(26), maxLength(26)),
});

validate reads a Request according to its Content-Type and answers with a Result, so a refused body is a branch rather than an exception. issuesFrom turns the schema's issues into errors entries whose pointer is a JSON Pointer into the body.

app/http/controllers/api/books/create.ts
import { created } from "@sdxc/http/response/json";
import { issuesFrom } from "@sdxc/problem";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import { createAction } from "remix/router";

import Book, { serializeBook } from "~/app/data/book";
import { BOOK_INPUT } from "~/app/http/schemas/books";
import { problems } from "~/app/services/problems";
import routes from "~/routes/web";

export const create = createAction(routes.api.books.create, async (ctx) => {
	let input = await validate(ctx.request, BOOK_INPUT);
	if (isFailure(input)) {
		let errors = issuesFrom(input.error);
		return problems.validationFailed({ extensions: { errors } });
	}

	let book = await Book.create(ctx.db, input.data);
	let location = routes.api.books.show.href({ bookId: book.id });
	return created(
		{ book: serializeBook(book) },
		{ headers: { Location: location } },
	);
});

Book is the app's own data module, with create, find and update over the books table, and serializeBook maps a row to the public shape. Keep that mapping explicit: the JSON a client sees is a contract, and a renamed column should not change it.

A 422 answer reads like this, which a client can map straight onto its form fields:

{
	"type": "https://books.example.com/docs/errors/validation-failed",
	"title": "The request failed validation",
	"status": 422,
	"errors": [
		{ "pointer": "/year", "code": "invalid", "message": "Expected number" }
	]
}

Answer a missing resource

Path params are input too, so show validates them with BOOK_PARAMS before it queries.

app/http/controllers/api/books/show.ts
import { ok } from "@sdxc/http/response/json";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import { createAction } from "remix/router";

import Book, { serializeBook } from "~/app/data/book";
import { BOOK_PARAMS } from "~/app/http/schemas/books";
import { problems } from "~/app/services/problems";
import routes from "~/routes/web";

export const show = createAction(routes.api.books.show, async (ctx) => {
	let params = await validate(ctx.params, BOOK_PARAMS);
	if (isFailure(params))
		return problems.notFound({ detail: "No book has that id." });

	let book = await Book.find(ctx.db, params.data.bookId);
	if (book === null) return problems.notFound({ detail: "No book has that id." });

	return ok({ book: serializeBook(book) });
});

Each builder also takes instance, a URI for this one occurrence. A fresh urn:uuid: value there gives a caller something to quote in a support request that you can find in your logs.

Page the list

A books table only grows, so the list pages by keyset: each page ends in an opaque cursor, and the next request seeks from it instead of counting and skipping rows. createPaging binds the parameter names and limits once, so the names you parse are the names the Link header advertises.

app/http/controllers/api/books/index.ts
import { ok } from "@sdxc/http/response/json";
import { createPaging, InvalidCursorError, Pagination } from "@sdxc/pagination";
import { issuesFrom } from "@sdxc/problem";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";

import { serializeBook } from "~/app/data/book";
import { problems } from "~/app/services/problems";
import { books } from "~/database/schema";
import routes from "~/routes/web";

const paging = createPaging({ perPage: 25, maxPerPage: 100 });

export const index = createAction(routes.api.books.index, async (ctx) => {
	let params = paging.parse(ctx.url.searchParams);
	if (isFailure(params)) {
		let errors = issuesFrom(params.error);
		return problems.validationFailed({ extensions: { errors } });
	}

	let page = await Pagination.byKeyset(ctx.db.query(books), {
		orderBy: [
			["created_at", "desc"],
			["id", "desc"],
		],
		cursor: params.data.cursor,
		limit: params.data.perPage,
	});
	if (isFailure(page)) {
		if (page.error instanceof InvalidCursorError) return problems.badRequest();
		return problems.internal();
	}

	let headers = paging.paginate(new Headers(), page.data, { url: ctx.url });
	return ok({ books: page.data.items.map(serializeBook) }, { headers });
});

The ordering ends in id because two books created in the same millisecond would otherwise share a boundary and one of them would be skipped or served twice; byKeyset refuses a single non-unique sort key for that reason. The response carries rel="next" and rel="prev" links, and a client walks the list by following them.

A numbered pager wants offset paging instead: Pagination.byOffset counts the query, and the same paginate call then writes first, last and an X-Total-Count header.

Rate limit the API

rateLimit counts each request before the handler runs and writes the RateLimit and RateLimit-Policy headers onto whatever comes back. onLimit replaces the default 429 body with an entry from your catalog, and the quota headers, Retry-After included, are applied to it either way.

app/http/middleware/api-rate-limit.ts
import { env } from "cloudflare:workers";
import { CloudflareAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";

import { problems } from "~/app/services/problems";

export function apiRateLimit() {
	let limit = { limit: 100, window: "1 minute" } as const;
	return rateLimit({
		adapter: new CloudflareAdapter(env.API_RATE_LIMITER, limit),
		prefix: "api",
		key: (ctx) => ctx.request.headers.get("CF-Connecting-IP") ?? "unknown",
		onLimit: () =>
			problems.rateLimited({ detail: "Wait for the time in Retry-After." }),
	});
}

API_RATE_LIMITER is a Workers rate limiting binding, and the adapter's limit and window must mirror the binding's own: the binding does the counting, and the adapter uses the numbers to compute the reset. Once your API authenticates callers, key on the caller's identity instead of the address, so one client cannot spend another's budget.

Mount the limiter once over the three actions, so they share one budget per caller. The application() below shows only that mapping; the app's own router also runs the global middleware that publishes ctx.db:

bootstrap/app.tsx
import { createRouter } from "remix/router";

import { create } from "~/app/http/controllers/api/books/create";
import { index } from "~/app/http/controllers/api/books/index";
import { show } from "~/app/http/controllers/api/books/show";
import { apiRateLimit } from "~/app/http/middleware/api-rate-limit";
import routes from "~/routes/web";

export default function application() {
	let router = createRouter();
	router.map(routes.api.books, {
		middleware: [apiRateLimit()],
		actions: { index, create, show },
	});
	return router;
}

Where to go next