You don't need a JS framework, probably

A practical exploration of building web applications without deps, showing when you might not need complex JavaScript frameworks.

January 4, 2025

Typescript
Bun

In the last years, the web development world has been dominated by #Javascript frameworks.

Of course we all know Next Js, Svelte, Angular and all those Full Stack / Front end frameworks that are out there.

What about #bakcend?

Express.js in this case has been dominating the backend world for a while now. New ones comes out, like Hono, a very promising one, there's also Elysia for Bun, seems very interesting too.

The question is:

Do we really need all of this?

[!INFO] >TL;DR: No.

Let's see how far we can get without using any of these frameworks.

For personal preference I would go with #bun. It's a very simple and easy to use framework that doesn't require any setup at all.

Plus I much prefer Typescript instead of Javascript, so Bun support it natively...I know, I know, I can know use Typescript with Node.js too, but who cares? Bun is written in Zig, and that's cool.

For the sake of this article I'll just do a basic todo app whit some real time feature to spicy it up.

Setting Up Our Project

Let's start with a minimal setup:

mkdir vanilla-todo-app
cd vanilla-todo-app
bun init

This gives us a basic project structure. Now, let's create our files:

/vanilla-todo-app
  |- public/
     |- index.html
     |- style.css
     |- client.js
  |- server.ts
  |- tsconfig.json

The Backend Without Frameworks

Here's our minimal server implementation:

// server.ts
import { serve } from "bun";
import { join } from "path";
import { readFileSync } from "fs";
 
// In-memory todo store
const todos = new Map<
	string,
	{ id: string; text: string; completed: boolean }
>();
 
// WebSocket connections
const connections = new Set<any>();
 
serve({
	port: 3000,
	fetch(req) {
		const url = new URL(req.url);
 
		// Serve static files
		if (url.pathname === "/" || url.pathname === "/index.html") {
			return new Response(
				readFileSync(join(import.meta.dir, "public/index.html")),
				{
					headers: { "Content-Type": "text/html" },
				}
			);
		}
 
		if (url.pathname === "/style.css") {
			return new Response(
				readFileSync(join(import.meta.dir, "public/style.css")),
				{
					headers: { "Content-Type": "text/css" },
				}
			);
		}
 
		if (url.pathname === "/client.js") {
			return new Response(
				readFileSync(join(import.meta.dir, "public/client.js")),
				{
					headers: { "Content-Type": "application/javascript" },
				}
			);
		}
 
		// API endpoints
		if (url.pathname === "/api/todos" && req.method === "GET") {
			return Response.json(Array.from(todos.values()));
		}
 
		if (url.pathname === "/api/todos" && req.method === "POST") {
			const todo = await req.json();
			const id = crypto.randomUUID();
			const newTodo = { id, text: todo.text, completed: false };
			todos.set(id, newTodo);
 
			// Notify all clients
			broadcast({ type: "add", todo: newTodo });
 
			return Response.json(newTodo);
		}
 
		if (url.pathname.startsWith("/api/todos/") && req.method === "PUT") {
			const id = url.pathname.split("/").pop();
			const updates = await req.json();
			const todo = todos.get(id);
 
			if (!todo) {
				return new Response("Not found", { status: 404 });
			}
 
			const updatedTodo = { ...todo, ...updates };
			todos.set(id, updatedTodo);
 
			broadcast({ type: "update", todo: updatedTodo });
 
			return Response.json(updatedTodo);
		}
 
		if (url.pathname.startsWith("/api/todos/") && req.method === "DELETE") {
			const id = url.pathname.split("/").pop();
 
			if (!todos.has(id)) {
				return new Response("Not found", { status: 404 });
			}
 
			todos.delete(id);
			broadcast({ type: "delete", id });
 
			return new Response(null, { status: 204 });
		}
 
		// WebSocket
		if (url.pathname === "/ws") {
			const upgraded = Bun.upgradeWebSocket(req);
 
			if (!upgraded.success) {
				return new Response("WebSocket upgrade failed", { status: 400 });
			}
 
			const { socket } = upgraded;
 
			socket.addEventListener("open", () => {
				connections.add(socket);
			});
 
			socket.addEventListener("close", () => {
				connections.delete(socket);
			});
 
			return upgraded.response;
		}
 
		return new Response("Not found", { status: 404 });
	},
});
 
