Building Astro Blog with headless BCMS: Step-by-step tutorial

Astro blog
By Branislav Vajagić
Read time 3 min
Posted on 27 Nov 2024

For this Astro blog tutorial, I will use Blog Starter as a design and structure guideline. Before you continue with this tutorial you will need to setup BCMS and populate it with data. I covered this in the BCMS Blog Structure tutorial.

In addition to the BCMS project, you will need your computer to be setup for Astro development. With all this done, you can run `npm create astro@latest` to initialize a new Astro project.

Astro Blog: Project setup

Now you can open the project in your favorite code editor. First, there are a few BCMS-related dependencies that you need to install and those are:

npm i --save @thebcms/cli @thebcms/client @thebcms/types @thebcms/utils @thebcms/components-react

As you can see you will use React utility components and because of this you will need to set up Astro to be able to use them. To do this you can run: npx astro add react

BCMS can generate TypeScript types based on the content inside of it. To the advantage of this, you will use the BCMS CLI by calling it before dev, start, build and preview scripts, like this:

"scripts": {  
  "dev": "bcms pull types lng ts && astro dev",  
  "start": "bcms pull types lng ts && astro dev",  
  "build": "bcms pull types lng ts && astro check && astro build",  
  "preview": "bcms pull types lng ts && astro preview",  
  "astro": "astro"  
},

You will also use the TailwindCSS and Prettier but this is completely optional and you can use any tools you like. If you want to follow our setup you can use:

Initializing BCMS Client

To fetch data from the BCMS you will use @thebcms/client library which abstracts Auth and REST API calls to the BCMS backend.

To initialize the client library you need an API Key. You will create 2 keys, public and private, by going to the BCMS project and navigating to Administration -> Settings -> API keys.

Here is the time to create 2 keys:

  • The first will be called "Public" to which you won't give any additional permission (see Figure 1)

  • The second will be called "Private" which will have permission to get all Entries for the Astro Blog Template (see Figure 2).

1.png
2.png

Important: You have created 2 keys because 1 will be used on the client while the other will be used on the server. As the name suggests, you do not mind exposing the public key to uses of a website.

At the root of the project, you will create .env file and place API key information inside of it:

PUBLIC_BCMS_ORG_ID=620528baca65b6578d29868d
PUBLIC_BCMS_INSTANCE_ID=67445023c8abc5e921e44650

PUBLIC_BCMS_API_KEY_ID=67446368c8abc5e921e4465e
PUBLIC_BCMS_API_KEY_SECRET=91aba2f6d045b5f0a4de368a66a785dd63d2063b4270e553be92736ba2c3622f

BCMS_API_KEY_ID=67446383c8abc5e921e4465f
BCMS_API_KEY_SECRET=a8ea9e8518b97268596b59f30fe949ad159d9355774eba653851cca08da7710d

After this, you can create bcms.config.cjs in the root of the project and add configuration for the BCMS CLI:

// bcms.config.cjs
/**  
 * @type {import('@thebcms/cli/config').BCMSConfig}  
 */  
module.exports = {  
  client: {  
    orgId: process.env.PUBLIC_BCMS_ORG_ID,  
    instanceId: process.env.PUBLIC_BCMS_INSTANCE_ID,  
    apiKey: {  
      id: process.env.PUBLIC_BCMS_API_KEY_ID,  
      secret: process.env.PUBLIC_BCMS_API_KEY_SECRET,  
    }  
  }  
}

