Full Stack App
React + Lambda + DynamoDB URL shortener
Open & fork this on Zstem →Architecture
- Visitor / SPA: Anyone clicking a short link (hot path), plus the React admin SPA for creating and managing links.
- CloudFront: One distribution, two behaviors: static SPA from S3, everything else (short codes + /api/*) to API Gateway. Custom short domains attach here as aliases with ACM certs.
- SPA Assets: Private bucket with the built React admin app, served via Origin Access Control.
- API Gateway (HTTP API): GET /{code} routes to the redirect function with no authorizer (redirects must be anonymous). /links and /domains routes use the Cognito JWT authorizer.
- Cognito User Pool: Accounts for people who create and manage links. Following a link never requires auth.
- Redirect Fn (hot path): One GetItem, one 301. Fires a link.clicked message to SQS without awaiting it — no synchronous writes on the hot path. 128 MB, provisioned concurrency in prod.
- Links API Fn: CRUD for links and custom domains: validates URLs, generates or reserves codes (conditional Put), lists a user's links via the owner GSI.
- links table: On-demand. PK = short code for O(1) redirect lookups; expires_at is the DynamoDB TTL attribute so dead links vanish for free. GSI1 (owner_id, created_at) lists "my links". Daily click rollups live alongside.
- click-events queue: Buffers link.clicked events so the redirect path never waits on analytics. DLQ after 3 receives.
- Analytics Fn: Consumes clicks in batches of up to 100, aggregates per code per day, then a handful of UpdateItem ADDs: total click_count on the link plus a clicks_daily rollup row.
Event flow
- Redirect Fn: Fires one link.clicked per successful 301; send is fire-and-forget so the hot path never waits.
- click-events: Standard SQS queue; at-least-once, so the consumer aggregates idempotently. DLQ after 3 receives.
- Analytics Fn: Batch size 100 / 30s window. Aggregates per (code, UTC day) then UpdateItem ADDs the rollups in the links table.
- link.clicked: Emitted by the Redirect Fn on every successful redirect, without awaiting the send. No PII: IP is salted-hashed for rough uniqueness only.
Sequence
Participants: Browser, CloudFront, API Gateway, Redirect Fn, links table, click-events (SQS), Analytics Fn, Destination site
- Browser→CloudFrontGET https://zst.li/x7Kp2q
- CloudFront→API Gatewayforward /{code} (cache miss)
- API Gateway→Redirect Fninvoke (no authorizer on this route)
- Redirect Fn→links tableGetItem pk = 'x7Kp2q'The only synchronous data access on the hot path.
- links table→Redirect Fn{ long_url, expires_at, ... }
- Redirect Fn→click-events (SQS)SendMessage link.clicked (fire-and-forget)Not awaited; a send failure is logged, never blocks the redirect.
- Redirect Fn→API Gateway301 Location: long_url
- API Gateway→CloudFront301
- CloudFront→Browser301 (Cache-Control: no-store)
- Browser→Destination siteGET long_urlBrowser follows the Location header.
- Redirect Fn→API Gateway404 unknown code / 410 expired410 when the item exists but expires_at has passed (TTL deletion lags a little).
- API Gateway→CloudFront404 / 410
- CloudFront→Browser404 / 410 error page
- click-events (SQS)→Analytics Fnbatch of link.clicked (≤100)
- Analytics Fn→links tableUpdateItem ADD click_count; ADD clicks_daily[code, day]Aggregated per code per UTC day before writing — a 100-click batch is a handful of writes.
- links table→Analytics FnOK (failed batch items retried, then DLQ)
API contract
- GET
/{code}Resolve a short link (hot path, no auth) - POST
/linksCreate a short link (auth) - GET
/linksList my links (auth) - DELETE
/links/{code}Delete a link (auth) - GET
/links/{code}Get one of my links (auth) - GET
/links/{code}/statsClick analytics for a link (auth) - POST
/domainsRegister a custom short domain (auth) - GET
/domainsList my custom domains (auth)
Database
| code | 7-char base62 (or user-chosen custom code); partition key |
|---|---|
| long_url | validated http(s) URL, max 2048 chars |
| domain | short domain this code lives on |
| owner_id | Cognito sub; GSI1 PK. Null for anonymous links |
| click_count | total, incremented by the Analytics Fn (ADD) |
| created_at | GSI1 SK — "my links, newest first" |
| expires_at | epoch seconds; DynamoDB TTL attribute. Absent = never expires |
| code | |
|---|---|
| day | UTC day bucket, e.g. 2026-07-08 |
| clicks | ADD-ed per analytics batch |
| referrers | JSON map of top referrers -> count |
| countries | JSON map of ISO country -> count (from CloudFront-Viewer-Country) |
| domain | e.g. go.acme.com |
|---|---|
| owner_id | |
| status | pending_dns | verified | failed |
| dns_target | CNAME target the customer must create |
| acm_cert_arn | set once DNS validation completes |
| created_at |
About this design
What this is
A serverless URL shortener with the shape of a real product: a hot-path redirect Lambda in front of DynamoDB, TTL-based link expiry, asynchronous click analytics over SQS, a Cognito-protected management API, and optional custom short domains. A React admin SPA (served from S3 via CloudFront) is where links get created; following a link needs no account and touches almost nothing.
The design optimises exactly one thing: GET /{code} must be one read and one 301. Everything else — counting, rollups, ownership, expiry — is arranged so it never adds latency to that path.
How it works
Redirect hot path — CloudFront forwards /{code} to API Gateway (no authorizer), the Redirect Fn does a single GetItem on the links table (PK = code), sends a link.clicked message to SQS without awaiting the result, and returns 301 Location: long_url. Missing codes return 404; TTL-expired ones 410.
Expiry — expires_at is the DynamoDB TTL attribute. Expired links are deleted by DynamoDB itself; the Redirect Fn also checks the timestamp so links that expired seconds ago (TTL deletion lags) still return 410.
Analytics — the Analytics Fn consumes SQS batches (up to 100 clicks), aggregates per code per UTC day, and issues a few UpdateItem ADDs: total clickcount on the link plus a clicksdaily rollup row with referrer/country maps. Stats queries never scan raw events. A DLQ catches poison messages.
Management — /links and /domains sit behind a Cognito JWT authorizer. Creating a link is a conditional Put (attributenotexists(code)), so custom-code collisions surface as 409 instead of silent overwrites. GSI1 (ownerid, createdat) lists "my links" newest first.
Custom domains — a domains row tracks DNS verification and the ACM cert; verified domains attach to CloudFront as aliases, and codes carry a domain attribute.
The sequence diagram traces the redirect hot path end to end, including the async aggregation leg; the event flow shows the link.clicked contract.
How to extend
Shave the hot path further: move redirects to CloudFront Functions/Lambda@Edge with DynamoDB global tables, or add short-TTL CloudFront caching per code.
Richer analytics: tee link.clicked to Kinesis Firehose → S3 and query raw events with Athena; add bot filtering before counting.
Product features: QR codes per link, password-protected links, per-owner rate limits (WAF), link preview/unfurl metadata.
Abuse controls: check long_url against Safe Browsing on create, and add a disabled flag the Redirect Fn honours.