← Zurueck zum Blog
next-jstypescriptprojekteportfoliostatic-generationkomponenten

Projektseiten und Labs — Dynamische Showcases mit Next.js und TypeScript

Marco Carstensen·5. März 2026·7 Min. Lesezeit

Das Problem mit Portfolio-Seiten

Die meisten Entwickler-Portfolios haben ein Problem: Sie sind statisch. Drei Projekte, jeweils ein Absatz, ein Link — fertig. Wenn ein neues Projekt dazukommt, muss man die gesamte Seite umbauen. Wenn man filtern will, wird es kompliziert. Und wenn man Screenshots zeigen will, klebt man ein paar <img>-Tags rein und hofft auf das Beste.

Fuer macip.de wollte ich ein System, das skaliert. Neue Projekte hinzufuegen sollte bedeuten: ein Objekt in ein Array pushen, Screenshots in einen Ordner legen — fertig. Alles andere generiert sich automatisch.


Die zentrale Datenquelle: projects.ts

Das Herzstueck des gesamten Projektsystems ist eine einzige TypeScript-Datei: src/data/projects.ts. Hier leben alle Projektdaten, Typen, Farb-Mappings und Helper-Funktionen.

Das Type-System

type AccentColor = "accent-teal" | "accent-blue" | "accent-purple";
 
interface Project {
  slug: string;
  tag: string;
  title: string;
  desc: string;
  longDesc: string;
  accent: AccentColor;
  href: string;
  stack: string[];
  features: string[];
  status: "live" | "in-entwicklung" | "intern";
  category: "saas" | "lab" | "infra";
  screenshots: string[];
}

Jedes Projekt hat einen klaren Typ, eine Kategorie und einen Status. Die AccentColor ist ein Union-Type aus genau drei Farben — das verhindert, dass jemand versehentlich eine nicht existierende Farbe zuweist.

Das Farb-Mapping

const ACCENT_HEX_MAP: Record<AccentColor, string> = {
  "accent-teal":   "#00d4aa",
  "accent-blue":   "#5b8fff",
  "accent-purple": "#aa77ff",
};

Dieses Mapping ist die einzige Quelle fuer Akzent-Hex-Werte im gesamten Projekt. Tag, ProjectCard, Detail-Seiten — alle importieren von hier. Keine Hex-Duplikate, keine Inkonsistenzen.

