Capstone: bookmarks API
Let’s put everything together. Over the last thirteen lessons, you’ve learned URL anatomy, the URL constructor, URLSearchParams, encoding, query parsing, routing, request/response handling, and input validation. Now we’ll build a complete, working API that uses all of it.
What we’re building
A bookmarks API where users can create, list, search, and delete bookmarks. Each bookmark has a URL (how fitting), a title, and tags.
This example touches every concept from the course:
- URL anatomy: every bookmark stores a URL
- URL constructor: we validate and parse bookmark URLs
- URLSearchParams / parseQuery: filtering and pagination via query strings
- Routing: multiple routes with dynamic path params
- Request and Response: web standard objects throughout
- app.request(): testing our API
- Zod validation: type-safe inputs
The full application
import { setup, route, group } from "@hectoday/http";
import { z } from "zod/v4";
// In-memory "database"
let nextId = 1;
const bookmarks = [];
// Routes
const bookmarkRoutes = group([
// LIST bookmarks with optional filters
// GET /bookmarks?tag=js&tag=ts&page=1&limit=10
route.get("/bookmarks", {
request: {
query: z.object({
tag: z.union([z.string(), z.array(z.string())]).optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(50).default(10),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ errors: c.input.issues }, { status: 400 });
}
const { tag, page, limit } = c.input.query;
// Filter by tags (if provided)
let filtered = bookmarks;
if (tag) {
const tags = Array.isArray(tag) ? tag : [tag];
filtered = bookmarks.filter((b) => tags.some((t) => b.tags.includes(t)));
}
// Paginate
const start = (page - 1) * limit;
const items = filtered.slice(start, start + limit);
return Response.json({
items,
total: filtered.length,
page,
limit,
totalPages: Math.ceil(filtered.length / limit),
});
},
}),
// GET a single bookmark by ID
// GET /bookmarks/42
route.get("/bookmarks/:id", {
request: {
params: z.object({
id: z.coerce.number().int().positive(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ errors: c.input.issues }, { status: 400 });
}
const bookmark = bookmarks.find((b) => b.id === c.input.params.id);
if (!bookmark) {
return Response.json({ error: "Bookmark not found" }, { status: 404 });
}
return Response.json(bookmark);
},
}),
// CREATE a new bookmark
// POST /bookmarks
route.post("/bookmarks", {
request: {
body: z.object({
url: z.url(),
title: z.string().min(1).max(200),
tags: z.array(z.string()).default([]),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ errors: c.input.issues }, { status: 400 });
}
// Parse the URL to extract and store the hostname
const parsed = new URL(c.input.body.url);
const bookmark = {
id: nextId++,
url: c.input.body.url,
hostname: parsed.hostname,
title: c.input.body.title,
tags: c.input.body.tags,
createdAt: new Date().toISOString(),
};
bookmarks.push(bookmark);
return Response.json(bookmark, { status: 201 });
},
}),
// DELETE a bookmark
// DELETE /bookmarks/42
route.delete("/bookmarks/:id", {
request: {
params: z.object({
id: z.coerce.number().int().positive(),
}),
},
resolve: (c) => {
if (!c.input.ok) {
return Response.json({ errors: c.input.issues }, { status: 400 });
}
const index = bookmarks.findIndex((b) => b.id === c.input.params.id);
if (index === -1) {
return Response.json({ error: "Bookmark not found" }, { status: 404 });
}
bookmarks.splice(index, 1);
return new Response(null, { status: 204 });
},
}),
]);
// App setup
const app = setup({
routes: [
...bookmarkRoutes,
route.get("/health", {
resolve: () => Response.json({ status: "ok" }),
}),
],
onNotFound: ({ request }) => {
const url = new URL(request.url);
return Response.json({ error: "Not Found", path: url.pathname }, { status: 404 });
},
onError: ({ error }) => {
console.error("Unhandled error:", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
},
});
export default app; Let’s highlight a few things that connect back to earlier lessons.
In the POST /bookmarks handler, after Zod validates the URL string, we pass it to new URL() to extract the hostname: new URL(c.input.body.url).hostname. This is the URL constructor in action, giving us structured access to the parts of the URL.
In the GET /bookmarks handler, the query schema uses z.union([z.string(), z.array(z.string())]) for the tag parameter. This is because parseQuery() returns a single string when there’s one tag=js, but an array when there are multiple tag=js&tag=ts. The union type handles both cases.
The pagination logic uses page and limit as numbers, not strings. That’s z.coerce.number() doing the conversion for us. Without it, (page - 1) * limit would do string math and produce garbage.
Testing it with app.request()
// CREATE bookmarks
await app.request("/bookmarks", {
method: "POST",
body: {
url: "https://developer.mozilla.org/en-US/docs/Web/API/URL",
title: "MDN: URL API",
tags: ["docs", "javascript", "url"],
},
});
await app.request("/bookmarks", {
method: "POST",
body: {
url: "https://zod.dev",
title: "Zod Documentation",
tags: ["docs", "typescript", "validation"],
},
});
// LIST all bookmarks
const all = await app.request("/bookmarks");
const allData = await all.json();
// { items: [...], total: 2, page: 1, limit: 10, totalPages: 1 }
// FILTER by multiple tags — uses append() internally!
const docs = await app.request("/bookmarks", {
query: { tag: ["docs", "typescript"] },
});
// Builds URL: /bookmarks?tag=docs&tag=typescript
// parseQuery turns this into: { tag: ["docs", "typescript"] }
// PAGINATE
const page2 = await app.request("/bookmarks", {
query: { page: "2", limit: "1" },
});
// GET single bookmark
const one = await app.request("/bookmarks/1");
const oneData = await one.json();
// { id: 1, url: "https://developer.mozilla.org/...",
// hostname: "developer.mozilla.org", ... }
// DELETE
const del = await app.request("/bookmarks/1", { method: "DELETE" });
// Status: 204 No Content
// INVALID INPUT
const bad = await app.request("/bookmarks", {
query: { page: "not-a-number" },
});
// Status: 400, { errors: [...] } Look at the filter request. When you pass { tag: ["docs", "typescript"] }, buildRequest() uses append() for each array item, producing ?tag=docs&tag=typescript. On the server side, parseQuery() sees the duplicate keys and converts them back into an array: { tag: ["docs", "typescript"] }. The set() vs append() distinction, the live URL/SearchParams connection, the custom query parser. It all connects.
Adding CORS
@hectoday/http exports a cors() helper that ties directly back to the concept of origin (protocol + hostname + port). CORS controls which origins are allowed to call your API:
import { setup, route, cors } from "@hectoday/http";
const { preflight, headers: corsHeaders } = cors({
origin: ["https://myapp.com", "http://localhost:3000"],
credentials: true,
});
const app = setup({
routes: [
preflight(route),
// ...your routes
],
onResponse: ({ request, response }) => {
return corsHeaders(request, response);
},
}); The request lifecycle, one last time
Every request flows through the same pipeline:
Browser: GET /bookmarks?tag=js&tag=ts&page=2
1. Request arrives as a Web Standard Request object
↓
2. onRequest hook runs (if configured)
↓
3. new URL(request.url)
↓
4. Route matching via url.pathname + request.method
↓
5. Path params extracted: { }
6. Query parsed: parseQuery("?tag=js&tag=ts&page=2")
Result: { tag: ["js", "ts"], page: "2" }
↓
7. Zod validates query:
tag: ["js", "ts"] ← valid
page: "2" → coerced to 2 ← valid
limit: missing → default 10 ← valid
↓
8. resolve(c) runs with validated, typed input
↓
9. onResponse hook runs (if configured, e.g., add CORS)
↓
10. Response.json({ items: [...], page: 2, ... })
Done. What you’ve learned
Over the course of these lessons, you’ve built an understanding from the ground up:
- What a URL is: protocol, hostname, port, pathname, search, hash
- The URL constructor: parsing and building URLs with a base
- URL properties: reading, writing, and the host/hostname/origin distinction
- URLSearchParams basics:
get(),getAll(),has(), constructors - Modifying params:
set()vsappend(),delete(),sort() - Iterating params:
for...of,forEach, converting to objects - URL and searchParams together: the live connection
- Encoding: percent-encoding, automatic vs manual
- Custom query parsing: how
parseQuery()works and why it exists - Routing: mapping URL pathnames to handler functions
- Request and Response: the web standard objects and the full lifecycle
- Building requests:
app.request()andbuildRequest()internals - Input validation: Zod schemas for params, query, and body
- A complete API: everything wired together
Every concept built on the previous one. The URL is the foundation, and @hectoday/http is a thin, well-designed layer on top of web standards.
In the next course, we’ll tackle Database Design with SQLite, where you’ll learn how to store and retrieve data so it doesn’t disappear when the server restarts. Right now our bookmarks live in a JavaScript array that gets wiped clean every time the app stops. A database fixes that.