// Broadcast to all WebSocket clients
function broadcast(message: any) {
	const data = JSON.stringify(message);
	for (const socket of connections) {
		socket.send(data);
	}
}
 
console.log("Server running at http://localhost:3000");

The Frontend Without Frameworks

Now for our HTML:

<!-- public/index.html -->
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta
			name="viewport"
			content="width=device-width, initial-scale=1.0" />
		<title>Vanilla Todo App</title>
		<link
			rel="stylesheet"
			href="/style.css" />
	</head>
	<body>
		<div class="container">
			<h1>Real-time Todo App</h1>
			<p><em>No frameworks were harmed in the making of this app</em></p>
 
			<form id="todo-form">
				<input
					type="text"
					id="todo-input"
					placeholder="Add a new task..."
					required />
				<button type="submit">Add</button>
			</form>
 
			<ul id="todo-list"></ul>
		</div>
 
		<script src="/client.js"></script>
	</body>
</html>

Our CSS:

/* public/style.css */
* {
	box-sizing: border-box;
}
 
body {
	font-family: system-ui, sans-serif;
	line-height: 1.6;
	color: #333;
	max-width: 600px;
	margin: 0 auto;
	padding: 20px;
}
 
.container {
	background-color: #f9f9f9;
	border-radius: 8px;
	padding: 20px;
	box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
 
h1 {
	margin-top: 0;
	color: #444;
}
 
#todo-form {
	display: flex;
	margin-bottom: 20px;
}
 
#todo-input {
	flex: 1;
	padding: 10px;
	border: 1px solid #ddd;
	border-radius: 4px 0 0 4px;
	font-size: 16px;
}
 
button {
	padding: 10px 15px;
	background-color: #4a90e2;
	color: white;
	border: none;
	border-radius: 0 4px 4px 0;
	cursor: pointer;
	font-size: 16px;
}
 
button:hover {
	background-color: #357ac7;
}
 
#todo-list {
	list-style: none;
	padding: 0;
}
 
.todo-item {
	display: flex;
	align-items: center;
	padding: 10px;
	border-bottom: 1px solid #eee;
	animation: fadeIn 0.3s ease-in-out;
}
 
