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
Warum diese Struktur?
-
Route Groups
(public),(auth),(dashboard): Ermöglichen unterschiedliche Layouts ohne URL-Änderung - Komponenten nach Funktion: Nicht nach Seite - so sind sie wiederverwendbar
-
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'],
},
};
}
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}
/>
);
}
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 };
}
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>
);
}
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();
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;
}
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
Die größten Übeltäter:
- Google Maps SDK: 180KB
- date-fns (komplett): 75KB
- Firebase Client SDK: 120KB
- 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>
);
}
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';
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>
);
}
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>
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:
- Tokens auf dem Server validiert
- Geschützte Routen serverseitig absichert
- 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 };
}
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'],
};
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} />;
}
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}
/>
);
}
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 };
}
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;
}
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
}
}
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>
);
}
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
- Next.js 15 App Router - Server Components sind ein Game-Changer für Performance
- Firebase + Vercel - Serverless-Architektur skaliert automatisch
- TypeScript Strict - Fängt Bugs ab, bevor sie Production erreichen
- Tailwind + shadcn/ui - Konsistente, schnelle UI-Entwicklung
⚠️ Worauf achten
- Firebase Kosten - Bei Scale wird es teuer, Reads optimieren!
- Caching - Next.js cacht aggressiv, verstehen wie es funktioniert
- Hybrid Rendering - Planen welche Seiten SSR/SSG/ISR brauchen
- 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)