|

Contentful für eine persönliche Website einrichten (Teil 2/7)

In Teil 1 habe ich erzählt, warum Astro mein Interesse geweckt hat. Jetzt geht es um die Inhaltsschicht.

Meine Blogbeiträge liegen aktuell als Markdown-Dateien in einem Git-Repository. Das funktioniert, aber es gibt Reibungen, die mich allmählich nerven: keine Vorschau, keine Planung, Bilder blähen das Repo auf. Ich will etwas Besseres. Lass mich zeigen, wie ich Contentful als Content-Backend einrichten würde.

Warum Inhalte aus Git auslagern?

Markdown in Git ist entwicklerfreundlich, aber seien wir ehrlich:

  • Du schreibst, pushst, wartest auf den Build und siehst dann erst, wie es aussieht. Keine Live-Vorschau.
  • Willst du an einem bestimmten Datum veröffentlichen? Viel Glück ohne CI-Cronjob.
  • Jedes Bild, das du hinzufügst, macht das Repo größer. Für immer.
  • Wenn jemand ohne Git-Kenntnisse beitragen möchte… geht das nicht.

Ein Headless-CMS wie Contentful löst all das. Visueller Editor, Asset-Management, Zeitplanung und eine API, die zur Build-Zeit aufgerufen wird. Die Seite bleibt statisch — der einzige Unterschied ist, woher die Inhalte kommen.

Warum Contentful?

Ich habe ein paar Optionen verglichen:

ContentfulStrapiSanity
HostingVerwaltetSelf-hostedVerwaltet
Kostenlose StufeGroßzügigUnbegrenzt (du hostest)Großzügig
Content-ModellierungSuperGutSuper
Asset-CDNEingebautManuellEingebaut

Für eine persönliche Website reicht die kostenlose Stufe von Contentful locker. Und ehrlich gesagt — ich will keinen weiteren Service selbst hosten. Das schließt Strapi für mich aus.

Content-Modelle planen

Hier spart man sich Zukunfts-Kopfschmerzen — oder schafft sie. So würde ich es einrichten:

Blog Post — das Hauptmodell:

FeldTypHinweise
titleKurztextPflicht, eindeutig
slugKurztextPflicht, separat vom Titel, damit URLs bei Umbenennungen nicht kaputt gehen
dateDatum & UhrzeitPflicht
excerptLangtextPflicht, max 300 Zeichen
bodyRich TextPflicht
tagsKurztext-ListePflicht
tagsColorKurztextHex-Farbe, z.B. #ff6b35
featuredImageMediaOptional
draftBooleanStandard: false

Ein paar Entscheidungen, die erwähnenswert sind:

Slug separat halten. Slug automatisch aus dem Titel generieren klingt praktisch, bis du einen Beitrag umbenennst und alle Links kaputt gehen.

Rich Text statt Markdown. Contentful speichert Rich Text als strukturierten JSON-Baum, nicht als String. Das bedeutet, du hast volle Kontrolle darüber, wie Inhalte gerendert werden. Mehr dazu in Teil 3.

Tags als einfache Liste. Man könnte Tags als eigenen Inhaltstyp mit Referenzen modellieren, aber für einen Blog? Übertrieben. Eine Liste von Strings reicht.

Project — für das Portfolio:

FeldTyp
titleKurztext
slugKurztext
descriptionLangtext
technologiesKurztext-Liste
urlKurztext (optional)
imageMedia (optional)
orderInteger

Page — für Über mich, Impressum, etc.:

FeldTyp
titleKurztext
slugKurztext
bodyRich Text
seoDescriptionLangtext (max 160 Zeichen)

API-Zugang einrichten

Contentful bietet zwei APIs:

  • Delivery API — nur veröffentlichte Inhalte. Was die Live-Seite sieht.
  • Preview API — enthält Entwürfe. Zum Überprüfen unveröffentlichter Inhalte.

Erstelle einen API-Schlüssel unter Einstellungen, kopiere die Tokens und speichere sie in .env:

