DarkWolfCave
webauftritt

Von PHP zu Nuxt: deine erste App selbst bauen

Cyberpunk-Wolf auf einer leuchtenden Brücke zwischen einem alten monolithischen Server und einem modernen, in Schichten getrennten Web-Stack
DarkWolf warnt KI-Bild Generiert mit Gemini

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 pnpm und ein Terminal. Mehr brauchst du nicht.

VIP Support
Wolf Support Avatar

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:3000 lädt nicht und du siehst connect 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 ins dev-Skript deiner package.json schreiben: "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 404 statt JSON? Neu angelegte Dateien im server/-Ordner erkennt der Dev-Server nicht immer von selbst. Stopp ihn einmal mit Strg+C und starte ihn mit pnpm dev neu - 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-sqlite3 ist ein natives Modul, und genau darum kümmert sich pnpm 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 musst better-sqlite3 ... install: Done in der Ausgabe sehen. Voraussetzung für den Quellcode-Build sind die Build-Werkzeuge deines Systems; unter macOS holst du sie bei Bedarf mit xcode-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 push sagt Could 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-builds meldet dann nur There 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 push

Der 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.ts um ein Feld wie gelesenAm, lass pnpm drizzle-kit push erneut 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.

FAQ - Frequently Asked Questions DarkWolfCave
DarkWolf hilft bei FAQs

Häufig gestellte Fragen

Brauche ich JavaScript-Vorkenntnisse, um diesem Nuxt-Guide zu folgen?
Grundlagen helfen, sind aber kein Muss. Wer PHP-Syntax kennt, findet in JavaScript Variablen, Funktionen, Schleifen und Bedingungen wieder. Die größere Umstellung sind die Konzepte wie Reaktivität und Server-Side Rendering, nicht die Sprache. Der Guide erklärt jeden Schritt mit einem PHP-Vergleich.
Welche Node-Version brauche ich für Nuxt und better-sqlite3?
Nuxt 4 braucht mindestens Node 22, empfohlen ist die aktive LTS-Version. Für die Datenbank nutzt der Guide better-sqlite3 - das ist ein natives Modul. Auf einer aktuellen LTS-Version gibt es in der Regel fertige Binaries, die Installation läuft sofort durch. Auf sehr neuen Node-Versionen muss es aus dem Quellcode gebaut werden, dafür brauchst du die Build-Tools (unter macOS die Xcode Command Line Tools).
Ist Nuxt ein Ersatz für PHP?
Nuxt deckt beide Rollen ab, die du aus dem PHP-Umfeld kennst: die Anzeige im Browser (früher dein HTML mit eingestreutem PHP) und den Server-Teil (dein eigentliches PHP). Der Server-Teil läuft über Nitro und übernimmt damit die Aufgabe von Apache plus PHP.
Was ist der Unterschied zwischen Vue und Nuxt?
Vue ist die Bibliothek, die die Oberfläche im Browser lebendig macht und bei Datenänderungen automatisch aktualisiert. Nuxt ist das Paket um Vue herum: Routing, Server-Rendering, ein Server-Teil und Projektstruktur. Vergleichbar mit Motor (Vue) und komplettem Fahrzeug (Nuxt).
Was bedeutet Server-Side Rendering bei Nuxt?
Die Seite wird beim ersten Aufruf auf dem Server zu fertigem HTML gebaut, wie bei PHP, und sofort an den Browser geschickt. Danach lädt im Browser JavaScript nach und macht die Seite interaktiv, ohne dass sie neu laden muss. Der gleiche Code läuft also einmal auf dem Server und einmal im Browser.
Muss ich für Drizzle noch SQL können?
Ja, und es hilft. Drizzle schreibt das SQL zwar für dich, bildet es aber fast eins zu eins ab. Wer SQL versteht, liest Drizzle-Code schneller und erkennt sofort, welche Abfrage dahintersteckt.
Warum eine SQLite-Datenbank und nicht MySQL?
SQLite braucht keinen Datenbank-Server - die ganze Datenbank ist eine einzige Datei. Für einen Einstieg ist das die niedrigste Hürde. Drizzle spricht genauso mit PostgreSQL oder MySQL, du tauschst dafür nur den Treiber und die Verbindung aus, die Abfragen bleiben gleich.

Kommentare

URLs werden automatisch verlinkt
Kommentare werden geladen...