How to Build a Blog with Astro and a Headless CMS - Marble

How to Build a Blog with Astro and a Headless CMS

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:

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:

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.

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.

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.

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>

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>

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.

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>

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.

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.

Categories and tags

Marble posts include category and tag data, so you can build filtered routes without duplicating content.

Deploy the site

The Astro example repository works with static hosts that support Astro, including Vercel, Netlify, Cloudflare Pages, and GitHub Pages.

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.