The list endpoint shows every URL at a glance, but you cannot pull up the full picture for a single code. This chapter adds GET /urls/:code/stats. This new endpoint returns the metadata and click count for one specific short code. We will lock the output behind a strict response schema and return a clean 404 if the code does not exist.

findRecordByCode(shortCode) method on the UrlStore seam, sitting behind a thin GET /urls/:code/stats handler.undefined. The handler turns that result into a 200 success body or a 404 error.createdAt as an ISO-8601 string.Here are the four concepts this endpoint relies on.
Stats endpoint. A read operation that returns the full stored metadata for one short code: { shortCode, originalUrl, clicks, createdAt, shortUrl }. It is the single-item counterpart to the list. Where GET /urls returns a page of rows, this returns the specific row you ask for.
ISO-8601. The standard, sortable, timezone-explicit date format. It looks like "2026-06-14T09:31:12.345Z". We serialize createdAt this way so the timestamp is deterministic over the network, rather than depending on local server settings or client parsing.
Response schema. A declared output shape that Fastify enforces. This ensures the JSON cannot silently drift. A field cannot change type or go missing without a test catching it. It guarantees the endpoint returns exactly the fields we promise.
404 for unknown. A missing code results in a clean not-found error, rather than an empty 200 success body. The handler asks the store for the record. If the result is undefined, it returns 404 { error, message }. This is the same contract the redirect route already uses.
A list view answers "what URLs exist?" but it does not answer "tell me everything about this specific one." The stats endpoint fills that gap. The details shaping its response are what separate a sloppy endpoint from a trustworthy one.
If you serialize createdAt however the runtime defaults to, the date format might drift depending on the server's locale. If you skip the response schema, a future refactor could quietly drop a field or flip its type without failing any tests. Locking both down means the response body stays consistent on every backend. Finally, the 404 keeps the contract honest. An unknown code is a deliberate not-found error, never a half-empty success response.
We already have two read methods on the UrlStore seam. findByCode returns just the original URL string, which is 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 just a string and not a full page.
Instead of overloading an existing method, we add a third read method:
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 }. If the code is unknown, it returns undefined, mirroring the behavior of findByCode. We could have widened findByCode to return the entire record. However, that would force the redirect route to handle a richer type it never actually uses. Creating a narrow method for a specific need keeps each caller honest about the data it requires.
Notice what did not change. The in-memory store has tracked the full UrlRecord (including createdAt and clicks) in a single Map since chapter 15. The seam was already consistent. Adding the new method to the in-memory store takes just one line:
typescript// src/services/url.service.tsasync findRecordByCode(shortCode: string): Promise<UrlRecord | undefined> {return this.records.get(shortCode);}
There is no record-shape change, no new field, and no migration. The data was already there. We are just exposing it through a new accessor.
The Prisma store projects the database row to the same UrlRecord shape. It drops the internal id and normalizes a miss just like 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,};}
Prisma's findUnique returns null on a miss. We normalize that to undefined so both backends honor the exact same contract. This ensures the route behaves identically whichever store it talks to. The projection also drops id. The ID is an internal primary key and never part of the public stats shape, so it stops at the repository boundary.
The stats body has five fields. 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 fields deserve a closer look. createdAt is a JavaScript Date object in both stores, but the schema types it as a string. Because of this, the handler explicitly serializes it using record.createdAt.toISOString() before returning. Fastify's JSON serializer would turn a Date into the same ISO string automatically. However, the explicit call makes the contract obvious in the code. It also lets the string-typed schema validate the output cleanly, keeping the behavior identical whether the record came from the in-memory Map or Postgres.
The shortUrl field is derived. It is not stored anywhere in the database. We build it from the shortCode inside the handler so the client receives a ready-to-use link without having to reassemble it. This mirrors the list endpoint from chapter 15 and keeps the item shapes consistent across both endpoints.
In chapter 15, we had to work around the /:code catch-all route. A request to GET /urls looks like a redirect for the code "urls" unless the list route is registered first. The stats route does not have that problem. The path /urls/:code/stats has a static /urls prefix and a distinct three-segment shape. Fastify's router will never confuse it with the bare one-segment /:code catch-all or the two-segment /urls list.
Even so, we register it before the catch-all alongside listRoute. Consistent placement prevents 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 do not just trust the router. A unit test asserts that GET /urls/abc123/stats returns the correct stats object with a 200 status, no Location header, and a data field that is not an array. This proves the request is served by the stats route and not swallowed by the redirect or list handlers.
Check out the start branch, install the dependencies, and bring up the database:
bashgit checkout 16-url-stats-startnpm installdocker compose up -d --wait
The start branch provides the tests first with no implementation. The Docker-free unit suite injects the in-memory UrlService and covers five cases against GET /urls/:code/stats. It tests the happy-path 200 response with the full body, the ISO-8601 createdAt serialization, the click count, 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 uses a neat round-trip assertion. The check createdAt === new Date(createdAt).toISOString() only passes if the string is already a canonical ISO-8601 value. Re-parsing and re-serializing it must produce the exact same string. If the handler returned a raw Date object or any non-ISO format, the comparison would fail.
The integration suite mirrors the happy path and the click count against the real database by wiring up the 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 runs end-to-end through Postgres. It shortens a URL and hits /:code three times so the redirect route's atomic increment runs. Then, it reads the stats and asserts clicks: 3. This proves the new endpoint surfaces the exact same counter the redirect bumps.
Run both test suites:
bashnpm testnpm run test:integration

There is a nuance worth catching in the failing tests. With no stats route registered, GET /urls/abc123/stats is a three-segment path that matches nothing. It does not even match the /:code catch-all, which is a single segment. Because of this, Fastify returns its own default 404.
This means the unknown-code 404 test actually passes on the start branch. A missing endpoint and a missing code both produce a 404. The happy-path cases are the ones that fail, because they expect a 200 but receive a 404.
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. It also adds the new route. The handler is thin. It reads the validated code, asks the store for the record, and branches based on the result.
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. This means the exact same code runs against the in-memory store during unit tests and against Prisma in integration and production.
An undefined record becomes a 404 { error, message }, reusing the same error shape the redirect route returns for a missing code. A found record is projected into the response body. Calling createdAt.toISOString() handles the date serialization that the schema's string type expects.
With the route registered before the catch-all in app.ts, requests to /urls/:code/stats successfully resolve to the new stats handler.
Run the tests again:
bashnpm testnpm run test:integration

Both suites pass. The unit suite proves the stats logic and the ISO serialization without needing Docker. The integration suite proves the exact same response body lands successfully from a real Postgres row.
When you are done, stop the container:
bashdocker compose down -v
The stats we return are limited to clicks and createdAt. These are the fields already sitting on the urls row. That is deliberate, and it is worth naming what we did not build. A real analytics view would usually want lastAccessedAt, top referrers, 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 cannot answer "when?" or "from where?". Those questions require a separate, append-only click-events table. That table would store one row per hit, recording the timestamp, referrer, and user-agent. The stats endpoint would then aggregate that data.
That is a fundamentally different data model. It involves high write volume and queries filtered by time range. This is exactly the workload the table-partitioning bonus chapter (chapter 24) tackles. It partitions the events table by created_at ranges so per-day rollups stay fast at scale. Keeping the stats simple right now is not an omission. It is a scope boundary we will cross deliberately once the data model is ready for it.
The full read side of the API is now in place. Next, we close the loop with a delete endpoint (DELETE /urls/:code). We will cover returning a 204 on success, handling idempotency, and writing a test that proves the row is actually gone.