# 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](https://astro.build/) and Marble. We will use the [Marble TypeScript SDK](https://www.npmjs.com/package/@usemarble/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](https://github.com/usemarble/astro-example). 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](https://github.com/usemarble/astro-example) 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](https://docs.astro.build/en/tutorial/0-introduction/) 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.

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](https://github.com/usemarble/astro-example) 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.
