DEV Community

Cover image for Von 0 auf Production: Wie ich eine komplette Service-Plattform mit Next.js 15 + Firebase baute
Andy Staudinger
Andy Staudinger

Posted on • Originally published at the-freelancer.marketing

Von 0 auf Production: Wie ich eine komplette Service-Plattform mit Next.js 15 + Firebase baute

TL;DR

Ich habe Taskilo gebaut - eine komplette Service-Plattform, die Kunden mit verifizierten Dienstleistern verbindet. Von Handwerk über Reinigung bis Gartenpflege - alles auf einer Plattform.

In diesem ausführlichen Artikel teile ich:

  • 🏗️ Architektur-Entscheidungen und warum ich sie getroffen habe
  • ⚡ Performance-Optimierungen: Von 3.5s LCP auf unter 1.5s
  • 🔐 Firebase Auth in Next.js Server Components
  • 📦 Component-Architektur mit über 150 wiederverwendbaren Komponenten
  • 🗺️ Google Maps Integration mit Performance-Optimierung
  • 📅 Kalender- und Buchungssystem
  • 💡 6 Monate Entwicklung: Was ich anders machen würde

Live Demo: taskilo.de

Source Code: GitHub


Die Ausgangslage: Das Problem

Jeder kennt es: Du brauchst einen Handwerker, Reinigungsservice oder Gärtner. Aber:

  • Vertrauen: Woher weißt du, ob der Anbieter seriös ist?
  • Verfügbarkeit: Endlose Telefonate um einen Termin zu finden
  • Preise: Keine Transparenz, böse Überraschungen auf der Rechnung
  • Kommunikation: WhatsApp-Chaos mit 5 verschiedenen Anbietern

Nach 3 No-Shows und 2 überteuerten Angeboten dachte ich: Das muss besser gehen!


Der Tech Stack im Detail

Nach Jahren Erfahrung mit verschiedenen Stacks habe ich mich für diese Kombination entschieden:

Frontend

Technologie Version Einsatz
Next.js 15.4 Framework mit App Router
React 19.2 UI Library
TypeScript 5.9 Type Safety
Tailwind CSS 4.0 Styling
shadcn/ui Latest UI Components
Framer Motion 12.x Animationen
Zustand 5.0 State Management

Backend & Infrastruktur

Technologie Einsatz
Firebase Firestore NoSQL Datenbank
Firebase Auth Authentifizierung
Firebase Functions Serverless Backend
Firebase Storage Datei-Upload
Vercel Hosting & Edge Functions
Google Maps API Karten & Geocoding

Dev Tools

Tool Zweck
pnpm Package Manager (schneller als npm)
Turbopack Dev Server (10x schneller als Webpack)
Husky Git Hooks
ESLint + Prettier Code Quality
TypeScript strict mode Keine any erlaubt

Projekt-Struktur: Skalierbare Architektur

Nach mehreren Refactorings bin ich bei dieser Struktur gelandet:

src/
├── app/                    # Next.js App Router
│   ├── (public)/          # Öffentliche Seiten
│   │   ├── page.tsx       # Landing Page
│   │   ├── services/      # Service-Kategorien
│   │   └── providers/     # Anbieter-Profile
│   ├── (auth)/            # Auth-Seiten
│   │   ├── login/
│   │   ├── register/
│   │   └── forgot-password/
│   ├── (dashboard)/       # Geschützter Bereich
│   │   ├── customer/      # Kunden-Dashboard
│   │   └── provider/      # Anbieter-Dashboard
│   └── api/               # API Routes
│       ├── auth/
│       ├── bookings/
│       └── webhooks/
├── components/
│   ├── ui/                # Basis-Komponenten (Button, Input, etc.)
│   ├── forms/             # Formular-Komponenten
│   ├── layouts/           # Layout-Komponenten
│   ├── cards/             # Card-Varianten
│   ├── maps/              # Google Maps Komponenten
│   └── providers/         # Context Provider
├── lib/
│   ├── firebase/          # Firebase Config & Helpers
│   ├── hooks/             # Custom React Hooks
│   ├── utils/             # Utility Functions
│   └── validations/       # Zod Schemas
├── types/                 # TypeScript Definitionen
└── styles/                # Globale Styles
Enter fullscreen mode Exit fullscreen mode

