Astro mit Contentful verbinden (Teil 3/7)
OK, Astro steht (Teil 1) und Contentful ist konzipiert (Teil 2). Zeit, beides zu verbinden und tatsächlich Seiten zu generieren.
Jetzt wird es spaßig — die Architektur nimmt Form an.
Die Blog-Übersichtsseite
Fangen wir einfach an. Eine Seite, die alle Blogbeiträge auflistet:
---
// src/pages/blog/index.astro
import Layout from '../../layouts/Base.astro'
import { getBlogPosts } from '../../lib/contentful'
const posts = await getBlogPosts()
---
<Layout title="Blog">
<section class="blog-listing">
<h1>Blog</h1>
<div class="posts-grid">
{posts.map(post => (
<article class="post-card">
<a href={`/blog/${post.fields.slug}`}>
<time datetime={post.fields.date}>
{new Date(post.fields.date).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric'
})}
</time>
<h2>{post.fields.title}</h2>
<p>{post.fields.excerpt}</p>
<div class="tags">
{post.fields.tags.map(tag => (
<span class="tag" style={`background: ${post.fields.tagsColor}`}>
{tag}
</span>
))}
</div>
</a>
</article>
))}
</div>
</section>
</Layout>
Posts abrufen, durchlaufen, rendern. Der Datenabruf passiert über dem ----Zaun und läuft zur Build-Zeit. Null JavaScript landet beim Browser.
Individuelle Blogbeitrags-Seiten
Jeder Beitrag braucht seine eigene URL. Astro löst das mit dynamischen Routen und getStaticPaths:
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Base.astro'
import { getBlogPosts } from '../../lib/contentful'
import RichText from '../../components/RichText.astro'
export async function getStaticPaths() {
const posts = await getBlogPosts()
return posts.map(post => ({
params: { slug: post.fields.slug },
props: { post }
}))
}
const { post } = Astro.props
const { title, date, body, tags, tagsColor } = post.fields
---
<Layout title={title}>
<article class="blog-post">
<header>
<time datetime={date}>
{new Date(date).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric'
})}
</time>
<h1>{title}</h1>
<div class="tags">
{tags.map(tag => (
<span class="tag" style={`background: ${tagsColor}`}>{tag}</span>
))}
</div>
</header>
<div class="post-content">
<RichText document={body} />
</div>
</article>
</Layout>
getStaticPaths() ist die Schlüsselfunktion. Zur Build-Zeit fragt Astro: „Welche Seiten gibt es?" und du sagst es ihm. Für jeden Blogbeitrag wird eine URL mit dem Slug erstellt und die Daten als Props übergeben. Jede Seite wird vorgerendert. Kein Server nötig.
Die Rich-Text-Herausforderung
Contentful speichert Inhalte nicht als Markdown — es verwendet einen JSON-Baum. Ein einfacher Absatz sieht so aus:
{
"nodeType": "paragraph",
"content": [{
"nodeType": "text",
"value": "Hallo Welt",
"marks": []
}]
}
Du musst diesen Baum in HTML umwandeln. Contentful bietet ein Renderer-Paket, aber es gibt dir wenig Kontrolle. Ich würde lieber einen eigenen Renderer bauen — mehr Code am Anfang, aber du bestimmst jede Entscheidung:
npm install @contentful/rich-text-types
---
// src/components/RichText.astro
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types'
import type { Document, Block, Inline, Text } from '@contentful/rich-text-types'
interface Props {
document: Document
}
const { document } = Astro.props
function renderMarks(text: Text): string {
let result = text.value
for (const mark of text.marks) {
switch (mark.type) {
case MARKS.BOLD: result = `<strong>${result}</strong>`; break
case MARKS.ITALIC: result = `<em>${result}</em>`; break
case MARKS.CODE: result = `<code>${result}</code>`; break
}
}
return result
}
function renderNode(node: Block | Inline | Text): string {
if (node.nodeType === 'text') return renderMarks(node as Text)
const children = (node as Block).content
?.map(child => renderNode(child)).join('') || ''
switch (node.nodeType) {
case BLOCKS.PARAGRAPH: return `<p>${children}</p>`
case BLOCKS.HEADING_2: return `<h2>${children}</h2>`
case BLOCKS.HEADING_3: return `<h3>${children}</h3>`
case BLOCKS.UL_LIST: return `<ul>${children}</ul>`
case BLOCKS.OL_LIST: return `<ol>${children}</ol>`
case BLOCKS.LIST_ITEM: return `<li>${children}</li>`
case BLOCKS.QUOTE: return `<blockquote>${children}</blockquote>`
case BLOCKS.HR: return '<hr />'
case BLOCKS.EMBEDDED_ASSET: return renderAsset(node as Block)
case BLOCKS.EMBEDDED_ENTRY: return renderEntry(node as Block)
case INLINES.HYPERLINK:
const uri = (node as Inline).data.uri
return `<a href="${uri}" target="_blank" rel="noopener">${children}</a>`
default: return children
}
}
function renderAsset(node: Block): string {
const { title, file } = node.data.target.fields
if (file.contentType.startsWith('image/')) {
return `<figure>
<img src="https:${file.url}" alt="${title || ''}" loading="lazy" />
${title ? `<figcaption>${title}</figcaption>` : ''}
</figure>`
}
return `<a href="https:${file.url}" download>${title}</a>`
}
function renderEntry(node: Block): string {
const entry = node.data.target
if (entry.sys.contentType.sys.id === 'codeBlock') {
const { code, language } = entry.fields
return `<pre><code class="language-${language}">${
code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
}</code></pre>`
}
return ''
}
const html = document.content.map(n => renderNode(n)).join('\n')
---
<div class="rich-text" set:html={html} />
Ein ordentlicher Block Code, aber jede Funktion ist klar verständlich. Und jetzt kontrollierst du genau, wie jedes Inhaltsstück gerendert wird.
Der Code-Block-Trick
Contentfuls Rich Text hat keinen nativen Code-Block mit Sprachunterstützung. Mein Workaround: Ein separater „Code Block"-Inhaltstyp mit code- und language-Feldern, der in Blogbeiträge eingebettet wird.
Für Syntax-Highlighting kommt Shiki zum Einsatz, und zwar zur Build-Zeit:
npm install shiki
// src/lib/highlight.ts
import { createHighlighter } from 'shiki'
let highlighter: Awaited<ReturnType<typeof createHighlighter>> | null = null
export async function highlight(code: string, lang: string) {
if (!highlighter) {
highlighter = await createHighlighter({
themes: ['github-dark'],
langs: ['typescript', 'javascript', 'bash', 'json', 'go', 'php', 'yaml']
})
}
return highlighter.codeToHtml(code, { lang, theme: 'github-dark' })
}
Syntax-Highlighting zur Build-Zeit. Keine clientseitige Bibliothek nötig.
Aufgepasst: Verlinkte Einträge
Kleiner Hinweis, der dir Debug-Zeit spart: Beim Abrufen von Einträgen werden verlinkte Inhalte in Rich Text standardmäßig nicht aufgelöst. Du brauchst den include-Parameter:
export async function getBlogPost(slug: string) {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
'fields.slug': slug,
include: 10, // verlinkte Einträge auflösen
limit: 1
})
return entries.items[0] || null
}
Ohne das zeigen eingebettete Bilder und Code-Blöcke leere Daten. Klassischer „funktioniert in Contentful, bricht im Build"-Bug.
Deutsch und Englisch
Meine aktuelle Seite unterstützt beide Sprachen. Contentful hat native Locale-Unterstützung — aktiviere en und de, dann übergib das Locale beim Abrufen:
export async function getBlogPosts(locale: string = 'en') {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
locale,
order: ['-fields.date'],
'fields.draft': false
})
return entries.items
}
Dann Locale-basierte Routen in Astro erstellen:
---
// src/pages/[lang]/blog/[slug].astro
import { getBlogPosts } from '../../../lib/contentful'
import RichText from '../../../components/RichText.astro'
import Layout from '../../../layouts/Base.astro'
export async function getStaticPaths() {
const locales = ['en', 'de']
const paths = []
for (const lang of locales) {
const posts = await getBlogPosts(lang)
for (const post of posts) {
paths.push({
params: { lang, slug: post.fields.slug },
props: { post, lang }
})
}
}
return paths
}
const { post, lang } = Astro.props
---
<Layout title={post.fields.title} lang={lang}>
<article>
<h1>{post.fields.title}</h1>
<RichText document={post.fields.body} />
</article>
</Layout>
Gleiche Struktur wie mein aktuelles Hugo-Setup, nur von der API gesteuert statt vom Dateisystem.
Was kommt als Nächstes
Seiten werden generiert, Rich Text rendert sauber, i18n läuft. In Teil 4 geht es um den produktionsreifen Build — Bildoptimierung, SEO, Sitemap und RSS.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
