How to Build a Blog with Astro and a Headless CMS

10 minute read

Astro is a great fit for content sites: you get static HTML, fast page loads, file-based routing, and the freedom to add interactive UI only where you need it. Marble gives you a hosted place to write and manage the content behind that site.

In this guide, we will build a blog with Astro and Marble. We will use the Marble TypeScript SDK, Astro's Content Collections, and a custom content loader that fetches posts from Marble at build time.

We will also cover the alternative approach: fetching from Marble directly inside an Astro page and passing the result through getStaticPaths(). Both approaches work. Content Collections are a good default for a static blog because they give you a central, typed content layer, while direct fetching can be simpler for a small site.

If you want to start from a working project, clone the Marble Astro blog template. It includes Content Collections, a Marble-powered loader, typed schemas, post pages, category pages, and reusable components.

What we are building

By the end, you will have:

  • A statically generated Astro blog index.

  • Dynamic pages for individual Marble posts.

  • Type-safe post data loaded through Astro Content Collections.

  • SEO metadata generated from each post's title and description.

  • Server-side rendering of Marble's sanitized HTML content.

  • A deployment workflow that rebuilds the site when content changes.

Choose a data-fetching strategy

There are two sensible ways to connect Astro to a headless CMS.

Build-time Content Collections

With Astro's default static output, a Content Collection loader fetches your posts while the site is building. Astro then generates ordinary HTML files for the blog pages. This is the approach used by the Marble Astro template and is usually the best choice for a blog or publication.

Build-time loading gives you fast pages and keeps your API key on the server. The trade-off is that a new or updated post becomes public after the next deployment. Marble webhooks can trigger that deployment automatically.

Direct page fetching

You can also call the Marble SDK directly from an Astro page. In a static build, that request still happens at build time. This is useful when you want a smaller setup or only need the data in one route.

For fresh data on every request, use Astro's server output with an adapter and opt a route out of prerendering. We will focus on the static blog path in this tutorial, then show where on-demand rendering fits.

Prerequisites

You need:

  • An Astro project. The Astro tutorial is a good place to start if you are new to Astro.

  • A Marble workspace with at least one published post.

  • A Marble API key.

  • Node.js and a package manager such as pnpm, npm, yarn, or Bun.

If you are starting from the template, use:

git clone https://github.com/usemarble/astro-example.git
cd astro-example
pnpm install

Install the Marble SDK

Install the SDK in your Astro project:

pnpm add @usemarble/sdk

With npm, use npm install @usemarble/sdk. The SDK includes TypeScript types, pagination helpers, and the client methods for posts, categories, tags, authors, media, and other Marble resources.

Configure the API key

Next, create an API key. In the Marble dashboard, open your workspace, go to Settings, then choose API Keys under the Developers section.

From the API Keys page, create a key and copy it. For a read-only blog, a public/read key is usually enough. If your app will create, update, or delete content through the API, use a private key and keep it strictly on the server.

Creating an API key from the Marble dashboard.

Create a .env file at the root of your Astro project:

MARBLE_API_KEY="your_api_key_here"

Do not prefix this variable with PUBLIC_. The API key should only be used in server-side code, build-time loaders, or server-rendered routes. Exposing even a read-only key in browser JavaScript can allow other people to consume your rate limit.

For more information about API keys and the SDK, see the Marble SDK documentation.

Create a shared Marble client

Create src/lib/marble.ts so the rest of the project can share one configured client:

import { Marble } from "@usemarble/sdk";

export const marble = new Marble({
  apiKey: import.meta.env.MARBLE_API_KEY,
});

Astro replaces import.meta.env values in server-side and build-time code. Because this module is imported by the content loader and Astro pages—not browser scripts—the key is not sent to visitors.

Load Marble posts into a Content Collection

Astro's Content Layer supports custom loaders for remote data. A loader can fetch data from an API and return entries with a unique id. Astro stores those entries and makes them available through getCollection() and getEntry().

