Modul 8: Nuxt Grundlagen

Server-Routes (API)

Baue Backend-Endpunkte direkt in deiner Nuxt-App — wie Rails API-Controller, aber ohne den ganzen Boilerplate.

API-Endpunkte mit Nitro

In Rails hast du Controller, die HTTP-Requests verarbeiten. In Nuxt erstellst du Dateien in server/api/ — jede Datei wird automatisch zu einem API-Endpunkt. Kein Router, kein Controller-Setup.

server/api/hello.ts
// server/api/hello.ts
// → GET /api/hello

export default defineEventHandler(() => {
  return {
    message: 'Hallo Welt! 👋',
    timestamp: new Date().toISOString()
  }
})

// Das ist alles! Kein Router, kein Controller.
// Die Datei IST der Endpunkt.

// Rails-Äquivalent:
// # config/routes.rb
// get '/api/hello', to: 'api/hello#index'
//
// # app/controllers/api/hello_controller.rb
// class Api::HelloController < ApplicationController
//   def index
//     render json: { message: 'Hallo Welt!' }
//   end
// end
🛤️

Rails-Vergleich: API-Controller

Rails: Route in routes.rb → Controller-Klasse → Action-Methode → JSON-Response
Nuxt: Datei in server/api/ → fertig!
Eine Datei ersetzt Route-Definition + Controller + Action. Das ist Convention over Configuration auf einem neuen Level.

HTTP-Methoden: GET, POST, PUT, DELETE

Nuxt unterstützt zwei Wege, HTTP-Methoden zu definieren: Über den Dateinamen oder innerhalb des Handlers.

Methode 1: Dateiname

HTTP-Methoden im Dateinamen
server/api/
├── posts/
│   ├── index.get.ts     → GET    /api/posts     (Liste)
│   ├── index.post.ts    → POST   /api/posts     (Erstellen)
│   ├── [id].get.ts      → GET    /api/posts/:id (Lesen)
│   ├── [id].put.ts      → PUT    /api/posts/:id (Aktualisieren)
│   └── [id].delete.ts   → DELETE /api/posts/:id (Löschen)

# Rails-Äquivalent:
# resources :posts  →  7 Routen in einem Befehl
# In Nuxt: 5 Dateien, aber null Konfiguration nötig

Methode 2: Im Handler prüfen

server/api/posts/[id].ts
// server/api/posts/[id].ts
// Alle Methoden für /api/posts/:id in einer Datei

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const method = event.method

  // GET /api/posts/:id
  if (method === 'GET') {
    const post = await findPost(id)
    if (!post) {
      throw createError({ statusCode: 404, message: 'Post nicht gefunden' })
    }
    return post
  }

  // PUT /api/posts/:id
  if (method === 'PUT') {
    const body = await readBody(event)
    return await updatePost(id, body)
  }

  // DELETE /api/posts/:id
  if (method === 'DELETE') {
    await deletePost(id)
    return { deleted: true }
  }

  // Methode nicht erlaubt
  throw createError({ statusCode: 405, message: 'Methode nicht erlaubt' })
})
💡

Welche Methode wählen?

Separate Dateien (Methode 1) sind übersichtlicher und empfohlen für einfache CRUD-Operationen. Ein Handler (Methode 2) ist besser, wenn GET/PUT/DELETE auf derselben Ressource gemeinsame Logik teilen.

Query-Parameter & Request Body

Nuxt bietet Hilfsfunktionen zum Lesen von URL-Parametern, Query-Strings und dem Request-Body — ähnlich wie params in Rails.

Parameter lesen
// server/api/posts/index.get.ts

export default defineEventHandler(async (event) => {
  // URL-Parameter: /api/posts/:id
  const id = getRouterParam(event, 'id')

  // Query-String: /api/posts?page=2&limit=10
  const { page = '1', limit = '10', search } = getQuery(event)

  // Request-Body (POST/PUT)
  const body = await readBody(event)
  // body = { title: '...', content: '...' }

  // Headers lesen
  const authToken = getHeader(event, 'authorization')

  // Cookies lesen
  const sessionId = getCookie(event, 'session_id')

  // Rails-Vergleich:
  // params[:id]         → getRouterParam(event, 'id')
  // params[:page]       → getQuery(event).page
  // params[:title]      → (await readBody(event)).title
  // request.headers['Authorization'] → getHeader(event, 'authorization')
  // cookies[:session_id] → getCookie(event, 'session_id')

  return { id, page, limit, search }
})
🛤️

Rails-Vergleich: params

In Rails greifst du auf alles über params zu — URL-Segmente, Query-String, Body. In Nuxt sind die Quellen getrennt: getRouterParam() für URL-Segmente, getQuery() für Query-Strings und readBody() für den POST-Body. Expliziter, aber klarer.

