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:
[!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.
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
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");
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
});
To run the application:
bun server.ts
Without any frameworks, we’ve created:
This example demonstrates that for many applications, you don’t need complex frameworks. The standard web platform provides powerful tools like:
While this approach works well for smaller applications, frameworks do offer benefits:
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:
Remember: frameworks are tools, not requirements. The best developers know when to use them—and when not to.