|

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:

ContentfulStrapiSanity
HostingManagedSelf-hostedManaged
Free tierGenerousUnlimited (you host)Generous
Content modelingGreatGoodGreat
Asset CDNBuilt-inManualBuilt-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:

FieldTypeNotes
titleShort textRequired, unique
slugShort textRequired, separate from title so URLs don’t break on renames
dateDate & timeRequired
excerptLong textRequired, max 300 chars
bodyRich textRequired
tagsShort text listRequired
tagsColorShort textHex color, like #ff6b35
featuredImageMediaOptional
draftBooleanDefault: 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:

FieldType
titleShort text
slugShort text
descriptionLong text
technologiesShort text list
urlShort text (optional)
imageMedia (optional)
orderInteger

Page — for About, Impressum, etc.:

FieldType
titleShort text
slugShort text
bodyRich text
seoDescriptionLong 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.