Setting Up Contentful for a Personal Site (Part 2/7)
In Part 1 I talked about why Astro caught my eye. Now let’s tackle the content side of things.
My blog posts currently live as markdown files in a Git repo. It works, but there’s friction I’m tired of: no preview, no scheduling, images bloating the repo. I want something better. Let me walk you through how I’d set up Contentful as the content backend.
Why move content out of Git?
Markdown in Git is developer-friendly, but let’s be honest about the downsides:
- You write, push, wait for the build, then see how it looks. No live preview.
- Want to publish on a specific date? Good luck without a CI cron job.
- Every image you add makes the repo bigger. Forever.
- If someone without Git skills wants to contribute… they can’t.
A headless CMS like Contentful fixes all of this. Visual editor, asset management, scheduling, and an API you call at build time. The site stays static — the only difference is where the content comes from.
Why Contentful over the alternatives?
I looked at a few options:
| Contentful | Strapi | Sanity | |
|---|---|---|---|
| Hosting | Managed | Self-hosted | Managed |
| Free tier | Generous | Unlimited (you host) | Generous |
| Content modeling | Great | Good | Great |
| Asset CDN | Built-in | Manual | Built-in |
For a personal site, Contentful’s free tier is plenty. And honestly, the last thing I want is to self-host yet another service. That rules out Strapi for me.
Designing the content models
This is the part where you save yourself future headaches — or create them. Here’s what I’d set up:
Blog Post — the main one:
| Field | Type | Notes |
|---|---|---|
title | Short text | Required, unique |
slug | Short text | Required, separate from title so URLs don’t break on renames |
date | Date & time | Required |
excerpt | Long text | Required, max 300 chars |
body | Rich text | Required |
tags | Short text list | Required |
tagsColor | Short text | Hex color, like #ff6b35 |
featuredImage | Media | Optional |
draft | Boolean | Default: false |
A few things I’d do differently than the obvious default:
Keep the slug separate from the title. Auto-generating slugs from titles sounds convenient until you rename a post and break every link to it.
Use Rich Text, not markdown. Contentful stores Rich Text as a structured JSON tree, not a string. Sounds annoying at first, but it means you get total control over how content renders. We’ll dig into that in Part 3.
Tags as a simple list. You could model tags as their own content type with references and everything, but for a blog? Overkill. A list of strings is fine.
Project — for the portfolio:
| Field | Type |
|---|---|
title | Short text |
slug | Short text |
description | Long text |
technologies | Short text list |
url | Short text (optional) |
image | Media (optional) |
order | Integer |
Page — for About, Impressum, etc.:
| Field | Type |
|---|---|
title | Short text |
slug | Short text |
body | Rich text |
seoDescription | Long text (max 160 chars) |
Getting API access
Contentful gives you two APIs:
- Delivery API — published content only. What the live site sees.
- Preview API — includes drafts. For checking unpublished stuff.
Create an API key under Settings, grab the tokens, and put them in .env:
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_DELIVERY_TOKEN=your_delivery_token
CONTENTFUL_PREVIEW_TOKEN=your_preview_token
Don’t forget to .gitignore that file.
Wiring up the SDK
npm install contentful
Then a simple 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
Flip that CONTENTFUL_PREVIEW flag and you get draft content. Super handy during development.
Data fetching functions
I like keeping these clean and single-purpose:
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
}
Each function does one thing. No surprises.
Migrating 20+ markdown posts
I’m not retyping all my blog posts into a CMS by hand. Life’s too short. Here’s the migration script I’d write:
// 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()
Run it once, double-check things in the Contentful UI, done. The markdown-to-rich-text conversion won’t be 100% perfect — code blocks might need a manual pass — but it’ll handle the bulk of the work.
What’s next
Content models: done. SDK: wired up. Migration plan: ready. Next in Part 3, I’ll connect the dots — how Astro pulls content from Contentful, generates pages, and handles that Rich Text JSON.
Keep pushing forward and savor every step of your coding journey.
