Skip to content
Back to Blog
Python FastAPI Architecture No Framework

Offline-First HTML Apps with a 30-Line FastAPI Sync Backend

Nur Ikhwan Idris · · 6 min read

I have a category of personal tools that I want to access from both my laptop and my phone, that need to remember state between sessions, that I don't want to spend an afternoon setting up a database and auth system for, and that should still work if my home server is unreachable.

The pattern I've settled on: a single HTML file with localStorage as the primary data store, and a tiny FastAPI endpoint that persists state to a JSON file on the server for cross-device sync. The HTML works fully offline. The sync happens silently in the background when the API is reachable. If it isn't, the app carries on using whatever's in localStorage.

No React. No database. No auth library. The whole backend is about 30 lines of Python.


When This Makes Sense

This pattern is not for every project. It's for a specific category: personal tools with simple state. Things that would be overkill to build as a proper full-stack app but that you actually want to use regularly.

Good candidates:

  • A habit tracker or daily checklist
  • A personal budget or expense tracker
  • A reading list or bookmark manager
  • A workout log
  • Any "dashboard" that you're the only user of

The state for these tools is typically a single JSON blob — an array of items, a map of settings, a list of checkboxes. That maps perfectly onto a JSON file on disk. You don't need PostgreSQL for this.


The Backend: 30 Lines of FastAPI

The entire backend is a GET and POST endpoint. GET returns the current state; POST replaces it. No authentication on the endpoint itself — that's handled at the nginx layer.

# api.py
import json
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Any

STATE_FILE = Path("/var/data/myapp/state.json")
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

class StatePayload(BaseModel):
    state: Any

@app.get("/api/state")
def get_state():
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text())
    return {}

@app.post("/api/state")
def save_state(payload: StatePayload):
    STATE_FILE.write_text(json.dumps(payload.state))
    return {"ok": True}

That's it. The state is whatever JSON your app produces — the endpoint doesn't care about structure. You can evolve the shape of your data without any migrations.

Running it with systemd

I use a bare systemd service rather than Docker for tools this small. No container overhead, no compose file to maintain, easier to journalctl when something goes wrong.

# /etc/systemd/system/myapp-api.service
[Unit]
Description=MyApp Sync API
After=network.target

[Service]
Type=simple
User=myuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8300
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
# Set up and start
python3 -m venv /opt/myapp/venv
/opt/myapp/venv/bin/pip install fastapi uvicorn

sudo systemctl daemon-reload
sudo systemctl enable --now myapp-api
sudo systemctl status myapp-api

Bound to 127.0.0.1:8300 — never directly exposed to the internet. nginx handles the public-facing side.

nginx: basic auth on the app, open proxy on the API path

The HTML app lives at the root and is protected by HTTP basic auth. The API path is proxied through without auth — the state data isn't sensitive, and adding auth to the JS fetch calls would require storing credentials in the HTML, which is worse.

# /etc/nginx/conf.d/myapp.conf
server {
    listen 443 ssl;
    server_name myapp.example.com;
    # ... ssl config ...

    root /var/www/myapp;
    index index.html;

    # Protect the UI with basic auth
    auth_basic "My App";
    auth_basic_user_file /etc/nginx/.htpasswd-myapp;

    # Proxy API calls to FastAPI — no auth required on this path
    location /api/ {
        auth_basic off;
        proxy_pass http://127.0.0.1:8300;
        proxy_set_header Host $host;
    }
}
# Create the password file
htpasswd -c /etc/nginx/.htpasswd-myapp yourusername

The browser sends basic auth credentials automatically on every request to the site. The API path bypasses this because the fetch calls from JS don't include auth headers — and they don't need to, since the API is only reachable through nginx (which is only reachable from Cloudflare tunnel).


The Frontend: localStorage First, API Second

The HTML app treats localStorage as the ground truth. Reads always come from localStorage. Writes always go to localStorage first, then attempt an async sync to the API.

Loading state on page open

