The list endpoint shows every URL at a glance, but there's no way to pull up the full picture for a single code. This chapter adds GET /urls/:code/stats — the metadata and click count for one short code, returned through a locked response schema, with a clean 404 for a code that doesn't exist.

findRecordByCode(shortCode) method on the chapter-6 UrlStore seam, behind a thin GET /urls/:code/stats handler.undefined); the handler turning that into a 200 stats body or a 404.createdAt serialized as an ISO-8601 string.Before the code, here are the four ideas this endpoint rests on.
Stats endpoint. A read that returns the full stored metadata for one short code: { shortCode, originalUrl, clicks, createdAt, shortUrl }. It's the single-item counterpart to the list — where GET /urls returns a page of rows, this returns the one row you name.
ISO-8601. The standard, sortable, timezone-explicit date-string format, like "2026-06-14T09:31:12.345Z". We serialize createdAt this way so the timestamp is deterministic on the wire instead of depending on locale or client parsing.
Response schema. A declared output shape that Fastify enforces, so the JSON can't silently drift — a field can't change type or go missing without a test catching it. It turns "it happens to return these fields" into "it is guaranteed to return exactly these fields."
404 for unknown. A missing code is a clean not-found, not an empty 200 body. The handler asks the store for the record and, on undefined, returns 404 { error, message } — the same miss contract the redirect route already uses.
A list view answers "what URLs exist?" but not "tell me everything about this one." The stats endpoint fills that gap — and the two details that shape its response are exactly what separate a sloppy endpoint from a trustworthy one. Serialize createdAt however the runtime feels like, and the date format drifts with locale; skip the response schema, and a refactor can quietly drop a field or flip its type with no test to catch it. Locking both means the body is the same every time, on every backend, and the 404 keeps the contract honest: an unknown code is a deliberate not-found, never a half-empty success.
We already have two read methods on the UrlStore seam. findByCode returns just the original URL string — that's all the redirect route ever needed. list returns a page of records. Neither fits the stats endpoint: it needs the whole record for one specific code, not a string and not a page.
So we add a third read method rather than overloading an existing one:
typescript// src/services/url.service.tsexport interface UrlStore {save(shortCode: string, url: string): Promise<void>;findByCode(shortCode: string): Promise<string | undefined>;findRecordByCode(shortCode: string): Promise<UrlRecord | undefined>;incrementClicks(shortCode: string): Promise<void>;list(params: ListUrlsParams): Promise<ListUrlsResult>;}
findRecordByCode returns the same UrlRecord shape that list already surfaces — { shortCode, originalUrl, clicks, createdAt } — or undefined when the code is unknown, mirroring findByCode's miss contract. We could have widened findByCode to return the record, but that would force the redirect route — which only wants a string — to carry a richer type it never uses. A narrow method per need keeps each caller honest about what it actually reads.
The teaching beat here is what didn't change. The in-memory store has tracked the full UrlRecord (including createdAt and clicks) in a single Map since chapter 15, so the seam was already consistent. Adding the method to the in-memory store is one line:
typescript// src/services/url.service.tsasync findRecordByCode(shortCode: string): Promise<UrlRecord | undefined> {return this.records.get(shortCode);}
No record-shape change, no new field, no migration — the data was already there. We're just exposing it through a new accessor.
The Prisma store projects the row to the same UrlRecord shape, dropping the internal id and normalising a miss the way every other read method does:
typescript// src/services/prisma-url.repository.tsasync findRecordByCode(shortCode: string): Promise<UrlRecord | undefined> {const row = await this.prisma.url.findUnique({where: { shortCode },});if (!row) return undefined;return {shortCode: row.shortCode,originalUrl: row.originalUrl,clicks: row.clicks,createdAt: row.createdAt,};}
findUnique returns null on a miss; we normalise that to undefined so both backends honour the same contract — the route's behaviour is identical whichever store it talks to. The projection drops id: it's an internal serial primary key, never part of the public stats shape, so it stops at the repository boundary.
The stats body has five fields, and we lock all of them with a Fastify response schema:
| 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} |
Two of these deserve a closer look. createdAt is a JavaScript Date in both stores, but the schema types it as a string — so the handler serializes it explicitly with record.createdAt.toISOString() before returning. Fastify's JSON serializer would turn a Date into the same ISO string anyway, but the explicit call makes the contract obvious in the code and lets the string-typed schema validate the output cleanly — identical whether the record came from the in-memory Map or Postgres.
shortUrl is derived — it isn't stored anywhere. We build it from shortCode in the handler so the client gets a ready-to-use link without reassembling it. This mirrors the list endpoint from chapter 15, keeping the two endpoints' item shapes consistent.
Chapter 15 had to fight the /:code catch-all: GET /urls looks like a redirect for the code "urls" unless the list route is registered first. The stats route doesn't have that problem. /urls/:code/stats has a static /urls prefix and a three-segment shape, so Fastify's radix router never confuses it with the bare one-segment /:code catch-all or the two-segment-free /urls list.
Even so, we register it before the catch-all alongside listRoute — consistent placement, no surprises:
typescript// src/app.tsawait app.register(healthRoute);await app.register(shortenRoute, { urlStore, random: opts.random });await app.register(listRoute, { urlStore });await app.register(statsRoute, { urlStore });await app.register(redirectRoute, { urlStore });
We don't just trust the router — a unit test asserts GET /urls/abc123/stats returns the stats object (200, no Location header, data is not an array), proving it's served by the stats route and not swallowed by the redirect or list.
Check out the start branch, install, and bring up the database:
bashgit checkout 16-url-stats-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 five cases against GET /urls/:code/stats: the happy-path 200 with the full body, the ISO-8601 createdAt serialization, the click count reflected in the response, the 404 for an unknown code, and the route-ordering proof.
typescript// __tests__/stats.test.tsimport { FastifyInstance } from "fastify";import { buildApp } from "../src/app";import { UrlService } from "../src/services/url.service";describe("GET /urls/:code/stats", () => {let app: FastifyInstance;let store: UrlService;beforeEach(async () => {store = new UrlService();app = await buildApp({ logger: false, urlStore: store });await app.ready();});afterEach(async () => {await app.close();});it("returns 200 with the metadata for a known code", async () => {await store.save("abc123", "https://dalabs.academy");const response = await app.inject({method: "GET",url: "/urls/abc123/stats",});expect(response.statusCode).toBe(200);expect(response.json()).toEqual({shortCode: "abc123",originalUrl: "https://dalabs.academy",clicks: 0,createdAt: expect.any(String),shortUrl: "http://localhost:3000/abc123",});});it("serializes createdAt as an ISO 8601 string", async () => {await store.save("iso999", "https://example.com");const response = await app.inject({method: "GET",url: "/urls/iso999/stats",});const { createdAt } = response.json();expect(createdAt).toBe(new Date(createdAt).toISOString());});it("returns 404 for an unknown code", async () => {const response = await app.inject({method: "GET",url: "/urls/nope404/stats",});expect(response.statusCode).toBe(404);expect(response.json().error).toBe("Not Found");});it("returns stats, not a redirect or the list, for /urls/:code/stats", async () => {await store.save("abc123", "https://dalabs.academy");const response = await app.inject({method: "GET",url: "/urls/abc123/stats",});expect(response.statusCode).toBe(200);expect(response.headers.location).toBeUndefined();expect(Array.isArray(response.json().data)).toBe(false);expect(response.json().shortCode).toBe("abc123");});});
The ISO test is a neat round-trip assertion: createdAt === new Date(createdAt).toISOString() only holds if the string is already a canonical ISO-8601 value — re-parsing and re-serializing it produces the exact same string. If the handler returned a raw Date (or any non-ISO format), the comparison would fail.
The integration suite mirrors the happy path and the click count against the real database, wiring PrismaUrlRepository.
typescript// __tests__/integration/stats.test.tsit("returns the stored metadata for a known code", async () => {await prisma.url.create({data: { shortCode: "stat01", originalUrl: "https://dalabs.academy" },});const response = await app.inject({method: "GET",url: "/urls/stat01/stats",});expect(response.statusCode).toBe(200);const body = response.json();expect(body.shortCode).toBe("stat01");expect(body.originalUrl).toBe("https://dalabs.academy");expect(body.clicks).toBe(0);expect(body.shortUrl).toBe("http://localhost:3000/stat01");expect(body.createdAt).toBe(new Date(body.createdAt).toISOString());});it("reflects the click count after redirects", async () => {const code = (await app.inject({method: "POST",url: "/shorten",payload: { url: "https://example.com" },})).json().shortCode as string;await app.inject({ method: "GET", url: `/${code}` });await app.inject({ method: "GET", url: `/${code}` });await app.inject({ method: "GET", url: `/${code}` });const response = await app.inject({method: "GET",url: `/urls/${code}/stats`,});expect(response.statusCode).toBe(200);expect(response.json().clicks).toBe(3);});
The click-count case is end-to-end through Postgres: shorten a URL, hit /:code three times so the redirect route's atomic increment runs, then read the stats and assert clicks: 3 — the same counter the redirect bumps, surfaced through the new endpoint.
Run both suites:
bashnpm testnpm run test:integration