Error Handling mit createError

Für HTTP-Fehler nutzt du createError() — wie raise ActiveRecord::RecordNotFound in Rails, aber mit explizitem Status-Code.

server/api/posts/[id].get.ts
// server/api/posts/[id].get.ts

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  // ID validieren
  if (!id || isNaN(Number(id))) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Ungültige ID'
    })
  }

  // Post suchen (simuliert)
  const post = await findPostById(Number(id))

  // 404 wenn nicht gefunden
  if (!post) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Post nicht gefunden'
    })
  }

  return post
})

// Rails-Vergleich:
// def show
//   @post = Post.find(params[:id])
// rescue ActiveRecord::RecordNotFound
//   render json: { error: 'Not found' }, status: :not_found
// end
ℹ️

Validierung

Für Request-Validierung empfiehlt sich die Library zod. Sie validiert und typisiert gleichzeitig — wie Strong Parameters in Rails, aber mit vollständiger Typ-Sicherheit.

Server Middleware

Server-Middleware läuft vor jedem API-Request — wie Rack Middleware in Rails. Perfekt für Logging, Auth-Checks oder CORS-Header.

server/middleware/log.ts
// server/middleware/log.ts
// Läuft bei JEDEM Server-Request — wie Rack::Logger

export default defineEventHandler((event) => {
  const method = event.method
  const url = getRequestURL(event).pathname
  console.log(`[${new Date().toISOString()}] ${method} ${url}`)

  // Kein return → Request geht weiter zum eigentlichen Handler
})
server/middleware/auth.ts
// server/middleware/auth.ts
// API-Authentifizierung — wie before_action :authenticate!

export default defineEventHandler(async (event) => {
  // Nur API-Routen schützen
  const url = getRequestURL(event).pathname
  if (!url.startsWith('/api/admin')) return

  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')

  if (!token) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Nicht authentifiziert'
    })
  }

  // Token verifizieren (z.B. mit jose oder jsonwebtoken)
  try {
    const user = await verifyToken(token)
    // User am Event speichern — wie current_user in Rails
    event.context.user = user
  } catch {
    throw createError({
      statusCode: 403,
      statusMessage: 'Ungültiges Token'
    })
  }
})
⚠️

Server- vs. Route-Middleware

Server-Middleware (server/middleware/) läuft auf dem Server bei jedem HTTP-Request — wie Rack Middleware.
Route-Middleware (middleware/) läuft im Browser bei clientseitiger Navigation — wie before_action. Verwechsle die beiden nicht!

Datenbank-Integration

Nuxt hat kein eingebautes ORM wie Rails ActiveRecord. Du wählst selbst — die beliebtesten Optionen:

ORMStilRails-Äquivalent
PrismaSchema-first, MigrationsAm nächsten an ActiveRecord
DrizzleTypeScript-first, leichtgewichtigWie Sequel in Ruby
Nuxt Hub / D1Serverless SQLiteSQLite mit Rails
server/utils/db.ts (Prisma)
// server/utils/db.ts
// Datenbank-Verbindung als Server-Utility
// Wird automatisch in allen server/-Dateien verfügbar

import { PrismaClient } from '@prisma/client'

// Singleton-Pattern (wie in Rails mit ActiveRecord::Base.connection)
let prisma: PrismaClient

export function usePrisma() {
  if (!prisma) {
    prisma = new PrismaClient()
  }
  return prisma
}

// Verwendung in API-Routen:
// const db = usePrisma()
// const posts = await db.post.findMany()

Komplettes CRUD-Beispiel

Hier ist ein vollständiges CRUD-API für Posts — das Nuxt-Äquivalent zu einem Rails PostsController mit allen Standardaktionen:

server/api/posts/index.get.ts — Liste
// server/api/posts/index.get.ts — GET /api/posts
// Rails: PostsController#index