Die Farbzuweisung folgt einer semantischen Logik:

  • Teal (#00d4aa) → SaaS-Produkte (produktiv, kommerziell)
  • Blue (#5b8fff) → Lab-Experimente (explorativ, technisch)
  • Purple (#aa77ff) → Infrastruktur (intern, DevOps)

Helper-Funktionen

Zwei einfache Funktionen machen die Daten ueberall zugaenglich:

function getProjectBySlug(slug: string): Project | undefined {
  return PROJECTS.find((p) => p.slug === slug);
}
 
function getAllSlugs(): string[] {
  return PROJECTS.map((p) => p.slug);
}

getAllSlugs() wird fuer generateStaticParams() verwendet — dazu gleich mehr.


Die Projekt-Uebersicht: /projects

Die Uebersichtsseite ist ein Server Component, das die gesamte Projekt-Liste rendert und einen Client-seitigen Filter bereitstellt.

// src/app/(marketing)/projects/page.tsx
export default function ProjectsPage() {
  return (
    <div className="mx-auto max-w-[960px] px-6 py-20 sm:px-12">
      <h1 className="...">Projekte</h1>
      <Suspense>
        <ProjectFilter />
      </Suspense>
    </div>
  );
}

Wichtig ist der <Suspense>-Wrapper um <ProjectFilter />. Der Filter liest URL-Search-Params via useSearchParams() — ein Client-Hook, der in Next.js einen Suspense-Boundary benoetigt.

Der Kategorie-Filter

const CATEGORIES = [
  { key: "saas",  label: "SaaS",  accent: "accent-teal" },
  { key: "lab",   label: "Lab",   accent: "accent-blue" },
  { key: "infra", label: "Infra", accent: "accent-purple" },
];

Drei Kategorien, jede mit ihrer eigenen Akzent-Farbe. Der Filter arbeitet ueber URL-Parameter (?filter=lab), sodass Links direkt auf gefilterte Ansichten zeigen koennen. Das nutzt die Navbar: Der "Lab"-Link zeigt auf /projects?filter=lab.

Der Filter hat ein Toggle-Verhalten: Klickt man auf die bereits aktive Kategorie, wird der Filter zurueckgesetzt. Das ist intuitiver als ein separater "Alle"-Button.

Die ProjectCard-Komponente

Jede Karte folgt einem konsistenten Pattern:

+------------------------------+
|  ==========  (Gradient)      |
|                              |
|  SaaS                        |
|  YardWinner                  |
|  SaaS-Plattform fuer die     |
|  maritime Industrie...       |
|                              |
|                Ansehen ->    |
+------------------------------+

Von oben nach unten: Gradient-Linie (nur bei Hover), Tag mit Akzent-Farbe, Titel in font-sans, Beschreibung in font-mono und der Arrow-Link unten rechts

Die Karte entscheidet automatisch, welches Element sie rendert:

  • Interner Link (/...) → <Link> (client-side navigation)
  • Externer Link (http...) → <a target="_blank">
  • Platzhalter (#) → <a> ohne target

Die Hover-Effekte sind subtil, aber durchdacht:

  1. Gradient-Linie oben — 2px Hoehe, von Akzent-Farbe nach Transparent
  2. Border-Glow — Border wechselt zur Akzent-Farbe mit 30% Opacity
  3. Hintergrund-Tint — Dezenter Akzent-Hintergrund
  4. Arrow-Text — "Ansehen →" erscheint unten rechts

Alles mit transition-all duration-300 fuer geschmeidige Uebergaenge.


Die Detail-Seite: /projects/[slug]

Hier passiert die eigentliche Magie: Jedes Projekt bekommt eine eigene, statisch generierte Seite mit voller SEO-Unterstuetzung.

Static Generation mit generateStaticParams

export async function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

Next.js generiert beim Build fuer jeden Slug eine statische HTML-Seite. Kein Server-Rendering zur Laufzeit, kein Datenbankzugriff — reine HTML-Dateien, die sofort ausgeliefert werden.

Dynamische Metadata

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;  // Next.js 16: params ist ein Promise
  const project = getProjectBySlug(slug);
  if (!project) return {};
 
  return {
    title: project.title,
    description: project.desc,
    alternates: { canonical: `/projects/${slug}` },
    openGraph: {
      type: "article",
      title: `${project.title} — macip.de`,
      description: project.desc,
    },
  };
}

Jede Projekt-Seite hat eigene OpenGraph-Tags, eine kanonische URL und eine saubere Title-Struktur. Google sieht fuer jedes Projekt eine vollstaendige, eindeutige Seite.

JSON-LD Structured Data

Jede Detail-Seite liefert zwei JSON-LD-Bloecke:

  • SoftwareApplication — fuer Rich Results in der Google-Suche
  • BreadcrumbList — fuer die Breadcrumb-Anzeige in den SERPs (Home → Projekte → Projektname)

Die Screenshot-Galerie

Projekte mit Screenshots bekommen eine interaktive Galerie mit Keyboard-Navigation:

const ScreenshotGallery = dynamic(
  () => import("@/components/ui/ScreenshotGallery"),
  { ssr: false }
);

Die Galerie wird mit next/dynamic und ssr: false geladen — sie braucht Browser-APIs fuer Keyboard-Events und hat keinen SEO-relevanten Content. Features:

  • Haupt-Bild mit next/image (automatische Optimierung)
  • Thumbnail-Strip mit aktivem Zustand (Akzent-Border + Bottom-Highlight)
  • Pfeiltasten-Navigation (ArrowLeft/ArrowRight)
  • Glassmorphism-Arrows: Halbtransparente Navigations-Pfeile mit Backdrop-Blur

Status-Badge

Jedes Projekt zeigt seinen aktuellen Status:

  • Live → Gruener Pulse-Dot + "Live" (animierter Pulse-Effekt)
  • In Entwicklung → Gelber Dot + "In Entwicklung"
  • Intern → Grauer Dot + "Intern"

Das Lab-Konzept

"Lab" ist keine separate Sektion mit eigenen Seiten — es ist ein Filter auf die bestehende Projekt-Infrastruktur. Projekte mit category: "lab" werden als Experimente gekennzeichnet und bekommen die blaue Akzent-Farbe.

Dieses Design war eine bewusste Entscheidung:

  1. Kein Duplicate Content — Lab-Projekte und regulaere Projekte teilen sich dieselbe Infrastruktur
  2. Einfache Navigation — Der "Lab"-Link in der Navbar zeigt auf /projects?filter=lab
  3. Konsistente UX — Gleiche Karten, gleiche Detail-Seiten, gleiche Galerie
  4. Einfache Erweiterung — Ein neues Experiment ist ein weiterer Eintrag in PROJECTS

Neues Projekt hinzufuegen: Der Workflow

Um ein neues Projekt zur Seite hinzuzufuegen, sind genau drei Schritte noetig:

1. Daten in projects.ts eintragen

{
  slug: "neues-projekt",
  tag: "SaaS",
  title: "Neues Projekt",
  desc: "Kurze Beschreibung fuer die Karte",
  longDesc: "Ausfuehrliche Beschreibung fuer die Detail-Seite...",
  accent: "accent-teal",
  href: "/projects/neues-projekt",
  stack: ["Next.js", "Supabase", "TypeScript"],
  features: ["Feature 1", "Feature 2", "Feature 3"],
  status: "in-entwicklung",
  category: "saas",
  screenshots: [
    "/projects/neues-projekt/screenshot-1.webp",
  ],
}

2. Screenshots ablegen

public/projects/neues-projekt/
+-- screenshot-1.webp

3. Push auf main

Der GitHub Actions Runner baut die Seite neu, generiert die statische Detail-Seite und deployed automatisch.

Kein CMS, kein Dashboard, keine Datenbank — fuer Projektdaten ist die TypeScript-Datei die perfekte Quelle. Sie ist typsicher, versioniert (Git) und benoetigt keinen Runtime-Zugriff.


Performance-Ergebnis

Durch die Kombination aus Static Generation, Server Components und optimierten Images erreichen die Projektseiten:

  • First Contentful Paint: unter 0.8s
  • Largest Contentful Paint: unter 1.5s
  • Total Blocking Time: 0ms (kein Client-JS auf der Uebersichtsseite)
  • Cumulative Layout Shift: 0 (alle Bilder haben feste Dimensionen)

Die Detail-Seiten laden die Screenshot-Galerie per Dynamic Import — sie blockiert weder den initialen Render noch das Hydration-Budget.


Fazit

Das Projektsystem von macip.de beweist, dass man fuer ein dynamisches Portfolio kein CMS braucht. Eine typisierte TypeScript-Datei, kombiniert mit Next.js Static Generation und durchdachten Komponenten, liefert:

  • Type-Safety — Fehler werden beim Build erkannt, nicht zur Laufzeit
  • Performance — Statische HTML-Seiten, kein Server-Rendering
  • SEO — Individuelle Metadata, JSON-LD, kanonische URLs
  • Konsistenz — Ein Farb-Mapping, ein Card-Pattern, eine Detail-Struktur
  • Einfache Erweiterung — Ein Objekt hinzufuegen, Screenshots ablegen, pushen

Im naechsten Beitrag geht es um das Blogsystem — und warum dort eine Datenbank sinnvoller ist als statische Dateien.