Von PHP zu Nuxt: deine erste App selbst bauen
Wie sicher auch du habe ich damals mit PHP, HTML und CSS angefangen, Webseiten zu basteln. Seitdem hat sich viel getan: meine neueren Projekte laufen unter anderem mit Nuxt, Vue, Nitro und Drizzle. Statt dir den Stack nur zu erklären, bauen wir hier zusammen eine kleine App - Schritt für Schritt, mit Datenbank, API und Formular. Jeden Baustein vergleiche ich mit dem, was du aus PHP schon kennst.
DarkWolfCave.de
Die meisten Tutorials tun so, als könntest du noch gar nichts, und fangen bei “was ist eine Variable” an. Das nervt, wenn du seit Jahren Datenbanken abfragst und HTML ausgibst. Deshalb bauen wir hier etwas Echtes und ich übersetze dir jeden Schritt in PHP-Begriffe.
Was wir bauen
Ein kleines Bücherregal: eine Seite, die eine Liste gelesener Bücher zeigt und über ein Formular neue hinzufügt. Klein genug für einen Nachmittag, aber es steckt der komplette moderne Stack drin:
- Vue für die Oberfläche im Browser
- Nuxt als Rahmen drumherum (Seiten, Routing, Server-Rendering)
- Nitro für den Server-Teil mit der API
- Drizzle als Brücke zur SQLite-Datenbank
Jeder dieser Bausteine hat ein Gegenstück in deiner PHP-Welt. Diese Übersicht behältst du am besten im Kopf:
| Aufgabe | Früher (PHP) | In diesem Guide |
| --- | --- | --- |
| Anzeige im Browser | HTML/Template (echo, Blade, Twig) | Vue-Komponente (index.vue) |
| URL zu einer Seite | Datei = URL (index.php) | Ordner pages/ = URL |
| Server-Logik, DB-Zugriff | dein PHP-Code | Server-Teil (Nitro, server/api/) |
| Datenbankabfragen | SQL als String | SQL über Drizzle |
Am Ende läuft die App lokal auf deinem Rechner, und du verstehst, wie die Teile zusammenspielen.
ℹ️ Voraussetzungen: Node.js 22 oder neuer (am unkompliziertesten ist die aktuelle LTS-Version), der Paketmanager
pnpmund ein Terminal. Mehr brauchst du nicht.
Du wirst hier einen groben Überblick finden.
Allerdings biete ich dir auch noch etwas mehr Support an:
- Du benötigst persönlichen Support
- Du möchtest von Beginn an Unterstützung bei deinem Projekt
- Du möchtest ein hier vorgestelltes Plugin durch mich installieren und einrichten lassen
- Du würdest gerne ein von mir erstelltes Script etwas mehr an deine Bedürfnisse anpassen
Für diese Punkte und noch einiges mehr habe ich einen limitierten VIP-Tarif eingerichtet.
Falls der Tarif gerade nicht verfügbar ist, kontaktiere mich auf Discord!
Schritt 1: Das Nuxt-Projekt anlegen
In PHP hast du eine Datei in den Webserver-Ordner gelegt, und Apache hat sie ausgeliefert. Bei Nuxt legst du dir ein Projekt an, das seinen eigenen Server mitbringt. Wechsle im Terminal zuerst in den Ordner, in dem deine Projekte liegen sollen (oder leg ihn dir an):
mkdir -p ~/projekte && cd ~/projekte
npx nuxi@latest init buecherregal
nuxi ist das Nuxt-Kommandozeilen-Werkzeug. Es stellt dir drei Fragen: nach einer Vorlage (nimm minimal), nach dem Paketmanager (wähle pnpm) und ob es ein Git-Repository anlegen soll (Yes ist sinnvoll, dann ist dein Projekt von Anfang an versioniert). Es legt dann den Ordner buecherregal an und installiert die Grundpakete.
Dabei siehst du am Ende sehr wahrscheinlich eine Meldung, die wie ein Fehler aussieht (bei pnpm tritt sie auf):
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @parcel/watcher, esbuild
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
Das ist kein Defekt, sondern eine Schutzfunktion: pnpm führt die Installations-Skripte von Paketen nicht ungefragt aus, weil darüber in der Vergangenheit Schadcode verteilt wurde. Bevor du sie freigibst, lohnt ein kurzer Blick, welche Pakete da überhaupt ein Skript ausführen wollen - die Meldung nennt sie. Hier sind es esbuild und @parcel/watcher: zwei etablierte Bausteine, die zu Nuxt selbst gehören (esbuild bündelt deinen Code, @parcel/watcher bemerkt Datei-Änderungen für das automatische Neuladen). Sie kommen also nicht aus dem Nichts, sondern sind genau das, was du gerade installiert hast. Fällt dir hier dagegen ein Name auf, den du nicht erwartest, gib ihn nicht blind frei, sondern sieh ihn dir erst auf npmjs.com oder GitHub an: Download-Zahlen, Maintainer und Quellcode verraten schnell, ob ein Paket vertrauenswürdig ist.
Diese beiden brauchen ihren Skript wirklich - esbuild lädt darüber sein ausführbares Programm, @parcel/watcher baut einen nativen Bestandteil. Ohne die Builds startet der Dev-Server gar nicht. Wechsle also zuerst in den Projekt-Ordner (sonst meldet der nächste Befehl nur There are no packages awaiting approval), gib die Skripte frei und starte den Server:
cd buecherregal
pnpm approve-builds --all
pnpm dev
pnpm approve-builds ist dabei ein eigenständiger Befehl - du tippst ihn für sich, nicht als Anhängsel an pnpm add (sonst hält pnpm approve-builds für ein Paket, das du installieren willst). Lässt du --all weg, fragt er für jedes Paket einzeln nach - praktisch, wenn du gezielt nur bestimmte freigeben willst, statt alle auf einmal.
Im Terminal erscheint eine Adresse, meist http://localhost:3000/. Öffne sie im Browser, und du siehst die leere Nuxt-Startseite. Der Server läuft jetzt und lädt bei jeder Änderung automatisch neu - kein manuelles Hochladen, kein Apache-Neustart.
💡 macOS:
localhost:3000lädt nicht und du siehstconnect EINVAL ... .sock? Das ist ein bekanntes Nuxt-Problem auf dem Mac - der interne Socket-Pfad überschreitet die vom System erlaubten 104 Zeichen. Starte den Server mit einem kürzeren Temp-Verzeichnis:TMPDIR=/tmp pnpm dev. Damit du es nicht jedes Mal tippst, kannst du es fest insdev-Skript deinerpackage.jsonschreiben:"dev": "TMPDIR=/tmp nuxt dev".
Schritt 2: Die erste Seite mit Vue
Das minimal-Template bringt eine Datei app/app.vue mit, die eine feste Startseite anzeigt. Wir wollen stattdessen das Seiten-System von Nuxt nutzen, bei dem jede Datei im pages/-Ordner automatisch zu einer URL wird. Lösche dafür zuerst die app.vue:
rm app/app.vue
Lege jetzt die Datei app/pages/index.vue an. Den Ordner und die leere Datei erzeugst du im Terminal mit einem Befehl:
mkdir -p app/pages && touch app/pages/index.vue
Öffne die neue Datei dann in deinem Editor und füll sie mit dem Code unten. index.vue wird zur Startseite / - das kennst du aus PHP, wo index.php auch die Startseite war. Alle weiteren Dateien in diesem Guide legst du genauso an: bei Bedarf den Ordner mit mkdir -p, dann die Datei im Editor füllen.
Eine .vue-Datei hat zwei Teile, die dir vertraut vorkommen: einen Logik-Block (wie der PHP-Teil oben in deiner Datei) und einen HTML-Block.
<script setup lang="ts">
const titel = ref('')
const autor = ref('')
</script>
<template>
<main>
<h1>Mein Bücherregal</h1>
<input v-model="titel" placeholder="Titel" >
<input v-model="autor" placeholder="Autor" >
<p>Eingabe: {{ titel }} von {{ autor }}</p>
</main>
</template>
Tipp jetzt etwas in die Felder. Der Text hinter “Eingabe:” ändert sich sofort mit, ohne dass die Seite neu lädt. Das ist der Kern von Vue, die Reaktivität.
In klassischem PHP lädst du für so eine Aktualisierung die Seite neu, oder du schreibst dir das mit JavaScript und jQuery von Hand zusammen. Vue nimmt dir das ab: ref('') macht aus einem Wert einen beobachteten Wert. v-model koppelt das Eingabefeld daran, und {{ titel }} ist dein <?php echo $titel; ?>. Ändert sich der Wert, schreibt Vue alle Stellen, die ihn nutzen, von selbst neu.
Schritt 3: Ein eigener Server-Endpunkt
Bisher läuft alles im Browser. Aber der Browser darf nie direkt an die Datenbank - das wäre, als gäbst du jedem Besucher dein DB-Passwort. Es braucht einen vertrauenswürdigen Server-Teil, und das war in deiner Welt die Rolle von PHP. Bei Nuxt heißt dieser Teil Nitro, und seine Dateien liegen im Ordner server/api/.
Lege server/api/buecher.get.ts an:
export default defineEventHandler(() => {
return [
{ id: 1, titel: 'Der Wüstenplanet', autor: 'Frank Herbert' },
]
})
Ruf im Browser http://localhost:3000/api/buecher auf - du bekommst die Liste als JSON zurück.
Auch hier gilt: Dateiname gleich URL, und die Endung .get bestimmt die HTTP-Methode (GET zum Lesen, später .post zum Anlegen). Was du mit return zurückgibst, schickt Nitro automatisch als JSON - kein json_encode, kein echo, keine Header von Hand. Das defineEventHandler musst du nicht importieren, Nitro stellt es überall im server/-Ordner bereit.
💡 Kommt ein
404statt JSON? Neu angelegte Dateien imserver/-Ordner erkennt der Dev-Server nicht immer von selbst. Stopp ihn einmal mitStrg+Cund starte ihn mitpnpm devneu - dann ist die Route da.
Schritt 4: Eine Datenbank mit Drizzle
Die statische Liste ersetzen wir jetzt durch eine echte Datenbank. Wir nehmen SQLite - die braucht keinen Server, die ganze Datenbank ist eine einzige Datei. Und Drizzle als Werkzeug dazwischen, damit du nicht von Hand SQL-Strings zusammenbaust. Falls der dev-Server noch läuft, stopp ihn kurz mit Strg+C und installiere die Pakete:
pnpm add drizzle-orm better-sqlite3
pnpm add -D drizzle-kit
better-sqlite3 bringt wieder ein Installations-Skript mit, das pnpm zunächst blockt - genau wie vorhin. Genehmige es und starte den Server neu:
pnpm approve-builds --all
pnpm dev
⚠️ Wichtig:
better-sqlite3ist ein natives Modul, und genau darum kümmert sichpnpm approve-builds --all. Auf einer LTS-Version lädt es ein fertiges Binary, auf einer sehr neuen Node-Version (etwa 26) baut es das Binary aus dem Quellcode - das dauert ein paar Sekunden länger, läuft aber durch. Du musstbetter-sqlite3 ... install: Donein der Ausgabe sehen. Voraussetzung für den Quellcode-Build sind die Build-Werkzeuge deines Systems; unter macOS holst du sie bei Bedarf mitxcode-select --install.
Jetzt beschreibst du deine Tabelle als Code. Das ist der Drizzle-Ansatz: Du definierst das Schema einmal, und Drizzle kümmert sich um das SQL. Lege server/db/schema.ts an:
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
// Eine Tabelle "buecher" mit drei Spalten.
export const buecher = sqliteTable('buecher', {
id: integer('id').primaryKey({ autoIncrement: true }),
titel: text('titel').notNull(),
autor: text('autor').notNull(),
})
Das liest sich fast wie ein CREATE TABLE, nur in TypeScript. Dann die Verbindung in server/db/index.ts:
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
// Öffnet (oder erstellt) die SQLite-Datei buecher.db im Projekt-Ordner.
const sqlite = new Database('buecher.db')
export const db = drizzle(sqlite)
Damit Drizzle die Tabelle in der Datenbank anlegt, braucht es noch eine kleine Konfigurationsdatei drizzle.config.ts im Projekt-Hauptordner:
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './server/db/schema.ts',
dbCredentials: { url: './buecher.db' },
})
Und ein Befehl erzeugt die Tabelle aus deinem Schema:
pnpm drizzle-kit push
Jetzt liegt eine Datei buecher.db im Projekt mit der fertigen Tabelle.
💡
drizzle-kit pushsagtCould not locate the bindings file? Dann wurde das better-sqlite3-Binary doch nicht gebaut (das passiert, wenn pnpm den Build als “schon erledigt” ansieht, obwohl er fehlt -approve-buildsmeldet dann nurThere are no packages awaiting approval). Bau es einmal direkt, dann pushe erneut. Die Versionsnummer im Pfad kann bei dir abweichen:cd node_modules/.pnpm/better-sqlite3@12.10.0/node_modules/better-sqlite3 npm run build-release cd - pnpm drizzle-kit pushDer Build endet mit
gyp info ok. Auf einer Node-LTS-Version tritt dieser Fall in aller Regel gar nicht erst auf.
Öffne jetzt wieder die Datei server/api/buecher.get.ts aus Schritt 3 und ersetze ihren Inhalt. Statt der festen Beispiel-Liste liest sie nun aus der Datenbank:
import { db } from '../db'
import { buecher } from '../db/schema'
// GET /api/buecher - liefert alle Bücher aus der Datenbank.
export default defineEventHandler(() => {
return db.select().from(buecher).all()
})
Die Zeile db.select().from(buecher).all() ist Wort für Wort dieses SQL:
SELECT * FROM buecher;
Lies Drizzle einfach von oben nach unten als SQL, dann passt es. Der Vorteil gegenüber selbst gebauten Strings: Werte werden sicher eingesetzt, SQL-Injection ist praktisch ausgeschlossen, und du bekommst Tippfehler in Spaltennamen schon beim Schreiben angezeigt.
Schritt 5: Speichern und alles verbinden
Fehlt noch der Endpunkt zum Anlegen. Lege server/api/buecher.post.ts an:
import { db } from '../db'
import { buecher } from '../db/schema'
// POST /api/buecher - legt ein neues Buch an.
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (!body?.titel || !body?.autor) {
throw createError({ statusCode: 400, statusMessage: 'Titel und Autor sind Pflicht' })
}
return db
.insert(buecher)
.values({ titel: body.titel, autor: body.autor })
.returning()
.get()
})
readBody liest die gesendeten Daten aus, dein $_POST. Die Prüfung auf leere Felder ist deine Eingabevalidierung - Nutzereingaben traust du nie blind. Jetzt verbindest du die Seite mit beiden Endpunkten. Die fertige app/pages/index.vue:
<script setup lang="ts">
// Holt die Bücher beim Laden der Seite vom Server-Endpunkt.
const { data: buecher, refresh } = await useFetch('/api/buecher', {
default: () => [],
})
const titel = ref('')
const autor = ref('')
const fehler = ref('')
async function hinzufuegen() {
if (!titel.value || !autor.value) {
fehler.value = 'Bitte Titel und Autor ausfüllen.'
return
}
fehler.value = ''
await $fetch('/api/buecher', {
method: 'POST',
body: { titel: titel.value, autor: autor.value },
})
titel.value = ''
autor.value = ''
await refresh()
}
</script>
<template>
<main>
<h1>Mein Bücherregal</h1>
<form @submit.prevent="hinzufuegen">
<input v-model="titel" placeholder="Titel" >
<input v-model="autor" placeholder="Autor" >
<button type="submit">Hinzufügen</button>
</form>
<p v-if="fehler" style="color: crimson;">{{ fehler }}</p>
<ul>
<li v-for="buch in buecher" :key="buch.id">
<strong>{{ buch.titel }}</strong> von {{ buch.autor }}
</li>
</ul>
<p v-if="buecher.length === 0">Noch keine Bücher.</p>
</main>
</template>
Trag ein Buch ein, klick auf Hinzufügen - es erscheint sofort in der Liste und überlebt jetzt auch einen Neustart, weil es in der Datenbank liegt. v-for ist deine foreach-Schleife, direkt im HTML. @submit.prevent fängt das Absenden des Formulars ab, ohne dass die Seite neu lädt. Lässt du ein Feld leer, setzt hinzufuegen die Variable fehler, und das <p v-if="fehler"> zeigt sie an - so bekommt der Nutzer eine Rückmeldung, statt dass scheinbar nichts passiert.
Dir fällt vielleicht auf: Dein buecher.post.ts prüft die leeren Felder auch schon und wirft einen 400-Fehler. Diese Prüfung greift hier nie, weil das Frontend vorher abbricht - trotzdem gehört sie dorthin. Das Frontend prüft für die schnelle Rückmeldung an den Nutzer, das Backend prüft für die Sicherheit, falls jemand die API direkt aufruft, ohne deine Seite zu benutzen. Nutzereingaben prüfst du immer auf dem Server, nie nur im Browser - das kennst du aus PHP, wo du $_POST auch serverseitig prüfst, egal was das Formular vorgibt.
Sehen kannst du die Backend-Prüfung, indem du die API einmal direkt aufrufst, am Frontend vorbei (der dev-Server muss laufen):
curl -X POST http://localhost:3000/api/buecher -H "Content-Type: application/json" -d '{}'
Das liefert deinen 400-Fehler mit Titel und Autor sind Pflicht - weil dieser Aufruf nicht durch deine Seite geht und die Frontend-Prüfung damit umgeht.
Der Teil, der für PHP-Leute neu ist
Die Zeile await useFetch('/api/buecher') steht ganz oben, nicht in einer “wenn-geladen”-Funktion. Das hat einen Grund, und das ist der größte Unterschied zu PHP: Diese Seite läuft an zwei Orten.
1. Du rufst die Seite auf
|
2. Nuxt läuft ZUERST auf dem Server (wie PHP):
useFetch holt die Bücher, das HTML wird fertig gebaut
und sofort an den Browser geschickt
|
3. Browser zeigt sofort die fertige Liste (schnell, für Google lesbar)
|
4. DANN lädt im Browser JavaScript nach und macht die Seite interaktiv
- ab jetzt funktioniert das Formular, ohne Neuladen
Das nennt sich Server-Side Rendering. Der erste Aufbau passiert serverseitig wie bei PHP, danach übernimmt der Browser und es fühlt sich an wie eine App. Genau deshalb steht useFetch oben: Es soll schon auf dem Server laufen, bevor das HTML entsteht.
Die Kehrseite davon erwischt jeden einmal: Auf dem Server gibt es keinen Browser. Kein window, kein localStorage, kein document. Wenn du in einer Seite direkt auf localStorage zugreifst, bekommst du window is not defined, weil der Server-Durchlauf darüber stolpert. In PHP gibt es das Problem nicht, weil PHP nie im Browser läuft. Die Lösung: Browser-Sachen erst nach dem Laden ansprechen, in einem onMounted-Block, der nur im Browser läuft.
Was du jetzt hast und wie es weitergeht
Wenn du bis hier mitgemacht hast, läuft eine vollständige kleine Anwendung auf deinem Rechner: eine Seite mit Formular (Vue), zwei API-Endpunkte (Nitro), eine echte Datenbank (Drizzle + SQLite), und das alles mit Server-Rendering. Das ist derselbe Aufbau wie bei großen Projekten, nur kleiner.
Ein paar sinnvolle nächste Schritte:
- Spalten ergänzen: Erweitere das Schema in
schema.tsum ein Feld wiegelesenAm, lasspnpm drizzle-kit pusherneut laufen - so wächst deine Datenbank kontrolliert mit. - Auf PostgreSQL wechseln: Drizzle spricht genauso mit Postgres, du tauschst nur Treiber und Verbindung. Wenn du die Datenbank dann in einem Container betreibst, lohnt vorher ein Blick darauf, wo eine Postgres-DB im Container ihre Daten ablegt - sonst sind sie nach dem nächsten Neustart weg.
- Ans Deployen denken: Beim Ausliefern kommt nur der gebaute Code ins Image, die Daten gehören nach draußen in ein Docker-Volume oder einen Bind-Mount.
Der Sprung von PHP ist kleiner, als er von außen wirkt. JavaScript lernst du schnell, wenn du PHP-Syntax kennst. Die Zeit geht in die Konzepte - Reaktivität, getrenntes Frontend und Backend, Server-Rendering - nicht in die Sprache. Und die hast du mit diesem Bücherregal jetzt einmal komplett in der Hand gehabt.
Kommentare