Create src/content.config.ts:

import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import { Marble } from "@usemarble/sdk";

const marble = new Marble({
  apiKey: import.meta.env.MARBLE_API_KEY,
});

const posts = defineCollection({
  loader: async () => {
    const result = await marble.posts.list({
      limit: 100,
    });

    const allPosts = [];

    for await (const page of result) {
      allPosts.push(...(page.posts ?? []));
    }

    return allPosts.map((post) => ({
      id: post.id,
      ...post,
    }));
  },

  schema: z.object({
    title: z.string(),
    slug: z.string(),
    description: z.string(),
    content: z.string(),
    publishedAt: z.coerce.date(),
    coverImage: z.string().nullable().optional(),
    category: z
      .object({
        id: z.string(),
        name: z.string(),
        slug: z.string(),
        description: z.string().nullable(),
      })
      .nullable()
      .optional(),
  }),
});

export const collections = {
  posts,
};

The SDK's list method returns an async iterable. The for await...of loop makes sure the loader reads every page instead of silently stopping after the first 100 posts.

The schema is intentionally focused on the fields used by the blog. Marble returns additional fields such as authors, categories, tags, and custom fields; add those to the schema when your components need them.

Astro's official Content Loader documentation explains the loader API and the difference between simple function loaders and more advanced object loaders.

Build the blog index

Create src/pages/blog/index.astro and query the collection:

---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";

const posts = (await getCollection("posts")).sort(
  (a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf(),
);
---

<Layout
  title="Blog"
  description="Guides and articles about building content-driven websites with Marble."
>
  <main>
    <h1>Blog</h1>

    <ul>
      {posts.map((post) => (
        <li>
          <a href={'/blog/' + post.data.slug}>
            {post.data.title}
          </a>
          <p>{post.data.description}</p>
        </li>
      ))}
    </ul>
  </main>
</Layout>

Collection order is not guaranteed, so sort the entries yourself when the order matters. For a blog, publication date is usually the right default.

Generate a page for every post

Create src/pages/blog/[slug].astro. The route uses getStaticPaths() to create one static page for every collection entry:

---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("posts");

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

const { post } = Astro.props;
const formattedDate = post.data.publishedAt.toLocaleDateString("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
});
---

<Layout
  title={post.data.title}
  description={post.data.description}
>
  <article>
    <header>
      <p>{formattedDate}</p>
      <h1>{post.data.title}</h1>
      <p>{post.data.description}</p>
    </header>

    {post.data.coverImage && (
      <img
        src={post.data.coverImage}
        alt={post.data.title}
        width="1200"
        height="630"
      />
    )}

    <div set:html={post.data.content} />
  </article>
</Layout>

Marble stores post content as semantic HTML and sanitizes it before returning it through the API. Astro's set:html directive renders that HTML inside the page. Only use this pattern with content you trust and sanitize; do not pass arbitrary user input directly into set:html.

If you use your own Prose.astro component, pass the same HTML to it instead. The template includes a reusable prose component for typography, code blocks, links, and images.

Add canonical URLs and metadata

Each post should have a unique title, description, and canonical URL. Set your site URL in astro.config.mjs:

import { defineConfig } from "astro/config";

export default defineConfig({
  site: "https://your-domain.com",
});

Then let your layout generate the canonical link and Open Graph metadata from the route. A minimal layout can accept these props:

---
interface Props {
  title: string;
  description: string;
  canonical?: string;
}

const { title, description, canonical = Astro.url.href } = Astro.props;
---

<html lang="en">
  <head>
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonical} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
  </head>
  <body>
    <slot />
  </body>
</html>

For production, build the canonical from your configured site URL and the pathname so query strings do not create alternate canonical URLs. Keep the blog's URL pattern stable once the article is published.

Fetch directly from a page instead

Content Collections are useful when many routes share the same content layer. For a smaller project, you can fetch posts directly in the dynamic page and pass each post through props:

