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

How to Use DatoCMS with Astro: Complete Guide

Step-by-step guide to integrating DatoCMS with your Astro website.

How to Use DatoCMS with Astro: Complete Guide

DatoCMS is a headless CMS built with an API-first approach. It stands out for its built-in image optimization pipeline and solid localization support. If you are building a content-driven Astro site and want a CMS that handles media intelligently without extra services, DatoCMS is worth a serious look.

The GraphQL API is well designed, the free tier is generous enough for personal projects, and the content modeling interface is one of the cleanest I have worked with. Let me walk you through the full setup.

A note before we start. Astro is on version 6 now (6.4.2 at the time of writing), and the default output is fully static. There is no output: 'hybrid' mode anymore. It was removed in Astro 5. Your blog pages stay static by default, and you only reach for an adapter when you genuinely need on-demand rendering. More on that below.

Prerequisites

  • Node.js 22.12.0 or newer (Astro 6 dropped Node 18 and 20, so a current 22 or 24 LTS line is required)
  • An Astro project (npm create astro@latest)
  • A DatoCMS account (the free Developer plan covers small projects)

Installation

DatoCMS publishes an official lightweight client for the Content Delivery API. This is the package the DatoCMS docs and the official Astro starter kit both recommend now. The older datocms-client package on npm even prints a notice telling new users to switch.

npm install --save @datocms/cda-client

@datocms/cda-client (0.2.10) is a tiny, TypeScript-ready wrapper around the GraphQL Content Delivery API built on the browser Fetch API. It gives you a typed executeQuery helper instead of hand-rolling fetch calls against the GraphQL endpoint.

If you later render DatoCMS Structured Text fields to HTML on the server, add the renderer too:

npm install datocms-structured-text-to-html-string

Configuration

Grab your API token from DatoCMS under Settings > API Tokens. A read-only Content Delivery API token is what you want for the frontend.

The cleanest way to handle the token in Astro 6 is the built-in astro:env API, which gives you a type-safe, validated env schema instead of reaching into import.meta.env directly. Declare it in your astro.config.mjs:

// astro.config.mjs
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  env: {
    schema: {
      DATOCMS_CDA_TOKEN: envField.string({
        context: "server",
        access: "secret",
      }),
    },
  },
});

Then add the value to your .env file:

DATOCMS_CDA_TOKEN=your_read_only_cda_token

Create a helper that wraps executeQuery and injects the token:

// src/lib/datocms.ts
import { executeQuery as libExecuteQuery } from "@datocms/cda-client";
import { DATOCMS_CDA_TOKEN } from "astro:env/server";

export async function executeQuery<T = unknown>(
  query: string,
  options?: Record<string, unknown>
): Promise<T> {
  return await libExecuteQuery<T>(query, {
    ...options,
    token: DATOCMS_CDA_TOKEN,
  });
}

DATOCMS_CDA_TOKEN is a secret server variable, so it is only importable from astro:env/server. It will never leak into the client bundle. The naming follows the official starter kit convention. If you also want to fetch draft content for previews, the starter kit uses a separate DATOCMS_DRAFT_CONTENT_CDA_TOKEN and passes includeDrafts: true.

Basic Usage

With a "Blog Post" model in DatoCMS containing title, slug, content, coverImage, and publishDate fields, here is how to list posts:

---
// src/pages/blog/index.astro
import { executeQuery } from "../../lib/datocms";
import BaseLayout from "../../layouts/BaseLayout.astro";

const POSTS_QUERY = `
  query {
    allBlogPosts(orderBy: publishDate_DESC) {
      id
      title
      slug
      publishDate
      coverImage {
        responsiveImage(imgixParams: { w: 600, h: 400, fit: crop }) {
          src
          width
          height
          alt
        }
      }
    }
  }
`;

const data = await executeQuery<{ allBlogPosts: any[] }>(POSTS_QUERY);
const posts = data.allBlogPosts;
---

<BaseLayout title="Blog">
  <h1>Blog</h1>
  {posts.map((post: any) => (
    <article>
      <a href={`/blog/${post.slug}`}>
        {post.coverImage?.responsiveImage && (
          <img
            src={post.coverImage.responsiveImage.src}
            alt={post.coverImage.responsiveImage.alt}
            width={post.coverImage.responsiveImage.width}
            height={post.coverImage.responsiveImage.height}
            loading="lazy"
          />
        )}
        <h2>{post.title}</h2>
      </a>
    </article>
  ))}
</BaseLayout>

For individual post pages:

---
// src/pages/blog/[slug].astro
import { executeQuery } from "../../lib/datocms";
import BaseLayout from "../../layouts/BaseLayout.astro";

export async function getStaticPaths() {
  const data = await executeQuery<{ allBlogPosts: { slug: string }[] }>(`
    query { allBlogPosts { slug } }
  `);

  return data.allBlogPosts.map((post) => ({
    params: { slug: post.slug },
  }));
}

