What you will build
By the end of this tutorial you will have an HTTP API that accepts any URL, returns a short code (for example V1StGXR8), and redirects anyone who visits that code back to the original link. Three routes do the work: one to create a short link, one to redirect, and one to read how many times a link was used. The data lives in a SQLite file on disk, so your links are still there after you restart the server.
Why this project: it is small enough to finish in one sitting, but it touches three skills that show up in every backend role. You define routes, you read and write a database, and you return the right HTTP status codes (201 for created, 301 for a redirect, 404 for a code that does not exist). A URL shortener is also a real tool, not a toy. You can deploy it on a free tier and actually use it.
Plan for two to three hours if you follow along and test each route as you go. Every file you need is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone.
The stack, and why each piece is here
Four choices, each one the standard tool for its job:
Express is the HTTP framework most Node.js developers already know, so the routing reads the way real APIs do instead of a hand-rolled http.createServer. better-sqlite3 is the common way to use SQLite from Node (~1.2M downloads a week). Its API is synchronous, which sounds wrong for Node but is exactly right here: SQLite reads and writes are fast local file operations, so synchronous calls keep the code simple with no performance cost at this scale. nanoid generates the short codes; it is tiny, fast, and uses cryptographically secure random values, and it is the same generator used inside Vite, Next.js, and Prisma. TypeScript ties it together so the shape of a stored link is written down once and checked everywhere.
SQLite is the interesting pick. It is a database that runs inside your process, reading and writing a single file, with no separate server to install or connect to. That is why you can finish this tutorial without creating any accounts. It is a real database with SQL, indexes, and transactions, not a stand-in.
Section 1: project setup
Create a fresh project and install the packages. Express, better-sqlite3, and nanoid are the three runtime dependencies; TypeScript, tsx, and the @types/* packages are dev tooling for building and running the .ts files. nanoid ships its own types, so there is no @types/nanoid to add.
mkdir url-shortener
cd url-shortener
npm init -y
npm install express better-sqlite3 nanoid
npm install -D typescript tsx @types/node @types/express @types/better-sqlite3Add a tsconfig.json at the project root. This uses Node16 module resolution, which matches how Node runs ES modules: it is strict, and it expects relative imports to carry a .js extension (more on that when we hit the first import).
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
}Then open package.json, add "type": "module", and set these three scripts. dev runs the app straight from source and restarts on every save, build compiles to dist/, and start runs the compiled output the way a production host would:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}The three source files below all go in a src/ directory you create in the project root.
Section 2: the database module
Start with storage, because everything else depends on it. This module opens the SQLite file, creates the links table on first run if it is not already there, and exports three typed helpers so the routes never write SQL directly. Keeping the SQL in one file means the rest of the app deals in function calls, and if you later swap SQLite for Postgres, this is the only file that changes.
Create src/db.ts:
import Database from "better-sqlite3";
// The shape of one row in the links table. Written once, checked everywhere.
export interface Link {
short_code: string;
long_url: string;
hits: number;
created_at: string;
}
// Opens (or creates) the file on disk. Data persists across restarts.
const db = new Database("links.db");
// Run once on startup. IF NOT EXISTS makes it safe to run every time.
db.exec(`
CREATE TABLE IF NOT EXISTS links (
short_code TEXT PRIMARY KEY,
long_url TEXT NOT NULL,
hits INTEGER DEFAULT 0,
created_at TEXT
)
`);
// Prepared statements are compiled once and reused, which is both faster
// and safe against SQL injection: the ? placeholders bind values, never
// splice strings into the query.
const insertStmt = db.prepare(
"INSERT INTO links (short_code, long_url, created_at) VALUES (?, ?, ?)",
);
const findStmt = db.prepare("SELECT * FROM links WHERE short_code = ?");
const incrementStmt = db.prepare(
"UPDATE links SET hits = hits + 1 WHERE short_code = ?",
);
export function insertLink(shortCode: string, longUrl: string): void {
insertStmt.run(shortCode, longUrl, new Date().toISOString());
}
export function findByCode(shortCode: string): Link | undefined {
return findStmt.get(shortCode) as Link | undefined;
}
export function incrementHits(shortCode: string): void {
incrementStmt.run(shortCode);
}Two things worth noticing. The ? placeholders in the prepared statements are how you stay safe from SQL injection: better-sqlite3 binds each value as data, so a URL containing a quote or a semicolon can never change the query. And findByCode returns Link | undefined because better-sqlite3's .get() returns undefined when no row matches; the routes handle that case to send a 404. The created_at is stored as an ISO timestamp string, which sorts correctly and reads cleanly.
Section 3: the routes
Now the three routes, in an Express Router. Read the order carefully: /stats/:code is declared before the catch-all /:code redirect. Express matches routes top to bottom, and although these two do not actually collide (one has two path segments, the other has one), declaring the specific route before the general one is the habit that keeps you out of trouble as an app grows.
Create src/routes.ts:
import { Router, type Request, type Response } from "express";
import { nanoid } from "nanoid";
import { findByCode, incrementHits, insertLink } from "./db.js";
export const router = Router();
// Only accept real http(s) URLs. The built-in URL constructor throws on
// anything malformed, so a try/catch is the whole validation.
function isValidUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// POST /shorten: take { url }, store it, return a fresh short code.
router.post("/shorten", (req: Request, res: Response) => {
const { url } = req.body ?? {};
if (typeof url !== "string" || !isValidUrl(url)) {
res.status(400).json({ error: 'Send a valid URL in the "url" field.' });
return;
}
const shortCode = nanoid(8);
insertLink(shortCode, url);
const shortUrl = `${req.protocol}://${req.get("host")}/${shortCode}`;
res.status(201).json({ shortCode, shortUrl });
});
// GET /stats/:code: report where a code points and how often it was used.
router.get("/stats/:code", (req: Request, res: Response) => {
const link = findByCode(req.params.code);
if (!link) {
res.status(404).json({ error: "No link found for that code." });
return;
}
res.json({
shortCode: link.short_code,
longUrl: link.long_url,
hits: link.hits,
createdAt: link.created_at,
});
});
// GET /:code: look up the code, count the hit, and redirect to the original.
router.get("/:code", (req: Request, res: Response) => {
const link = findByCode(req.params.code);
if (!link) {
res.status(404).json({ error: "No link found for that code." });
return;
}
incrementHits(link.short_code);
res.redirect(301, link.long_url);
});The validation is the part people skip and regret. Passing the input to new URL(value) and catching the throw is a one-line way to reject not-a-url, javascript:alert(1), and empty strings before any of it reaches the database. The protocol check keeps it to http and https so nobody stores a file: or javascript: link. nanoid(8) gives an 8-character code; the redirect returns a real 301, which tells browsers and search engines the move is permanent.
Fill in the Node.js gaps first
New to Express routing or unsure how middleware fits together? freeCodeCamp's Back End Development and APIs certification teaches Express and routing from scratch for free, and our roundup at best free Node.js courses 2026 ranks the strongest options. Keep the Node.js cheat sheet open while you build.
Section 4: the app entry point
The entry point is short on purpose. It creates the Express app, adds the JSON body parser so req.body is a real object, mounts the router at the root, and starts listening. That is the whole file.
Create src/index.ts:
import express from "express";
import { router } from "./routes.js";
const app = express();
app.use(express.json());
app.use(router);
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`URL shortener listening on http://localhost:${port}`);
});One detail that trips up newcomers to ES modules: the imports read from "./routes.js" and from "./db.js" even though the files are routes.ts and db.ts. That .js suffix is the Node16 module convention, and writing it is what lets the same import work under tsx in development and under plain Node after you build to dist/.
Section 5: run it and test the three routes
Start the server in dev mode. It restarts automatically when you save a file:
npm run dev
# -> URL shortener listening on http://localhost:3000In a second terminal, walk through all three routes with curl. First, shorten a URL. The response includes the code and the full short URL:
curl -X POST http://localhost:3000/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/very-long-path"}'
# -> {"shortCode":"V1StGXR8","shortUrl":"http://localhost:3000/V1StGXR8"}Copy the shortCode from that response into the next two commands. Visiting the code follows the redirect to the original URL (the -L flag tells curl to follow it):
# Redirect: -L follows the 301 to the original URL
curl -L http://localhost:3000/V1StGXR8
# Stats: how many times the code has been used
curl http://localhost:3000/stats/V1StGXR8
# -> {"shortCode":"V1StGXR8","longUrl":"https://example.com/very-long-path","hits":1,"createdAt":"2026-07-24T10:00:00.000Z"}The hits count went up because the redirect route called incrementHits before sending you on. Now stop the server with Ctrl-C and start it again with npm run dev, then run the stats command once more. The link is still there, because SQLite wrote it to links.db on disk. That persistence is the whole reason to reach for a database instead of an in-memory array.
Section 6: what to try next
You have a working shortener. A few small additions turn it into something you would run for real. Add rate limiting with express-rate-limit so one client cannot flood the /shorten route. Accept an optional alias field so users can pick their own code (insertLink already takes any string, so you mostly add validation and a duplicate check). Add an expires_at column and skip links past their date. Or put a minimal HTML form in front of the API so non-developers can use it.
For patterns you can carry straight over (typed handlers, validation, status codes), our tutorial at /tutorials/build-rest-api-express-typescript builds a CRUD REST API with the same Express and TypeScript stack. If you want the course path around this kind of work, /learn/backend sequences the free options in order.
Frequently asked questions
Why SQLite and not Postgres?
SQLite runs in-process with no server to set up, which means you can finish this tutorial without creating any accounts. It is a real database (SQL, indexes, transactions), just one that lives in a single file. When you are ready for a cloud-hosted database, our guide at /guides/how-to-learn-data-science-for-free and the free-Postgres options around Supabase cover the next step. The db.ts module is the only file you would change to switch.
Why Express instead of a newer Node.js framework?
Express is the framework covered in most free Node.js courses, including freeCodeCamp's Back End certification. The patterns here (a router, middleware, typed handlers) transfer directly to Fastify or Hono, so learning Express first costs you nothing and means the code reads the way most existing Node.js codebases do.
Is nanoid safe for generating short codes?
Yes. nanoid uses cryptographically secure random values, not Math.random. An 8-character nanoid gives roughly one quadrillion possible IDs before a collision becomes likely, which is far more headroom than any personal project needs. If you ran this at massive scale you would add a uniqueness check on insert, but for a normal shortener the odds of a clash are negligible.
Can I deploy this for free?
Yes. Railway, Render, and Fly.io all have free tiers that run a single-process Node.js app. SQLite persists fine on any single-instance deploy. If you later need multiple instances or managed backups, swap the db.ts module for Postgres and update the three queries; the routes stay the same.