There's a nuance worth catching in the red. With no stats route registered, GET /urls/abc123/stats is a three-segment path that matches nothing — not even the /:code catch-all, which is a single segment — so Fastify returns its own 404. That means the unknown-code 404 test passes on the start branch: a missing endpoint and a missing code both produce a 404. The happy-path cases are what fail — they expect 200 and get 404. The red is purely behavioural.
findRecordByCode and the Route (Green)Switch to the finish branch:
bashgit checkout 16-url-stats-finishnpm install
The finish branch adds findRecordByCode to the UrlStore interface and both stores (shown above), plus the new route. The handler is thin: read the validated code, ask the store for the record, and branch.
typescript// src/routes/stats.tsimport { FastifyPluginAsync } from "fastify";import { UrlStore } from "../services/url.service";interface StatsRouteParams {code: string;}interface StatsRouteOptions {urlStore: UrlStore;}export const statsRoute: FastifyPluginAsync<StatsRouteOptions> = async (app,opts) => {const { urlStore } = opts;app.get<{ Params: StatsRouteParams }>("/urls/:code/stats", {schema: {params: {type: "object",required: ["code"],properties: {code: { type: "string" },},},response: {200: {type: "object",required: ["shortCode","originalUrl","clicks","createdAt","shortUrl",],properties: {shortCode: { type: "string" },originalUrl: { type: "string" },clicks: { type: "integer" },createdAt: { type: "string" },shortUrl: { type: "string" },},},},},handler: async (request, reply) => {const { code } = request.params;const record = await urlStore.findRecordByCode(code);if (record === undefined) {reply.code(404);return {error: "Not Found",message: `No URL found for code "${code}"`,};}return {shortCode: record.shortCode,originalUrl: record.originalUrl,clicks: record.clicks,createdAt: record.createdAt.toISOString(),shortUrl: `http://localhost:3000/${record.shortCode}`,};},});};
The handler never touches a database directly — it talks only to urlStore.findRecordByCode, so the same code runs against the in-memory store in the unit tests and Prisma in integration and production. An undefined record becomes a 404 { error, message }, reusing the same shape the redirect route returns for a missing code. A found record is projected to the response body, with createdAt.toISOString() doing the date serialization the schema's string type expects.
With the route registered before the catch-all in app.ts, /urls/:code/stats resolves to the stats handler.
Run everything:
bashnpm testnpm run test:integration

Green on both. The unit suite proves the stats logic and the ISO serialization Docker-free; the integration suite proves the same body lands from a real Postgres row.
When you're done, stop the container:
bashdocker compose down -v
The stats we return — clicks and createdAt — are everything already sitting on the urls row. That's deliberate, and it's worth naming what we didn't build. A real analytics view would want lastAccessedAt, the top referrers, the user-agents, or a per-day click breakdown. None of those can come from a single counter column.
A clicks integer can only ever answer "how many?" — it can't answer "when?" or "from where?". Those questions need a separate, append-only click-events table: one row per hit, recording the timestamp, referrer, and user-agent, that the stats endpoint then aggregates. That's a fundamentally different data model — high write volume, queried by time range — and it's exactly the workload the table-partitioning bonus chapter (chapter 24) tackles, partitioning the events table by created_at range so per-day rollups stay fast at scale. So this isn't an omission; it's a scope boundary we'll cross deliberately once the data model is ready for it.
The full read side of the API is in place. Next we close the loop with delete (DELETE /urls/:code) — a 204 on success, the idempotency question, and a test that proves the row is actually gone.