Now you can go back to the code editor and inside of the `src` you will create 2 files: bcms-public.ts and `bcms-private.ts

// src/bcms-public.ts
import { Client } from "@thebcms/client";  
  
export const bcmsPublic = new Client(  
  import.meta.env.PUBLIC_BCMS_ORG_ID!,  
  import.meta.env.PUBLIC_BCMS_INSTANCE_ID!,  
  {  
    id: import.meta.env.PUBLIC_BCMS_API_KEY_ID!,  
    secret: import.meta.env.PUBLIC_BCMS_API_KEY_SECRET!,  
  },  
  {  
    injectSvg: true,  
  },  
);
// src/bcms-private.ts
import { Client } from "@thebcms/client";  
  
export const bcmsPrivate = new Client(  
  import.meta.env.PUBLIC_BCMS_ORG_ID!,  
  import.meta.env.PUBLIC_BCMS_INSTANCE_ID!,  
  {  
    id: import.meta.env.BCMS_API_KEY_ID!,  
    secret: import.meta.env.BCMS_API_KEY_SECRET!,  
  },  
  {  
    injectSvg: true,  
  },  
);

Making pages for the Astro Blog website

With setup done and the BCMS client initialized you can move to create a page on which we will list all blog posts.

Listing blogs

First, you will create a simple layout like shown in the snippet below:

<!--src/layouts/Layout.astro-->
<!doctype html>
<html lang="en" class="overflow-hidden">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="description"
      content="Jumpstart your Astro project with this Blog. Easily manage your content and scale your application without the backend hassle. Get started now!"
    />
    <meta name="viewport" content="width=device-width" />
    <title>Astro Blog Tutorial</title>
  </head>
  <body class="font-inter overflow-x-hidden bg-appBody w-screen h-screen overflow-auto">
    <div class="overflow-hidden flex flex-col min-h-screen">
      <header class="sticky">
        <ul class="flex gap-2 items-center">
          <li><a href="/">Home</a></li>
          <li><a href="/blog">Blogs</a></li>
        </ul>
      </header>
      <main class="flex flex-col flex-1 mt-10">
        <slot />
      </main>
    </div>
  </body>
</html>

As you can see you aren't doing anything fancy because your focus is on listing blogs and displaying a single blog content. Because of this, on the home page, you will just add a link to go to the list of blog posts:

---
// src/pages/index.astro
import Layout from "../layouts/Layout.astro";
---

<Layout>
  <h1 class="text-2xl font-bold">My Astro Blog</h1>
  <div>
    <a href="/blog">Go to blogs page</a>
  </div>
</Layout>

Now you can move to the page src/pages/blog/index.astro where to list all blogs and you can do it like this:

---
// src/pages/blog/index.astro

import Layout from "../../layouts/Layout.astro";
import { bcmsPrivate } from "../../bcms-private";
import { BlogEntry, BlogEntryMetaItem } from "../../../bcms/types/ts";
import { BCMSImage } from "@thebcms/components-react";
import { PropMediaDataParsed } from "@thebcms/types";
import { bcmsPublic } from "../../bcms-public";

interface BlogCardData {
  title: string;
  slug: string;
  image: PropMediaDataParsed;
}

const blogEntries = (await bcmsPrivate.entry.getAll("blog")) as BlogEntry[];
const blogCards: BlogCardData[] = blogEntries.map((blogEntry) => {
  const blogMeta = blogEntry.meta.en as BlogEntryMetaItem;
  return {
    slug: blogMeta.slug,
    title: blogMeta.title,
    image: blogMeta.cover_image,
  };
});
---

<Layout>
  <h1 class="text-2xl font-bold mb-5">All Blogs</h1>
  <div>
    {
      blogCards.map((card) => {
        return (
          <a
            class="flex items-center gap-4 hover:bg-gray-100 transition-colors duration-300"
            href={`/blog/${card.slug}`}
            key={card.slug}
          >
            <div class="size-32">
              <BCMSImage
	            client:load
                className="size-full object-cover"
                media={card.image}
                clientConfig={bcmsPublic.getConfig()}
              />
            </div>
            <div>
              <h3 class="text-xl font-bold">{card.title}</h3>
            </div>
          </a>
        );
      })
    }
  </div>
</Layout>

As you can see, first you are fetching all blog entries for the BCMS and then mapping them to a simpler model: BlogCardData. Here you are hardcoding .en locale since you are not using localization. With data fetched, you have everything to render the page.

You can also notice, that you are using 1 utility component BCMSImage that handles auth for file fetching and will also render the most optimal image for the provided container.

Single Astro blog page

With this done, you can move to the single blog page. You will follow similar steps:

  • fetch data for specified blogs

  • display data on the page

---
// src/pages/blog/[slug].astro

import Layout from "../../layouts/Layout.astro";
import { bcmsPrivate } from "../../bcms-private";
import { BlogEntry, BlogEntryMetaItem } from "../../../bcms/types/ts";
import { tryCatch } from "@thebcms/utils/try-catch";
import { EntryContentParsedItem } from "@thebcms/types";
import { BCMSImage, BCMSContentManager } from "@thebcms/components-react";
import { bcmsPublic } from "../../bcms-public";

export async function getStaticPaths() {
  const blogEntries = (await bcmsPrivate.entry.getAll("blog")) as BlogEntry[];
  return blogEntries.map((e) => ({
    params: {
      slug: e.meta.en?.slug || "",
    },
  }));
}

const { slug } = Astro.params;
const blogEntry = (await bcmsPrivate.entry.getBySlug(
  slug,
  "blog",
)) as BlogEntry;
const meta = blogEntry.meta.en as BlogEntryMetaItem;
const content = blogEntry.content.en as EntryContentParsedItem[];
const date = new Date(meta.date.timestamp);
const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];
---

<Layout>
  <div class="flex flex-col items-center max-w-[1400px] w-full mx-auto">
    <div class="text-gray-500 text-xl">
      {date.getDate()}
      {months[date.getMonth()]}, {date.getFullYear()}
    </div>
    <h1 class="text-6xl mt-10">{meta.title}</h1>
    <div class="size-full mt-10 rounded-2xl overflow-hidden">
      <BCMSImage
        client:load
        className="flex-shrink-0 flex-1 size-full object-cover"
        media={meta.cover_image}
        clientConfig={bcmsPublic.getConfig()}
      />
    </div>
    <div class="mt-10">
      <BCMSContentManager client:load items={content} />
    </div>
  </div>
</Layout>
<style is:global>
  .bcms-content {
    h2 {
      @apply text-2xl mb-4 mt-6 font-bold;
    }
  }
</style>

Here you are using 2 utility components: BCMSImage and BCMSContentManager. As mentioned above BCMSImage is used to render the most optimal image for the provided container automatically while BCMSContentManager is used to render content responses from the BCMS.

If you go to a single blog page you will be able to see that there is a problem, you are not able to see Text and image and Image Widgets. This is because you didn't tell BCMSContantManager how to handle them.

If you inspect the DOM we will be able to see that there are 2 nodes with the message "Widget ... is not handled". You can see this in Figure 3.

3.png

Since you need to provide custom components for Widget rendering, you will need to handle content yourself, but it is pretty straightforward.

First, create a component for Text and image Widget.

---
// src/components/widgets/TextAndImage.astro

import { TextAndImageWidget } from "../../../bcms/types/ts";
import { BCMSImage, BCMSContentManager } from "@thebcms/components-react";
import { bcmsPublic } from "../../bcms-public";

const {
  data,
}: {
  data: TextAndImageWidget;
} = Astro.props;
---

<div
  class={`flex gap-4 ${data.image_position === "RIGHT" ? "flex-row-reverse" : ""}`}
>
  <div class="size-64 rounded-xl overflow-hidden flex-shrink-0">
    <BCMSImage
      client:load
      media={data.image}
      className="size-full object-cover"
      clientConfig={bcmsPublic.getConfig()}
    />
  </div>
  <BCMSContentManager items={data.text.nodes} />
</div>

Here you are checking if the image should be on the left or right and after that, you are using BCMSContentManager utility component to render rich text property.

You have one more Widget called Image and you will create it:

---
// src/components/widgets/Image.astro

import { ImageWidget } from "../../../bcms/types/ts";
import { BCMSImage } from "@thebcms/components-react";
import { bcmsPublic } from "../../bcms-public";

const {
  data,
}: {
  data: ImageWidget;
} = Astro.props;
---

<div class="w-full max-h-[450px] h-full rounded-xl overflow-hidden flex-shrink-0 my-5">
  <BCMSImage
    client:load
    media={data.src}
    className="size-full object-cover"
    clientConfig={bcmsPublic.getConfig()}
  />
</div>

In this Widget, you are only displaying provided images using BCMSImage utility component.

With Widget components created, you can use them to render content on the blog page.

---
// src/pages/blog/[slug].astro

import Layout from "../../layouts/Layout.astro";
import { bcmsPrivate } from "../../bcms-private";
import { BlogEntry, BlogEntryMetaItem } from "../../../bcms/types/ts";
import { tryCatch } from "@thebcms/utils/try-catch";
import { EntryContentParsedItem } from "@thebcms/types";
import {
  BCMSImage,
  BCMSContentManager,
  BCMSWidgetComponents,
} from "@thebcms/components-react";
import { bcmsPublic } from "../../bcms-public";
import TextAndImage from "../../components/widgets/TextAndImage.astro";
import Image from "../../components/widgets/Image.astro";

export async function getStaticPaths() {
  const blogEntries = (await bcmsPrivate.entry.getAll("blog")) as BlogEntry[];
  return blogEntries.map((e) => ({
    params: {
      slug: e.meta.en?.slug || "",
    },
  }));
}

const { slug } = Astro.params;
const blogEntry = (await bcmsPrivate.entry.getBySlug(
  slug,
  "blog",
)) as BlogEntry;
const meta = blogEntry.meta.en as BlogEntryMetaItem;
const content = blogEntry.content.en as EntryContentParsedItem[];
const date = new Date(meta.date.timestamp);
const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];
---

<Layout>
  <div class="flex flex-col items-center max-w-[1400px] w-full mx-auto">
    <div class="text-gray-500 text-xl">
      {date.getDate()}
      {months[date.getMonth()]}, {date.getFullYear()}
    </div>
    <h1 class="text-6xl mt-10">{meta.title}</h1>
    <div class="size-full mt-10 rounded-2xl overflow-hidden">
      <BCMSImage
        client:load
        className="flex-shrink-0 flex-1 size-full object-cover"
        media={meta.cover_image}
        clientConfig={bcmsPublic.getConfig()}
      />
    </div>
    <div class="mt-10 blogContent max-w-[784px]">
      <!--Handle BCMS entry content with Widgets-->
      {
        content.map((item) => {
          if (!item.widgetName) {
            return <Fragment set:html={item.value} />;
          }
          if (item.widgetName === "text-and-image") {
            return <TextAndImage data={item.value} />;
          } else if (item.widgetName === "image") {
            return <Image data={item.value} />;
          }
          return (
            <div class="hidden">Widget "{item.widgetName}" is not handled</div>
          );
        })
      }
    </div>
  </div>
</Layout>
<style is:global>
  .blogContent {
    h2 {
      @apply text-2xl mb-4 mt-6 font-bold first:mt-0;
    }
  }
</style>

As you can see you are looping through content items and mounting them depending on the Widget name. If widgetName does not exist, that means that the content item is a primitive node, which are: paragraph, heading, ordered list, and so on.

Learn more about BCMS widgets: Reusable structured content guide

Your blog is ready to go live

I hope this tutorial helped you to understand how to use Astro with a headless BCMS. Of course, you can use headless CMS for even more complicated projects in Astro development. If you found this blog-building straightforward, maybe you are ready to use BCMS as an Astro CMS for more complex tasks.

It takes a minute to start using BCMS

Gradient

Join our Newsletter

Get all the latest BCMS updates, news and events.

You’re in!

The first mail will be in your inbox next Monday!
Until then, let’s connect on Discord as well:

Join BCMS community on Discord

By submitting this form you consent to us emailing you occasionally about our products and services. You can unsubscribe from emails at any time, and we will never pass your email to third parties.

Gradient