DEV Community

Cover image for How to Integrate Astro with ApostropheCMS pt. 2
Antonello Zanini for Apostrophe

Posted on • Edited on • Originally published at apostrophecms.com

How to Integrate Astro with ApostropheCMS pt. 2

This is the second and conclusive part of the ApostropheCMS / Astro integration tutorial. In Part 1, you learned how to set up an Astro frontend that communicates with an ApostropheCMS backend through the ApostropheCMS Astro Integration Starter Kit.

In this tutorial, you will complete the Astro blog application by integrating React into it and using React components to improve its user interface and interactivity.

Let’s look at the remaining aspects of the ApostropheCMS integration into Astro!

What We Achieved So Far

In Part 1, you used the ApostropheCMS Astro Integration Starter Kit to integrate an ApostropheCMS backend with an Astro frontend application. In this setup, ApostropheCMS handles content management, URL routing, and content retrieval, while Astro renders the HTML documents and delivers them to the user's browser.

The Astro-ready ApostropheCMS application we started from uses the @apostrophecms/blog module. This includes a blog index page with a list of all posts on the site and a page for each individual blog post.

This is what the blog index page currently looks like in the Astro frontend:

And this is the dedicated blog post page:

As you can see, there is still a lot to do when it comes to UI and UX to transform this web application into a more usable and interesting site. To accomplish that, you will see how to take advantage of the features offered by Astro, such as the support of multiple UI frameworks.

By adding custom styling and using interactive React components, you will learn how to take the ApostropheCMS-powered Astro blog application to the next level!

Add React to Astro

Astro comes with a CLI command to automate the setup of React. In the Astro project folder, launch the command below to install @astrojs/react and configure it to use React components in Astro:

npx astro add react
Enter fullscreen mode Exit fullscreen mode

This will launch a CLI wizard where you will be asked a few questions. Answer “yes” to each of them to:

  1. Install the libraries required for integrating React.
  2. Add the react() integration to the astro.config.mjs configuration file.
  3. Update the tsconfig.json file for JSX support.

This process will take a while, so be patient. If something goes wrong, refer to the official documentation for more information.

Excellent! You can now use React components in Astro.

Prepare your astro-frontend project to host React components by adding a components folder to ./src.

Customize the UI of Your ApostropheCMS-Powered Astro Blog

Follow the steps below and learn how to improve the UI and interactivity of the ApostropheCMS Astro blog application!

If you are eager to take a look at the code of the final Astro codebase or want to use it as a reference while you follow the tutorial, clone the GitHub repository supporting the article:

git clone https://github.com/Tonel/astro-frontend.git
Enter fullscreen mode Exit fullscreen mode

Note that we will use the apostrophecms/astro-frontend project as a starting point.

General UI Improvements
If you inspect the Astro template components under the ./src/templates folder, you will see that they all share this structure:

---
// JS imports
---

<section class='bp-content'>
  <!-- HTML structure -->
</section>
Enter fullscreen mode Exit fullscreen mode

This means you can target the bp-content class to add a responsive layout to your site. To define a global CSS rule that applies to those Astro components, you need to define a local stylesheet file.

In this tutorial, we are going to use SCSS. Thus, add the sass npm package to your project’s dependency:

npm install sass 
Enter fullscreen mode Exit fullscreen mode

Next, create a styles folder inside ./src and add the following blog.scss file to it:

// .src/styles/blog.scss

