/ astro-integrations / How to Use Contentful with Astro: Complete Guide
astro-integrations 9 min read

How to Use Contentful with Astro: Complete Guide

Step-by-step guide to integrating Contentful with your Astro website. Installation, configuration, and best practices.

How to Use Contentful with Astro: Complete Guide

Contentful is one of the most popular enterprise headless CMS platforms out there, and it pairs really well with Astro. You get a powerful content modeling API on the backend and lightning-fast static pages on the frontend. If you want structured content without the weight of a traditional CMS, this is a solid combo.

This guide follows the approach in the official Astro Contentful docs and the Contentful JavaScript SDK docs, updated for Astro 6 and the current contentful SDK.

Prerequisites

  • Node.js 18 or newer. The contentful SDK declares engines.node >= 18, and Astro 6 also targets modern Node.
  • An Astro project (npm create astro@latest).
  • A Contentful account. The free plan currently includes 10 users, 10,000 records, 100K API calls per month, and 50 GB of CDN bandwidth. Check the pricing page for the current numbers since these change.

Installation

Install the Contentful JavaScript SDK along with the rich text renderer. These are the two packages the official Astro guide installs:

npm install contentful @contentful/rich-text-html-renderer

At the time of writing the current versions are contentful 11.12.3 and @contentful/rich-text-html-renderer 17.2.2. Both package names are current and resolve on the npm registry. Pin whatever versions are current when you install; npm install writes the resolved version into your lockfile.

Configuration

Create a .env file in your project root with your Contentful credentials. You can find these in your Contentful dashboard under Settings then API keys.

CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_DELIVERY_TOKEN=your_delivery_api_token
CONTENTFUL_PREVIEW_TOKEN=your_preview_api_token

Astro exposes these on import.meta.env. To get type safety and autocomplete, declare them in src/env.d.ts exactly as the Astro docs show:

// src/env.d.ts
interface ImportMetaEnv {
  readonly CONTENTFUL_SPACE_ID: string;
  readonly CONTENTFUL_DELIVERY_TOKEN: string;
  readonly CONTENTFUL_PREVIEW_TOKEN: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Now create a helper file to initialize the Contentful client. Note the import * as contentful namespace import. The current SDK and the official Astro guide use this form, not a default import, so the EntryFieldTypes helpers and createClient resolve correctly:

// src/lib/contentful.ts
import * as contentful from "contentful";
import type { EntryFieldTypes } from "contentful";

export interface BlogPost {
  contentTypeId: "blogPost";
  fields: {
    title: EntryFieldTypes.Text;
    body: EntryFieldTypes.RichText;
    publishDate: EntryFieldTypes.Date;
    description: EntryFieldTypes.Text;
    slug: EntryFieldTypes.Text;
  };
}

export const contentfulClient = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: import.meta.env.DEV
    ? import.meta.env.CONTENTFUL_PREVIEW_TOKEN
    : import.meta.env.CONTENTFUL_DELIVERY_TOKEN,
  host: import.meta.env.DEV ? "preview.contentful.com" : "cdn.contentful.com",
});

This setup automatically switches between the preview API during development and the delivery API in production. In the official Astro guide's words, in development mode your content is fetched from the Contentful preview API, and at build time it is fetched from the delivery API.

The BlogPost interface above is a content type skeleton. Passing it as a generic to getEntries gives you typed fields and lets you filter on field-specific query keys. This is the recommended TypeScript pattern from both the Contentful SDK docs and the Astro guide.

Basic Usage

Let's say you have a "Blog Post" content type in Contentful with fields like title, slug, body, and publishDate. Pass the skeleton type as a generic so the items come back typed:

---
// src/pages/blog/index.astro
import { contentfulClient, type BlogPost } from "../../lib/contentful";
import BaseLayout from "../../layouts/BaseLayout.astro";

const entries = await contentfulClient.getEntries<BlogPost>({
  content_type: "blogPost",
  order: ["-fields.publishDate"],
});

const posts = entries.items;
---

<BaseLayout title="Blog">
  <h1>Blog</h1>
  <ul>
    {posts.map((post) => (
      <li>
        <a href={`/blog/${post.fields.slug}`}>
          <h2>{post.fields.title}</h2>
          <time>{new Date(post.fields.publishDate).toLocaleDateString()}</time>
        </a>
      </li>
    ))}
  </ul>
</BaseLayout>

The order parameter sorts results. Prefixing a field with a - reverses the sort, so -fields.publishDate gives you newest first. Multiple fields are supported as comma separated values (or as an array in the SDK).

Fetching Data for Dynamic Pages

For individual blog post pages, use getStaticPaths to generate routes at build time. This is the static generation path, which is Astro's default behavior:

---
// src/pages/blog/[slug].astro
import { contentfulClient, type BlogPost } from "../../lib/contentful";
import { documentToHtmlString } from "@contentful/rich-text-html-renderer";
import BaseLayout from "../../layouts/BaseLayout.astro";