async function initApp() {
    // 1. Load from localStorage immediately (fast, works offline)
    const localState = localStorage.getItem('app_state');
    if (localState) {
        applyState(JSON.parse(localState));
    }

    // 2. Try to sync from API (may be newer if edited on another device)
    try {
        const res = await fetch('/api/state', { signal: AbortSignal.timeout(3000) });
        if (res.ok) {
            const remoteState = await res.json();
            if (remoteState && Object.keys(remoteState).length > 0) {
                applyState(remoteState);
                localStorage.setItem('app_state', JSON.stringify(remoteState));
            }
        }
    } catch {
        // API unreachable — carry on with localStorage version
        console.log('Offline mode: using local state');
    }
}

window.addEventListener('DOMContentLoaded', initApp);

The 3-second timeout on the fetch prevents the app from hanging when the server is unreachable. If the API call fails for any reason, the app is fully functional with the local state — the user won't see any difference.

Saving state with debounce

let saveTimer = null;

function saveData() {
    const state = collectState(); // build your state object

    // Write to localStorage immediately
    localStorage.setItem('app_state', JSON.stringify(state));

    // Debounce the API write — don't POST on every keystroke
    clearTimeout(saveTimer);
    saveTimer = setTimeout(async () => {
        try {
            await fetch('/api/state', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ state }),
                signal: AbortSignal.timeout(5000),
            });
        } catch {
            // Silent fail — localStorage already has the latest
        }
    }, 1000);
}

The debounce means we're not making an API call on every checkbox click or input change. After 1 second of inactivity, the current state is flushed to the server. If that POST fails, nothing is lost — localStorage has it, and the next time the app opens, it'll use the local version.


The Sync Model and Its Limits

This is last-write-wins sync. When the app opens, it pulls the remote state and overwrites local. When you make changes, local state is pushed to remote. If you have the app open on two devices simultaneously and make changes on both before either syncs, one device's changes will overwrite the other's.

For personal single-user tools, this is almost never a problem. You're not editing a budget tracker on your laptop and your phone at the same time. The sync on page load is enough to keep both devices current.

If you need multi-user collaboration or conflict resolution, this pattern is the wrong tool. Use a proper backend with operational transforms or CRDTs. But for "just me, two devices", last-write-wins is fine.

A Note on State Schema Evolution

Since the state is a raw JSON blob and the API stores it as-is, you can evolve the structure freely. However, your applyState() function needs to handle both old and new shapes — old state files on the server won't automatically migrate.

A simple pattern: write applyState() defensively with defaults, and add a version field to your state when you make breaking changes:

function applyState(state) {
    const version = state.version ?? 1;

    if (version < 2) {
        // Migrate: v1 used 'items', v2 uses 'tasks'
        state.tasks = state.items ?? [];
        delete state.items;
        state.version = 2;
    }

    // Now safely read from state.tasks
    renderTasks(state.tasks ?? []);
    renderSettings(state.settings ?? defaultSettings());
}

Keep old state files around as backups. Since the state is just a JSON file, a quick cp state.json state.json.bak before a structural change costs nothing.


Full Setup Checklist

  1. Write your HTML app using localStorage for all reads/writes
  2. Add initApp() to load remote state on page open, with a timeout and silent fallback
  3. Add debounced saveData() that writes local first, then POSTs to API
  4. Create api.py with GET and POST /api/state endpoints
  5. Set up a Python venv and install fastapi uvicorn
  6. Create a systemd service bound to 127.0.0.1:<port>
  7. Configure nginx: basic auth on the root, auth_basic off + proxy on /api/
  8. Deploy the HTML file to the nginx webroot

Total setup time: under an hour, including the first deploy. Ongoing maintenance: essentially zero — systemd restarts the API on crash, there's no database to back up (just one JSON file), and the HTML is a single file you can edit and scp to the server.


The instinct to reach for a full-stack framework for every tool is worth resisting. Some things really are just a localStorage wrapper with an optional sync endpoint. If it solves the problem and you'll actually use it, that's enough.

Questions or corrections? Reach out via the contact section of my portfolio.