URL Shortener
Link shortening API with redirects and click analytics
Role
Backend developer
Year
2024
Stack
NestJSTypeScriptPostgreSQLPrismaDockerGitHub ActionsREST API
The task
I needed short links on my own domain — for my CV, portfolio, articles and mailings. Off-the-shelf services such as bit.ly do solve that, but with caveats:
- the link lives on someone else’s domain and dies together with someone else’s pricing plan;
- click statistics are locked inside someone else’s dashboard;
- there is no convenient programmatic access for my own scenarios — generating a link from a script or from the CMS of my own site.
Hence the brief: my own API service that can be called from anywhere and that will outlive a change of hosting — in other words portable, reproducible and automatically deployed.
Stages
Skeleton and domain model
- A modular NestJS structure: the infrastructure layer (
core) kept separate from the domain module (url). - A Prisma schema with a single
Urlentity and a unique index on the short link. DatabaseServiceas an injectable wrapper around the Prisma client, so the domain never imports the client directly.
API functionality
- Five endpoints: create, list, redirect, update, delete.
UidServicebuilt on nanoid, with the identifier length passed as a call parameter.UrlExistsPipe— resolves auidinto an entity and produces one consistent 404 across three routes.PaginationService: the page of results and the total count derived from one and the same condition, ready-madenextPage/prevPagelinks in the metadata, and case-insensitive search across title, description and URL at once.
Application boundaries
AuthGuardbased onx-api-keyon the CRUD routes; the key is read viagetOrThrow, so the application fails at startup instead of coming up with wide-open CRUD.- A global
ValidationPipewithwhitelist: trueandtransform: true, plus@IsUrl()on the redirect field. TransformResponseInterceptor— a single envelope,{ data }or{ data, meta }.helmet()and structured winston logs: JSON in production, colourised output in development.
Three levels of tests
- Unit tests against mocks, integration tests against a real PostgreSQL, e2e tests over HTTP through the entire application.
- Two jest configurations: fast tests across
src, and the heavyweight ones behind a separate command with--runInBand. - Isolation through
TRUNCATE ... CASCADEinafterEachrather than recreating the database.
A reproducible test environment
- A dedicated compose file for tests: its own ports (5444 and 6380), containers, network, volumes and
.env.test. pg_isreadyandredis-cli pinghealthchecks, combined with starting the stack using--wait.- A custom wait script: first the TCP port, then a genuine
prisma.$connect()— up to 120 attempts at one-second intervals.
Containerisation and automated deployment
- A multi-stage Dockerfile on
node:20-alpine: onlydist,node_modulesand the Prisma artefacts move into the runtime layer — together with the schema and the migrations folder. - CI moved onto the same package manager used locally, with installation extracted into a composite action.
- Deployment triggered by the
workflow_runevent from the test workflow, checking out the exact commit by itshead_sha. - The image published to GHCR under both a
mainand asha-<commit>tag.
The process
Requirements
| # | Requirement | Rationale |
|---|---|---|
| 1 | POST /url creates a link in the form https://url.dphil.ru/{uid} | short domain plus short identifier |
| 2 | GET /:uid — a 302 redirect that increments the click counter | basic analytics without external systems |
| 3 | Full CRUD over links, protected by authentication | links need editing and deleting, but not publicly |
| 4 | A list with pagination and full-text search | dozens of links are awkward to page through without a filter |
| 5 | A single API response format | so that the frontend and scripts never have to parse two different shapes |
| 6 | Structured logs | reading logs by eye in production is not an option |
| 7 | Tests that catch regressions at the HTTP level | a side project without tests breaks in its second month |
| 8 | Deployment with no manual steps | otherwise updates simply never ship |
Architecture
A modular NestJS structure that separates the infrastructure layer from the domain module:
src/
├── core/ # infrastructure (global module)
│ ├── cache/ # Redis via Cacheable + keyv
│ ├── logger/ # winston: JSON in production, colourised in dev
│ ├── middleware/logger/ # logging of every HTTP request
│ └── interceptors/ # unified response shape { data, meta }
├── database/ # the Prisma client as an injectable service
├── auth/ # AuthGuard based on x-api-key
├── modules/url/ # domain module: controller, service, DTOs, pipe
├── services/
│ ├── uid/ # identifier generation (nanoid)
│ └── pagination/ # pagination and filtering
└── utils/ # database readiness wait, test fixtures
The guiding principle: the domain module knows nothing about the infrastructure. UrlService talks to DatabaseService, UidService, PaginationService and ConfigService, all injected through DI — which is why unit tests can swap them for mocks without touching a single line of infrastructure code.
The path a request takes through the application:
Data model
model Url {
id Int @id @default(autoincrement())
redirect String // where we send the visitor
url String @unique // the complete short link itself
title String
description String?
clicks Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The unique index on url is not merely a lookup convenience but database-level protection against generator collisions: two identical short links physically cannot be stored.
Key decisions
1. The existence check lives in a pipe, not in the controller
Three endpoints — GET /:uid, PATCH /url/:uid and DELETE /url/:uid — would each have started with the same five lines of “look it up by uid, throw a 404 if it isn’t there”. Instead there is a custom pipe that turns the string route parameter straight into an entity:
@Injectable()
export class UrlExistsPipe implements PipeTransform {
constructor(private readonly urlService: UrlService) {}
async transform(uid: any) {
const redirectUrl = await this.urlService.findOne(uid);
if (!redirectUrl) throw new NotFoundException('Url not found');
return redirectUrl;
}
}
What remains in the controller is a declarative signature — the handler receives a ready-made object:
@Get(':uid')
findOne(@Param('uid', UrlExistsPipe) url: Url, @Res() res: Response) {
this.urlService.incrementClicks(+url.id);
return res.redirect(url.redirect);
}
A welcome side effect: the 404 is guaranteed to be identical on every route, and it cannot be forgotten.
2. A single response contract through a global interceptor
TransformResponseInterceptor wraps any result into { data }, and a paginated one into { data, meta }. The services meanwhile return clean domain objects and know nothing about the transport format.
3. Pagination that ships ready-made links to adjacent pages
PaginationService computes the result set and the total count from one and the same where, and its meta returns not just numbers but fully assembled URLs for the next and previous pages:
"meta": {
"totalCount": 42, "currentPage": 2, "perPage": 10, "totalPages": 5,
"nextPage": "https://url.dphil.ru/url?limit=10&page=3",
"prevPage": "https://url.dphil.ru/url?limit=10&page=1"
}
Search is a case-insensitive contains across title, description and url simultaneously, so that a single query string covers the “find that link I made back in May” scenario.
4. Validation at the application boundary
A global ValidationPipe with whitelist: true and transform: true, plus DTOs built on class-validator: @IsUrl() on the redirect field, @Min(1) and type coercion for the pagination query parameters. Anything absent from the DTO is stripped out of the request body — a client cannot award itself clicks: 9999. That scenario is pinned down by a dedicated e2e test.
5. Security at the boundaries
helmet()at application level;AuthGuardon thex-api-keyheader, with the key read viagetOrThrow— the application fails at startup if the key is missing, rather than quietly running with wide-open CRUD;- the redirect is the only thing that stays public; everything else is locked down.
6. Logging that machines can read
winston in two modes: a colourised, human-readable format in development, and timestamped JSON in production that drops straight into any log collector. Middleware records every completed response and picks the level from the status code (5xx → error, 4xx → warn, everything else → info); in the test environment it switches off so as not to pollute jest output.
The redirect scenario end to end
Testing: three levels instead of one
The largest part of the work. The tests are split by cost and by purpose:
| Level | Files | What it verifies | Dependencies |
|---|---|---|---|
| Unit | *.spec.ts | service logic, guard, pipe | everything mocked |
| Integration | *.int-spec.ts | what the service actually writes to and reads from the database | PostgreSQL in Docker |
| E2E | *.e2e-spec.ts | the whole HTTP contract: status codes, bodies, authentication | application + database + Redis |
Fast and heavyweight tests are separated into different jest configs: npm run test runs only the unit tests across src, while npm run test:e2e brings up the environment and runs the integration and e2e tests in a single pass with --runInBand, so that they do not fight over one database.
Isolation between tests is not a database rebuild but a TRUNCATE ... CASCADE across every table except _prisma_migrations, executed in afterEach. That is noticeably faster than applying migrations before each test, and every test still starts from a clean state.
The e2e suite also covers the unpleasant cases that refactoring breaks easily: a request without an API key → 401, with the wrong key → 401, an empty body → 400, an invalid URL in redirect → 400, an attempt to pass extra fields → they are discarded by validation.
Infrastructure and CI/CD
Docker. A multi-stage build on node:20-alpine: the build stage installs dependencies through corepack/pnpm with --frozen-lockfile, generates the Prisma client and compiles the application; only dist, node_modules and the Prisma artefacts move into the runtime image. schema.prisma and the migrations folder are copied into the final image separately — without them prisma migrate deploy has nothing to work with in production.
Two compose files. A regular one for local development and a separate one for tests, with its own ports (5444 for Postgres, 6380 for Redis), its own .env.test, its own network and healthchecks on both services.
Deployment is triggered not by a push but by the workflow_run event from the test workflow, and it checks out precisely the commit that was tested. Red CI physically cannot reach production. The image is published to GHCR under several tags at once: main for “the latest version” and sha-<commit> — so that a rollback is a one-line change rather than a rebuild.
Problems and how they were solved
1. Flaky e2e failures in CI. Everything green locally, intermittent database connection errors in Actions. The cause is a classic one: docker compose up -d returns control once the container is running, not once Postgres is ready to accept connections. The fix has two layers: healthchecks using pg_isready and redis-cli ping together with the --wait flag, and a custom wait script that first checks that the TCP port is reachable and then performs a genuine prisma.$connect() — up to 120 attempts at one-second intervals. Checking through Prisma specifically matters: an open port does not yet mean the database is reachable by the user the migrations will run as.
2. Port conflicts between the development and test environments. Test runs interfered with the local development database. I separated the environments completely: a dedicated compose file, a fixed port 5444, separate container, network and volume names, and a separate .env.test loaded through dotenv-cli.
3. Prisma inside the container. The first working image build failed in production while applying migrations — neither the schema nor the migrations made it into the runtime layer. On top of that, client generation has to happen inside the build for the target platform rather than being dragged across from the developer machine: installation runs with --ignore-scripts, and prisma generate is invoked explicitly at the right moment.
4. Migrating CI to pnpm. The project used pnpm locally while the workflows used npm; as a result the lockfile was not honoured and CI picked up dependency versions I never had. I unified everything on one package manager and extracted installation into a reusable composite action.
5. Redis configuration from environment variables. Different hosting providers offer different sets: sometimes only a password, sometimes a username/password pair, sometimes neither. The connection string is assembled dynamically with URL-encoded credentials, so special characters in the password do not break the connection.
What comes next
An honest list of what I know about the project and where it is heading:
- A short domain instead of a subdomain.
- Redirect caching. Redis and a wrapper around it are already wired in, but the hot path
GET /:uidstill goes to the database. The next step is cachinguid → redirectwith invalidation on update and delete: it is the service’s most frequent query and an ideal candidate for a cache. - The click counter. The increment deliberately does not block the redirect, but it also has no delivery guarantees. The right evolution is buffering and writing in batches.
- Rate limiting on the public redirect and validation of target URLs — protection against the service being used as an open redirector.
- Users instead of a single API key, if anyone besides me ever uses the service.
- Cursor-based pagination instead of offset — once there are enough links for the difference to show.
The results
Automated tests in the project
Image build and deployment
API endpoints