Every redirect now counts itself, but there is still no way to see what is stored. This chapter adds GET /urls. This endpoint returns a paginated list of every shortened URL, newest first. Now a caller can browse the data and see exactly how many rows exist.

list({ page, limit }) method to the UrlStore interface, placed behind a simple GET /urls handler.page and limit parameters and lock the response shape./:code catch-all. This ensures /urls resolves to the list instead of triggering a redirect.Before writing the code, here are four concepts you will use in this chapter.
Pagination. Returning a long list one page at a time instead of sending every row in a single response. For example, you might request rows 1 through 20, then 21 through 40. A database table with thousands of URLs is too large to ship as a single array.
limit/offset vs cursor. These are two ways to slice a list. We use limit/offset, which maps a page number to a specific slice of data (skip = (page-1)*limit, ). It is simple and allows you to jump straight to any page. A approach remembers the last item seen and asks for "everything after this row." Cursors are faster on massive datasets but usually only allow moving forward or backward. You cannot jump directly to page 42.
take = limitResponse envelope. Wrapping the page results in an object that carries the data alongside its paging metadata, like { data, page, limit, total }. The total is the count of all rows in the database. This allows the client to render text like "page 2 of 7" using Math.ceil(total / limit).
Stable ordering. A predictable sort with a tiebreaker, such as ORDER BY created_at DESC, id DESC. This prevents page boundaries from shifting. Without the id tiebreaker, two rows sharing the exact same createdAt timestamp might swap order between requests. If that happens, a row could appear on two different pages or vanish entirely.
A list endpoint requires more thought than a simple SELECT * FROM urls. Three specific decisions shape our approach.
First, consider the response shape. A client paging through results needs to know which page it requested and how many rows exist in total. Otherwise, it cannot render navigation links. We return an envelope object instead of a bare array. Each item in the data array 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} |
The shortUrl field is derived. It is not stored in the database. We build it from the shortCode directly in the handler so the client receives a ready-to-use link. We lock this entire shape with a Fastify response schema. This validates our output and serializes the JSON quickly.
Second, the paging math is controlled by the caller, which means it needs strict validation. The page parameter is an integer with a minimum of 1 (defaulting to 1). The limit parameter is an integer between 1 and 100 (defaulting to 20). By setting additionalProperties: false, any malformed request is rejected before the handler even runs. This includes page=0, limit=101, or unknown parameters. The maximum cap of 100 is highly practical. Without it, a caller could request a limit of one million and force a massive database scan. Any rejection results in a 400 error, which is normalized into our standard { error, message } shape by the error handler from chapter 8.
Third, we must handle route order to avoid the trap from chapter 13. We registered /:code as a catch-all route, placing it last so static paths win. The new /urls endpoint is another static path. If the list route were registered after the catch-all, Fastify would read GET /urls as a redirect for a short code named "urls" and return a 404. The fix relies on the same rule: register specific static routes 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 placed before redirectRoute, Fastify's router correctly prefers the specific /urls path over the /:code parameter. We lock this behavior in with a test. This is exactly the failure the start branch will demonstrate.
Check out the start branch, install dependencies, and bring up the database:
bashgit checkout 15-list-urls-startnpm installdocker compose up -d --wait
The start branch provides the tests with no implementation. The Docker-free unit suite injects the in-memory UrlService and covers nine cases against GET /urls. These include the empty page, newest-first ordering, page boundaries, the last partial page, three 400-rejection cases, and the route-ordering proof. The most interesting test is the last one:
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 that GET /urls returns a 200 status, carries no Location header, and includes an array in the data field. This proves it is the list endpoint, not a redirect or a 404. On the start branch, since no list route is registered yet, the /:code catch-all grabs the /urls request. It looks for a short code named "urls", finds nothing, and returns a 404. That single assertion motivates the route-ordering fix.
The integration suite mirrors these checks against the real database. It wires up PrismaUrlRepository to prove the same response envelope and ordering work in Postgres. The closing test case ties the whole section together. It shortens a URL, hits it twice, and then requests the list. The listed item correctly reports 2 clicks, verified end-to-end through the database.
Run both test suites:
bashnpm testnpm run test:integration

The test failures are consistent. Every list case expected a 200 or 400 status but received a 404. Because the /urls route does not exist yet, the /:code catch-all answers every request and fails to find a code named "urls". The surrounding test suites stay green. This confirms we are dealing with a missing endpoint rather than a broken application.
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 that our existing routes depend on. We add one method named list({ page, limit }), along with the shared types it returns. Previously, findByCode only needed to return the original URL string. Listing requires the entire 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 used in chapter 14. Each saved entry carries its shortCode, originalUrl, clicks, and createdAt data in one record. The list method sorts these records newest-first and slices the array:
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 makes the in-memory ordering deterministic. If two save calls happened in the exact same millisecond, they would get identical createdAt values and sort unpredictably. Offsetting each timestamp by an increasing seq value guarantees a strict newest-first order. This is the in-memory equivalent of the database's id tiebreaker.
The Prisma store handles the paging where it belongs: in the database.
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,};}
Using findMany with skip and take creates the limit/offset slice. The orderBy array provides the stable ordering. Both operations run inside a single $transaction so the page data and the total count come from the same database snapshot. If a new row were inserted between the two queries, the page items and the total count might disagree. The transaction keeps them perfectly consistent.
The route reads the validated page and limit values, calls urlStore.list, and maps each record to the final response shape. This mapping step adds the ISO timestamp and the derived shortUrl. Everything else is handled by 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 default values of page=1 and limit=20. By the time request.query reaches the handler, both values are guaranteed to be present. We do not need manual fallback logic in our code. The handler never touches a database directly. It only talks to the UrlStore interface. This means the exact same code runs against the in-memory store during unit tests and against Prisma in production. Because the route is registered before the catch-all in app.ts, requests to /urls now successfully resolve to the list.
Run the tests again:
bashnpm testnpm run test:integration

Both suites pass, including the route-ordering test. The /urls endpoint returns the list instead of a redirect because listRoute is registered before the /:code catch-all.
When you are done, stop the container:
bashdocker compose down -v
The list endpoint only shows surface-level details like the short code, original URL, and click count. Next, we will add a dedicated URL stats endpoint (GET /urls/:code/stats). This will return the full metadata for a single code and cleanly handle unknown codes with a 404.