const { slug } = Astro.params;
const data = await executeQuery<{ blogPost: any }>(
  `
  query PostBySlug($slug: String!) {
    blogPost(filter: { slug: { eq: $slug } }) {
      title
      content { value }
      publishDate
      coverImage {
        responsiveImage(imgixParams: { w: 1200, h: 630, fit: crop }) {
          src
          alt
        }
      }
    }
  }
`,
  { variables: { slug } }
);

const post = data.blogPost;
---

<BaseLayout title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <div set:html={post.content.value} />
  </article>
</BaseLayout>

Static or On-Demand Rendering

By default Astro prerenders your whole site to static HTML, which is exactly what you want for a content blog backed by DatoCMS. Both code blocks above use getStaticPaths, so every post is built once at deploy time and served as a flat file. To keep content fresh, wire up a DatoCMS build webhook (covered below) so a content change triggers a rebuild.

If you need a route to render per request instead (a search page, a gated preview, an editor-only draft viewer), Astro 6 requires two things. First, install a server adapter, for example @astrojs/node (10.1.2). Second, opt that specific route out of prerendering:

---
export const prerender = false;
// this page now renders on demand at request time
---

Astro's own guidance is to start with the default static mode and only add an adapter once you are sure you actually need on-demand rendering. The old output: 'hybrid' value no longer exists, so do not copy it from older tutorials. The current model is static by default, export const prerender = false per route for on-demand pages, or output: 'server' plus export const prerender = true if you want to invert the default.

Production Tips

  1. Use the Responsive Image API. DatoCMS runs images through imgix automatically. You get on-the-fly resizing, format conversion, and quality optimization through URL parameters. No need for Cloudinary or a separate image CDN.

  2. Leverage Structured Text. DatoCMS's Structured Text field type gives you a portable JSON format instead of raw HTML. Use datocms-structured-text-to-html-string (6.0.0) to render it to an HTML string on the server, then drop it in with set:html, or build custom node renderers for full control.

  3. Set up build webhooks. In DatoCMS under Settings > Webhooks, configure a trigger for your hosting platform. This ensures content updates go live without manual builds.

  4. Use preview mode for drafts. DatoCMS has a drafts system. Pass includeDrafts: true in your headers during development so editors can preview unpublished content before it goes live.

  • Batch your GraphQL queries. Instead of making separate requests for posts, authors, and categories, combine them into a single query. DatoCMS handles nested data well through its GraphQL API.

  • Alternatives to Consider

    • Contentful if you need a larger ecosystem with more third-party integrations and community resources.
    • Storyblok if your content team needs a visual drag-and-drop editor rather than a form-based interface.
    • Sanity if you want more flexibility in schema design and real-time collaborative editing.

    Common Errors and Fixes

    import.meta.env.DATOCMS_API_TOKEN is undefined. This usually means the variable is not exposed where you are reading it. The robust fix in Astro 6 is the astro:env schema shown above. Declare DATOCMS_CDA_TOKEN as a server + secret field and import it from astro:env/server. Secret server variables are intentionally unavailable to client code, so do not try to read the token from a client: component or a browser-side script.

    Cannot import from astro:env/server. The astro:env virtual module only resolves inside the Astro context (components, pages, endpoints, middleware, framework components). Per the Astro docs it does not work inside astro.config.mjs itself, which is exactly why the schema is defined there with envField rather than imported there.

    Old output: 'hybrid' is not a valid config value. It was removed in Astro 5. If you copied it from an older guide, delete it. Static is the default, and you opt individual routes into on-demand rendering with export const prerender = false (which also requires an installed adapter).

    On-demand route fails with an adapter error. Setting export const prerender = false without an adapter installed will fail the build. Add a server adapter such as @astrojs/node first, then mark the route.

    npm shows a notice on datocms-client. The legacy datocms-client package now describes itself as recommending the newer official clients. For the read-only Content Delivery API in a frontend, use @datocms/cda-client. Reserve the Content Management clients (@datocms/cma-client and friends) for write operations and tooling, not page rendering.

    GraphQL query returns null for a single record. DatoCMS single-instance versus collection queries differ. allBlogPosts returns an array, blogPost(filter: ...) returns one record or null. Guard with optional chaining (post?.title) and confirm your model API key matches the query field name exactly, since DatoCMS derives the GraphQL field names from the model and field API keys.

    Variables not passed through. With @datocms/cda-client, query variables go in the options object as { variables: { slug } }, not as a second positional argument. Passing them in the wrong shape silently sends an empty variable set.

    Official Docs and Examples

    Wrapping Up

    DatoCMS pairs naturally with Astro. The GraphQL Content Delivery API keeps your data fetching clean through @datocms/cda-client, the image pipeline eliminates the need for a separate optimization service, and the localization features are built right in. Lean on Astro's static default with a DatoCMS build webhook for the freshest content, and only reach for an adapter when a route genuinely needs to render on demand. For small to medium content sites, the free plan covers more than enough, and the developer experience stays consistent as you scale up.

    Sources

    Checked on 2026-05-29.