40 KiB
Tech Stack Dokumentation
my.experimenta.science
Version: 1.0 Datum: 28. Oktober 2025
Übersicht
Dieser Dokument beschreibt den vollständigen Tech Stack für die my.experimenta.science E-Commerce App und begründet die getroffenen Entscheidungen.
1. Frontend Framework
Nuxt 4
Entscheidung: Nuxt 4 (Vue.js Meta-Framework)
Begründung:
- Vue 3 Composition API: Moderne, reaktive Architektur
- Server-Side Rendering (SSR): Bessere Performance & SEO
- File-based Routing: Intuitive Projektstruktur
- Auto-Import: Weniger Boilerplate-Code
- Hybrid Rendering: Flexible Rendering-Modi (SSR, SSG, CSR)
- TypeScript Support: Native TypeScript-Integration
- Server APIs: Backend & Frontend in einem Projekt
- Active Development: Nuxt 4 ist die neueste Version mit langfristigem Support
Alternativen:
- Next.js (React): Großartig, aber Team bevorzugt Vue
- SvelteKit: Weniger Ecosystem, kleinere Community
- Remix: Noch jünger, weniger etabliert
Version: ^4.0.0
2. UI Framework
Optionen: Nuxt UI vs. shadcn-vue
Option A: Nuxt UI
Pro:
- Speziell für Nuxt entwickelt
- Out-of-the-box Integration
- Headless UI Components
- Tailwind CSS basiert
- Dark Mode Support
- Accessibility eingebaut
- Icons & Colors mitgeliefert
- Nuxt-native (optimiert)
Contra:
- Kleinere Component-Bibliothek
- Weniger flexibel als shadcn
- Stärkere Meinung zum Styling
Best for: Schneller Start, weniger Customization
Option B: shadcn-nuxt
Pro:
- Copy-Paste Components (volle Kontrolle)
- Radix Vue (hochwertige Primitives)
- Vollständig anpassbar
- Kein Package Lock-in
- Große Component-Bibliothek
- Sehr flexibel
- Hervorragende TypeScript-Support
- Nuxt-native: Offizielle Nuxt-Integration
Contra:
- Manuelles Hinzufügen von Components via CLI
- Mehr Setup als Nuxt UI
Best for: Maximale Anpassung, langfristige Wartbarkeit
Empfehlung: shadcn-nuxt
Grund:
- Corporate Design Integration benötigt maximale Flexibilität
- Copy-Paste-Ansatz = kein Vendor Lock-in
- Vollständige Kontrolle über Komponenten
- Einfacher zu debuggen (Code liegt im Projekt)
- Bessere Anpassung an experimenta Styleguide möglich
- Offizielle Nuxt-Module Integration
Installation:
npx nuxi@latest module add shadcn-nuxt
npx shadcn-nuxt@latest init
Official Docs: https://www.shadcn-vue.com/docs/installation/nuxt.html
Dependencies:
shadcn-nuxt- Nuxt moduleradix-vue- UI Primitives (auto-installed)tailwindcss- Utility-first CSSclass-variance-authority- Varianten-Managementtailwind-merge- Classname-Mergingclsx- Conditional Classes
3. Styling
Tailwind CSS
Entscheidung: Tailwind CSS v4 (Beta)
Begründung:
- Utility-First: Schnelle Entwicklung
- Responsive Design: Mobile-first eingebaut
- Customization: Einfache Theme-Anpassung
- Performance: PurgeCSS integriert (kleine Bundles)
- Developer Experience: Hervorragende IDE-Integration (Intellisense)
- Konsistenz: Design-Tokens erzwingen Konsistenz
Corporate Design Integration:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
experimenta: {
primary: '#YOUR_PRIMARY_COLOR',
secondary: '#YOUR_SECONDARY_COLOR',
// ... weitere Farben aus Styleguide
},
},
fontFamily: {
sans: ['Your Corporate Font', 'sans-serif'],
},
},
},
}
4. Datenbank
PostgreSQL
Entscheidung: PostgreSQL 16+
Begründung:
- Relational: Klare Datenstrukturen für E-Commerce
- ACID-Compliant: Transaktionssicherheit für Bestellungen
- Robust: Bewährte Technologie
- Performance: Exzellent für Read-Heavy Workloads
- JSON Support: Flexibilität für metadata
- Full-Text Search: Native Suchfunktionen
- Extensions: PostGIS, pg_trgm, etc.
- Open Source: Keine Lizenzkosten
Hosting:
- Docker Container auf Hetzner Proxmox
- Persistent Volume für Daten
- Automated Backups (tägliche Snapshots)
Connection Pooling:
pg_bouncerfür effizientes Connection Management
5. ORM
Drizzle ORM
Entscheidung: Drizzle ORM
Begründung:
- TypeScript-First: Native TypeScript ohne Code-Generation
- Performance: Minimal Runtime Overhead
- SQL-Like Syntax: Leicht zu lernen für SQL-Kenner
- Type Safety: Compile-time Checks
- PostgreSQL-Optimiert: Nutzt PostgreSQL-Features voll aus
- Drizzle Studio: GUI für DB-Verwaltung
- Migrations: Einfache Schema-Verwaltung
- Lightweight: Keine schwere Runtime wie Prisma
Alternativen:
- Prisma: Mehr Overhead, langsamer, aber größere Community
- TypeORM: Veraltet, weniger TypeScript-fokussiert
- Kysely: Gut, aber weniger Features
Setup:
// drizzle.config.ts
export default {
schema: './server/database/schema.ts',
out: './server/database/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL,
},
}
Version: ^0.29.0
5.1 Data Storage Design: Billing Address
Entscheidung: Structured Columns in Users Table
Problem: Should we store billing addresses in the user profile or only in orders?
Entscheidung:
Store billing address directly in the users table as structured columns (not JSONB).
Begründung:
Pro Speicherung im Profil:
- UX-Verbesserung: Wiederkehrende Kunden müssen Daten nicht erneut eingeben
- Conversion-Rate: Reduziert Checkout-Abbrüche (10-20% lt. Branchenstandard)
- Mobile-First: Schnellerer Checkout ist kritisch für mobile Nutzer
- Geschäftsmodell: Jahreskarten = wiederkehrende Kunden (jährliche Erneuerung)
- E-Commerce Best Practice: Amazon, PayPal, alle großen Shops speichern Adressen
- DSGVO-konform: Rechtsgrundlage "Vertragserfüllung" (Art. 6(1)(b))
Strukturierte Spalten vs. JSONB:
| Aspekt | Structured Columns | JSONB Column |
|---|---|---|
| Type Safety | ✅ Voll typsicher | ⚠️ Zod-Validierung nötig |
| Queries | ✅ Einfach zu filtern/sortieren | ❌ Komplexere Queries |
| Performance | ✅ Besser indexierbar | ⚠️ Langsamer |
| Flexibility | ❌ Schema-Änderungen = Migration | ✅ Flexibel |
| X-API Mapping | ✅ Direkt mappbar | ⚠️ Parsing nötig |
Entscheidung: Strukturierte Spalten
Felder:
export const users = pgTable('users', {
// ... existing fields
// Billing address (optional - filled during checkout)
salutation: varchar('salutation', { length: 20 }), // 'male', 'female', 'other'
dateOfBirth: date('date_of_birth'), // Required for annual passes
street: varchar('street', { length: 100 }),
postCode: varchar('post_code', { length: 20 }),
city: varchar('city', { length: 100 }),
countryCode: varchar('country_code', { length: 10 }).default('DE'),
})
UX Flow:
- First Checkout: User fills address form, checkbox "Save for future orders" is pre-checked
- Subsequent Checkouts: Form pre-filled, editable before submission
- Profile Management:
/profil/adressepage to view/edit/delete address
Migration Strategy:
- Address fields are optional in DB schema
- Required validation only at checkout (Zod)
- Existing users: Backfill from most recent order (optional)
Privacy:
- Opt-out via unchecking checkbox at checkout
- Can be deleted in profile settings
- Included in GDPR data export/deletion
5.2 Queue System: BullMQ
Entscheidung: BullMQ (MIT License)
Problem: Asynchrone Verarbeitung von zeitaufwendigen Operationen (X-API Order Submission, NAV Product Sync).
Entscheidung: BullMQ als Queue-System mit Redis als Backend.
Begründung:
Pro BullMQ:
- TypeScript-First: Native TypeScript-Integration, perfekt für Nuxt
- Einfache Integration: npm package, kein separater Broker-Service
- Redis-basiert: Nutzt Redis (für Sessions/Caching eh sinnvoll)
- MIT License: Kostenlos, echtes Open Source
- Features:
- Retry Logic mit exponential backoff
- Priority Queues
- Delayed Jobs
- Rate Limiting
- Dead Letter Queue
- Flow Control (Job Chains)
- Monitoring: BullBoard (kostenlos) für Web-Dashboard
- Performance: 10.000+ Jobs/Sekunde (ausreichend)
- Community: Große, aktive Community
- Production-Ready: Von vielen Unternehmen eingesetzt
Alternativen:
| Lösung | Pro | Contra | Empfehlung |
|---|---|---|---|
| BullMQ | TypeScript, einfach, MIT | Nur Node.js | ✅ Empfohlen |
| RabbitMQ | Robust, Multi-Language | Zu komplex, separater Service | ❌ Overkill |
| Database Queue | Keine neue Dependency | Weniger Features, Polling | ❌ Nicht skalierbar |
| Kafka | Extreme Skalierung | Viel zu komplex | ❌ Overkill |
Für my.experimenta.science ist BullMQ ideal:
- ✅ Nuxt-only (Node.js)
- ✅ Einfache Use Cases (Order → X-API, Product Sync)
- ✅ Moderater Traffic (~10-500 Orders/Tag)
- ✅ Redis eh nützlich (Sessions, Caching)
Use Cases
1. Order Submission (Async)
// server/queues/orderQueue.ts
import { Queue } from 'bullmq'
export const orderQueue = new Queue('x-api-orders', {
connection: { host: 'redis', port: 6379 },
})
// Add job after PayPal success
export async function queueOrderSubmission(orderId: string) {
await orderQueue.add(
'submit-order',
{ orderId },
{
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
removeOnFail: false, // Keep for manual review
}
)
}
2. Product Sync (Async)
// server/queues/productQueue.ts
export const productSyncQueue = new Queue('product-sync', {
connection: { host: 'redis', port: 6379 },
})
// API endpoint: POST /api/erp/products
export default defineEventHandler(async (event) => {
const product = await readBody(event)
// Validate & queue (instant response!)
await productSyncQueue.add('sync-product', product, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
})
return { status: 'accepted', queued: true }
})
3. Worker Implementation
// server/workers/orderWorker.ts
import { Worker } from 'bullmq'
export const orderWorker = new Worker(
'x-api-orders',
async (job) => {
const { orderId } = job.data
// 1. Fetch order from DB
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: { items: true, user: true },
})
// 2. Transform & submit to X-API
const payload = transformOrderToXAPI(order)
await submitToXAPI(payload)
// 3. Update status
await db.update(orders).set({ status: 'completed' }).where(eq(orders.id, orderId))
},
{
connection: { host: 'redis', port: 6379 },
concurrency: 5,
limiter: { max: 10, duration: 1000 },
}
)
Monitoring: BullBoard
// server/api/admin/queues.ts
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { NuxtAdapter } from '@bull-board/nuxt'
const serverAdapter = new NuxtAdapter()
createBullBoard({
queues: [new BullMQAdapter(orderQueue), new BullMQAdapter(productSyncQueue)],
serverAdapter,
})
// Access: http://localhost:3000/admin/queues
Dashboard Features (kostenlos):
- Real-time Job-Status
- Manual Retry
- Job Details & Logs
- Queue-Metriken
- Fehler-Analyse
BullMQ Dependencies
{
"dependencies": {
"bullmq": "^5.0.0",
"@bull-board/api": "^5.0.0",
"@bull-board/nuxt": "^5.0.0"
}
}
Alle MIT License - kostenlos!
BullMQ Kosten
BullMQ: €0 (MIT License) BullMQ Pro: €99-299/Monat (nicht nötig!) Taskforce.sh: €49-199/Monat (BullBoard reicht!)
Für dich: €0 - alles kostenlos!
5.3 In-Memory Store: Redis
Entscheidung: Redis 7
Verwendung:
- BullMQ Queue Storage (Hauptzweck)
- Session Storage (HTTP-only cookies)
- Caching (Produkt-Daten, etc.)
- Rate Limiting (Brute-Force-Schutz)
Begründung:
- ✅ Persistenz mit AOF + RDB (keine Datenverluste)
- ✅ Schnell (In-Memory)
- ✅ Bewährt (Millionen Installationen)
- ✅ Docker-ready (redis:7-alpine)
Lizenz-Situation:
Redis License (seit März 2024):
- RSALv2 (Redis Source Available License)
- SSPL (Server Side Public License)
Dein Use Case:
✅ Vollständig legal und kostenlos!
Was du machst:
- Redis selbst hosten (in docker-compose)
- Für interne App-Funktionen nutzen
- Nicht als Hosting-Service verkaufen
→ Keine Lizenzkosten, keine Probleme!
Was verboten wäre:
- ❌ "experimenta Redis Cloud" Service anbieten
- ❌ Managed Redis Hosting verkaufen
Alternative: Valkey (100% Open Source)
Falls Lizenz-Unsicherheit:
- Valkey: Redis-Fork (Linux Foundation)
- Lizenz: BSD-3-Clause (echtes Open Source)
- Kompatibilität: 100% Drop-in Replacement
- Docker:
valkey/valkey:7.2-alpine
Empfehlung: Start mit Redis, später optional auf Valkey migrieren.
Persistenz-Konfiguration
# docker-compose.yml
services:
redis:
image: redis:7-alpine
command: >
redis-server
--appendonly yes
--appendfsync everysec
--save 60 1000
--save 300 100
--save 900 1
volumes:
- redis_data:/data
restart: unless-stopped
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
volumes:
redis_data:
Garantie: Max. 1 Sekunde Datenverlust bei Server-Crash.
Erklärung:
--appendonly yes: AOF aktiviert--appendfsync everysec: Jede Sekunde auf Disk--save: RDB Snapshots als Backup
Redis Dependencies
{
"dependencies": {
"ioredis": "^5.3.0" // Redis client für Node.js
}
}
Redis Kosten
Redis/Valkey: €0 (Open Source) Einzige Kosten: Server-RAM (~512MB für Redis)
6. Authentifizierung
Cidaas (OIDC/OAuth2)
Entscheidung: Cidaas Integration mit Custom UI
Begründung:
- Unternehmensvorgabe: Bereits im Einsatz bei experimenta
- OIDC/OAuth2: Standard-Protokolle
- Compliance: DSGVO-konform (EU-basiert, Widas)
- Features: MFA, Social Login (optional)
Architektur:
- Cidaas: Nur für Authentifizierung (User Identity)
- Lokale DB: User-Profile & Rollen
- Custom UI: Eigene Login/Registrierungs-Masken im experimenta Design
Integration:
// Nuxt OAuth Module oder Custom Implementation
import { createOAuth2Client } from '@nuxt/oauth'
export default defineNuxtConfig({
oauth: {
cidaas: {
clientId: process.env.CIDAAS_CLIENT_ID,
clientSecret: process.env.CIDAAS_CLIENT_SECRET,
authorizeUrl: 'https://experimenta.cidaas.de/authorize',
tokenUrl: 'https://experimenta.cidaas.de/token',
userInfoUrl: 'https://experimenta.cidaas.de/userinfo',
},
},
})
Libraries:
nuxt-auth-utilsoder Custom OAuth2 Implementationjosefür JWT-Validierung
7. Payment Gateway
PayPal (MVP)
Entscheidung: PayPal Checkout Integration
Begründung:
- Weit verbreitet: Hohe Akzeptanz in Deutschland
- Einfache Integration: Offizielle SDK & Dokumentation
- Buyer Protection: Käuferschutz erhöht Vertrauen
- Schnell: Weniger Daten als Kreditkarte erforderlich
- Mobile-optimiert: PayPal App-Integration
SDK:
// PayPal JavaScript SDK
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID¤cy=EUR"></script>
Server-Side:
- PayPal REST API für Order Creation & Capture
- Webhook für Payment-Benachrichtigungen
Post-MVP:
- Stripe (Kreditkarte, Apple Pay, Google Pay)
- SEPA-Lastschrift
- Klarna / Ratenzahlung
8. External API Integration: X-API
X-API Client (Order Submission)
Entscheidung: Native fetch with retry logic
Purpose: Submit completed orders to NAV ERP via X-API /shopware/order endpoint
Begründung:
- Native: No additional dependencies (fetch is built-in)
- TypeScript: Full type safety with Zod validation
- Retry Logic: Custom implementation with exponential backoff
- Error Handling: Comprehensive error tracking and logging
Environments:
| Environment | URL |
|---|---|
| Development | https://x-api-dev.experimenta.science |
| Staging | https://x-api-stage.experimenta.science |
| Production | https://x-api.experimenta.science |
X-API Authentication
Entscheidung: HTTP Basic Authentication
Begründung:
- Standard: HTTP Basic Auth ist ein bewährter Standard (RFC 7617)
- Einfach: Keine komplexe OAuth2-Integration nötig
- Secure: HTTPS-verschlüsselte Übertragung der Credentials
- Kompatibel: Unterstützt von allen HTTP-Clients
Implementierung:
// server/utils/xapi.ts
const config = useRuntimeConfig()
// Prepare Basic Auth header
const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')
const response = await fetch(`${config.xApiBaseUrl}/shopware/order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${authString}`,
},
body: JSON.stringify(payload),
})
Sicherheitshinweise:
- ✅ Credentials nur über HTTPS übertragen
- ✅ Credentials niemals im Code hardcoden
- ✅ Separate Service-Accounts pro Environment (dev/stage/prod)
- ✅ Credentials regelmäßig rotieren (alle 90 Tage)
- ✅ In Production: Docker Secrets verwenden statt Environment Variables
Environment Variables:
# .env
X_API_BASE_URL=https://x-api-dev.experimenta.science
X_API_USERNAME=shop_user_dev
X_API_PASSWORD=xxx
Docker Secrets (Production):
# docker-compose.yml
services:
app:
secrets:
- xapi_username
- xapi_password
environment:
X_API_USERNAME_FILE: /run/secrets/xapi_username
X_API_PASSWORD_FILE: /run/secrets/xapi_password
secrets:
xapi_username:
file: ./secrets/xapi_username.txt
xapi_password:
file: ./secrets/xapi_password.txt
Implementation:
// server/utils/xapi.ts
import { z } from 'zod'
// Zod schema for X-API request validation
const XAPIOrderSchema = z.object({
shopPOSOrder: z.object({
documentType: z.enum(['Order', 'Credit Memo']),
externalDocumentNo: z.string().max(35),
externalPOSReceiptNo: z.string().max(250),
salesChannel: z.enum(['Shop', 'POS']),
shoppingCartCompletion: z.string().datetime(),
visitType: z.enum(['Private', 'Institution']),
invoiceDiscountValue: z.number().int(),
invoiceDiscPct: z.number().int(),
amountIncludingVAT: z.number().int(), // Cents!
language: z.enum(['DEU', 'ENU']),
salesLine: z.array(
z.object({
type: z.enum(['', 'Item']),
lineNo: z.string().max(30),
no: z.string().max(20),
variantCode: z.string().max(10),
description: z.string().max(50),
description2: z.string().max(50),
quantity: z.number().int(),
unitPrice: z.number().int(), // Cents!
vatPct: z.number(),
lineAmountIncludingVAT: z.number(),
lineDiscountPct: z.number(),
lineDiscountAmount: z.number().int(),
seasonTicketCode: z.string().max(20),
couponCode: z.string().max(20),
visitorCategory: z.enum(['00', '10', '40', '50', '60', '99', '120', '128']),
ticketPriceType: z.string().max(50),
ticketCode: z.string().max(250),
annualPass: z
.object({
salutationCode: z.enum(['K_ANGABE', 'FRAU', 'HERR']),
firstName: z.string().max(30),
lastName: z.string().max(30),
streetAndHouseNumber: z.string().max(50),
postCode: z.string().max(20),
city: z.string().max(30),
countryCode: z.string().max(10),
dateOfBirth: z.string(),
eMail: z.string().email(),
validFrom: z.string(),
})
.optional(),
})
),
payment: z.array(
z.object({
paymentEntryNo: z.number().int(),
amount: z.number().int(), // Cents!
paymentType: z.enum(['Shop PayPal', 'Shop Credit Card', 'Shop Direct Debit']),
createdOn: z.string().datetime(),
reference: z.string().max(50),
paymentId: z.string().max(50),
})
),
personContact: z.object({
experimentaAccountID: z.string().uuid(),
eMail: z.string().email(),
salutationCode: z.enum(['K_ANGABE', 'FRAU', 'HERR']),
jobTitle: z.string().max(30),
firstName: z.string().max(30),
lastName: z.string().max(30),
streetAndHouseNumber: z.string().max(50),
postCode: z.string().max(20),
city: z.string().max(30),
countryCode: z.string().max(10),
phoneNumber: z.string().max(30),
guest: z.boolean(),
}),
}),
})
export type XAPIOrderPayload = z.infer<typeof XAPIOrderSchema>
// Submit order to X-API with retry logic
export async function submitOrderToXAPI(payload: XAPIOrderPayload) {
// Validate payload
const validatedPayload = XAPIOrderSchema.parse(payload)
const config = useRuntimeConfig()
const baseURL = config.xApiBaseUrl // From environment
// Retry logic with exponential backoff
const maxRetries = 3
const retryDelays = [1000, 3000, 9000] // 1s, 3s, 9s
// Prepare Basic Auth header
const authString = Buffer.from(`${config.xApiUsername}:${config.xApiPassword}`).toString('base64')
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(`${baseURL}/shopware/order`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${authString}`,
},
body: JSON.stringify(validatedPayload),
})
if (!response.ok) {
throw new Error(`X-API returned ${response.status}: ${await response.text()}`)
}
const result = await response.json()
// Log success
console.info(`Order submitted to X-API successfully`, {
orderNo: payload.shopPOSOrder.externalDocumentNo,
attempt: attempt + 1,
})
return result
} catch (error) {
// Log attempt
console.error(`X-API submission failed (attempt ${attempt + 1}/${maxRetries})`, {
orderNo: payload.shopPOSOrder.externalDocumentNo,
error,
})
// If this was the last attempt, throw
if (attempt === maxRetries - 1) {
throw error
}
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]))
}
}
}
// Helper: Transform order from DB to X-API format
export function transformOrderToXAPI(order: Order, user: User): XAPIOrderPayload {
return {
shopPOSOrder: {
documentType: 'Order',
externalDocumentNo: order.orderNumber,
externalPOSReceiptNo: '',
salesChannel: 'Shop',
shoppingCartCompletion: order.createdAt.toISOString(),
visitType: 'Private',
invoiceDiscountValue: 0,
invoiceDiscPct: 0,
amountIncludingVAT: Math.round(order.totalAmount * 100), // EUR → Cents
language: 'DEU',
salesLine: order.items.map((item, index) => ({
type: 'Item',
lineNo: String((index + 1) * 10000),
no: item.product.navProductId,
variantCode: item.product.variantCode || '',
description: item.product.name.substring(0, 50),
description2: item.product.description?.substring(0, 50) || '',
quantity: item.quantity,
unitPrice: Math.round(item.priceSnapshot * 100), // EUR → Cents
vatPct: 7,
lineAmountIncludingVAT: Math.round(item.quantity * item.priceSnapshot * 100),
lineDiscountPct: 0,
lineDiscountAmount: 0,
seasonTicketCode: '',
couponCode: '',
visitorCategory: '120',
ticketPriceType: 'Jahreskarte Makerspace',
ticketCode: generateTicketCode(),
annualPass: {
salutationCode: mapSalutation(user.salutation),
firstName: user.firstName,
lastName: user.lastName,
streetAndHouseNumber: user.address.street,
postCode: user.address.postCode,
city: user.address.city,
countryCode: user.address.countryCode || 'DE',
dateOfBirth: user.dateOfBirth,
eMail: user.email,
validFrom: new Date().toISOString().split('T')[0], // YYYY-MM-DD
},
})),
payment: [
{
paymentEntryNo: 10000,
amount: Math.round(order.totalAmount * 100), // EUR → Cents
paymentType: 'Shop PayPal',
createdOn: order.paymentCompletedAt.toISOString(),
reference: order.paymentId,
paymentId: order.paymentId,
},
],
personContact: {
experimentaAccountID: user.experimentaId,
eMail: user.email,
salutationCode: mapSalutation(user.salutation),
jobTitle: user.jobTitle || '',
firstName: user.firstName,
lastName: user.lastName,
streetAndHouseNumber: order.billingAddress.street,
postCode: order.billingAddress.postCode,
city: order.billingAddress.city,
countryCode: order.billingAddress.countryCode || 'DE',
phoneNumber: user.phone || '',
guest: false,
},
},
}
}
Environment Variables:
# .env (Development/Staging)
X_API_BASE_URL=https://x-api-dev.experimenta.science
X_API_USERNAME=shop_user_dev
X_API_PASSWORD=xxx
# .env.stage
# X_API_BASE_URL=https://x-api-stage.experimenta.science
# X_API_USERNAME=shop_user_stage
# X_API_PASSWORD=xxx
# Production: Use Docker Secrets (see X-API Authentication section above)
Alternative: Use ofetch (Nuxt's built-in fetch wrapper) for better error handling:
import { ofetch } from 'ofetch'
const xapi = ofetch.create({
baseURL: config.xApiBaseUrl,
retry: 3,
retryDelay: 1000,
})
9. Backend & APIs
Nuxt Server APIs
Entscheidung: Nuxt Server Routes (Nitro)
Begründung:
- Monorepo: Backend & Frontend in einem Projekt
- Serverless-Ready: Kann als Serverless Functions deployed werden
- Auto-Import: Shared Utils zwischen Client & Server
- TypeScript: Type-Safety über Frontend/Backend-Grenze
- Performance: Nitro Engine (schneller als Express)
- Flexible Deployment: Node.js, Deno, Workers, Docker
API Struktur:
server/
api/
auth/
login.post.ts
callback.get.ts
products/
index.get.ts
[id].get.ts
cart/
items.post.ts
items/[id].patch.ts
orders/
index.post.ts
[id].get.ts
payment/
paypal/
create.post.ts
capture.post.ts
webhook.post.ts
erp/
products.post.ts
9. Internationalization (i18n)
@nuxtjs/i18n
Entscheidung: @nuxtjs/i18n (Official Nuxt module)
Begründung:
- Offizielles Nuxt-Modul: Beste Integration mit Nuxt 4
- Vue I18n powered: Industry-Standard für Vue.js
- SEO-optimiert: Automatische locale routes, hreflang tags
- SSR/SSG Support: Perfekt für Nuxt's rendering modes
- Lazy Loading: Sprachdateien on-demand laden
- TypeScript Support: Vollständig typisiert
- Auto-Import: Composables wie
useI18n(),$t()direkt verfügbar
Supported Languages:
- 🇩🇪 Deutsch (Primary, default locale)
- 🇬🇧 English (Secondary, für internationale Besucher)
Installation:
npx nuxi@latest module add i18n
Configuration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{
code: 'de',
iso: 'de-DE',
name: 'Deutsch',
file: 'de-DE.json',
},
{
code: 'en',
iso: 'en-US',
name: 'English',
file: 'en-US.json',
},
],
defaultLocale: 'de',
strategy: 'prefix_except_default', // URLs: /produkte (de), /en/products (en)
langDir: 'locales',
lazy: true,
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root',
},
},
})
Project Structure:
/locales
├── de-DE.json # German translations
└── en-US.json # English translations
Translation Files Example:
// locales/de-DE.json
{
"nav": {
"home": "Startseite",
"products": "Produkte",
"cart": "Warenkorb"
},
"product": {
"addToCart": "In den Warenkorb",
"price": "Preis",
"description": "Beschreibung"
},
"checkout": {
"title": "Zur Kasse",
"total": "Gesamt",
"payNow": "Jetzt bezahlen"
}
}
// locales/en-US.json
{
"nav": {
"home": "Home",
"products": "Products",
"cart": "Cart"
},
"product": {
"addToCart": "Add to Cart",
"price": "Price",
"description": "Description"
},
"checkout": {
"title": "Checkout",
"total": "Total",
"payNow": "Pay Now"
}
}
Usage in Components:
<template>
<div>
<h1>{{ $t('nav.home') }}</h1>
<p>{{ $t('product.description') }}</p>
<!-- With parameters -->
<p>{{ $t('product.price', { amount: formatPrice(99.0) }) }}</p>
<!-- Language switcher -->
<USelectMenu v-model="locale" :options="availableLocales">
<template #label>
{{ locale === 'de' ? '🇩🇪 Deutsch' : '🇬🇧 English' }}
</template>
</USelectMenu>
</div>
</template>
<script setup lang="ts">
const { locale, t, availableLocales } = useI18n()
// Format price according to locale
const formatPrice = (price: number) => {
return new Intl.NumberFormat(locale.value, {
style: 'currency',
currency: 'EUR',
}).format(price)
}
// Format date according to locale
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
</script>
SEO Benefits:
- Automatische Routes:
/produkte(de),/en/products(en) - Automatische
<link rel="alternate" hreflang="de-DE" />Tags - Locale-specific meta tags
- Sitemap mit allen Sprachen
X-API Integration:
// server/utils/xapi.ts
import { useI18n } from '#i18n'
export function transformOrderToXAPI(order: Order, user: User) {
const { locale } = useI18n()
return {
shopPOSOrder: {
// Map locale to X-API language format
language: locale.value === 'de' ? 'DEU' : 'ENU',
// ... rest of payload
},
}
}
Version: ^8.0.0
Official Docs: https://i18n.nuxtjs.org/
10. Validation & Forms
Zod + VeeValidate
Entscheidung:
- Zod: Schema Validation
- VeeValidate: Form Handling
Begründung:
- Type Safety: Zod generiert TypeScript-Types
- Reusable Schemas: Validierung auf Client & Server
- Error Handling: Strukturierte Error-Messages
- VeeValidate: Vue-native Form Library
- Composition API Support: Moderne Vue 3 Syntax
Example:
import { z } from 'zod'
export const checkoutSchema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
address: z.string().min(5),
})
export type CheckoutForm = z.infer<typeof checkoutSchema>
Dependencies:
zod- Schema validationvee-validate- Form handling@vee-validate/zod- Integration
10. State Management
Pinia (Optional)
Entscheidung: Pinia für globale State (sparsam einsetzen)
Begründung:
- Vue 3 Native: Offizielle State Library für Vue
- TypeScript: Hervorragende Type-Inference
- Devtools: Vue Devtools Integration
- Composition API: Moderne Syntax
- SSR-Ready: Nuxt Auto-Import
Verwendung:
- User Store (Auth-Status)
- Cart Store (Warenkorb)
- Minimal halten, meiste State in Composables
Alternative:
- Nuxt Composables (
useState) für einfache Cases
11. Deployment & Infrastructure
Docker
Entscheidung: Docker Containerisierung
Begründung:
- Konsistenz: Gleiche Umgebung überall (Dev, Staging, Prod)
- Portabilität: Läuft auf Hetzner, AWS, überall
- Isolation: Keine Konflikte mit anderen Services
- Skalierung: Einfach mehrere Container starten
Dockerfile (Multi-Stage Build):
# Build Stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production Stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", "server/index.mjs"]
Docker Compose (für Dev):
services:
app:
build: .
ports:
- '3000:3000'
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/experimenta
depends_on:
- db
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=experimenta
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
postgres_data:
Hetzner Cloud
Hosting:
- Hetzner Dedicated Server oder VPS
- Proxmox für Virtualisierung
- Docker Container für App & DB
- Nginx/Traefik als Reverse Proxy
- Let's Encrypt für SSL
Empfohlene Server-Specs (Production):
- CPU: 4 Cores
- RAM: 8 GB
- Storage: 160 GB SSD
- Backup: Tägliche Snapshots
CI/CD mit GitLab
Pipeline Stages:
- Build: Docker Image bauen
- Test: Unit & E2E Tests laufen
- Deploy Staging: Automatisch auf Staging deployen
- Deploy Production: Manuell getriggert (approval)
Deployment-Optionen:
Option A: GitLab Runner auf Server
- Runner auf Hetzner Server installiert
- Direkter Zugriff zu Docker
- Schnelleres Deployment
Option B: SSH-Zugriff
- Runner im GitLab-Server
- Deployment via SSH + rsync/docker commands
- Mehr Isolation
Empfehlung: Runner auf Server (Option A)
.gitlab-ci.yml:
stages:
- build
- test
- deploy
build:
stage: build
script:
- docker build -t experimenta-app:$CI_COMMIT_SHA .
only:
- main
test:
stage: test
script:
- npm ci
- npm run test
deploy_production:
stage: deploy
script:
- docker pull experimenta-app:$CI_COMMIT_SHA
- docker stop experimenta-app || true
- docker rm experimenta-app || true
- docker run -d --name experimenta-app -p 3000:3000 experimenta-app:$CI_COMMIT_SHA
only:
- main
when: manual
12. Testing
Vitest
Entscheidung: Vitest für Unit & Integration Tests
Begründung:
- Vite-Native: Gleiche Config wie Nuxt
- Fast: Parallelisierung, Watch Mode
- Vue Support: @vue/test-utils Integration
- TypeScript: Native TS-Support
- API kompatibel mit Jest: Einfache Migration
Config:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
threshold: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
})
Playwright
Entscheidung: Playwright für E2E Tests
Begründung:
- Modern: Neueste Browser-Automation
- Multi-Browser: Chrome, Firefox, Safari, Edge
- Mobile Emulation: Responsive Testing
- Fast: Parallelisierung
- Debug-Tools: Hervorragende Dev Experience
Tests:
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('complete checkout flow', async ({ page }) => {
await page.goto('/')
await page.click('text=Makerspace Jahreskarte')
await page.click('text=In den Warenkorb')
await page.click('[aria-label="Warenkorb"]')
await page.click('text=Zur Kasse')
// ... weitere Schritte
})
13. Monitoring & Logging
Sentry (Optional)
Entscheidung: Sentry für Error Tracking
Begründung:
- Real-time Errors: Sofortige Benachrichtigung
- Stack Traces: Detaillierte Error-Informationen
- User Context: Welcher User ist betroffen?
- Release Tracking: Errors pro Version
- Performance Monitoring: Transaction Tracing
Integration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sentry'],
sentry: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
},
})
Alternative: Eigenes Logging-System mit Loki/Grafana
14. Email-Versand
Optionen
Option A: Eigener SMTP-Server
- Pro: Volle Kontrolle, keine Kosten
- Contra: Wartung, Spam-Probleme, Deliverability
Option B: Transactional Email Service
- SendGrid: Zuverlässig, gute API
- Postmark: Spezialisiert auf Transactional
- Mailgun: Flexibel, gute Dokumentation
- AWS SES: Günstig, aber mehr Setup
Empfehlung: Postmark oder SendGrid
- Bessere Deliverability
- Weniger Wartung
- Templates & Analytics
- Webhooks für Bounce/Spam
Nuxt Mailer:
// server/utils/mail.ts
import nodemailer from 'nodemailer'
export const sendOrderConfirmation = async (email: string, order: Order) => {
const transporter = nodemailer.createTransporter({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
await transporter.sendMail({
from: 'noreply@experimenta.science',
to: email,
subject: `Bestellbestätigung ${order.orderNumber}`,
html: renderOrderEmail(order),
})
}
15. Development Tools
ESLint + Prettier
Entscheidung: ESLint + Prettier für Code Quality
Config:
{
"extends": ["@nuxt/eslint-config", "prettier"],
"rules": {
"vue/multi-word-component-names": "off"
}
}
TypeScript
Entscheidung: Strict TypeScript
tsconfig.json:
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
}
}
16. Package Manager
pnpm
Entscheidung: pnpm statt npm/yarn
Begründung:
- Schneller: Symlinks statt Kopien
- Disk Space: Shared Dependencies
- Strict: Verhindert Ghost Dependencies
- Monorepo-Ready: Workspaces Support
17. Vollständige Dependency-Liste
Dependencies (package.json)
{
"dependencies": {
"nuxt": "^4.0.0",
"vue": "^3.4.0",
"@nuxtjs/tailwindcss": "^6.11.0",
"radix-vue": "^1.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"drizzle-orm": "^0.29.0",
"postgres": "^3.4.0",
"zod": "^3.22.0",
"vee-validate": "^4.12.0",
"@vee-validate/zod": "^4.12.0",
"pinia": "^2.1.0",
"@pinia/nuxt": "^0.5.0",
"jose": "^5.2.0",
"@paypal/checkout-server-sdk": "^1.0.3",
"nodemailer": "^6.9.0"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.2.0",
"@nuxtjs/sentry": "^8.0.0",
"drizzle-kit": "^0.20.0",
"eslint": "^8.56.0",
"prettier": "^3.2.0",
"typescript": "^5.3.0",
"vitest": "^1.2.0",
"@vue/test-utils": "^2.4.0",
"@playwright/test": "^1.41.0",
"@nuxt/test-utils": "^3.11.0"
}
}
18. Zusammenfassung
| Kategorie | Technologie | Version |
|---|---|---|
| Framework | Nuxt | 4.x |
| UI Library | shadcn-vue | latest |
| Styling | Tailwind CSS | 4.x |
| Datenbank | PostgreSQL | 16+ |
| ORM | Drizzle | 0.29+ |
| Auth | Cidaas | - |
| Payment | PayPal | - |
| Validation | Zod | 3.x |
| Forms | VeeValidate | 4.x |
| State | Pinia | 2.x |
| Testing (Unit) | Vitest | 1.x |
| Testing (E2E) | Playwright | 1.x |
| Deployment | Docker | latest |
| CI/CD | GitLab CI | - |
| Hosting | Hetzner | - |
| Error Tracking | Sentry (optional) | 8.x |
| Postmark/SendGrid | - |
Ende des Dokuments