Building a Homelab Dashboard in One File — The Design and Dilemmas of a Single-file SPA
2026-03-29 | techsfree-web
When building a homelab dashboard, the first question is what stack to use. Go proper with React + Vite? Consider SSR with Next.js? Or… put everything in a single HTML file?
We went with the last option. The result: index.html surpassed 1,100 lines.
Why Single-file SPA?
The reason is simple: deployment is a single cp command.
Anyone who's run something in a "production" environment — even a home server — knows the panic when the build pipeline breaks. node_modules missing, unlocked dependencies, wrong paths… every time that happens, you think: "does this dashboard really need all that?"
Run Flask as a backend, serve the HTML from /. Load Vue and Chart.js from a CDN. That's a fully functional SPA. The lightness of managing it with a single systemd service is ideal for a homelab running 24/7.
Keeping 1,000+ Lines Maintainable
The problem is scale. Every new feature bloats the file. Before I knew it, index.html had crossed 1,100 lines.
The reason it's still maintainable: organizing code by view.
<!-- ===== VIEW: overview ===== -->
<div id="view-overview" class="view">
...
</div>
<!-- ===== VIEW: nodes ===== -->
<div id="view-nodes" class="view">
...
</div>
The JavaScript follows the same pattern — a showView(name) function switches between all views. The active view gets display: block, everything else display: none. No router needed.
function showView(name) {
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById(`view-${name}`).style.display = 'block';
localStorage.setItem('dashboardCurrentView', name);
// Initialize each view
if (name === 'nodes') loadNodes();
if (name === 'overview') loadOverview();
}
localStorage preserves the current view across page reloads. Subtle, but useful.
The Flask Integration Pattern
The backend is Flask. Define simple API endpoints, call them from the frontend with fetch(). That's it.
@app.route('/api/nodes-status')
def api_nodes_status():
# Retrieve node status via SSH
...
return jsonify(result)
Frontend:
async function loadNodes() {
const res = await fetch('/api/nodes-status');
const data = await res.json();
renderNodes(data);
}
No CORS configuration needed — it runs on the same origin. That simplicity is the strength of the single-file approach.
When Complexity Creeps In
When bot creation and deletion required 16 steps of async processing, I started feeling the limits of the single-file SPA.
Step progress display, error handling, choosing between SSE and polling… I ended up solving all of it within the "one file" constraint.
The solution: a modal dialog with a log display area, appending each step's result to a <div>. No WebSocket, no SSE — just fetch + 120ms delay animations. It creates enough of a real-time feel.
Things I Deliberately Dropped
- Build step: Unnecessary. CDN is fine.
- TypeScript: Autocomplete would be nice, but writing type definitions in a single file has little upside.
- Component architecture: The Vue SFC temptation was real, but SFCs need a build step. Settled for template literals.
- Tests: …I haven't written any. Sorry.
When to Graduate
Honestly, the single-file SPA will hit its limits eventually. If my dashboard gets another 2-3 views, I'll probably consider splitting it.
My rough thresholds:
- When
index.htmlexceeds 2,000 lines - When multiple people start editing it
- When I have the bandwidth to set up a CI/CD pipeline
Until then, I'll enjoy the simplicity of "it runs in one file." Homelab should be fun.
Summary
| Aspect | Single-file SPA | Modern Framework |
|---|---|---|
| Deployment | Single cp
|
Build pipeline required |
| Dependency management | CDN only | node_modules to manage |
| Dev experience | Simple, no type hints | Full IDE support |
| Scale ceiling | ~2,000 lines | Unlimited |
| Best for | Personal / homelab | Team dev / production |
"Can you use this in production?" — honestly, questionable. But for a homelab where you're the only user and you fix it yourself when it breaks, this tradeoff is entirely rational.
Top comments (0)