CONTENTFUL_SPACE_ID=deine_space_id
CONTENTFUL_DELIVERY_TOKEN=dein_delivery_token
CONTENTFUL_PREVIEW_TOKEN=dein_preview_token

Nicht vergessen: .gitignore.

SDK einrichten

npm install contentful

Dann ein einfacher Client:

// src/lib/contentful.ts
import contentful from 'contentful'

const isPreview = import.meta.env.CONTENTFUL_PREVIEW === 'true'

const client = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: isPreview
    ? import.meta.env.CONTENTFUL_PREVIEW_TOKEN
    : import.meta.env.CONTENTFUL_DELIVERY_TOKEN,
  host: isPreview ? 'preview.contentful.com' : 'cdn.contentful.com'
})

export default client

Schalte den CONTENTFUL_PREVIEW-Flag um und du siehst Entwürfe. Super praktisch während der Entwicklung.

Datenabruf-Funktionen

Ich halte die gerne sauber und zweckgebunden:

export async function getBlogPosts() {
  const entries = await client.getEntries<IBlogPostFields>({
    content_type: 'blogPost',
    order: ['-fields.date'],
    'fields.draft': false
  })
  return entries.items
}

export async function getBlogPost(slug: string) {
  const entries = await client.getEntries<IBlogPostFields>({
    content_type: 'blogPost',
    'fields.slug': slug,
    limit: 1
  })
  return entries.items[0] || null
}

export async function getProjects() {
  const entries = await client.getEntries<IProjectFields>({
    content_type: 'project',
    order: ['fields.order']
  })
  return entries.items
}

export async function getPage(slug: string) {
  const entries = await client.getEntries<IPageFields>({
    content_type: 'page',
    'fields.slug': slug,
    limit: 1
  })
  return entries.items[0] || null
}

Jede Funktion macht genau eine Sache. Keine Überraschungen.

20+ Markdown-Posts migrieren

Ich werde meine Blogbeiträge nicht von Hand in ein CMS eintippen. Das Leben ist zu kurz. Hier ist das Migrationsscript, das ich schreiben würde:

// scripts/migrate-to-contentful.ts
import { createClient } from 'contentful-management'
import { readFileSync, readdirSync } from 'fs'
import matter from 'gray-matter'
import { richTextFromMarkdown } from '@contentful/rich-text-from-markdown'

const client = createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!
})

async function migrate() {
  const space = await client.getSpace(process.env.CONTENTFUL_SPACE_ID!)
  const env = await space.getEnvironment('master')

  const posts = readdirSync('./content/en/blog').filter(f => f.endsWith('.md'))

  for (const file of posts) {
    const raw = readFileSync(`./content/en/blog/${file}`, 'utf-8')
    const { data, content } = matter(raw)
    const richText = await richTextFromMarkdown(content)
    const slug = file.replace('.md', '')

    try {
      const entry = await env.createEntry('blogPost', {
        fields: {
          title: { 'en-US': data.title },
          slug: { 'en-US': slug },
          date: { 'en-US': data.date },
          excerpt: { 'en-US': content.substring(0, 200) + '...' },
          body: { 'en-US': richText },
          tags: { 'en-US': data.tags || [] },
          tagsColor: { 'en-US': data.tags_color || '#333333' },
          draft: { 'en-US': data.draft || false }
        }
      })
      await entry.publish()
      console.log(`✓ ${data.title}`)
    } catch (err) {
      console.error(`✗ ${data.title}`, err.message)
    }
  }
}

migrate()

Einmal ausführen, im Contentful-UI überprüfen, fertig. Die Markdown-zu-Rich-Text-Konvertierung ist nicht perfekt — Code-Blöcke brauchen eventuell etwas Nacharbeit — aber sie erledigt den Großteil.

Was kommt als Nächstes

Content-Modelle: stehen. SDK: verdrahtet. Migrationsplan: bereit. In Teil 3 verbinde ich die Punkte — wie Astro Inhalte von Contentful abruft, Seiten generiert und mit dem Rich-Text-JSON umgeht.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.