---
import { Marble } from "@usemarble/sdk";
import Layout from "../../layouts/Layout.astro";

const marble = new Marble({
  apiKey: import.meta.env.MARBLE_API_KEY,
});

export async function getStaticPaths() {
  const result = await marble.posts.list({
    limit: 100,
  });

  const posts = [];

  for await (const page of result) {
    posts.push(...(page.posts ?? []));
  }

  return posts.map((post) => ({
    params: {
      slug: post.slug,
    },
    props: {
      post,
    },
  }));
}

const { post } = Astro.props;
---

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

This is the Astro equivalent of fetching data in a Next.js page and passing it into the component tree. The key difference is that Astro's static build runs the request while generating the route.

Use the Content Collection approach when you want centralized schemas, collection filters, and shared querying. Use direct page fetching when the project is small or a route has very specific data needs.

Use server rendering when content must be fresh

Static generation is ideal for a blog, but you may want a route to fetch the latest content on every request. Astro supports on-demand rendering when your project uses a server output and a compatible adapter.

The shape is:

---
export const prerender = false;

import { Marble } from "@usemarble/sdk";
import Layout from "../../layouts/Layout.astro";

const marble = new Marble({
  apiKey: import.meta.env.MARBLE_API_KEY,
});

const slug = Astro.params.slug;

if (!slug) {
  throw new Error("Missing post slug");
}

const { post } = await marble.posts.get({
  identifier: slug,
});
---

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

In this mode, you do not use getStaticPaths() for the route. Astro resolves Astro.params at request time. Read the Astro on-demand rendering guide before choosing this approach, because your deployment target needs an Astro adapter and server runtime.

Keep static pages fresh with webhooks

With a statically generated Astro site, changing a post in Marble does not change the already-deployed HTML. Connect a Marble webhook to your host's deploy hook or CI workflow:

  1. Create a deploy hook in your hosting provider.

  2. In Marble, open Settings, then choose Webhooks and create a JSON webhook.

  3. Use the deploy-hook URL as the webhook destination.

  4. Subscribe to events such as post.published, post.updated, and post.deleted.

  5. Publish or update a post and confirm that a new deployment starts.

For a custom webhook endpoint, verify the x-marble-signature header before starting a build. The Marble webhooks documentation explains delivery retries, event payloads, and signature verification.

Categories and tags

Marble posts include category and tag data, so you can build filtered routes without duplicating content. A category page can filter the same collection:

---
import { getCollection } from "astro:content";

const { slug } = Astro.params;
const posts = await getCollection("posts", ({ data }) => {
  return data.category?.slug === slug;
});
---

<h1>{slug}</h1>

{posts.map((post) => (
  <a href={'/blog/' + post.data.slug}>
    {post.data.title}
  </a>
))}

If your collection schema includes the full category object, you can filter by its slug. Otherwise, store a normalized category slug in the loader before returning the entry. The same pattern works for tags and featured posts.

Deploy the site

The Astro example repository works with static hosts that support Astro, including Vercel, Netlify, Cloudflare Pages, and GitHub Pages. Set MARBLE_API_KEY in the host's environment variables and make sure it is available during the build.

Before deploying, check:

  • The API key is not prefixed with PUBLIC_.

  • The build can reach api.marblecms.com.

  • Your loader paginates through all published posts.

  • Each post page has a unique title, description, and canonical URL.

  • Your sitemap includes the generated blog routes.

  • Your host deploy hook is connected to Marble webhooks if you need automatic updates.

Next steps

You now have a statically generated Astro blog powered by Marble. From here, you can add pagination, category pages, tag pages, RSS, search, or a custom post layout.

For the complete working project, see the Marble Astro blog template. You can also compare this approach with the Next.js and headless CMS guide, or continue with the Astro integration documentation.

Try Marble today.

A simpler way to publish articles and manage your blog.