What We Are Going to Build
In this workshop, I am going to walk you through building a full-stack creature catalog — a filterable, searchable UI backed by a REST API — using nothing but vanilla JavaScript on the frontend. No React. No Vue. No build step.
By the end, you will have:
- A clean REST API contract you can test with
curl - A vanilla JS frontend that fetches, renders, and filters data
- A clear architecture boundary between client and server
- A pattern you can reuse for every MVP you ship from here on out
Let me show you a pattern I use in every project, and I think it will change how you think about frontend complexity at the MVP stage.
Prerequisites
- Basic JavaScript (ES6+ syntax: template literals,
async/await, destructuring) - Familiarity with REST concepts (GET, POST, query parameters)
- A backend in any language that can serve JSON (I will use examples against a generic
/apiendpoint — Spring Boot, Express, FastAPI, whatever you have works) - A modern browser (we are using built-in
fetch(), no polyfills needed)
Step 1: Understand Why We Are Skipping the Framework
Before we write code, let me ground this with numbers I have seen across real projects:
| Metric | Vanilla JS | React (CRA) | Next.js |
|---|---|---|---|
node_modules size |
0 KB | ~300 MB | ~400 MB |
| Initial bundle (gzipped) | 0–5 KB | ~45 KB | ~70 KB |
| Build tooling required | None | Webpack/Babel | Webpack/SWC |
| Time to first meaningful code | Minutes | 30–60 min | 30–60 min |
| Dependencies to audit | 0 | 1,400+ | 1,800+ |
For an MVP, your frontend needs to do four things: fetch data, render it, handle user input, and manage basic navigation. Every modern browser ships with APIs for all four. We are going to use them directly.
Step 2: Define Your API Contract First
Here is the gotcha that will save you hours: design your API before you touch the frontend. This is the single highest-leverage decision in an MVP.
Define your endpoints on paper or in a simple markdown file:
GET /api/creatures — List all creatures (supports query filters)
GET /api/creatures/:id — Get a single creature
POST /api/creatures — Create a creature
PUT /api/creatures/:id — Update a creature
DELETE /api/creatures/:id — Delete a creature
And define your response shape:
{
"id": 1,
"name": "Shadow Lynx",
"type": "Beast",
"description": "A nocturnal predator that phases through solid matter.",
"abilities": ["Phase Shift", "Night Vision", "Silent Step"],
"habitat": "Old Growth Forest"
}
The docs do not mention this, but the reason we lock down the contract first is that it makes your frontend disposable. If you decide to rebuild in React six months from now, your API does not change. If you add a mobile client, your API does not change. This is the real architecture win.
Step 3: Build the Data Fetching Layer
Here is the minimal setup to get this working. Create a single api.js file that wraps all your backend calls:
// api.js — thin wrapper around fetch()
const API_BASE = '/api';
export async function getCreatures(filters = {}) {
const params = new URLSearchParams(filters);
const response = await fetch(`${API_BASE}/creatures?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch creatures: ${response.status}`);
}
return response.json();
}
export async function getCreature(id) {
const response = await fetch(`${API_BASE}/creatures/${id}`);
if (!response.ok) {
throw new Error(`Creature not found: ${response.status}`);
}
return response.json();
}
export async function createCreature(data) {
const response = await fetch(`${API_BASE}/creatures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to create creature: ${response.status}`);
}
return response.json();
}
Notice what is not here: no Axios, no TanStack Query, no SWR. The fetch() API is well-supported and does exactly what we need. Every line is debuggable — set a breakpoint and you see exactly what goes over the wire.
Step 4: Render Data With Template Literals
This is where people assume they need JSX or a templating engine. They do not. Template literals handle this cleanly:
// render.js — pure functions that return HTML strings
export function renderCreatureCard(creature) {
return `
<div class="creature-card" data-id="${creature.id}">
<h3>${creature.name}</h3>
<span class="type-badge">${creature.type}</span>
<p>${creature.description}</p>
<div class="abilities">
${creature.abilities
.map(a => `<span class="ability">${a}</span>`)
.join('')}
</div>
</div>
`;
}
export function renderCreatureList(creatures) {
if (creatures.length === 0) {
return '<p class="empty-state">No creatures found. Try adjusting your filters.</p>';
}
return creatures.map(renderCreatureCard).join('');
}
Now wire it together in your main file:
// app.js
import { getCreatures } from './api.js';
import { renderCreatureList } from './render.js';
const creatureList = document.getElementById('creature-list');
async function loadCreatures(filters = {}) {
try {
const creatures = await getCreatures(filters);
creatureList.innerHTML = renderCreatureList(creatures);
} catch (error) {
creatureList.innerHTML = `<p class="error">Something went wrong. ${error.message}</p>`;
}
}
// Initial load
loadCreatures();
Your HTML file is minimal — just a shell with an element to render into:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bestiario</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Bestiario</h1>
<form id="filter-form">
<select name="type">
<option value="">All Types</option>
<option value="Beast">Beast</option>
<option value="Spirit">Spirit</option>
<option value="Elemental">Elemental</option>
</select>
<input type="text" name="name" placeholder="Search by name...">
<button type="submit">Filter</button>
</form>
<div id="creature-list"></div>
<script type="module" src="app.js"></script>
</body>
</html>
No build step. Open the file through your backend server and it works.
Step 5: Handle User Input With Event Delegation
Add filtering by listening for form submissions:
// Add to app.js
document.getElementById('filter-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const filters = Object.fromEntries(formData.entries());
// Remove empty values so we don't send ?type=&name= to the API
Object.keys(filters).forEach(key => {
if (!filters[key]) delete filters[key];
});
loadCreatures(filters);
});
For card clicks, use event delegation instead of attaching listeners to each card:
creatureList.addEventListener('click', (e) => {
const card = e.target.closest('.creature-card');
if (!card) return;
const id = card.dataset.id;
// Navigate to detail view, open a modal, whatever your MVP needs
console.log(`Selected creature ${id}`);
});
This pattern scales to hundreds of cards without creating hundreds of listeners. The browser handles it natively.
Gotchas and Common Mistakes
1. XSS via innerHTML. If any of your creature data comes from user input, you must sanitize it before injecting with innerHTML. Use a helper or switch to textContent for user-generated strings:
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Use escapeHtml(creature.name) in your templates anywhere user-supplied data appears. This is non-negotiable.
2. Forgetting Content-Type on POST/PUT requests. Your API will likely return a 415 or silently fail if you omit headers: { 'Content-Type': 'application/json' }. I have seen this waste an hour of debugging.
3. Building a SPA router too early. For an MVP, separate HTML pages or a simple hash-based approach works fine. Do not reach for history.pushState routing until you actually have multiple views that need it.
4. Over-abstracting the fetch layer. Resist the urge to build a generic HTTP client class with interceptors and retry logic on day one. The three functions we wrote in api.js are enough. Add complexity when you hit a real problem, not before.
5. Not defining the upgrade trigger. Write down when you will adopt a framework: "When we exceed 20 interactive components," or "When we add real-time collaboration." Without this, framework adoption becomes an emotional decision instead of an engineering one.
Conclusion
Here is what we built: a full-stack, filterable catalog with a clean API layer, vanilla rendering, and event handling — all in under 100 lines of JavaScript with zero dependencies.
The architecture boundary is explicit. Your API serves JSON. Your frontend consumes it with fetch(). If you decide next month that you need React, you swap the frontend and the backend does not change. If you need a mobile app, the API is already waiting.
Treat your frontend as disposable and your API as durable. That is the pattern that ships MVPs fast and does not punish you when requirements change.
Now go build something. Start with fetch(), a template literal, and an addEventListener. You will be surprised how far it takes you.
Further reading:
Top comments (0)