export default defineEventHandler(async (event) => {
  const { page = '1', limit = '10' } = getQuery(event)
  const db = usePrisma()

  const posts = await db.post.findMany({
    skip: (Number(page) - 1) * Number(limit),
    take: Number(limit),
    orderBy: { createdAt: 'desc' },
    select: { id: true, title: true, slug: true, excerpt: true, createdAt: true }
  })

  const total = await db.post.count()

  return {
    posts,
    pagination: {
      page: Number(page),
      limit: Number(limit),
      total,
      pages: Math.ceil(total / Number(limit))
    }
  }
})
server/api/posts/index.post.ts — Erstellen
// server/api/posts/index.post.ts — POST /api/posts
// Rails: PostsController#create

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Validierung (wie Strong Parameters)
  if (!body.title || !body.content) {
    throw createError({
      statusCode: 422,
      statusMessage: 'Titel und Inhalt sind erforderlich'
    })
  }

  const db = usePrisma()

  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      slug: body.title.toLowerCase().replace(/\s+/g, '-'),
      excerpt: body.content.substring(0, 200),
      authorId: event.context.user?.id  // Aus Auth-Middleware
    }
  })

  // 201 Created zurückgeben
  setResponseStatus(event, 201)
  return post
})
server/api/posts/[id].get.ts — Einzeln lesen
// server/api/posts/[id].get.ts — GET /api/posts/:id
// Rails: PostsController#show

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, 'id'))
  const db = usePrisma()

  const post = await db.post.findUnique({
    where: { id },
    include: {
      author: { select: { name: true, avatar: true } },
      comments: { orderBy: { createdAt: 'desc' }, take: 20 }
    }
  })

  if (!post) {
    throw createError({ statusCode: 404, statusMessage: 'Post nicht gefunden' })
  }

  return post
})
server/api/posts/[id].put.ts — Aktualisieren
// server/api/posts/[id].put.ts — PUT /api/posts/:id
// Rails: PostsController#update

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, 'id'))
  const body = await readBody(event)
  const db = usePrisma()

  // Prüfen ob Post existiert
  const existing = await db.post.findUnique({ where: { id } })
  if (!existing) {
    throw createError({ statusCode: 404, statusMessage: 'Post nicht gefunden' })
  }

  // Nur erlaubte Felder aktualisieren (wie permit in Rails)
  const post = await db.post.update({
    where: { id },
    data: {
      title: body.title ?? existing.title,
      content: body.content ?? existing.content,
      slug: body.title
        ? body.title.toLowerCase().replace(/\s+/g, '-')
        : existing.slug,
      updatedAt: new Date()
    }
  })

  return post
})
server/api/posts/[id].delete.ts — Löschen
// server/api/posts/[id].delete.ts — DELETE /api/posts/:id
// Rails: PostsController#destroy

export default defineEventHandler(async (event) => {
  const id = Number(getRouterParam(event, 'id'))
  const db = usePrisma()

  // Prüfen ob Post existiert
  const existing = await db.post.findUnique({ where: { id } })
  if (!existing) {
    throw createError({ statusCode: 404, statusMessage: 'Post nicht gefunden' })
  }

  await db.post.delete({ where: { id } })

  // 204 No Content (wie in Rails: head :no_content)
  setResponseStatus(event, 204)
  return null
})
🛤️

Rails CRUD zum Vergleich

In Rails wäre das alles in einer Datei: app/controllers/posts_controller.rb mit index, show, create, update, destroy. In Nuxt ist es auf mehrere Dateien verteilt — jede Datei ist dafür kleiner und fokussierter. Beides hat Vor- und Nachteile.

Die komplette API-Struktur

So sieht eine typische Server-Struktur für eine Nuxt-App aus:

Server-Verzeichnis
server/
├── api/                          # API-Endpunkte
│   ├── posts/
│   │   ├── index.get.ts          # GET    /api/posts
│   │   ├── index.post.ts         # POST   /api/posts
│   │   ├── [id].get.ts           # GET    /api/posts/:id
│   │   ├── [id].put.ts           # PUT    /api/posts/:id
│   │   └── [id].delete.ts        # DELETE /api/posts/:id
│   ├── auth/
│   │   ├── login.post.ts         # POST   /api/auth/login
│   │   ├── logout.post.ts        # POST   /api/auth/logout
│   │   └── me.get.ts             # GET    /api/auth/me
│   └── health.get.ts             # GET    /api/health
├── middleware/                    # Server-Middleware (jeder Request)
│   ├── log.ts                    # Request-Logging
│   └── auth.ts                   # Token-Validierung
├── utils/                        # Server-Utilities (auto-importiert)
│   ├── db.ts                     # Datenbank-Verbindung
│   └── auth.ts                   # Auth-Hilfsfunktionen
└── plugins/                      # Server-Plugins (Startup)
    └── migrations.ts             # DB-Migrations beim Start

# Rails-Äquivalent:
# app/controllers/api/  → server/api/
# config/initializers/  → server/plugins/
# app/middleware/        → server/middleware/
# lib/                  → server/utils/
💡

Geschafft!

Du kennst jetzt die Grundlagen von Nuxt: Projekt-Struktur, Routing, Layouts, Data Fetching und Server-Routes. Mit diesen Bausteinen kannst du bereits vollständige Webanwendungen bauen — Frontend und Backend in einem Projekt.