Zero Dependencies: Build a URL Shortener with node:sqlite and Nothing Else

Jul 5, 2026

A step-by-step tutorial building a complete URL shortener with Node 24's built-in SQLite, HTTP server, crypto, and test runner - no npm install required.

Last weekend I deleted a node_modules folder that weighed 213 MB. The app it belonged to was a URL shortener. A URL shortener. Express, better-sqlite3, nanoid, dotenv, nodemon, jest, and the several hundred transitive packages they drag in, all so I could turn long strings into short strings. Here's the thing: in 2026, with Node 24 sitting comfortably in LTS, every single one of those packages is replaceable by something already inside the runtime. node:sqlite ships unflagged (still labeled 'active development' in the docs, but I've been running it in small production apps since last autumn without a single issue). The HTTP server has been there since 2009. crypto.randomBytes replaces nanoid. --env-file replaces dotenv. --watch replaces nodemon. node --test replaces jest. After a year of npm supply-chain incidents making everyone nervous about their lockfiles, the strongest security posture for a small service is the one with nothing to compromise. So this is the node:sqlite tutorial I wish existed: we're going to build a complete, tested URL shortener with redirects, hit counting, input validation, and environment config. Total dependency count: zero. Total files: two, and one of them is the test. You need Node 24 or later, and that's it. No package.json unless you want one for the scripts.

server.mjs
import { createServer } from "node:http";

function json(res, status, data) {
  res.writeHead(status, { "content-type": "application/json" });
  res.end(JSON.stringify(data));
}

const server = createServer((req, res) => {
  json(res, 200, { ok: true });
});

server.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});
server.mjs

Start with a bare HTTP server

No Express, no Fastify. node:http is verbose compared to a framework, but for a service with two routes the difference is a dozen lines, and those lines don't come with a dependency tree. The json helper is the one piece of ceremony worth writing up front: it sets the content-type header and serializes in one call, so every route stays a one-liner. Save this as server.mjs, run node server.mjs, and hit http://localhost:3000 to see the JSON response. The .mjs extension gives us ES modules without any config, which matters because we have no package.json to set "type": "module" in.

Open a database with node:sqlite

This is the headline act. DatabaseSync from node:sqlite opens (or creates) a SQLite file with zero configuration and zero native compilation. If you've used better-sqlite3, the API will feel instantly familiar: it's synchronous, which sounds wrong for Node until you remember SQLite reads are microseconds and better-sqlite3 proved the sync model years ago. db.exec runs raw SQL, perfect for schema setup. The IF NOT EXISTS clause makes the script idempotent, so restarting the server never clobbers data. Note what didn't happen here: no npm install, no node-gyp compiling C++ for ten minutes, no prebuilt-binary download failing on Alpine. The engine is compiled into the Node binary you already have.

Create short links with prepared statements and crypto

Two more built-ins replace two more packages. randomBytes(4).toString("base64url") gives us a 6-character URL-safe code with 4 billion possible values, which is nanoid's job done in one line of stdlib. And db.prepare gives us a compiled, parameterized statement: the ? placeholders mean user input never touches the SQL string, so injection is off the table by construction. The readBody helper is the part frameworks usually hide from you. Node request objects are async iterables, so collecting the body is a three-line for await loop. The POST route validates the URL by just constructing new URL(url) and catching the throw, which is more reliable than any regex you'd copy off Stack Overflow. Test it: curl -X POST localhost:3000/links -d '{"url":"https://nodejs.org"}'.

Redirect and count hits

The read path. getLink.get(code) returns the matching row as a plain object, or undefined if the code doesn't exist, so the not-found check is a truthiness test. On a hit we bump the counter with a second prepared statement and answer with a 302 plus a location header, and the browser does the rest. A note on the sync API, because someone always asks: yes, getLink.get() blocks the event loop. For SQLite that block is measured in single-digit microseconds on a warm page cache, which is thousands of times shorter than one network round trip to Postgres. For read-heavy workloads like a shortener, this architecture will comfortably outrun a networked database. If you genuinely need to serve tens of thousands of writes per second, you've outgrown this post, not SQLite.

Harden it: body limits and real error handling

Frameworks handle two footguns silently, so if we're skipping the framework we own them explicitly. First: an unbounded request body. Someone can POST a gigabyte at your for await loop and watch your memory climb, so readBody now bails with a RangeError past 16 KB, which is generous for a JSON payload containing one URL. Second: unhandled throws. Malformed JSON makes JSON.parse throw a SyntaxError, and without a catch that kills the request with an ugly stack. The whole handler now sits in one try/catch that maps error types to status codes: 413 for oversized bodies, 400 for bad JSON, 500 for everything else. One catch block instead of error-handling middleware, and you can see exactly what it does.

Config without dotenv, reloads without nodemon

dotenv has hundreds of millions of downloads a month for functionality Node absorbed years ago. Create a .env file with PORT=8080, BASE_URL=https://s.example.com, and DB_PATH=/var/data/links.db, then start the server with: node --watch --env-file=.env server.mjs The --env-file flag loads your variables, and --watch restarts the process on file changes, so nodemon is gone too. Every value falls back to a sensible local default, so node server.mjs with no flags still works on a fresh clone. Bonus flag while you're at it: run with node --permission --allow-fs-read=. --allow-fs-write=. --env-file=.env server.mjs and the process is sandboxed to reading and writing the current directory only. If someone does find a hole in your 70 lines, they can't touch the rest of the filesystem. Try getting that guarantee from an Express app with 200 transitive dependencies.

server.test.mjs

Test it with node --test and built-in fetch

The last dependency to fall is the test framework. node:test has been stable since Node 20 and pairs with the global fetch that's been built in just as long. This is an end-to-end test: it creates a link through the real HTTP API, then follows it with redirect: "manual" so fetch hands us the raw 302 instead of chasing it, and asserts the location header points where we said. Start the server in one terminal, then run node --test in another. Node discovers any *.test.mjs files automatically, runs them, and prints TAP-style output with pass counts and timing. No jest.config.js, no transform pipeline, no 30-second cold start while a test framework bootstraps itself. The assert module's strict mode gives you the deep-equality and message quality you'd expect from any modern assertion library. And that's the whole project: two files, zero packages, one runtime.

That's a complete service. Persistence, validation, config, tests. Run wc -l on it and you'll get about 70 lines of application code, and npm ls would return nothing because there's no npm involved at all. I'm not arguing you should build your next 40-endpoint API this way. Once you need migrations, auth middleware, and OpenAPI specs, frameworks earn their keep. But the threshold where dependencies become worth it has moved way up, and most of us haven't updated our instincts. The default Node project template in your head probably dates from 2019, when you genuinely needed a package for UUIDs and a package for reading .env files. Those days are over and the muscle memory should go with them. The part that changed my behavior most isn't even the convenience. It's that a zero-dependency service has no Dependabot noise, no weekly audit warnings, no risk that a maintainer's compromised npm token ships malware into my build. The attack surface of this app is Node itself and my 70 lines. I can actually read all of it. When was the last time you could say that about anything you deployed?

#code#tutorials