Webentwicklung ohne SPA: Wie Clash mit VLang, Veb und htmx entsteht

Zurück zum Server: Eine Meetup-Plattform mit VLang, SQLite und htmx
Abstract
- #React
- #React Performance
- #React Compiler
- #useTransition
- #useDeferredValue
- #Code-Splitting
- #Lazy Loading
Clash im Praxistest: Moderne Web-Apps ganz ohne JavaScript-Framework
Wer heute ein neues Webprojekt beginnt, greift fast reflexartig zu Next.js, SvelteKit oder Nuxt. Das ist ein bisschen so, als würde man für jede Fahrt zum Bäcker automatisch den Kombi mit Anhängerkupplung nehmen, auch wenn das Fahrrad völlig ausreichen würde. Das Projekt Clash geht bewusst einen anderen Weg: eine serverseitig gerenderte Web-App, gebaut mit VLang, dem Webframework Veb, SQLite und htmx 4. In diesem Artikel schauen wir uns Schritt für Schritt an, wie diese Anwendung funktioniert, was der Ansatz im Alltag bedeutet und wo seine Grenzen liegen.
Warum überhaupt ohne JavaScript-Framework?
Der Grundgedanke hinter Clash ist erfrischend einfach: Der Server behält die Kontrolle. Es gibt keinen React-State, keinen Client-Router und keine eigene API-Schicht, die nur existiert, damit das Frontend Daten abholen kann. Stattdessen gilt eine klare Arbeitsteilung, der Server verwaltet den Zustand, Templates erzeugen HTML, htmx lädt bei Bedarf einzelne Teile der Seite nach, und JavaScript kommt nur dort zum Einsatz, wo der Browser es wirklich braucht, etwa bei interaktiven Karten.
Man kann sich das wie ein gut geführtes Restaurant vorstellen: Die Küche (der Server) bereitet die Gerichte zu, der Kellner (htmx) bringt nur die Teller an den Tisch, die sich geändert haben. Der Gast muss nicht selbst kochen. Das wirkt auf den ersten Blick fast altmodisch, passt aber erstaunlich gut zu vielen Anwendungen mit Formularen, Listen und Arbeitsabläufen.
Der Stack im Überblick
Clash setzt auf VLang 0.5.x als Backend-Sprache, Veb als Webframework und SQLite als Datenbank. Das Frontend besteht aus serverseitigen Templates, ergänzt durch htmx 4 für gezielte Interaktivität. Gestaltet wird mit Tailwind CSS, Karten liefert Leaflet auf Basis von OpenStreetMap, und die Authentifizierung läuft über eigene Cookie-Sessions mit bcrypt-gehashten Passwörtern.
Was ist Clash eigentlich?
Clash ist eine kleine Social-Plattform für Meetups, also keine reine Demo-Seite, sondern eine Anwendung mit echtem Alltag: Login, Berechtigungen, Datenmodell und Arbeitsabläufen.
Die Bausteine der Anwendung
Das Herzstück bilden sechs Kernobjekte: registrierte Nutzer, Venues (Orte, optional mit Koordinaten), Clashes (die eigentlichen Events), Participations (Teilnahme-Anfragen mit Status), In-App-Benachrichtigungen und Login-Sessions. Diese überschaubare Menge an Objekten reicht aus, um alle typischen Herausforderungen einer echten Web-App abzubilden.
Ein typischer Ablauf
Stellen wir uns Anna vor, die einen Spieleabend organisieren möchte. Sie legt einen Clash an. Ben entdeckt das Event und fragt seine Teilnahme an, ähnlich wie ein Klingeln an der Tür. Anna sieht die offene Anfrage, kann sie annehmen oder ablehnen, und Ben erhält anschließend eine Benachrichtigung. Genau dieser Ablauf macht Clash zu einem guten Testfall, denn er verlangt mehr als simple Listen: Eigentumsregeln, Statusübergänge, Datenbank-Constraints und partielle Aktualisierungen der Oberfläche.
Die Architektur: So übersichtlich wie ein Behördengang
Der Request-Flow
Der Weg einer Anfrage durch die Anwendung ist bewusst geradlinig gehalten: Der Browser schickt eine Anfrage, ein Veb-Route-Handler nimmt sie entgegen, spricht mit SQLite beziehungsweise der Domänenlogik, ein Template rendert das Ergebnis und zurück kommt entweder eine vollständige HTML-Seite oder ein htmx-Fragment. Keine Umwege, keine versteckten Zwischenschichten.
Die Anwendung startet in einer zentralen Datei, in der die Datenbank initialisiert, das statische Verzeichnis registriert und der Server auf Port 8080 gestartet wird. Eine pragmatische Eigenheit: Die Datenbankverbindung liegt als globale Variable vor, weil der zentrale Vorab-Hook von Veb keinen direkten Zugriff auf den App-Zustand hat. Elegant ist das nicht unbedingt, aber ehrlich und der Build berücksichtigt es über ein entsprechendes Compiler-Flag.
Zentrale Zugangskontrolle mit before_request
Einer der angenehmsten Aspekte der Architektur ist der zentrale before_request-Hook. Er funktioniert wie ein Pförtner am Werkstor: Bevor irgendein Handler zum Zug kommt, prüft er bei verändernden Formular-Anfragen den CSRF-Schutz, lädt die Session aus dem Cookie und leitet nicht eingeloggte Besucher auf die Login-Seite um, inklusive Rücksprungadresse. Die einzelnen Handler müssen sich dadurch nicht mehr selbst um die Frage kümmern, ob überhaupt jemand eingeloggt ist. Sie konzentrieren sich auf ihre fachliche Aufgabe.
pub fn (mut ctx Context) before_request() {
path := ctx.req.url.all_before('?')
if is_mutating(ctx) && is_form_post(ctx) && !csrf_ok(ctx) {
ctx.res.set_status(.forbidden)
_ := ctx.html('Cross-site form submissions are forbidden')
return
}
if token := ctx.get_cookie(session_cookie) {
if user := find_session_user(token) {
ctx.user = user
ctx.session_token = token
renew_if_needed(mut ctx, token)
}
}
if is_public_path(path) {
return
}
if ctx.user == none {
target := '/login?redirectTo=' + urllib.query_escape(path)
_ := ctx.redirect(target, typ: .see_other)
}
}
Der CSRF-Schutz ist dabei bewusst schlank gelöst: Bei mutierenden Requests wird verglichen, ob der Origin-Host zum Request-Host passt. Für eine produktive Anwendung, insbesondere hinter Proxies, wäre ein echtes CSRF-Token die robustere Wahl, das räumt das Projekt selbst offen ein.
fn csrf_ok(ctx &Context) bool {
origin := ctx.get_header(.origin) or { '' }
if origin == '' {
return false
}
host := ctx.get_header(.host) or { '' }
origin_host := origin.all_after('://')
return origin_host == host
}
Serverseitiges Rendering mit htmx
Fragmente statt ganzer Seiten
Die zentrale Idee von htmx lässt sich in einem Satz zusammenfassen: Der Browser fragt HTML an, nicht JSON. Bei einer normalen Seitenanfrage bettet Veb den Inhalt in ein Layout ein. Kommt die Anfrage jedoch von htmx, erkennbar am HX-Request-Header, liefert der Server nur das benötigte Fragment zurück. Der Handler prüft also lediglich, wer fragt, und antwortet passend.
Beispiel: Die Live-Suche
Besonders anschaulich wird das bei der Suche in der Event-Liste. Ein Suchfeld ist mit htmx-Attributen versehen, die bei jeder Eingabe (mit kurzer Verzögerung von 250 Millisekunden) eine Anfrage an den Server schicken. Dieser rendert nur die Liste neu und htmx tauscht den entsprechenden Bereich der Seite aus. Der Nutzer erlebt eine flüssige Live-Suche, ganz ohne eine Zeile eigenen JavaScript-Codes. Der Server entscheidet, wie der neue Zustand aussieht, so wie ein Regisseur, der jede Szene selbst abnimmt.
<input
type="search"
name="q"
value="@{esc(q)}"
placeholder="Search by title..."
hx-get="/clashes"
hx-trigger="input changed delay:250ms"
hx-target="#clash-list"
hx-swap="innerHTML"
/>
<div id="clash-list">
@include '_clash_list'
</div>
Sicherheit: Wo man genau hinschauen muss
Templates und der XSS-Fallstrick
Ein wichtiger Punkt, der Einsteiger überraschen kann: Veb-Templates escapen Benutzereingaben nicht automatisch. Man kann sich das wie eine Haustür ohne automatisches Schloss vorstellen, sie schützt nur, wenn man selbst abschließt. Das Projekt setzt deshalb konsequent einen eigenen Escape-Helfer ein, der kritische Zeichen wie spitze Klammern und Anführungszeichen in ihre HTML-Entitäten umwandelt. Jede Ausgabe von Nutzerdaten muss durch diese Funktion laufen. Wer das an einer Stelle vergisst, öffnet potenziell die Tür für Cross-Site-Scripting. In Frameworks mit Auto-Escaping ist diese Fehlerklasse deutlich stärker abgefedert, hier ist Disziplin gefragt.
pub fn esc(s string) string {
return s
.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"')
.replace("'", ''')
}
Passwörter und Sessions: Der Garderobenmarken-Trick
Bei der Authentifizierung zeigt Clash, wie man es richtig macht. Passwörter werden mit bcrypt gehasht, wie es sich gehört. Noch interessanter ist der Umgang mit Sessions: In der Datenbank landet nicht das rohe Session-Token, sondern nur dessen SHA-256-Hash. Das Prinzip erinnert an eine Garderobe: Der Gast bekommt die Marke (das rohe Token im Cookie), das Personal führt nur eine Liste mit Prüfnummern. Würde die Session-Tabelle einmal in falsche Hände geraten, wären die Tokens nicht direkt verwendbar. Das Cookie selbst wird mit HttpOnly, SameSite=Lax und im Produktivbetrieb zusätzlich mit dem Secure-Flag gesetzt.
SQLite und das handgeschriebene Schema
Das Datenmodell wird in V zwar als Structs mit ORM-Annotationen beschrieben, die eigentliche Datenbankdefinition ist jedoch bewusst von Hand geschrieben. Der Grund: Die Annotationen des V-ORM können nicht alle gewünschten Regeln ausdrücken, etwa das automatische Löschen abhängiger Datensätze (ON DELETE CASCADE) oder das Nullsetzen von Referenzen (ON DELETE SET NULL).
@[table: 'clashes']
pub struct Clash {
pub mut:
id string @[primary]
title string
description ?string
starts_at time.Time
host_id string
venue_id ?string
created_at time.Time
}
Besonders wichtig ist die Tabelle der Teilnahmen. Eine Check-Constraint erlaubt nur die Status-Werte "pending", "accepted" und "declined", und eine Unique-Constraint stellt sicher, dass jeder Nutzer pro Event nur genau eine Teilnahme haben kann. Die Datenbank fungiert damit als letzter Wächter der Geschäftsregeln, selbst wenn im Code einmal etwas schiefgeht, lässt sie ungültige Zustände gar nicht erst zu.
CREATE TABLE IF NOT EXISTS participations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
clash_id TEXT NOT NULL REFERENCES clashes(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (user_id, clash_id)
)
Der Teilnahme-Workflow als State Machine
Der spannendste Teil der Geschäftslogik ist der Teilnahme-Ablauf, der wie eine kleine Ampelschaltung funktioniert: Eine Anfrage steht zunächst auf "pending", der Host schaltet sie auf "accepted" oder "declined", und nach einer Ablehnung darf erneut angefragt werden.
Die gesamte Logik steckt in einer zentralen Funktion. Sie prüft zunächst, ob der Anfragende nicht zufällig selbst der Gastgeber ist, behandelt dann bestehende Teilnahmen je nach Status unterschiedlich und legt andernfalls eine neue Anfrage an, jeweils mit Benachrichtigung an den Host. Der HTTP-Handler bleibt dadurch angenehm dünn: Er holt Nutzer und Event, ruft die Domänenfunktion auf und übersetzt deren Ergebnis in eine HTTP-Antwort. Die Fachlogik entscheidet, was erlaubt ist; der Handler entscheidet nur, wie daraus eine Antwort wird. Diese Trennung ist wie die zwischen Küche und Service, jeder macht das, was er am besten kann.
Benachrichtigungen und Karten
Notifications per htmx-Polling
Das Benachrichtigungs-Badge in der Navigation aktualisiert sich alle 20 Sekunden sowie beim Fokuswechsel zurück ins Browserfenster, gesteuert allein durch htmx-Attribute. Der Server liefert dabei jeweils nur ein winziges HTML-Fragment mit der aktuellen Zahl ungelesener Nachrichten. Auch hier bleibt das Grundprinzip erhalten: kein JSON-State im Browser, nur ausgetauschtes HTML.
<span
id="notif-count"
hx-get="/notifications/unread"
hx-trigger="every 20s, focus from:window"
hx-swap="outerHTML">
@include '_notif_count'
</span>
Der Handler liefert nur ein kleines HTML-Fragment:
@['/notifications/unread']
pub fn (mut app App) notifications_unread(mut ctx Context) veb.Result {
user := ctx.user or {
return ctx.text('')
}
unread := unread_count(user.id)
return ctx.html($tmpl('templates/_notif_count.html'))
}
Leaflet für die Kartenansicht
Karten sind einer der wenigen Bereiche, in denen echtes Client-JavaScript unverzichtbar ist. Clash bettet die Venue-Daten als JSON in ein Script-Tag der Seite ein, wohlgemerkt mit escapten spitzen Klammern, um Injection-Probleme zu vermeiden. Im Browser liest Leaflet diese Daten aus und setzt Marker auf eine OpenStreetMap-Karte. Die App ist also serverseitig geprägt, aber nicht dogmatisch JavaScript-frei: Wo der Browser gebraucht wird, wird er genutzt.
Licht und Schatten des Ansatzes
Was gut funktioniert
Für Anwendungen dieser Art hat sich der Stack bewährt. Die Request/Response-Struktur bleibt einfach, es gibt kaum Client-State, und die Trennung zwischen Handlern, Datenbankabfragen und Domänenlogik ist klar. SQLite erweist sich als robuste lokale Datenbank, htmx ergänzt kleine, gezielte Interaktionen, und das Deployment reduziert sich im Idealfall auf ein Binary plus ein statisches Verzeichnis. Gerade für CRUD-Anwendungen, Admin-Oberflächen, Workflow-Tools und interne Werkzeuge ist dieses Modell attraktiv, viele davon brauchen schlicht keine große SPA-Architektur.
Wo es hakt
Ehrlich betrachtet gibt es aber klare Kompromisse. V und Veb sind längst nicht so ausgereift wie etablierte Stacks, man muss bereit sein, Dinge selbst herauszufinden. Das fehlende Auto-Escaping der Templates ist der wichtigste Sicherheitspunkt, denn ein einziges vergessenes Escaping kann zu XSS führen. Fragment-Rendering will bewusst geplant sein: Welche Seitenteile funktionieren als Partial, und welche Variablen brauchen sie? ORM und Datenbankschema decken sich nicht vollständig, weshalb handgeschriebenes SQL nötig war. Und obwohl die vorhandene Testsuite Modelle, Auth, Datenbankregeln und den Teilnahme-Workflow gut abdeckt, wären für eine produktionsnähere App HTTP- und Browser-Smoke-Tests der logische nächste Schritt.
Fazit
Clash beweist, dass sich mit VLang, Veb und htmx 4 eine vollständige kleine Web-App bauen lässt, ganz ohne klassisches JavaScript-Framework. Der Stack ist sicher nicht der bequemste Standardweg: Es gibt weniger fertige Middleware, ein kleineres Ökosystem, und bei Sicherheit und Rendering muss man genauer hinschauen. Dafür erhält man eine erfrischend direkte Architektur, in der HTML vom Server kommt, die Datenbank nah an der Domäne bleibt und JavaScript klein und zweckgebunden ist. Die wichtigste Erkenntnis des Projekts lautet: Nicht jede moderne Web-App braucht ein SPA. Für viele Anwendungen genügen ein schneller Server, gute Templates, klare Domänenlogik und eine Prise htmx, so wie eben nicht jede Fahrt zum Bäcker den großen Kombi verlangt.
FAQ
Für welche Projekte eignet sich der Stack aus VLang, Veb und htmx besonders?
Vor allem für CRUD-Anwendungen, interne Tools, Admin-Oberflächen und Workflow-Systeme, bei denen der Server ohnehin die Datenhoheit hat. Wer komplexe, hochinteraktive Oberflächen mit viel Client-Zustand braucht, etwa einen Grafik-Editor, fährt mit einem SPA-Framework weiterhin besser.
Ist der Verzicht auf ein JavaScript-Framework nicht ein Sicherheitsrisiko?
Nicht per se, aber die Verantwortung verschiebt sich. Da Veb-Templates nicht automatisch escapen, muss jede Ausgabe von Nutzerdaten diszipliniert durch eine Escape-Funktion laufen. Wer das beherzigt und zusätzlich auf gehashte Session-Tokens, sichere Cookies und CSRF-Schutz achtet, erreicht ein solides Sicherheitsniveau, nur eben in Handarbeit statt per Framework-Automatik.
Warum wurde das Datenbankschema von Hand geschrieben statt über das ORM generiert?
Weil die ORM-Annotationen von V nicht alle gewünschten Datenbankregeln abbilden können. Verhaltensweisen wie das automatische Löschen abhängiger Datensätze oder Check- und Unique-Constraints ließen sich nur mit handgeschriebenem SQL sauber umsetzen. So wacht die Datenbank selbst über die Geschäftsregeln, unabhängig davon, was im Anwendungscode passiert.
- Technologien
- Programmiersprachen
- Tools