.blog {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";

  .bp-content {
    padding-right: 15px;
    padding-left: 15px;
    margin-right: auto;
    margin-left: auto;

    @media (min-width: 576px) {
      width: 540px;
    }

    @media (min-width: 768px) {
      width: 720px;
    }

    @media (min-width: 992px) {
      width: 960px;
    }

    @media (min-width: 1200px) {
      width: 1140px;
    }
  }

  .h1 {
    text-align: center;
    margin: 0 0 20px;
    font-size: 4em;
    font-weight: 200;
  }

  a {
    color: #6236ff;
    text-decoration: none;
    background-color: transparent;

    &:hover {
      text-decoration: underline;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This defines global styles for the page structure, links, and blog fonts. Also, it contains useful global classes that we will use later on. As you can see, the top wrapping class of this SCSS file is blog.

Import .src/styles/blog.scss and set the <body> class to blog in [...slug].astro as follows:

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

import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js'
import AposLayout from '@apostrophecms/apostrophe-astro/components/layouts/AposLayout.astro'
import AposTemplate from '@apostrophecms/apostrophe-astro/components/AposTemplate.astro'
import '../styles/blog.scss' // <-- import the custom SCSS file  

const aposData = await aposPageFetch(Astro.request)
const bodyClass = `blog` // <-- update the <body> class

if (aposData.redirect) {
  return Astro.redirect(aposData.url, aposData.status)
}
if (aposData.notFound) {
  Astro.response.status = 404
}
---
<AposLayout title={aposData.page?.title} {aposData} {bodyClass}>
    <Fragment slot='standardHead'>
      <meta name='description' content={aposData.page?.seoDescription} />
      <meta name='viewport' content='width=device-width, initial-scale=1' />
      <meta charset='UTF-8' />
    </Fragment>
    <AposTemplate {aposData} slot='main'/>
</AposLayout>
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:4321/blog in the browser and you will now see:

Similarly, this is what a blog post page will look like:

Awesome! The appearance of the blog has already improved a bit, but there is still much to be done.

Create a Blog Card Component
The current index page of the blog is nothing more than a list of links. As such, the user experience is very limited. Improve it with a custom React UI component representing a blog post card to use in that list!

In the ./src/components folder, add a BlogCard.jsx file that contains these lines:

// ./src/components/BlogCard.jsx

import './BlogCard.scss'
import dayjs from 'dayjs'

export default function BlogPost({ blog }) {
  return (
    <div className='blog-card'>
      <div className='date'>
        Released On {dayjs(blog.publishedAt).format('MMMM D, YYYY')}
      </div>
      <div className='title'>
        <a href={blog._url}>{blog.title}</a>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This JSX React component wraps and extends the following blog post representation logic in ./src/templates/BlogIndexPage.astro:

<h4>
  Released On { dayjs(piece.publishedAt).format('MMMM D, YYYY') }
</h4>
<h3>
  <a href={ piece._url }>{ piece.title }</a>
</h3>
Enter fullscreen mode Exit fullscreen mode

As you are about to learn, the main advantage of using React components is that they make it easier to implement advanced user interactions without any impact on SEO.

Notice that the first line of the component is an import to a SCSS file. So, define the BlogCard.scss file this way:

// ./src/components/BlogCard.scss

.blog-card {
  border-radius: 5px;
  border: solid #111111 1px;
  padding: 20px;
  margin-bottom: 10px;

  .date {
    font-style: italic;
    color: #505050;
    font-size: 14px;
    margin-bottom: 15px;
  }

  .title {
    font-size: 20px;
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep in mind that Astro supports CSS imports via ESM inside any JavaScript file, including JSX components. This is useful for writing granular, per-component styles for your React components.

Import the Blog Card Component in the Blog Index Page
Open over the BlogIndexPage.astro template component and add the BlogCard import in the JavaScript section:

import BlogCard from '../components/BlogCard'
Enter fullscreen mode Exit fullscreen mode

You do not need to import BlogCard.scss as well, since the React component already imports it.

In the template section of the Astro component, replace the blog post HTML representation logic with <BlogCard />:

{
  pieces.map((piece) => {
    return <BlogCard blog={piece} />
  })
}
Enter fullscreen mode Exit fullscreen mode

Note that Astro will manage the keys for each DOM element under the hood. So, you do not need to manually provide a key prop as you would normally do in React.

This is where the Astro magic happens. Your BlogIndexPage.astro Astro template component now contains HTML mixed with a React component. In the same way, you could use other components written in Vue.js, Preact, or other frameworks.

Your BlogIndexPage.astro file will now contain:

---
// ./src/templates/BlogIndexPage.astro

import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'
import BlogCard from '../components/BlogCard'

const {
  page,
  user,
  query,
  piecesFilters,
  pieces,
  currentPage,
  totalPages
} = Astro.props.aposData

const pages = []
for (let i = 1; i <= totalPages; i++) {
  console.log(page, currentPage)
  pages.push({
    number: i,
    current: i === currentPage,
    url: setParameter(Astro.url, 'page', i),
  })
}
---
<section class='bp-content'>
  <h1>{page.title}</h1>

  <h2>Blog Posts</h2>

  {
    pieces.map((piece) => {
      return <BlogCard blog={piece} />
    })
  }

  {pages.map(page => (
    <a
      class={(page === currentPage) ? 'current' : ''} 
      href={page.url}>{page.number}
    </a>
  ))}
</section>
Enter fullscreen mode Exit fullscreen mode

For more information on how aposSetQueryParameter() works, check out the official documentation.

This is what the http://localhost:4321/blog index page will look like:

The biggest concern you might have is that those React components are rendered client-side. This would not be good for SEO, especially in a blog. To check whether the React components are rendered on the client or the server, you need to inspect the HTML pages returned by Astro to the client.

To do so, stop the local development server and build your application with:

npm run build
Enter fullscreen mode Exit fullscreen mode

The /dist folder in your project will now contain the Astro bundle. Serve it with:

npm run serve 
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:4321/blog again, right-click, and select the “View page source” option. Take a look at the source code of the page, and you will see HTML code like:

<div class="blog-card">
  <div class="date">
    Released On February 1, 2023
   </div>
   <div class="title">
     <a href="/blog/lorem-ipsum-1">Lorem Ipsum 1</a>
   </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Fantastic! This is proof that Astro automatically renders React components on the server, as their HTML is embedded in the document sent to the client. Bear in mind that this is just the default behavior, and you can customize that with the Astro template directives. Learn more in the next section.

Add Client-Side Interaction to the Blog Card Component
Currently, the only way to interact with the blog card component is to click on the title link inside it. Suppose you want to make the entire card interactive. Specifically, you want users to be redirected to the blog post page even when they click on the card element.

You could easily achieve that by passing an onClick handler to the <div> card in the React component, as shown below:

// ./src/components/BlogCard.scss

// ...

export default function BlogPost({ blog }) {
  return (
    <div
      className='blog-card'
      onClick={(e) => {
        e.preventDefault()
        // redirect to the blog post page
        window.location.href = blog._url
      }}
    >
      {/* ... */}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

To help users understand this interaction, you should also add a hover effect to the card:

// ./src/components/BlogCard.scss

.blog-card {
  // ...

  &:hover {
    border: solid #6236ff 1px;
    background-color: #f2f0f9;
    cursor: pointer;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Note that this is just a simple example but you could implement more complex interactions using React state management and hooks.

Visit the http://localhost:4321/blog page and move the mouse on a card to see that the background of the card will change as expected. At the same time, if you click on it, nothing will happen. Why? Because Astro does not hydrate UI framework components in the client by default.

If you are not familiar with this process, hydration refers to the process of attaching event listeners and state to UI components in the browser, making them interactive. In the scenario described above, the visual changes triggered by mouse movement and handled via CSS are possible, but interactive functionality such as clicking is not enabled.

To instruct Astro to hydrate the blog card component in the client, pass the client:load directive to <BlogCard /> in BlogIndexPage.astro:

{
  pieces.map((piece) => {
    return <BlogCard blog={piece} client:load />
  })
}
Enter fullscreen mode Exit fullscreen mode

Interact with the blog index page again, and the click interaction will now work as desired:

To make this possible, Astro will send the minimum amount of JavaScript possible to the client. This way, the web pages served by your site will remain quick to be retrieved and rendered by the browser.

Add a Pagination Component
You must have noticed that little "1" at the bottom of the index blog page:

This is a pagination element that allows you to explore the list of all articles in your blog. Use ApostropheCMS to populate your application with more than 10 posts, and more pagination numbers will appear:

Currently, the pagination logic is handled by the following lines in ./src/templates/BlogIndexPage.astro:

{pages.map(page => (
  <a
    class={(page === currentPage) ? 'current' : ''} 
    href={page.url}>{page.number}
  </a>
))}
Enter fullscreen mode Exit fullscreen mode

This is definitely not an optimal interaction, and you should replace it with a dedicated React interactive component. Inside ./src/components, add a BlogPagination.jsx file with this code:

// ./src/components/BlogPagination.jsx

import './BlogPagination.scss'

export default function BlogPagination({ pages = [] }) {
  return (
    <div className='blog-pagination'>
      {pages.map((page) => {
        return (
          <span
            key={page.number}
            className={`pagination-element ${page.current ? 'current' : ''}`}
            onClick={(e) => {
              e.preventDefault()
              // redirect to the blog index pagination page
              window.location.href = page.url
            }}
          >
            <a href={page.url}>{page.number}</a>
          </span>
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

As before, this component contains both a crawlable link for SEO and a more intuitive click interaction for users.

The style and interactivity of each pagination element should change whether the number matches the current page or not. You can define this logic in a dedicated BlogPagination.scss style file:

// ./src/components/BlogPagination.scss

.blog-pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 40px;
  margin-bottom: 40px;

  .pagination-element {
    border: solid #111111 1px;
    text-align: center;
    padding: 10px 20px;
    margin-right: 10px;
    border-radius: 5px;

    a {
      text-decoration: none;
      color: inherit;
      &:hover {
        text-decoration: none;
      }
    }

    &.current {
      pointer-events: none;
      background-color: #111111;
      color: white;
    }

    &:hover {
      color: white;
      background-color: #111111;
      cursor: pointer;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Import the component in BlogIndexPage.astro and use it to replace the aforementioned pagination lines:

---
// ./src/templates/BlogIndexPage.astro

import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'
import BlogCard from '../components/BlogCard'
import BlogPagination from '../components/BlogPagination'

const {
  page,
  user,
  query,
  piecesFilters,
  pieces,
  currentPage,
  totalPages
} = Astro.props.aposData

const pages = []
for (let i = 1; i <= totalPages; i++) {
  pages.push({
    number: i,
    current: i === currentPage,
    url: setParameter(Astro.url, 'page', i),
  })
}
---
<section class='bp-content'>
  <h1>{page.title}</h1>

  <h2>Blog Posts</h2>

  {
    pieces.map((piece) => {
      return <BlogCard blog={piece} client:load />
    })
  }

  <BlogPagination pages={pages} client:load />
</section>
Enter fullscreen mode Exit fullscreen mode

Again, notice the client:load directive required to load the click interactivity in the client. This is what the new pagination component will look like:

If you click on one of the active number elements, you will be redirected to the selected pagination page:

Complete the Blog Index Page
The blog index page has improved a lot in terms of both UI and UX. It is just a matter of adding the finishing touches. For example, you could assign the h1 class from the global blog.scss style file to the <h1> element in BlogIndexPage.astro:

Also, assume you want to center the <h2> element in this Astro component. Instead of defining a global CSS rule, you can use the Astro special <style> tag to write scoped CSS:

<style>
  h2 {
    text-align: center
  }
</style>
Enter fullscreen mode Exit fullscreen mode

CSS rules defined here always come last in the order of appearance. Therefore, if you import a style sheet that conflicts with a scoped style, the scoped style’s value will apply.

The final BlogIndexPage.astro template component will contain:

---
// ./src/templates/BlogIndexPage.astro

import setParameter from '@apostrophecms/apostrophe-astro/lib/aposSetQueryParameter.js'
import BlogCard from '../components/BlogCard'
import BlogPagination from '../components/BlogPagination'

const {
  page,
  user,
  query,
  piecesFilters,
  pieces,
  currentPage,
  totalPages
} = Astro.props.aposData

const pages = []
for (let i = 1; i <= totalPages; i++) {
  pages.push({
    number: i,
    current: i === currentPage,
    url: setParameter(Astro.url, 'page', i),
  })
}
---
<style>
  h2 {
    text-align: center
  }
</style>

<section class='bp-content'>
  <h1 class='h1'>{page.title}</h1>

  <h2>Blog Posts</h2>

  {
    pieces.map((piece) => {
      return <BlogCard blog={piece} client:load />
    })
  }

  <BlogPagination pages={pages} client:load />
</section>
Enter fullscreen mode Exit fullscreen mode

This is how the definitive blog page index will appear:

Wonderful! You just learned how to customize the UI and UX of the index page of your blog. Time to focus on the blog post page!

Style the Blog Show Page
The current page for individual blog posts is rather ugly and does not provide a good reading experience. As a first step to improve it, modify BlogShowPage.astro as below:

---
// ./src/templates/BlogShowPage.astro

import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro'
import dayjs from 'dayjs'

const { page, piece, user, query } = Astro.props.aposData
const { main } = piece
---
<style>
  .publication-date {
    text-align: center;
    font-style: italic;
    color: #505050;
    font-size: 16px;
    margin-bottom: 25px;
  }
</style>

<section class='bp-content'>
  <h1 class='h1'>{ piece.title }</h1>
  <div class='publication-date'>
    { dayjs(piece.publishedAt).format('MMMM D, YYYY') }
  </h4>
  <AposArea area={main} />
</section>
Enter fullscreen mode Exit fullscreen mode

This Astro component has a custom style for the date element and relies on the global h1 class seen earlier.

The new blog show page definitely looks better:

If you inspect the blog post content rendered in the AposArea component, you will notice that it is nothing more than a list of <p>, each of which represents a paragraph:

As a first approach to style that section of the page, you might consider adding a CSS rule for p tags in <style>:

<style>
  // ...

  p {
    margin-bottom: 35px;
    line-height: 35px;
    font-size: 18px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

This will not work because that CSS snippet is scoped and the <p> elements are not directly in the template component but within AposArea. In detail, it is ApostropheCMS that takes care of handling and returning that content.

To define CSS rules for HTML documents whose content lives outside Astro—as in this case—Astro provides a special global() CSS function:

<style>
  // ...

  :global(p) {
    margin-bottom: 35px;
    line-height: 35px;
    font-size: 18px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

That function allows you to write global, unscoped CSS in the <style> tag.

Equivalently, you can add another <style> tag marked with the is:global attribute:

<style is:global>
  p {
    margin-bottom: 35px;
    line-height: 35px;
    font-size: 18px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

The CSS rules marked as “global” will apply to all the HTML elements in the DOM subtree of the Astro component.

Put it all together, and you will get the following BlogShowPage.astro file:

---
// ./src/templates/BlogShowPage.astro

import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro'
import dayjs from 'dayjs'

const { page, piece, user, query } = Astro.props.aposData
const { main } = piece
---
<style>
  .publication-date {
    text-align: center;
    font-style: italic;
    color: #505050;
    font-size: 16px;
    margin-bottom: 25px;
  }

  :global(p) {
    margin-bottom: 35px;
    line-height: 35px;
    font-size: 18px;
  }
</style>

<section class='bp-content'>
  <h1 class='h1'>{ piece.title }</h1>
  <div class='publication-date'>
    { dayjs(piece.publishedAt).format('MMMM D, YYYY') }
  </h4>
  <AposArea area={main} />
</section>
Enter fullscreen mode Exit fullscreen mode

This is what the blog show page will now look like:

Congratulations! You now know how to build a site that relies on Astro on the frontend and uses ApostropheCMS for content management.

Conclusion

Part 2 of this two-article series ends up here. But, suggested next steps for you are revamping the home page, improving the 404 page, designing a proper 500 page, and adding new features such as a top menu, a footer, sharing buttons, the ability to comment, and more. Thanks to what you have learned here, you have all the building blocks you need to achieve those goals and get the most out of the Astro/ApostropheCMS integration!

Once again, ApostropheCMS has proven to be a modern, robust, future-oriented technology that can support new approaches to web development due to its unopinionated approach on the frontend. Try Apostrophe today!

FAQ

How does Astro communicate with ApostropheCMS?
The @apostrophecms/apostrophe-astro npm library automatically proxies certain ApostropheCMS endpoints in Astro. In particular, these are the routes proxied by the package:

  • /apos-frontend/[...slug]: For serving ApostropheCMS assets.
  • /uploads/[...slug]: For serving ApostropheCMS uploaded assets.
  • /api/v1/[...slug] and /[locale]/api/v1/[...slug]: For contacting the ApostropheCMS API endpoints.
  • /login and /[locale]/login: For accessing the ApostropheCMS login page.

So, for example, when you visit the /login page in Astro, the aposPageFetch() function from @apostrophecms/apostrophe-astro takes care of forwarding the request to the /login endpoint of your ApostropheCMS backend and retrieving the returned HTML.

Note that this proxy mechanism forwards all the headers of the original request, including cookies. This also explains how Apostrophe login works in Astro.

Do ApostropheCMS widget players still work in Astro?
In ApostropheCMS, widget players are a frontend feature that allows developers to provide special behavior to widgets, calling each widget's player exactly once at page load and when new widgets are inserted or replaced with new values. This interactive widget feature should still work without a page refresh, even if the widget was just added to the page. To achieve the same result, you can use Astro web components.

Defining and using an HTMLElement inside an Astro widget component has much the same effect as defining a widget player in a standalone ApostropheCMS project. For a complete example, check out the source code of VideoWidget.astro in the apostrophecms/astro-frontend project.

How are error messages presented in Astro?
Astro comes with a default error page that contains the error message, the snippet that caused the error, and the stack trace as below:

If you receive the following error after integrating the @apostrophecms/apostrophe-astro library:

Only URLs with a scheme in: file and data are supported by the default ESM
loader. Received protocol 'virtual:'
Enter fullscreen mode Exit fullscreen mode

Then, you most likely left out this part from the astro.config.mjs file:

export default defineConfig({
  // other settings here ...
  vite: {
    ssr: {
      noExternal: [ '@apostrophecms/apostrophe-astro' ],
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Without this logic, the virtual: URLs used to access configuration information will cause the build to fail.

Top comments (0)