Connecting Astro to Contentful (Part 3/7)
Alright, we’ve got Astro set up (Part 1) and Contentful designed (Part 2). Time to connect them and actually generate some pages.
This is the fun part — where the architecture starts to feel real.
The blog listing page
Let’s start simple. A page that lists all blog posts:
---
// 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('en-US', {
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>
Fetch posts, loop, render. All the data fetching happens above the --- fence and runs at build time. Zero JavaScript ships to the browser.
Individual blog post pages
Each post needs its own URL. Astro handles this with dynamic routes and 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('en-US', {
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() is the magic. At build time, Astro asks “what pages exist?” and you tell it. For each blog post, it creates a URL with the slug and passes the data as props. Every page gets pre-rendered. No server needed.
Dealing with Rich Text
Here’s where it gets interesting. Contentful doesn’t store content as markdown — it uses a JSON tree. A paragraph looks like this:
{
"nodeType": "paragraph",
"content": [{
"nodeType": "text",
"value": "Hello world",
"marks": []
}]
}
You need to turn that tree into HTML. Contentful has a renderer package, but it gives you limited control. I’d rather build a custom one — more code upfront, but you own every decision:
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} />
It’s a decent chunk of code, but each function is straightforward. And now you control exactly how every piece of content renders — headings, images, code blocks, everything.
The code block trick
Contentful’s Rich Text doesn’t have a native code block with language support. My workaround: create a separate “Code Block” content type with code and language fields, then embed those in blog posts. The renderEntry function above handles it.
For syntax highlighting, Shiki does the job at build time:
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 at build time. No client-side library loading.
Watch out for linked entries
Quick gotcha that’ll save you debugging time: when fetching entries, linked content inside Rich Text isn’t resolved by default. You need the include parameter:
export async function getBlogPost(slug: string) {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
'fields.slug': slug,
include: 10, // resolve linked entries
limit: 1
})
return entries.items[0] || null
}
Without this, embedded images and code blocks show up with empty data. Classic “works in Contentful, breaks in the build” bug.
Handling English and German
My current site has both languages. Contentful supports locales natively — enable en and de, then pass the locale when fetching:
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
}
Then create locale-prefixed routes:
---
// 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>
Same structure as my current Hugo setup with content/en/ and content/de/, just driven by the API instead of the file system.
What’s next
We’ve got pages generating from Contentful data, Rich Text rendering cleanly, and i18n working. Next in Part 4: making the build output production-ready — image optimization, SEO metadata, sitemap, and RSS.
Keep pushing forward and savor every step of your coding journey.
