You don't need a JS framework, probably

6 min

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.