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
This will launch a CLI wizard where you will be asked a few questions. Answer “yes” to each of them to:
- Install the libraries required for integrating React.
- Add the
react()
integration to theastro.config.mjs
configuration file. - 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
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>
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
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;
}
}
}
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>
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>
)
}
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>
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;
}
}
Keep in mind that Astro supports CSS import
s 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'
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} />
})
}
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>
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
The /dist
folder in your project will now contain the Astro bundle. Serve it with:
npm run serve
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>
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>
)
}
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;
}
// ...
}
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 />
})
}
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>
))}
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>
)
}
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;
}
}
}
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>
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>
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>
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>
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>
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>
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>
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>
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:'
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' ],
}
}
})
Without this logic, the virtual:
URLs used to access configuration information will cause the build to fail.
Top comments (0)