@keyframes fadeIn {
	from {
		opacity: 0;
		transform: translateY(-10px);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}
 
.todo-item.completed .todo-text {
	text-decoration: line-through;
	color: #999;
}
 
.todo-item input[type="checkbox"] {
	margin-right: 10px;
}
 
.todo-text {
	flex: 1;
}
 
.delete-btn {
	background-color: #e74c3c;
	color: white;
	border: none;
	border-radius: 4px;
	padding: 5px 10px;
	cursor: pointer;
	font-size: 14px;
}
 
.delete-btn:hover {
	background-color: #c0392b;
}

Finally, our client-side JavaScript:

// public/client.js
document.addEventListener("DOMContentLoaded", () => {
	const todoForm = document.getElementById("todo-form");
	const todoInput = document.getElementById("todo-input");
	const todoList = document.getElementById("todo-list");
 
	// Connect to WebSocket
	const ws = new WebSocket(`ws://${window.location.host}/ws`);
 
	ws.addEventListener("message", (event) => {
		const message = JSON.parse(event.data);
 
		switch (message.type) {
			case "add":
				addTodoToDOM(message.todo);
				break;
			case "update":
				updateTodoInDOM(message.todo);
				break;
			case "delete":
				removeTodoFromDOM(message.id);
				break;
		}
	});
 
	// Initial load of todos
	fetchTodos();
 
	// Form submission
	todoForm.addEventListener("submit", async (e) => {
		e.preventDefault();
 
		const text = todoInput.value.trim();
		if (!text) return;
 
		await createTodo(text);
		todoInput.value = "";
	});
 
	// Event delegation for todo list actions
	todoList.addEventListener("click", async (e) => {
		const todoItem = e.target.closest(".todo-item");
		if (!todoItem) return;
 
		const id = todoItem.dataset.id;
 
		if (e.target.classList.contains("delete-btn")) {
			await deleteTodo(id);
		} else if (e.target.type === "checkbox") {
			await updateTodo(id, { completed: e.target.checked });
		}
	});
 
	async function fetchTodos() {
		const response = await fetch("/api/todos");
		const todos = await response.json();
 
		todoList.innerHTML = "";
		todos.forEach((todo) => {
			addTodoToDOM(todo);
		});
	}
 
	async function createTodo(text) {
		const response = await fetch("/api/todos", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ text }),
		});
 
		return await response.json();
	}
 
	async function updateTodo(id, updates) {
		const response = await fetch(`/api/todos/${id}`, {
			method: "PUT",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify(updates),
		});
 
		return await response.json();
	}
 
	async function deleteTodo(id) {
		await fetch(`/api/todos/${id}`, { method: "DELETE" });
	}
 
	function addTodoToDOM(todo) {
		const todoItem = document.createElement("li");
		todoItem.className = `todo-item ${todo.completed ? "completed" : ""}`;
		todoItem.dataset.id = todo.id;
 
		todoItem.innerHTML = `
      <input type="checkbox" ${todo.completed ? "checked" : ""}>
      <span class="todo-text">${escapeHtml(todo.text)}</span>
      <button class="delete-btn">Delete</button>
    `;
 
		todoList.appendChild(todoItem);
	}
 
	function updateTodoInDOM(todo) {
		const todoItem = document.querySelector(`.todo-item[data-id="${todo.id}"]`);
		if (!todoItem) return;
 
		todoItem.className = `todo-item ${todo.completed ? "completed" : ""}`;
		todoItem.querySelector('input[type="checkbox"]').checked = todo.completed;
		todoItem.querySelector(".todo-text").textContent = todo.text;
	}
 
	function removeTodoFromDOM(id) {
		const todoItem = document.querySelector(`.todo-item[data-id="${id}"]`);
		if (todoItem) {
			todoItem.remove();
		}
	}
 
	function escapeHtml(unsafe) {
		return unsafe
			.replace(/&/g, "&amp;")
			.replace(/</g, "&lt;")
			.replace(/>/g, "&gt;")
			.replace(/"/g, "&quot;")
			.replace(/'/g, "&#039;");
	}
});

Running Our App

To run the application:

bun server.ts

What Did We Accomplish?

Without any frameworks, we've created:

  1. A RESTful API for CRUD operations
  2. Real-time updates via WebSockets
  3. Client-side DOM rendering
  4. Static file serving

This example demonstrates that for many applications, you don't need complex frameworks. The standard web platform provides powerful tools like:

  • Fetch API
  • WebSockets
  • Modern DOM manipulation
  • ES modules
  • CSS animations

When Do Frameworks Make Sense?

While this approach works well for smaller applications, frameworks do offer benefits:

  • Standardized architecture for larger teams
  • Built-in solutions for common problems (routing, state management)
  • Development velocity for complex applications
  • Performance optimizations out of the box

Conclusion

The next time you start a project, consider whether you really need a framework. For many applications, vanilla JavaScript with a minimal server can provide everything you need with fewer dependencies, smaller bundle sizes, and a simpler mental model.

Using the platform directly helps you:

  1. Better understand how the web actually works
  2. Create leaner applications with fewer dependencies
  3. Avoid framework lock-in and upgrade fatigue
  4. Improve performance with less overhead

Remember: frameworks are tools, not requirements. The best developers know when to use them—and when not to.