Every redirect now counts itself, but there's still no way to see what's stored. This chapter adds GET /urls — a paginated list of every shortened URL, newest first — so a caller can browse the data and know how many rows exist in total.

list({ page, limit }) method on the chapter-6 UrlStore seam, behind a thin GET /urls handler.page/limit params and locks the response shape./:code catch-all so /urls resolves to the list, not a redirect.Before the code, here are the four ideas this chapter rests on.
Pagination. Returning a long list one page at a time instead of dumping every row in one response — e.g. "rows 1–20, then 21–40." A table with thousands of URLs can't ship as a single array.
limit/offset vs cursor. Two ways to slice a list. limit/offset (what we use) maps a page number to a slice: skip = (page-1)*limit, take = limit — simple, and you can jump straight to any page. instead remembers the last item and asks for "everything after this row"; it's faster on huge datasets but can only move forward or back, not jump to page 42.
Response envelope. Wrapping the page in an object that carries the data plus its paging metadata: { data, page, limit, total }. The total is the count of all rows, so the client can render "page 2 of 7" via Math.ceil(total / limit).
Stable ordering. A deterministic sort with a tiebreaker — ORDER BY created_at DESC, id DESC — so page boundaries don't shift. Without the id tiebreaker, two rows sharing the same createdAt could swap order between requests and a row could appear on two pages or vanish between them.
A list endpoint is more than SELECT * FROM urls, and three decisions are why.
First, the response shape. The client paging through results has to know which page it asked for and how many rows exist in total, or it can't render "page 2 of 7" — so we return the envelope, not a bare array. Each item in data exposes five fields:
| Field | Type | Notes |
|---|---|---|
shortCode | string | the public handle |
originalUrl | string | the long URL |
clicks | integer | current click count |
createdAt | string | ISO-8601 timestamp |
shortUrl | string | derived: http://localhost:3000/{shortCode} |
shortUrl is derived — it isn't stored. We build it from shortCode in the handler so the client gets a ready-to-use link, and lock the whole shape with a Fastify response schema, which validates our output and serializes it fast.
Second, the paging math is caller-controlled, so it gets a schema. page is integer, minimum: 1 (default 1); limit is integer, minimum: 1, maximum: 100 (default 20). With additionalProperties: false, anything malformed — page=0, limit=101, an unknown param — is rejected with 400 { error, message } before the handler runs. The maximum: 100 cap matters: without it a caller could ask for limit=1000000 and turn one request into a full-table scan. That 400 is normalized into our { error, message } shape by the app.setErrorHandler from chapter 8.
Third, route order — the trap from chapter 13. We registered /:code as the bare catch-all, placed last so static paths win. /urls is another static path: if the list route were registered after the catch-all, Fastify would read GET /urls as a redirect for the code "urls" and return 404. The fix is the same rule — register the specific static route first:
typescript// src/app.tsawait app.register(healthRoute);await app.register(shortenRoute, { urlStore, random: opts.random });await app.register(listRoute, { urlStore });await app.register(redirectRoute, { urlStore });
With listRoute before redirectRoute, Fastify's radix router prefers the specific static /urls over the /:code parameter. We don't trust it — we lock it with a test, which is exactly the failure the start branch is about to demonstrate.
Check out the start branch, install, and bring up the database:
bashgit checkout 15-list-urls-startnpm installdocker compose up -d --wait
The start branch ships the tests first and no implementation. The Docker-free unit suite injects the in-memory UrlService and covers nine cases against GET /urls: the empty page, newest-first ordering with every item field, page boundaries, the last partial page, the three 400-rejection cases, and the route-ordering proof. The one to watch is the last:
typescript// __tests__/list.test.tsit("does not let the /:code catch-all swallow /urls", async () => {await seed(1);const response = await app.inject({ method: "GET", url: "/urls" });expect(response.statusCode).toBe(200);expect(response.headers.location).toBeUndefined();expect(Array.isArray(response.json().data)).toBe(true);});
It asserts GET /urls returns 200, carries no Location header, and has an array in data — i.e. it's the list, not a redirect or a 404. On the start branch, with no list route registered, the /:code catch-all grabs /urls, finds no code "urls", and returns 404. That single assertion is what motivates the route-ordering fix.
The integration suite mirrors the rest against the real database, wiring PrismaUrlRepository to prove the same envelope and ordering land from Postgres. Its closing case ties the whole section together — shorten a URL, hit it twice, then list, and the listed item reports clicks: 2, end to end through Postgres.
Run both suites:
bashnpm testnpm run test:integration