export async function getStaticPaths() {
  const entries = await contentfulClient.getEntries<BlogPost>({
    content_type: "blogPost",
  });

  return entries.items.map((item) => ({
    params: { slug: item.fields.slug },
    props: {
      title: item.fields.title,
      content: documentToHtmlString(item.fields.body),
      date: new Date(item.fields.publishDate).toLocaleDateString(),
    },
  }));
}

const { title, content, date } = Astro.props;
---

<BaseLayout title={title}>
  <article>
    <h1>{title}</h1>
    <time>{date}</time>
    <div set:html={content} />
  </article>
</BaseLayout>

The rich text renderer comes from the package you already installed (@contentful/rich-text-html-renderer). It turns a Contentful rich text Document into an HTML string that you inject with set:html.

Rendering at Request Time Instead of Build Time

The example above generates every post at build time, which is the right default for a content site. If you instead want a route to render on demand (for example to read draft content with the preview API per request), Astro 6 handles this without a separate output mode.

A few facts to get right here, because the older guidance is out of date:

  • output: 'static' is the default in Astro. Your whole site is prerendered to static HTML unless you opt a route out.
  • output: 'hybrid' was removed in Astro 5. The static and hybrid modes were merged into the default static mode, so if you see output: 'hybrid' in an old tutorial, delete it.
  • To render any route on demand you add an adapter (for example @astrojs/node, current version 10.1.2) and set export const prerender = false at the top of that page. No other config change is needed.
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
});
---
// src/pages/preview/[slug].astro
export const prerender = false; // render this route on demand
---

If you keep things fully static (the common case for a Contentful blog), you do not need an adapter at all.

Production Tips

  1. Cache aggressively. Contentful's delivery API is fast, but fetching on every build still adds up. Keep Astro on its default static output and rebuild only when content changes via webhooks.

  2. Use the include parameter. When fetching entries with linked content (images, references), set include to resolve nested entries in a single API call instead of multiple round trips. The default is 1 and the maximum is 10; the API throws a BadRequestError for higher values. A value of include: 2 is a sensible starting point for most blogs.

  3. Set up webhooks for rebuilds. In Contentful, go to Settings then Webhooks and trigger a rebuild on your hosting platform (Vercel, Netlify, etc.) whenever content is published or unpublished. This pairs perfectly with Astro's static output: content edits trigger a fresh build instead of a per-request fetch.

  • Type your content models. Beyond the hand-written skeleton above, you can auto-generate TypeScript types from your Contentful content models with contentful-typescript-codegen (current version 3.4.0). It saves you from guessing field names across a large schema.

  • Optimize images. Contentful's Images API supports on-the-fly resizing and format conversion. The width parameter is w (up to 4000 px), the format parameter is fm (accepts jpg, png, webp, gif, avif, tiff), and quality is q (1 to 100). For example append ?w=800&fm=webp&q=80 to an asset URL for a smaller payload.

  • Common Errors and Fixes

    • createClient is not a function or missing EntryFieldTypes. This usually means a default import. Use the namespace import import * as contentful from "contentful" (and import type { EntryFieldTypes } from "contentful") as the official Astro guide does. A default import does not expose the SDK the same way.

    • include rejected with a 400 / BadRequestError. The include parameter caps at 10. Anything higher, or a non-integer, throws BadRequestError. Drop it to 10 or lower.

    • output: 'hybrid' is not a valid option. Hybrid mode was removed in Astro 5. Remove the output line entirely (static is the default) or use output: 'server' if you genuinely want every route on demand. Per-route on-demand rendering is done with export const prerender = false plus an adapter.

    • On-demand route returns static HTML or fails without an adapter. On-demand rendering requires a server adapter. Add one (such as @astrojs/node) before setting prerender = false, otherwise Astro has no server runtime to render the route.

    • Empty fields or unresolved links in the response. By default linked entries resolve only one level deep (include defaults to 1). Raise include to pull nested references, and remember that linked entries which are unpublished will not resolve. The preview host (preview.contentful.com) is what surfaces draft content during development.

    • undefined env vars at build time. Astro reads import.meta.env.CONTENTFUL_*, not process.env, in .astro and client code. Confirm the names match your .env and your src/env.d.ts declarations exactly.

    Alternatives to Consider

    • Sanity if you want real-time collaboration and a more flexible schema builder. It has an official Astro guide.
    • Keystatic if you prefer a git-based CMS that stores content directly in your repo. Zero external dependencies.
    • Storyblok if you need a visual editor for non-technical content teams. Great drag-and-drop experience.

    Official Docs and Examples

    Wrapping Up

    Contentful and Astro make a strong pair for content-heavy sites that need structure and performance. Set up the typed client, model your content, fetch it at build time with getStaticPaths, and you have a fast, maintainable site with a proper CMS behind it. Reach for an adapter and prerender = false only when a specific route truly needs to render on demand.

    Sources

    All versions and facts below were checked on 2026-05-29.