So far, the URL shortener can only create short links. There is nowhere to send a visitor who actually clicks one. This chapter adds the read side. The new GET /:code route looks up a short code and either redirects the user to the original URL or returns a clean 404.

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.Here are the core concepts we will use to build the redirect feature.
HTTP redirect. A response that tells the browser to go to a different URL instead. It carries a 3xx status and a Location header naming the target. For example, comes back as with .
GET /abc123302Location: https://dalabs.academyLocation header. The response header that says where to go. Without it, a redirect status is meaningless because there is nowhere to send the client.
301 vs 302. A 301 is permanent and gets cached hard. After the first hit, the browser jumps straight to the target and never touches our server again. A 302 is temporary and usually isn't cached. We use 302 so click tracking (coming in chapter 14) keeps working, since repeat visits will keep hitting the server.
Catch-all / param route. GET /:code has a :code placeholder that matches any single path segment. Routes like /health and /shorten also look like a one-segment "code" to it. Route order matters here. Register it after the static routes so it doesn't swallow them.
The HTTP status code decides whether click tracking is even possible. A 301 is marginally better for raw SEO link equity and saves a network round trip. However, once a browser caches a 301, repeat clicks skip our server entirely. If we used a 301, the click counter we add in the next chapter would freeze. A 302 keeps every hit flowing through the handler. The count actually grows, and we stay free to repoint a short code later without fighting stale caches. For a tracking-oriented shortener, that is the trade-off we want.
Route order is the other major piece. GET /:code is a bare parameter route at the root. In principle, it could match /health and read it as a redirect for the code "health". The fix is 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. This means /health resolves to the health route, and only genuinely unknown one-segment paths fall through. We prove this with a test rather than just trusting it.
We start with tests that fail for the right reason. The start branch includes the redirect tests, but the route they exercise does not exist yet. Check it out:
bashgit checkout 13-redirect-startnpm install
The unit suite injects the in-memory UrlService, meaning it needs no database. It covers three cases. A known code redirects with 302 and a Location header. An unknown code returns a 404. Finally, /health is still served by the health route instead of being 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 watch. The assertion confirms that /health still returns 200 with { message: "hello" } and no Location header. It is served by the health route, not redirected by the catch-all. We make this pass based on where we register the route.
Run the unit suite:
bashnpm test

We get one honest failure. With no redirect route, the known-code request falls through to Fastify's default 404 where the test expects a 302. The unknown-code and /health cases pass already, and every pre-existing suite stays green. The red test is behavioral.
Switch to the finish branch:
bashgit checkout 13-redirect-finishnpm install
The route is a FastifyPluginAsync that receives the urlStore through plugin options. This is the same dependency-injection pattern used in shortenRoute. The handler does no data access of its own. It calls urlStore.findByCode and then branches based on the result.
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);},});};
A few design choices keep this route small.
The lookup stays in the storage layer. The handler calls urlStore.findByCode(code) from the chapter-6 UrlStore interface. It never reaches for Prisma or a Map directly. The exact same route code works against the in-memory UrlService for unit tests and the PrismaUrlRepository for integration and production. The route does not know or care which store is behind the interface.
Fastify handles the redirect headers. In Fastify 5, the signature is reply.redirect(url, statusCode). That single call sets the Location header to originalUrl and the status to 302. If the code is missing, it returns the same { error, message } shape the rest of the API uses and sets the status to 404.
The defense against the catch-all swallowing reserved paths is the registration order. We 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, Fastify's radix-tree router prefers a concrete static path over a parameter segment. A request to /health matches the static GET /health route instead of the GET /:code param route. This is exactly what the "does not swallow the reserved /health path" test proves. Future reserved paths, like the /urls route in chapter 15, are static too. They will win the same way. The routing conflict is settled by ordering rather than maintaining a hardcoded list of names.
The unit suite proves the redirect logic in milliseconds using the in-memory store. However, it cannot prove the round trip through a real database. That is the integration test's job. It wires buildApp with PrismaUrlRepository, uses POST /shorten to write a real Postgres row, and then calls GET /:code to confirm the database read redirects correctly.
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 a code, ensuring it exercises the full create-then-read path through real Postgres. A sibling test case confirms an unknown code still returns a 404 from the database. Run both suites:
bashdocker compose up -d --waitnpm testnpm run test:integration

Both suites pass. The unit suite proves the redirect logic quickly without Docker. The integration suite proves the short code actually round-trips through the database and back into a Location header.
When you are done, stop the container:
bashdocker compose down -v
Using a 302 redirect means every click flows back through this handler. Next, we will track those clicks. We will increment a per-URL counter on each redirect using an atomic SQL update so concurrent hits never lose a count.