The red is honest and uniform: every list case expected 200 (or 400) and received 404. With no /urls route registered, the /:code catch-all answers every request and finds no code "urls". The surrounding suites stay green, so this is a missing endpoint, not a broken app — the red is purely behavioural.
list to the Seam (Green)Switch to the finish branch:
bashgit checkout 15-list-urls-finishnpm install
The new capability goes behind the same UrlStore interface every route already depends on. We add one method — list({ page, limit }) — plus the shared types it returns. findByCode only ever needed the original URL string, but listing has to surface the whole record, so we add a richer UrlRecord shape.
typescript// src/services/url.service.tsexport interface UrlRecord {shortCode: string;originalUrl: string;clicks: number;createdAt: Date;}export interface ListUrlsParams {page: number;limit: number;}export interface ListUrlsResult {items: UrlRecord[];total: number;}export interface UrlStore {save(shortCode: string, url: string): Promise<void>;findByCode(shortCode: string): Promise<string | undefined>;incrementClicks(shortCode: string): Promise<void>;list(params: ListUrlsParams): Promise<ListUrlsResult>;}
The in-memory store now keeps a single Map<string, UrlRecord> instead of the two parallel maps from chapter 14, so each saved code carries its shortCode, originalUrl, clicks, and createdAt in one record. list sorts newest-first and slices:
typescript// src/services/url.service.tsexport class UrlService implements UrlStore {private readonly records = new Map<string, UrlRecord>();private seq = 0;async save(shortCode: string, url: string): Promise<void> {this.seq += 1;this.records.set(shortCode, {shortCode,originalUrl: url,clicks: 0,createdAt: new Date(Date.now() + this.seq),});}async list({ page, limit }: ListUrlsParams): Promise<ListUrlsResult> {const sorted = [...this.records.values()].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());const offset = (page - 1) * limit;const items = sorted.slice(offset, offset + limit);return { items, total: sorted.length };}}
The seq counter is what makes the in-memory ordering deterministic. Two save calls in the same millisecond would otherwise get identical createdAt values and sort unpredictably; offsetting each by an increasing seq guarantees a strict newest-first order — the in-memory equivalent of the database's id tiebreaker.
The Prisma store does the paging where it belongs — in SQL.
typescript// src/services/prisma-url.repository.tsasync list({ page, limit }: ListUrlsParams): Promise<ListUrlsResult> {const [rows, total] = await this.prisma.$transaction([this.prisma.url.findMany({orderBy: [{ createdAt: "desc" }, { id: "desc" }],skip: (page - 1) * limit,take: limit,}),this.prisma.url.count(),]);return {items: rows.map((row) => ({shortCode: row.shortCode,originalUrl: row.originalUrl,clicks: row.clicks,createdAt: row.createdAt,})),total,};}
findMany with skip/take is the limit/offset slice; orderBy: [{ createdAt: "desc" }, { id: "desc" }] is the stable ordering. Both run inside one $transaction so the page and the total come from the same snapshot — if a row were inserted between the two queries, the page and the count could disagree. The transaction makes them consistent.
The route reads the validated { page, limit }, calls urlStore.list, and maps each record to the response shape — adding the ISO createdAt and the derived shortUrl. Everything else is the schema.
typescript// src/routes/list.tsapp.get<{ Querystring: ListQuerystring }>("/urls", {schema: {querystring: {type: "object",additionalProperties: false,properties: {page: { type: "integer", minimum: 1, default: DEFAULT_PAGE },limit: {type: "integer",minimum: 1,maximum: MAX_LIMIT,default: DEFAULT_LIMIT,},},},response: {200: {type: "object",required: ["data", "page", "limit", "total"],properties: {data: {type: "array",items: {type: "object",required: ["shortCode", "originalUrl", "clicks", "createdAt", "shortUrl"],properties: {shortCode: { type: "string" },originalUrl: { type: "string" },clicks: { type: "integer" },createdAt: { type: "string" },shortUrl: { type: "string" },},},},page: { type: "integer" },limit: { type: "integer" },total: { type: "integer" },},},},},handler: async (request) => {const { page, limit } = request.query;const { items, total } = await urlStore.list({ page, limit });return {data: items.map((item) => ({shortCode: item.shortCode,originalUrl: item.originalUrl,clicks: item.clicks,createdAt: item.createdAt.toISOString(),shortUrl: `http://localhost:3000/${item.shortCode}`,})),page,limit,total,};},});
The querystring schema applies the defaults (page=1, limit=20), so by the time request.query reaches the handler both values are always present — no ?? 20 fallbacks in our code. The handler never touches a database directly; it talks only to the UrlStore seam, so the same code runs against the in-memory store in the unit tests and Prisma in integration and production. With the route registered before the catch-all in app.ts, /urls now resolves to the list.
Run everything:
bashnpm testnpm run test:integration

Green on both, including the route-ordering test: /urls returns the list, not a redirect, because listRoute is registered before the /:code catch-all.
When you're done, stop the container:
bashdocker compose down -v
The list only shows the surface — code, original URL, click count. Next we add URL stats (GET /urls/:code/stats), returning the full metadata for a single code with a 404 for unknown ones.