Building internal tools often starts with frustration. For our team, it began with two recurring problems: the opaque permissions of our project management system and the sluggish interface that made even small tasks feel like a chore. We needed a tool that could combine project management and operational reporting. Something that allowed us to track work, understand priorities, and maintain context without fighting the tool itself.
At first, I tried to bend existing platforms to our will. Jira, for example, is incredibly powerful, but inviting someone to a project would always bring a question: What can they actually do? Permissions were layered and opaque, and only after someone tried to act did we realize the gaps. Even beyond permissions, the interface was heavy. It had more features than we needed, and navigating it slowed us down rather than helping. I realized that if I wanted a tool that worked for us, we had to build it ourselves.
A self-imposed constraint
I decided to take on a challenge: build the tool using Node.js alone, without a single external dependency. That meant no Express, no ORMs, no frontend frameworks, not even a small utility library. Everything from the HTTP server to session management had to be built using Node’s standard library.
It wasn’t about proving if I can do it; it was about seeing how much clarity and speed I could achieve when I removed every layer of abstraction that wasn’t absolutely necessary.
Guiding principles
Before writing code, I thought carefully about the kind of tool we wanted:
- Every task must have an owner. Without ownership, work stalls.
- Nothing should stay in limbo. Each task follows a clear lifecycle.
- Current focus should be visible. Users need to know what matters now.
- Progress should be observable without meetings. Status updates must live in the tool, not in extra reports.
- The interface should stay out of the way. Interactions should be effortless.
- Stale work must be visible. Tasks that linger too long should surface automatically.
- Context must be preserved. Requirements, discussions, and files belong together.
- The system is a tool, not a process. Flexibility is important, but clarity always comes first.
We also decided upfront what the tool would not do: no Gantt charts, no Slack or Zoom integrations, no AI features, no deep hierarchies. By cutting out complexity, we could focus on what really mattered: clarity, ownership, and speed.
Architecture: simplicity first
At its core, the system is straightforward: a request comes in, a router determines the correct handler, the handler generates a template, and HTML is sent back. That’s it. And yet, simplicity here masks some clever engineering decisions.
Core runtime
The HTTP server is built directly on Node’s http module. It handles routing, request parsing, and response generation. For example, the custom router maps routes to handlers using a small trie, allowing parameterized paths and wildcards:
class TrieNode {
constructor() {
this.children = new Map();
this.handler = undefined;
this.isParam = false;
this.paramName = undefined;
this.isWildcard = false;
}
}
class Router {
constructor() {
this.routes = new Map();
this.notFoundHandler = async () => ({ type: "html", data: "", status: 404 });
this._matchCache = new Map();
this._matchCacheMax = 1024;
}
addRoute(method, path, handler) {
if (!this.routes.has(method)) {
this.routes.set(method, new TrieNode());
}
const root = this.routes.get(method);
const parts = path.split("/").filter(Boolean);
let currentNode = root;
for (const part of parts) {
if (part.startsWith(":")) {
if (!currentNode.children.has(":")) {
const paramNode = new TrieNode();
paramNode.isParam = true;
paramNode.paramName = part.slice(1);
currentNode.children.set(":", paramNode);
}
currentNode = currentNode.children.get(":");
continue;
}
if (part === "*") {
const wildcardNode = new TrieNode();
wildcardNode.isWildcard = true;
currentNode.children.set("*", wildcardNode);
currentNode = wildcardNode;
break;
}
if (!currentNode.children.has(part)) {
currentNode.children.set(part, new TrieNode());
}
currentNode = currentNode.children.get(part);
}
currentNode.handler = handler;
if (this._matchCache.size) this._matchCache.clear();
}
matchRoute(method, pathname) {
const cacheKey = method + ":" + pathname;
const cached = this._matchCache.get(cacheKey);
if (cached) return cached;
const root = this.routes.get(method);
let res = { handler: this.notFoundHandler, params: {} };
if (!root) {
this._cacheResult(cacheKey, res);
return res;
}
const parts = pathname.split("/").filter(Boolean);
const params = {};
let currentNode = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
let nextNode = currentNode.children.get(part);
if (nextNode) {
currentNode = nextNode;
continue;
}
nextNode = currentNode.children.get(":");
if (nextNode) {
currentNode = nextNode;
params[currentNode.paramName] = decodeURIComponent(part);
continue;
}
nextNode = currentNode.children.get("*");
if (nextNode) {
currentNode = nextNode;
params.wildcard = parts.slice(i).join("/");
break;
}
this._cacheResult(cacheKey, res);
return res;
}
if (currentNode.handler) {
res = { handler: currentNode.handler, params };
}
this._cacheResult(cacheKey, res);
return res;
}
_cacheResult(key, res) {
if (this._matchCache.size >= this._matchCacheMax) {
const first = this._matchCache.keys().next().value;
if (first) this._matchCache.delete(first);
}
this._matchCache.set(key, res);
}
}
export default Router;
This small router gave us the flexibility of a framework like Express but without the overhead or hidden magic.
Data layer
For persistence, I leaned on SQLite through Node’s built-in support. Rather than use an ORM, I wrote a thin wrapper to manage queries, transactions, and result mapping. This kept the data layer explicit and predictable. Each query could be traced easily, which made debugging far simpler than in systems where abstractions obscure what’s happening under the hood.
Application layer
The business logic is organized into discrete handlers: authentication, tasks, dashboards, projects, reports, search, and settings. Each handler has one responsibility: process input and render output. This small, deliberate design meant that the system could scale in features without becoming tangled.
Frontend without frameworks
The UI is server-rendered with HTML templates and CSS. We use partial rendering to update small sections without a full page reload, keeping the frontend extremely lightweight. Here’s a simplified example:
function partialRenderer() {
const iframe = document.querySelector("iframe");
if (!iframe) return;
function onLoad() {
const l = iframe.contentWindow.location;
document.querySelector(l.hash || null)?.replaceWith(...iframe.contentDocument.body.childNodes);
if (l.pathname !== "blank") {
window.history.replaceState({}, "", l.pathname + l.search);
}
}
iframe.addEventListener("load", onLoad);
}
and this is an example on how it’s being triggered:
<a target="partial" href="/projects/${project.id}/tasks/new#main">
<i class="feather icon-plus-square"></i>
<span class="font-semibold">Create task</span>
</a>
<!-- ... -->
<main id="main">${body}</main>
<iframe hidden name="partial"></iframe>
By letting the server do most of the work, the frontend remains fast and predictable.
Clever use of node built-ins
Node’s standard library handles everything: http for the server, crypto for password hashing, zlib for compression, fs and path for files, and SQLite for storage. With careful design, these built-ins support authentication, secure cookies, rate limiting, caching, file uploads, and safe static file serving. Without ever touching an external library.
For template rendering, we also optimized .map().join() patterns to reduce string concatenation overhead:
export function mapJoin(array, fn, separator = "") {
if (!array?.length) return "";
let result = fn(array[0], 0);
for (let i = 1; i < array.length; i++) {
result += separator + fn(array[i], i);
}
return result;
}Little touches like this mattered when every millisecond counted.
Solving real problems
Perhaps the most satisfying feature came from the intersection of email and task management. Work often arrived via email, and manually converting it into tasks was tedious. I integrated Google Tasks so that I could turn emails into actionable tasks with a single click. Suddenly, all work lived in one place, without hopping between apps or losing context.
Reflections
When I began, the goal was modest: could a production-ready tool be built in Node.js with zero dependencies? The answer turned out to be more than “yes.” The tool is fast, clear, and maintainable. It demonstrates that sometimes, the most powerful architecture is the one with the fewest moving parts. By stripping away frameworks, ORMs, and helper libraries, the experiment forced me to focus on outcomes, clarity, speed, and usability rather than on conventions or abstractions.
This project wasn’t just about Node.js or dependencies. It was about building a tool that actually works for humans, not just machines. And in that sense, the experiment succeeded far beyond my expectations.