Warum diese Struktur?

  1. Route Groups (public), (auth), (dashboard): Ermöglichen unterschiedliche Layouts ohne URL-Änderung
  2. Komponenten nach Funktion: Nicht nach Seite - so sind sie wiederverwendbar
  3. Strikte Trennung: Firebase-Code nur in lib/firebase/

Deep Dive: Next.js 15 App Router

Server Components als Default

Der größte Paradigmenwechsel: Komponenten sind standardmäßig Server Components.

// app/providers/[id]/page.tsx
// Das ist ein Server Component - läuft NUR auf dem Server!

import { getProviderById } from '@/lib/firebase/providers';
import { ProviderProfile } from '@/components/providers/ProviderProfile';

export default async function ProviderPage({ 
  params 
}: { 
  params: Promise<{ id: string }> 
}) {
  const { id } = await params;

  // Direkter Datenbankzugriff - kein API-Call nötig!
  const provider = await getProviderById(id);

  if (!provider) {
    notFound();
  }

  return <ProviderProfile provider={provider} />;
}

// Metadata für SEO - auch serverseitig
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const provider = await getProviderById(id);

  return {
    title: `${provider?.name} | Taskilo`,
    description: provider?.description,
    openGraph: {
      images: [provider?.avatar || '/default-avatar.png'],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Wann "use client"?

Nur wenn wirklich nötig:

'use client';

// Interaktive Komponenten
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';

export function BookingCalendar({ providerId }: { providerId: string }) {
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [availableSlots, setAvailableSlots] = useState<TimeSlot[]>([]);

  // Real-time Updates mit Firebase
  useEffect(() => {
    const unsubscribe = onSnapshot(
      query(collection(db, 'availability'), where('providerId', '==', providerId)),
      (snapshot) => {
        const slots = snapshot.docs.map(doc => doc.data() as TimeSlot);
        setAvailableSlots(slots);
      }
    );

    return () => unsubscribe();
  }, [providerId]);

  return (
    <Calendar
      selected={selectedDate}
      onSelect={setSelectedDate}
      availableSlots={availableSlots}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Actions: Goodbye API Routes

Für Mutations nutze ich Server Actions - kein Boilerplate mehr:

// actions/bookings.ts
'use server';

import { revalidatePath } from 'next/cache';
import { getCurrentUser } from '@/lib/auth';
import { adminDb } from '@/lib/firebase-admin';

export async function createBooking(formData: FormData) {
  // Auth Check
  const user = await getCurrentUser();
  if (!user) {
    throw new Error('Nicht eingeloggt');
  }

  // Validierung mit Zod
  const validated = bookingSchema.parse({
    providerId: formData.get('providerId'),
    serviceId: formData.get('serviceId'),
    date: formData.get('date'),
    time: formData.get('time'),
    notes: formData.get('notes'),
  });

  // Buchung erstellen
  const bookingRef = await adminDb.collection('bookings').add({
    ...validated,
    customerId: user.uid,
    status: 'pending',
    createdAt: new Date(),
  });

  // Benachrichtigung an Anbieter senden
  await sendProviderNotification(validated.providerId, bookingRef.id);

  // Cache invalidieren
  revalidatePath('/dashboard/bookings');
  revalidatePath(`/providers/${validated.providerId}`);

  return { success: true, bookingId: bookingRef.id };
}
Enter fullscreen mode Exit fullscreen mode

Im Client:

'use client';

import { createBooking } from '@/actions/bookings';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <Button type="submit" disabled={pending}>
      {pending ? 'Wird gebucht...' : 'Jetzt buchen'}
    </Button>
  );
}

export function BookingForm({ providerId }: { providerId: string }) {
  return (
    <form action={createBooking}>
      <input type="hidden" name="providerId" value={providerId} />
      {/* ... weitere Felder */}
      <SubmitButton />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Firebase in Server Components

Das Problem

Firebase SDK ist für den Client optimiert. Aber Server Components laufen auf dem Server - wie nutzt man Firebase dort?

Die Lösung: Firebase Admin SDK

// lib/firebase-admin.ts
import { getApps, initializeApp, cert, App } from 'firebase-admin/app';
import { getFirestore, Firestore } from 'firebase-admin/firestore';
import { getAuth, Auth } from 'firebase-admin/auth';

let app: App;
let db: Firestore;
let auth: Auth;

function initializeFirebaseAdmin() {
  if (getApps().length === 0) {
    app = initializeApp({
      credential: cert({
        projectId: process.env.FIREBASE_PROJECT_ID,
        clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
        // Private Key mit Newlines
        privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
      }),
    });
  }

  db = getFirestore();
  auth = getAuth();

  return { app, db, auth };
}

export const { db: adminDb, auth: adminAuth } = initializeFirebaseAdmin();
Enter fullscreen mode Exit fullscreen mode

Server-Side Data Fetching

// lib/firebase/providers.ts
import { adminDb } from './firebase-admin';

export async function getProvidersByCategory(category: string) {
  const snapshot = await adminDb
    .collection('providers')
    .where('category', '==', category)
    .where('verified', '==', true)
    .where('active', '==', true)
    .orderBy('rating', 'desc')
    .limit(20)
    .get();

  return snapshot.docs.map(doc => ({
    id: doc.id,
    ...doc.data(),
  })) as Provider[];
}

export async function getProviderById(id: string) {
  const doc = await adminDb.collection('providers').doc(id).get();

  if (!doc.exists) return null;

  return { id: doc.id, ...doc.data() } as Provider;
}
Enter fullscreen mode Exit fullscreen mode

Performance-Optimierung: Der Weg zu 90+ Lighthouse

Ausgangslage

Mein erster Production Build war... nicht gut:

  • LCP: 3.5 Sekunden 😱
  • TBT: 890ms
  • CLS: 0.25
  • Bundle Size: 450KB (gzipped)

Schritt 1: Bundle-Analyse

ANALYZE=true pnpm build
Enter fullscreen mode Exit fullscreen mode

Die größten Übeltäter:

  1. Google Maps SDK: 180KB
  2. date-fns (komplett): 75KB
  3. Firebase Client SDK: 120KB
  4. Framer Motion: 65KB

Schritt 2: Dynamic Imports

Schwere Komponenten werden erst geladen, wenn sie gebraucht werden:

// components/maps/DynamicMap.tsx
import dynamic from 'next/dynamic';
import { MapSkeleton } from './MapSkeleton';

export const DynamicMap = dynamic(
  () => import('./GoogleMap').then(mod => mod.GoogleMap),
  {
    loading: () => <MapSkeleton />,
    ssr: false, // Google Maps braucht window
  }
);

// Verwendung
function ProviderLocation({ coordinates }: Props) {
  const [showMap, setShowMap] = useState(false);

  return (
    <div>
      {showMap ? (
        <DynamicMap coordinates={coordinates} />
      ) : (
        <Button onClick={() => setShowMap(true)}>
          Karte anzeigen
        </Button>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Schritt 3: Tree Shaking für date-fns

Statt alles zu importieren:

// ❌ Schlecht - importiert alles
import { format, addDays, subDays } from 'date-fns';

// ✅ Gut - importiert nur was nötig
import format from 'date-fns/format';
import addDays from 'date-fns/addDays';
Enter fullscreen mode Exit fullscreen mode

Schritt 4: Image Optimization

import Image from 'next/image';

export function ProviderCard({ provider }: { provider: Provider }) {
  return (
    <div className="relative">
      <Image
        src={provider.avatar}
        alt={provider.name}
        width={400}
        height={300}
        // Erste 6 Karten priorisieren (above the fold)
        priority={provider.index < 6}
        // Placeholder für bessere UX
        placeholder="blur"
        blurDataURL={provider.blurHash}
        // Responsive Sizes
        sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
        className="object-cover rounded-lg"
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Schritt 5: Route Prefetching

import Link from 'next/link';

// Next.js prefetcht Links automatisch wenn sie im Viewport sind
<Link 
  href={`/providers/${provider.id}`}
  prefetch={true} // Default bei production
>
  {provider.name}
</Link>
Enter fullscreen mode Exit fullscreen mode

Ergebnis

Nach allen Optimierungen:

Metrik Vorher Nachher Verbesserung
LCP 3.5s 1.2s -66%
TBT 890ms 180ms -80%
CLS 0.25 0.02 -92%
Bundle 450KB 165KB -63%
Lighthouse 54 94 +40 Punkte

Auth-System: Firebase + Next.js Middleware

Die Challenge

Firebase Auth ist für SPAs konzipiert. Aber mit Server Components brauchte ich eine Lösung, die:

  1. Tokens auf dem Server validiert
  2. Geschützte Routen serverseitig absichert
  3. User-Daten in Server Components verfügbar macht

Die Lösung: Cookie-basierte Sessions

1. Login-Flow:

// actions/auth.ts
'use server';

import { cookies } from 'next/headers';
import { adminAuth } from '@/lib/firebase-admin';

export async function createSession(idToken: string) {
  // Token validieren
  const decodedToken = await adminAuth.verifyIdToken(idToken);

  // Session Cookie erstellen (5 Tage gültig)
  const sessionCookie = await adminAuth.createSessionCookie(idToken, {
    expiresIn: 60 * 60 * 24 * 5 * 1000, // 5 Tage
  });

  // Cookie setzen
  const cookieStore = await cookies();
  cookieStore.set('__session', sessionCookie, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 5, // 5 Tage
    path: '/',
  });

  return { success: true, userId: decodedToken.uid };
}
Enter fullscreen mode Exit fullscreen mode

2. Middleware für Route Protection:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const protectedRoutes = ['/dashboard', '/bookings', '/settings'];
const authRoutes = ['/login', '/register'];

export async function middleware(request: NextRequest) {
  const sessionCookie = request.cookies.get('__session')?.value;
  const { pathname } = request.nextUrl;

  // Geschützte Route ohne Session -> Login
  if (protectedRoutes.some(route => pathname.startsWith(route))) {
    if (!sessionCookie) {
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('redirect', pathname);
      return NextResponse.redirect(loginUrl);
    }
  }

  // Auth-Route mit Session -> Dashboard
  if (authRoutes.includes(pathname) && sessionCookie) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/bookings/:path*', '/login', '/register'],
};
Enter fullscreen mode Exit fullscreen mode

3. User in Server Components:

// lib/auth.ts
import { cookies } from 'next/headers';
import { adminAuth } from '@/lib/firebase-admin';

export async function getCurrentUser() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get('__session')?.value;

  if (!sessionCookie) return null;

  try {
    const decodedClaims = await adminAuth.verifySessionCookie(sessionCookie, true);
    return decodedClaims;
  } catch (error) {
    return null;
  }
}

// Verwendung in Server Component
export default async function DashboardPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect('/login');
  }

  const userData = await getUserData(user.uid);

  return <Dashboard user={userData} />;
}
Enter fullscreen mode Exit fullscreen mode

Kalender & Buchungssystem

FullCalendar Integration

'use client';

import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

export function BookingCalendar({ 
  providerId,
  initialEvents 
}: { 
  providerId: string;
  initialEvents: CalendarEvent[];
}) {
  const [events, setEvents] = useState(initialEvents);

  // Real-time Updates
  useEffect(() => {
    const unsubscribe = onSnapshot(
      query(
        collection(db, 'bookings'),
        where('providerId', '==', providerId),
        where('status', 'in', ['confirmed', 'pending'])
      ),
      (snapshot) => {
        const bookings = snapshot.docs.map(doc => ({
          id: doc.id,
          title: doc.data().serviceName,
          start: doc.data().startTime.toDate(),
          end: doc.data().endTime.toDate(),
          backgroundColor: doc.data().status === 'confirmed' ? '#22c55e' : '#eab308',
        }));
        setEvents(bookings);
      }
    );

    return () => unsubscribe();
  }, [providerId]);

  const handleDateSelect = async (selectInfo: DateSelectArg) => {
    // Neue Buchung öffnen
    openBookingModal({
      start: selectInfo.start,
      end: selectInfo.end,
      providerId,
    });
  };

  return (
    <FullCalendar
      plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
      initialView="timeGridWeek"
      events={events}
      selectable={true}
      select={handleDateSelect}
      headerToolbar={{
        left: 'prev,next today',
        center: 'title',
        right: 'dayGridMonth,timeGridWeek,timeGridDay',
      }}
      locale="de"
      firstDay={1} // Montag
      slotMinTime="08:00:00"
      slotMaxTime="20:00:00"
      allDaySlot={false}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Google Maps: Performance-Tipps

Lazy Loading der Maps API

// hooks/useGoogleMaps.ts
import { useJsApiLoader } from '@react-google-maps/api';

const libraries: Libraries = ['places', 'geometry'];

export function useGoogleMaps() {
  const { isLoaded, loadError } = useJsApiLoader({
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
    libraries,
    // Vermeide Race Conditions
    id: 'google-map-script',
  });

  return { isLoaded, loadError };
}
Enter fullscreen mode Exit fullscreen mode

Geocoding mit Caching

// lib/geocoding.ts
import { adminDb } from './firebase-admin';

export async function geocodeAddress(address: string) {
  // Check Cache first
  const cached = await adminDb
    .collection('geocode_cache')
    .where('address', '==', address.toLowerCase())
    .limit(1)
    .get();

  if (!cached.empty) {
    return cached.docs[0].data().coordinates;
  }

  // API Call
  const response = await fetch(
    `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${process.env.GOOGLE_MAPS_SERVER_KEY}`
  );

  const data = await response.json();

  if (data.results[0]) {
    const coordinates = data.results[0].geometry.location;

    // Cache speichern
    await adminDb.collection('geocode_cache').add({
      address: address.toLowerCase(),
      coordinates,
      createdAt: new Date(),
    });

    return coordinates;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned: Was ich anders machen würde

1. TypeScript Strict Mode von Anfang an

Ich habe mit strict: false angefangen und später umgestellt. Das war ein Fehler - hunderte Type-Fehler auf einmal zu fixen ist kein Spaß.

// tsconfig.json - von Anfang an!
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Component Library früh aufbauen

Ich habe anfangs jeden Button individuell gestylt. Später auf shadcn/ui umzusteigen bedeutete viel Refactoring.

3. Error Boundaries überall

// components/ErrorBoundary.tsx
'use client';

export function ErrorBoundary({
  children,
  fallback = <DefaultErrorUI />,
}: {
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  return (
    <ReactErrorBoundary fallback={fallback}>
      {children}
    </ReactErrorBoundary>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Monitoring von Tag 1

Ich habe Vercel Analytics und Sentry erst spät hinzugefügt. Hätte ich es früher gemacht, hätte ich Performance-Probleme früher erkannt.

5. Feature Flags

Hätte ich Feature Flags genutzt, wäre A/B-Testing und schrittweises Rollout einfacher gewesen.


Fazit: Lohnt sich der Stack?

Nach 6 Monaten intensiver Entwicklung:

✅ Was großartig funktioniert

  1. Next.js 15 App Router - Server Components sind ein Game-Changer für Performance
  2. Firebase + Vercel - Serverless-Architektur skaliert automatisch
  3. TypeScript Strict - Fängt Bugs ab, bevor sie Production erreichen
  4. Tailwind + shadcn/ui - Konsistente, schnelle UI-Entwicklung

⚠️ Worauf achten

  1. Firebase Kosten - Bei Scale wird es teuer, Reads optimieren!
  2. Caching - Next.js cacht aggressiv, verstehen wie es funktioniert
  3. Hybrid Rendering - Planen welche Seiten SSR/SSG/ISR brauchen
  4. Bundle Size - Regelmäßig analysieren, dynamic imports nutzen

📊 Die Zahlen

  • 150+ React Komponenten
  • 50+ Server Actions
  • 20+ API Routes
  • 94 Lighthouse Performance Score
  • 1.2s LCP (von 3.5s)
  • 165KB Bundle Size (von 450KB)

Resources & Links


Über mich

Ich bin Andy, Full-Stack Developer spezialisiert auf Next.js und Firebase. Ich helfe Unternehmen dabei, performante Web-Applikationen zu bauen.

Interesse an einem ähnlichen Projekt?

👉 the-freelancer.marketing

Fragen oder Feedback? Schreib mir auf Instagram

Top comments (0)