So far the URL shortener can only create short links — there is nowhere to send a visitor who actually follows one. This chapter adds the read side: GET /:code looks up a short code and either redirects to the original URL or returns a clean 404. The interesting decisions are which redirect status to use and where this route belongs in the registration order.

GET /:code route that takes the short code, asks the store for the original URL, and redirects with 302 + Location or returns 404.UrlStore.findByCode seam, so it never touches the database directly./health, /shorten, or /documentation.Before the code, here are the terms this chapter rests on.
HTTP redirect. A response that tells the browser "go to a different URL instead." It carries a 3xx status and a header naming the target — e.g. comes back as with .
LocationGET /abc123302Location: https://dalabs.academyLocation header. The response header that says where to go. Without it a redirect status is meaningless — there's nowhere to send the client.
301 vs 302. A 301 is permanent and gets cached hard, so after the first hit the browser jumps straight to the target and never touches our server again. A 302 is temporary and isn't cached, so every click flows back through our handler. We use 302 so click tracking (chapter 14) keeps working — repeat visits keep hitting the server.
Catch-all / param route. GET /:code has a :code placeholder that matches any single path segment, so /health and /shorten also look like a one-segment "code" to it. Route order matters: register it after the static routes (/health, /shorten) so it can't swallow them.
Two decisions carry this chapter, and both are deliberate.
The status code isn't cosmetic — it decides whether click tracking is even possible. A 301 is marginally better for raw SEO link-equity and saves a round trip, but once a browser caches it, repeat clicks skip our server entirely and the counter we add next chapter freezes. A 302 keeps every hit flowing through the handler, so the count actually grows and we stay free to repoint a short code later without fighting stale caches. For a tracking-oriented shortener that's the trade we want.
Route order is the other load-bearing piece. GET /:code is a bare parameter route at the root, so in principle it could match /health and read it as a redirect for the code "health". The fix isn't special-casing path names — it's registration order: register the specific static routes first and the /:code catch-all last. Fastify's radix-tree router prefers a concrete static path over a parameter segment, so /health resolves to the health route and only genuinely unknown one-segment paths fall through. We prove it with a test rather than trusting it.
We start from tests that fail for the right reason. The start branch ships the redirect tests — the route they exercise doesn't exist yet. Check it out:
bashgit checkout 13-redirect-startnpm install
The unit suite injects the in-memory UrlService, so it needs no database. It covers three cases: a known code redirects with 302 + Location, an unknown code 404s, and — the guard that matters — /health is still served by the health route, not swallowed by the catch-all.
typescript// __tests__/redirect.test.tsit("does not swallow the reserved /health path", async () => {const response = await app.inject({ method: "GET", url: "/health" });expect(response.statusCode).toBe(200);expect(response.headers.location).toBeUndefined();expect(response.json()).toEqual({ message: "hello" });});
That third test is the one to notice. The assertion pins down that /health still returns 200 with { message: "hello" } and no Location header — it's served by the health route, not redirected by the catch-all. We make that true by where we register the route, not by special-casing the path.
Run the unit suite:
bashnpm test

One honest failure: with no redirect route, the known-code request falls through to Fastify's default 404 where the test expects 302. The unknown-code and /health cases pass already, and every pre-existing suite stays green — so the red is behavioral, not a compile error.
Switch to the finish branch:
bashgit checkout 13-redirect-finishnpm install
The route is a FastifyPluginAsync that receives the urlStore through plugin options — the same dependency-injection pattern as shortenRoute. The handler does no data access of its own: it calls urlStore.findByCode, then branches.
typescript// src/routes/redirect.tsimport { FastifyPluginAsync } from "fastify";import { UrlStore } from "../services/url.service";interface RedirectRouteParams {code: string;}interface RedirectRouteOptions {urlStore: UrlStore;}export const redirectRoute: FastifyPluginAsync<RedirectRouteOptions> = async (app,opts) => {const { urlStore } = opts;app.get<{ Params: RedirectRouteParams }>("/:code", {schema: {description: "Redirect a short code to its original URL",tags: ["URLs"],params: {type: "object",required: ["code"],properties: {code: { type: "string" },},},},handler: async (request, reply) => {const { code } = request.params;const originalUrl = await urlStore.findByCode(code);if (originalUrl === undefined) {reply.code(404);return {error: "Not Found",message: `No URL found for code "${code}"`,};}return reply.redirect(originalUrl, 302);},});};
Two things make this route as small as it is.
The lookup stays in the storage layer. The handler calls urlStore.findByCode(code) — the chapter-6 UrlStore interface — and never reaches for Prisma or a Map directly. So the same route code works against the in-memory UrlService (unit tests) and the PrismaUrlRepository (integration and production). The route doesn't know or care which store is behind the interface; that's the seam doing its job again.
reply.redirect takes the URL first, status second. In Fastify 5 the signature is reply.redirect(url, statusCode). That one call sets the Location header to originalUrl and the status to 302. A missing code returns the same { error, message } shape the rest of the API uses, with the status set to 404.
The defence against the catch-all swallowing reserved paths is the registration order we defined earlier: register redirectRoute last, after the static routes and the swagger plugins.
typescript// src/app.ts (excerpt)await app.register(healthRoute);await app.register(shortenRoute, { urlStore, random: opts.random });await app.register(redirectRoute, { urlStore });
Because redirectRoute is registered last and Fastify's radix-tree router prefers a concrete static path over a parameter segment, /health matches the static GET /health route, not the GET /:code param route. This is exactly what the "does not swallow the reserved /health path" test proves: GET /health still returns 200 with { message: "hello" } and no Location header. Future reserved paths (like /urls in chapter 15) are static too, so they'll win the same way — the question is settled by ordering, not a hardcoded list of names.
The unit suite proves the redirect logic in milliseconds with the in-memory store, but it can't prove the round trip through a real database. That's the integration test's job: wire buildApp with PrismaUrlRepository, POST /shorten to write a real Postgres row, then GET /:code to confirm the database read redirects.
typescript// __tests__/integration/redirect-persists.test.tsit("redirects a persisted short code to its original URL", async () => {const url = "https://dalabs.academy/courses/test-driven-development-with-nodejs";const created = await app.inject({method: "POST",url: "/shorten",payload: { url },});const { shortCode } = created.json();const response = await app.inject({ method: "GET", url: `/${shortCode}` });expect(response.statusCode).toBe(302);expect(response.headers.location).toBe(url);});
This test takes the short code straight from the POST /shorten response — it never hardcodes one — so it exercises the full create-then-read path through real Postgres. A sibling case confirms an unknown code still 404s from the database. Run both suites:
bashdocker compose up -d --waitnpm testnpm run test:integration

Green on both. The unit suite proves the redirect logic fast and Docker-free; the integration suite proves the short code actually round-trips through the database and back into a Location header.
When you're done, stop the container:
bashdocker compose down -v
The 302 means every click flows back through this handler — exactly the hook we need. Next we track clicks, incrementing a per-URL counter on each redirect with an atomic SQL update so concurrent hits never lose a count.