In the previous Part of this tutorial series, we looked at an introduction to Strapi headless CMS, Strapi installation, Strapi Content type builder, Collection type, enabling API access, internationalization in Strapi, Dynamic zones, and more.
In this final Part, we will build a Multilingual Blog Website using Astro and Strapi! Let's go!
Before we begin, we must create a basic Astro project. To see the installation process, please refer to Part 1.
1npm create astro@latest ui
As seen above, the name of our project is ui
.
Navigate to the public
folder and create a new folder called styles
. Inside the new folder styles
, create a new file styles.css
and add the following code:
1html,
2body {
3 height: 100%;
4 margin: 0;
5 padding: 0;
6 font-family: "Cinzel", serif;
7 background-color: #f0e5d8;
8}
9
10nav {
11 background-color: #1a1a2e;
12 color: #d4af37;
13 text-align: center;
14 padding: 10px 0;
15 position: fixed;
16 width: 100%;
17 top: 0;
18 left: 0;
19 z-index: 1000;
20 font-size: 18px;
21 font-weight: bold;
22 border-bottom: 2px solid #d4af37;
23}
24
25nav a {
26 color: #d4af37;
27 text-decoration: none;
28 padding: 0 15px;
29 transition: color 0.3s ease, transform 0.3s ease;
30}
31nav a:hover {
32 color: #eaeaea;
33 transform: scale(1.1);
34}
35
36a {
37 color: #645452;
38 text-decoration: none;
39 transition: color 0.5s ease-in-out, transform ease-in-out;
40}
41
42a:hover {
43 color: #861657;
44 transform: scale(1.05);
45}
46
47main {
48 display: flex;
49 justify-content: center;
50 align-items: center;
51 padding-top: 40px;
52 color: #3e2723;
53 box-sizing: border-box;
54}
55
56section {
57 display: flex;
58 flex-direction: column;
59 align-items: center;
60 margin: 20px;
61}
The CSS code above represents the styles and color scheme we will use for this project. Feel free to reuse these styles or modify them as you see fit.
Inside the src
folder, we will create a layout folder called layouts
, which will contain some of our layout files.
Here, we will only utilize the head
HTML element. Inside the layouts
folder, create a new file called Head.astro
and add the following code:
1---
2const { title, description } = Astro.props;
3---
4
5<head>
6 <meta charset="utf-8" />
7 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8 <meta name="viewport" content="width=device-width" />
9 <meta name="generator" content={ Astro.generator} />
10 <meta name="description" content={description} />
11 <title>{title}</title>
12</head>
We notice title
and description
in the code above. Just like in other frameworks, we can pass and access the properties or props of a given component. This is achieved using the props
property of the global Astro
object.
In this case, we accessed the title
and description
as the props and added them to the meta
and title
tags. This is a practical example of how to pass these props to the Head
component, a key component in the Astro framework that we'll delve into in the next section.
This time around, we will create a layout component for navigation. Inside the layouts
folder, create another file called Nav.astro
and add the following code:
1<nav>
2 <a href="/"> Home </a>
3 |
4 <a href="/blog">Blog </a>🇬🇧 |
5 <a href="/blog/es">Blog 🇪🇸</a>
6</nav>
Because we will be building a multilingual blog, we have created links to the home page, the blog page in English, and the blog page in Spanish.
We will modify this later when we delve deeper into this project.
Now, inside the main layout, we will add both the Head
and Nav
components.
Inside the layouts
folder, create a new file called Layout.astro
and add the following code:
1---
2import Head from "./Head.astro";
3import Nav from "./Nav.astro";
4const { title = "Just a title", description = "Adescription" } = Astro.props;
5---
6
7<html>
8 <Head {title} {description} />
9 <link
10 href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap"
11 rel="stylesheet"
12 />
13 <link rel="stylesheet" href="/styles/styles.css" />
14 <body>
15 <Nav />
16 <main>
17 <section>
18 <slot />
19 </section>
20 </main>
21 </body>
22</html>
We imported the Head
and Nav
components in the main layout file above. We also created the properties title
and description
, which we then passed to the Head
component.
Notice the relationship between the title
variable and the prop we passed to the Head
component. We didn't need to write it as title = "Just a title"
. This is because the value of the title
variable is the same as the prop we are sending to the Head
component, illustrating the concept of prop passing in React or similar frameworks.
To see our changes, we need to modify the index.astro
file inside the pages
folder. Replace the code inside the index.astro
file with the code below:
1---
2import Layout from "../layouts/Layout.astro";
3---
4
5<Layout>
6 <h1>Hello, World!</h1>
7</Layout>
We imported the main layout file Layout.astro
from the code above and wrapped an h1
element inside it. When we now preview the home page, this is what we will see:
As seen above, we have the home page, the English blog, and the Spanish blog pages.
Next, let's build the landing page!
Recall that we have access to the landing page content coming from Strapi CMS when we open up the URL http://localhost:1337/api/pages?populate[LandingPage][populate]=*
on our browser.
The Astro docs have a dedicated section that provides clear and concise steps for integrating Astro and Strapi. As we proceed with building our application, we can confidently follow these steps in the documentation guide. The first of these steps is adding the Strapi URL to the environment variable.
Inside the root of our project folder, ui
, create a new file .env
and add the following environment variable.
STRAPI_URL='http://localhost:1337'
We added our Strapi URL running on localhost. It is important to know that we could also modify this to our Strapi URL running on production or development.
Next, let's make API requests from Astro to Strapi CMS!
It's crucial to create a new folder called lib
inside the src
folder. Equally important is to create a file called strapi.js
inside this new folder. These are the key components where we will implement the fetchApi
function as per the Astro-Strapi documentation we mentioned above.
1export default async function fetchApi({
2 endpoint,
3 query,
4 wrappedByKey,
5 wrappedByList,
6 page,
7 locale,
8}) {
9 if (endpoint.startsWith("/")) {
10 endpoint = endpoint.slice(1);
11 }
12 const url = new URL(`${import.meta.env.STRAPI_URL}/api/${endpoint}`);
13
14 if (locale) {
15 url.searchParams.append("locale", locale);
16 }
17
18 if (query) {
19 Object.entries(query).forEach(([key, value]) => {
20 url.searchParams.append(key, value);
21 });
22 }
23
24 if (page) {
25 url.searchParams.append(`populate[${page}][populate]`, "*");
26 } else {
27 url.searchParams.append("populate", "*");
28 }
29
30 const res = await fetch(url.toString());
31 let data = await res.json();
32
33 if (wrappedByKey) {
34 data = data[wrappedByKey];
35 }
36
37 if (wrappedByList) {
38 data = data[0];
39 }
40
41 if (page) {
42 data = data[0]["attributes"][page];
43 }
44 return data;
45}
Here is what the code above does:
fetchApi()
function from the Astro documentation to include page
and parameter
.endpoint
, query
, wrappedByKey
, wrappedByList
, page
, and locale
.${import.meta.env.STRAPI_URL}/api/${ endpoint }
, we will be able to pass some paramters such as endpoint
, locale
, and query
to the requesting URL.locale
: This is the locale we want to get the contents. Just like we already know, this could be English or Spanish. Without specifying the value of this, we get by default the English blog posts.query
: If there is a query we want to pass, this will contain it. This can be a query containing filters, pagination, etc. You can learn more here.page
: Refers to the page we will be looking for when we want to paginate. GET
request using the fetch
API. The response is transformed using the json()
function.data
.wrappedByKey
and wrappedByList
perform in the code.NOTE: In the code above, we used
import.meta.env.STRAPI_URL
to access theSTRAPI_URL
environment variable.
In summary, the code above is a helper function that allows us to request the Strapi URL from the Astro UI we are building.
As a reminder, in our previous work with Strapi Dynamic Zones, we crafted two essential components for our landing page. The hero
component, responsible for the eye-catching top section, and the about
component, providing a detailed description of our project.
In light of that, we should also display these two components on our landing page's UI. So, create a folder called components
inside the src
and create the following files.
The hero
component encapsulates both the heroText
and heroDescription
. To begin, create a file named Hero.astro
within the components
directory and insert the following code:
1---
2const { heroText, heroDescription } = Astro.props;
3---
4
5<h1>{heroText}</h1>
6<p>{heroDescription}</p>
In the code above, we get the heroText
and heroDescription
as props of the Hero.astro
component and display them on the UI using the h1
and p
HTML elements.
The about
component comprises the aboutText
andaboutPhoto
.
When we make request to the landing page, and we access the about
component, we realize that the aboutPhoto
comes with a URL which represents an image as shown below.
The question now is, how do we access this data? Luckily, Astro has a built-in Image component that we will use to render this image on our UI.
Start by creating a file called About.astro
in the same directory as the Hero.astro
file and populate it with the following code:
1---
2import { Image } from "astro:assets";
3const { aboutText, aboutPhotoURL } = Astro.props;
4const photoURL = `${import.meta.env.STRAPI_URL}${aboutPhotoURL}`;
5---
6
7<p>{aboutText}</p>
8<Image src={photoURL} alt="Me" width="400" height="400" />
We have created both the About
and Hero
components. Let's display them on the UI. Start by modifying the index.astro
file by importing the two components:
1---
2import Layout from "../layouts/Layout.astro";
3import Hero from "../components/Hero.astro";
4import About from "../components/About.astro";
5import fetchApi from "../lib/strapi";
6
7const pageData = await fetchApi({
8 endpoint: "pages",
9 page: "LandingPage",
10 wrappedByKey: "data",
11});
12
13const heroData = pageData.find((pd) => pd.__component === "hero.hero");
14const { heroText, heroDescription } = heroData;
15
16const aboutData = pageData.find((pd) => pd.__component === "about.about");
17const { aboutText } = aboutData;
18const {
19 aboutPhoto: {
20 data: {
21 attributes: { url: aboutPhotoURL },
22 },
23 },
24} = aboutData;
25---
26
27<Layout>
28 <Hero {heroText} {heroDescription} />
29 <About {aboutText} {aboutPhotoURL} />
30</Layout>
Here is what the code above does:
fetchAPI
function, the Hero
and About
components and the Layout
components.We create pageData
which is the data we will be using to get the data of the landing page. And then we added the following properties:
endpoint
to be pages
. Recall that this is the endpoint for the landing page. And that we are trying to construct the URL : http://localhost:1337/api/pages?populate[LandingPage][populate]=*
page
here represents the page we want to populate, and which is the LandingPage
we created using the Strapi Dynamic Zones.wrappedByKey
here is used because anytime we make a request to Strapi, we get eevery information wrapped by the data
property. So with the wrappedByKey
, we can reference the information returned without having to manually go into the data
property.Because the hero
and about
components have the property __component
as shown below, which we used to differentiate each component and their data, we used it to extract each component's data.
aboutText
, aboutPhoto
, heroText
, and heroDescription
as props to the About
and Hero
components. If we view the result on our browser, we can see that we now have a landing page built using Strapi Dynamic Zones.
The next thing we want to do is to display each blog post in English. However, because we don't want to display all articles in a single page, we instead implement pagination.
When we make an API call to http://localhost:1337/api/blogs?populate=*
, we will see that we get the English and Spanish versions, images and every other thing else. Along with it, Strapi sends a meta information with pagination as shown below:
The meta information returned can actually be manipulated by sending a query to Strapi. For example, we could specify that the pageSize
should be returned as 2, 5, or 10. It could be any way we want it.
Another advantage we have is that Astro, as we can learn from the docs, has built-in support for Pagination. This means that implementing Pagination is a straightforward process, giving you the confidence and capability to handle large data sets with ease.
blog
FolderCreate a folder called blog
inside the pages
folder. This folder will be the route to all blog posts.
[page].astro
FileCreate a new file called [page].astro
inside the blog
folder. This file will help us make a fetch API request to get us all the blog posts, the pagination information, and the titles of the blog posts. Inside this file, add the following code:
1---
2import type { GetStaticPaths } from "astro";
3import Layout from "../../layouts/Layout.astro";
4import fetchApi from "../../lib/strapi";
5export async function getStaticPaths({ paginate }) {
6 const response = await fetchApi({
7 endpoint: "blogs",
8 wrappedByKey: "data",
9 });
10 const articles = response;
11 return paginate(articles, { pageSize: 2 });
12}
13
14const { page } = Astro.props;
15---
16
17<Layout>
18 <h1>Page {page.currentPage}</h1>
19 <ul>
20 {page.data.map((article) => <li>{article.attributes.title}</li>)}
21 </ul>
22 {page.url.prev ? <a href={page.url.prev}>Previous Page</a> : null} |
23 {page.url.next ? <a href={page.url.next}>Next Page</a> : null}
24</Layout>
In the code above:
/blogs
from the Strapi CMS and grab all the articles. page
property Astro
object, we get currentPage
. The currentPage
here refers to the page we are currently on.page
property, we map it and display each article as a list.With the above implementation, we saw that when we access the /blog
URL together with the page number, as shown below, we can get the pagination of the articles.
page.url.prev
and page.url.next
.Notice that when we click on the previous page or the next page, the previous page goes to page 1. When we click on the next page, it goes to page 2, and so on.
Now that we have implemented pagination, how do we view an article's content? Using each article's slug, we can create a dynamic slug page to view its content dynamically.
Head to the blog
folder and create a file called [slug].astro
for our dynamic endpoint. Inside the new file, add the following code:
1---
2import Layout from "../../layouts/Layout.astro";
3import fetchApi from "../../lib/strapi";
4
5export async function getStaticPaths() {
6 const articles = await fetchApi({
7 endpoint: "blogs",
8 wrappedByKey: "data",
9 });
10
11 return articles.map((article) => ({
12 params: { slug: article.attributes.slug },
13 props: article,
14 }));
15}
16const article = Astro.props;
17---
18
19<Layout>
20 <h1>{article.attributes.title}</h1>
21</Layout>
In the code above, we implemented the following:
Layout
component.getStaticPaths
function.fetchApi
function, we make a GET
request to the blogs
endpoint.getStaticPaths
must return a params
object. So we loop through all the articles and returned their slugs. Also, we ruturned articles
as the props so will can loop through it and display them on the UI.Next, we modify the [page].astro
so that when we click on a blog article, we will be redirected to the slug page on /blog/[the slug of the blog]
.
1---
2import type { GetStaticPaths } from "astro";
3import Layout from "../../layouts/Layout.astro";
4import fetchApi from "../../lib/strapi";
5
6export async function getStaticPaths({ paginate }) {
7 const response = await fetchApi({
8 endpoint: "blogs",
9 wrappedByKey: "data",
10 });
11 const articles = response;
12 return paginate(articles, { pageSize: 2 });
13}
14
15const { page } = Astro.props;
16---
17
18<Layout>
19 <h1>Page {page.currentPage}</h1>
20 <ul>
21 {
22 page.data.map((article) => (
23 <li>
24 <a href={`/blog/${article.attributes.slug}`}>{article.attributes.title}</a>
25 </li>
26 ))
27 }
28 </ul>
29 {page.url.prev ? <a href={page.url.prev}>Previous Page</a> : null} |
30 {page.url.next ? <a href={page.url.next}>Next Page</a> : null}
31</Layout>
When we click on an article as shown below, we see its page content, which is the title. Bravo!
As we know, the content
data of each article is in a markdown format. So, we need to display the content in HTML format.
The first step is to install marked. This is a markedown parser that will convert our markdown content into HTML. Run the command below to install marked
.
marked
1npm i marked
Create a separate component to display the parsed mardown content. So inside the components
folder, create a new file called MarkdownComponent.astro
and add the following code:
1---
2import { marked } from "marked";
3const content = marked.parse(Astro.props.content);
4---
5
6<article set:html={content} />
In the code above, we imported the installed marked
package. We used it to format the content
props, which we will pass on in the next section, and then displayed the formatted content in the article
HTML element.
Next, we update the [slug].astro
page to display the Markdown
component. This allows us to display the content in HTML format instead of Markdown, which is the default.
1---
2import MarkdownComponent from "../../components/MarkdownComponent.astro";
3import Layout from "../../layouts/Layout.astro";
4import fetchApi from "../../lib/strapi";
5
6export async function getStaticPaths() {
7 const articles = await fetchApi({
8 endpoint: "blogs",
9 wrappedByKey: "data",
10 });
11
12 return articles.map((article) => ({
13 params: { slug: article.attributes.slug },
14 props: article,
15 }));
16}
17const article = Astro.props;
18---
19
20<Layout>
21 <h1>{article.attributes.title}</h1>
22 <MarkdownComponent content={article.attributes.content} />
23</Layout>
When this is done, we should now see the content of a blog post.
As we know, each article has an image. So update the [slug].asto
file to include it.
1---
2import { Image } from "astro:assets";
3import MarkdownComponent from "../../components/MarkdownComponent.astro";
4import Layout from "../../layouts/Layout.astro";
5import fetchApi from "../../lib/strapi";
6
7export async function getStaticPaths() {
8 const articles = await fetchApi({
9 endpoint: "blogs",
10 wrappedByKey: "data",
11 });
12
13 return articles.map((article) => ({
14 params: { slug: article.attributes.slug },
15 props: article,
16 }));
17}
18const article = Astro.props;
19---
20
21<Layout>
22 <Image
23 src={import.meta.env.STRAPI_URL +
24 article.attributes.image.data[0].attributes.formats.small.url}
25 width="500"
26 height="500"
27 alt={article.attributes.title}
28 />
29 <h1>{article.attributes.title}</h1>
30 <MarkdownComponent content={article.attributes.content} />
31</Layout>
In the image below, we can see the article's image displayed.
So far, we have only worked on the English blog posts. Let us implement Internationalization to enable us to render blog content in Spanish.
The Astro documentation shows that Astro has support for Internationalization routing.
astro.config.mjs
FileStart by modifying the astro.config.mjs
file. We need to specify the i18n
object where we specify the default locale defaultLocale
to be en
, which is English. Any other available locales could be Engilish and Spanish, en
and es
.
1import { defineConfig } from "astro/config";
2
3// https://astro.build/config
4export default defineConfig({
5 i18n: {
6 defaultLocale: "en",
7 locales: ["es", "en"],
8 },
9});
Next is to create the route for Spanish blog posts. Inside the existing blog
folder, create a new folder called es
. Inside this new folder, create the two files [page].astro
and [slug.astro]
.
The two files we have created are pretty much the same as the ones we have created initially. The only difference is that we want it displayed in Spanish and that the API request will include locale
as "es"
.
For this reason, we will copy the codes inside the blog/[page].astro
and blog/[slug].astro
and paste into the new blog/es/[page.astro]
and blog/es/[slug].astro
files respectively.
So, add the following code in the blog/es/[page].astro
file.
1---
2import { getRelativeLocaleUrl } from "astro:i18n";
3import Layout from "../../../layouts/Layout.astro";
4import fetchApi from "../../../lib/strapi";
5
6export async function getStaticPaths({ paginate }) {
7 const response = await fetchApi({
8 endpoint: "blogs",
9 wrappedByKey: "data",
10 locale: "es",
11 });
12 const articles = response;
13 return paginate(articles, { pageSize: 2 });
14}
15
16const { page } = Astro.props;
17---
18
19<Layout>
20 <h1>Página {page.currentPage}</h1>
21 <ul>
22 {
23 page.data.map((article) => (
24 <li>
25 <a href={`/blog/${getRelativeLocaleUrl('es')}${article.attributes.slug}`}>
26 {article.attributes.title}
27 </a>
28 </li>
29 ))
30 }
31 </ul>
32 {page.url.prev ? <a href={page.url.prev}>Página Anterior</a> : null} |
33 {page.url.next ? <a href={page.url.next}>Página Siguiente</a> : null}
34</Layout>
The code above requests the Strapi endpoint localhost:1337/api/blogs?populate=*&locale=es
. The response will only be the Spanish articles in our Strapi backend. We modified the English contents of the file to Spanish and then used the getRelativeLocaleUrl('es')
from Astro Internationalization to get the right path to the article content, which is /blog/es/[the slug]
.
In the blog/es/[slug].astro
file, we only need to add locale: "es"
to the fetchApi
function.
NOTE: Remember to modify the layout and component imports now that we are one directory deeper into our project.
With that, when we access /blog/es/1
for example, we get the blog in Spanish!
We want to print out the date an article was published, both in English and Spanish, using the JavaScript internationalization API. Luckily, this data comes back as metadata from Strapi headless CMS, as seen below.
Next, we will create a helper function to generate the relative time an article was published.
Replace the code inside /blog/[slug].astro
with the following code:
1---
2import { Image } from "astro:assets";
3import MarkdownComponent from "../../components/MarkdownComponent.astro";
4import Layout from "../../layouts/Layout.astro";
5import fetchApi from "../../lib/strapi";
6
7export async function getStaticPaths() {
8 const articles = await fetchApi({
9 endpoint: "blogs",
10 wrappedByKey: "data",
11 });
12
13 return articles.map((article) => ({
14 params: { slug: article.attributes.slug },
15 props: article,
16 }));
17}
18
19const article = Astro.props;
20
21function formatRelativeTime(dateString) {
22 const date = new Date(dateString);
23 const now = new Date();
24 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
25
26 const daysDifference = Math.round((now - date) / (1000 * 60 * 60 * 24));
27
28 if (daysDifference < 1) {
29 const hoursDifference = Math.round((now - date) / (1000 * 60 * 60));
30 if (hoursDifference < 1) {
31 const minutesDifference = Math.round((now - date) / (1000 * 60));
32 return rtf.format(-minutesDifference, "minute");
33 }
34 return rtf.format(-hoursDifference, "hour");
35 }
36
37 return rtf.format(-daysDifference, "day");
38}
39const relativeTime = formatRelativeTime(article.attributes.publishedAt);
40---
41
42<Layout>
43 <Image
44 src={import.meta.env.STRAPI_URL +
45 article.attributes.image.data[0].attributes.formats.small.url}
46 width="500"
47 height="500"
48 alt={article.attributes.title}
49 />
50 <h1>{article.attributes.title}</h1>
51 <p>Article written {relativeTime}</p>
52 <MarkdownComponent content={article.attributes.content} />
53</Layout>
Here is what we did in the code above:
formatRelativeTime
.dateString
parameter is passed to the function created above. In this case, this is the publishedAt
meta information provided by Strapi.RelativeTimeFormat
, we set it to English by passing 'en'
.daysDifference
, the hours difference hoursDifference
and the minutes difference minutesDifference
.relativeTime
variable, we get the true relative time of which the article was published. This could be "2 days ago", "an hour ago", "2 weeks ago", etc.We can see the result of the relative time in the image below:
Copy the helper function above and add it to the blog/es/[slug].asto
file. We will make only two changes. The first is to change the JavaScript Internationalization API from English "en" to Spanish
"es". The second is to change "Article Written" to "Artículo escrito," which is from the English version to the Spanish version.
NOTE: For redundancy and the DRY principle of Software Development, we might want to create the helper function inside the
lib
folder, import it, and call it in these files.
Below is what we should see in the Spanish blog.
So far, we have been doing Static Site Generation (SSG). This means that articles will still have the same information they had during the build process when we built and hosted this site. If we visit an article 100 days later, we will still see it as '2 DAYS AGO' and so on.
To correct this, there are two approaches: 1. Set up a Hook on a Deployment Platform: Let's say we deploy this project to Netlify. This hook will fire up a redeployment or rebuilding of our site when our blog posts change. For example, when we post a new blog, the hook will fire up, and our site will be rebuilt to display the new blog post. We can achieve this with Netlify. 2. Change Astro Default Behaviour: We could change Astro's default behavior from Static Site Generation (SSG) to Server-Side Rendering (SSR).
As we know, Astro uses Static Site Generation by default. Also, we learned in Part 1 of this tutorial series that to change Astro from Static Site Generation (SSG) to Server-Side Rendering (SSR), we will need an Adapter. In this case, we will use the node
adapter.
Let's proceed by quitting the running development server and installing the node
adapter:
npx astro add node
The command above will add the node
adapter to the configuration file and set the output to be server
. After successful installation, we will restart our Astro development server once again by running npm run dev
.
With the code below, we can make a page pre-rendered.
export const prerender = true;
Add the code above to the top of the following files:
index.astro
blog/[page].astro
blog/es/[page].astro
Our focus will be on blog/[slug].astro
and blog/es/[slug].astro
files because they have the getStaticPaths
function which is for Static Site Generation(SSG) and not Server-Side Rendering(SSR).
Inside the blog/[slug].astro
and blog/es/[slug].astro
files, we can find the getStaticPaths
. Recall that this function is only used for Static Site Generation. Because data can change, for example, when we create a new post, we want to add Server-Side rendering to these pages.
Modify the blog/[slug].astro
file by replacing the code inside it with the following:
1---
2import { Image } from "astro:assets";
3import MarkdownComponent from "../../components/MarkdownComponent.astro";
4import Layout from "../../layouts/Layout.astro";
5import fetchApi from "../../lib/strapi";
6
7// export async function getStaticPaths() {
8// const articles = await fetchApi({
9// endpoint: "blogs",
10// wrappedByKey: "data",
11// });
12
13// return articles.map((article) => ({
14// params: { slug: article.attributes.slug },
15// props: article,
16// }));
17// }
18
19// const article = Astro.props;
20
21let article;
22
23const { slug } = Astro.params;
24
25try {
26 article = await fetchApi({
27 endpoint: "blogs",
28 wrappedByKey: "data",
29 wrappedByList: true,
30 query: {
31 "filters[slug][$eq]": slug || "",
32 },
33 });
34} catch (error) {
35 Astro.redirect("/404");
36}
37
38function formatRelativeTime(dateString) {
39 const date = new Date(dateString);
40 const now = new Date();
41 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
42
43 const daysDifference = Math.round((now - date) / (1000 * 60 * 60 * 24));
44
45 if (daysDifference < 1) {
46 const hoursDifference = Math.round((now - date) / (1000 * 60 * 60));
47 if (hoursDifference < 1) {
48 const minutesDifference = Math.round((now - date) / (1000 * 60));
49 return rtf.format(-minutesDifference, "minute");
50 }
51 return rtf.format(-hoursDifference, "hour");
52 }
53
54 return rtf.format(-daysDifference, "day");
55}
56const relativeTime = formatRelativeTime(article.attributes.publishedAt);
57---
58
59<Layout>
60 <image
61 src="{import.meta.env.STRAPI_URL"
62 +
63 article.attributes.image.data[0].attributes.formats.small.url}
64 width="500"
65 height="500"
66 alt="{article.attributes.title}"
67 />
68 <h1>{article.attributes.title}</h1>
69 <p>Article written {relativeTime}</p>
70 <MarkdownComponent content="{article.attributes.content}" />
71</Layout>
In the code above:
getStaticPaths
function. Astro.params
property. Remember that this is a dynamic page, so the slug
can change at any request. try/catch
block, we get all the blog posts using the fetchApi
function. This time around, notice that we added a query
and wrappedByList
. wrappedByList
is a boolean parameter to “unwrap” the list returned by Strapi headless CMS, and return only the first item. query
is going to be sent to the Strapi to filter out only the slug
that was passed in params, otherwise it should be empty. /404
page.Inside the blog/es/[slug].astro
file, we will repeat the same steps but add the locale
parameter to the fetchApi
function and give it the value 'es'
to tell Strapi that we are fetching the Spanish blog posts. Also, we will modify the links as shown in the code below:
1---
2import Layout from "../../../../layouts/Layout.astro";
3import fetchApi from "../../../../lib/strapi";
4
5const page = parseInt(Astro.params.page);
6const postsPerPage = 2;
7
8const response = await fetchApi({
9 endpoint: "blogs",
10 locale: "es",
11 query: {
12 "pagination[page]": page,
13 "[pagination][pageSize]": postsPerPage,
14 },
15});
16
17const articles = response.data;
18const pagination = response.meta.pagination;
19---
20
21<Layout>
22 <h1>Page {page}</h1>
23 <ul>
24 {
25 articles.map((article) => (
26 <li>
27 <a href={`/blog/es/${article.attributes.slug}`}>
28 {article.attributes.title}
29 </a>
30 </li>
31 ))
32 }
33 </ul>
34 {
35 pagination.page > 1 && (
36 <a href={`/blog/es/page/${pagination.page - 1}`}>Previous Page</a>
37 )
38 } | {
39 pagination.page < pagination.pageCount && (
40 <a href={`/blog/es/page/${pagination.page + 1}`}>Next Page</a>
41 )
42 }
43</Layout>
As we said, Server-Side Rendering will allow us to update the UI in real time. For example, when we add a new blog post, we should see the relative time as something very recent. See the image below after we added a new blog post.
When we visit the application with its slug URL, we notice that it was created 12 minutes ago. Bravo! This is possible only through Server-Side Rendering!
However, without rebuilding our application, notice that the new article doesn't appear in the pagination.
In the next section, we will fix this!
Recall that we implemented pre-rendering in the blog/[page].astro
and blog/es/[page].astro
files. This means that they are going to do Static Site Generation, and as such, if you create an article, even though it will show up, it won't come up in the pagination.
Start by replacing the code inside the blog/[page].astro
with the code below:
1---
2import Layout from "../../layouts/Layout.astro";
3import fetchApi from "../../lib/strapi";
4
5const page = parseInt(Astro.params.page);
6const postsPerPage = 2;
7
8const response = await fetchApi({
9 endpoint: "blogs",
10 query: {
11 'pagination[page]': page,
12 '[pagination][pageSize]': postsPerPage,
13 },
14});
15
16const articles = response.data;
17const pagination = response.meta.pagination;
18---
19
20<Layout>
21 <h1>Page {page}</h1>
22 <ul>
23 {
24 articles.map((article) => (
25 <li>
26 <a href={`/blog/${article.attributes.slug}`}>
27 {article.attributes.title}
28 </a>
29 </li>
30 ))
31 }
32 </ul>
33 {
34 pagination.page > 1 && (
35 <a href={`/blog/${pagination.page - 1}`}>Previous Page</a>
36 )
37 } | {
38 pagination.page < pagination.pageCount && (
39 <a href={`/blog/${pagination.page + 1}`}>Next Page</a>
40 )
41 }
42</Layout>
When we go back to the browser, we can see that the pagination works. But when we click on a blog post from the pagination, we get the following error:
This happened because we are trying to render the same path as blog/[page].astro
and blog/[slug].astro
files. They are both reading the same data. So, Astro doesn't know if it is a number or a slug.
The solution for this will be to create a folder called [page]
inside the blog
folder and move the blog/[page].astro
file there. And then make sure to update the links inside the file. See the code below:
1---
2import Layout from "../../../layouts/Layout.astro";
3import fetchApi from "../../../lib/strapi";
4
5const page = parseInt(Astro.params.page);
6const postsPerPage = 2;
7
8const response = await fetchApi({
9 endpoint: "blogs",
10 query: {
11 'pagination[page]': page,
12 '[pagination][pageSize]': postsPerPage,
13 },
14});
15
16const articles = response.data;
17console.log("Thbe artiflces ", page)
18const pagination = response.meta.pagination;
19---
20
21<Layout>
22 <h1>Page {page}</h1>
23 <ul>
24 {
25 articles.map((article) => (
26 <li>
27 <a href={`/blog/${article.attributes.slug}`}>
28 {article.attributes.title}
29 </a>
30 </li>
31 ))
32 }
33 </ul>
34 {
35 pagination.page > 1 && (
36 <a href={`/blog/page/${pagination.page - 1}`}>Previous Page</a>
37 )
38 } | {
39 pagination.page < pagination.pageCount && (
40 <a href={`/blog/page/${pagination.page + 1}`}>Next Page</a>
41 )
42 }
43</Layout>
From the code above, we updated the links to paginated pages from /blog/[page]
to /blog/page/[page]
, where [page]
refers to the page number.
To ensure that this works, you can modify the astro.config.mjs
file by adding a redirect or by modifying the Nav.astro
file to ensure that there won't be errors when we navigate between pages.
1<nav>
2 <a href="/"> Home </a>
3 |
4 <a href="/blog/page/1">Blog </a>🇬🇧 |
5 <a href="/blog/es">Blog 🇪🇸</a>
6</nav>
From the Nav
component above, when we click the "Blog", we see that it takes us to /blog/page/1
. See the image below. This shows that our application is working correctly!
We have reached the end of this tutorial. Here are a couple of steps. Congratulations on building a Multilingual blog using Astro and Strapi! You’ve done an excellent job. Applying your skills with hands-on projects like this is a great way to get comfortable with new techniques and technologies.
The following are some suggested next steps:
formatRelativeTime
to take locale
as a parameter.Throughout this tutorial we have learnt the basics of Astro and the basics of Strapi. We explored Dynamic Zones, different content types, how to grab data from a CMS and internationalization in Strapi as well.
We also saw how both we can render pages using Stati Site Generation (SSG) and how to update our code to enable Server-Side Rendering.
And finally, we were able to build a multilingual blog website using Astro and Strapi CMS.
You can visit the Strapi blog for more exciting tutorials and blog posts.
part-3
.We are excited to have Ben Holmes from Astro chatting with us about why Astro is awesome and best way to build content-driven websites fast.
Topics
Join us to learn more about why Astro can be great choice for your next project.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.
Tamas is a Google Developer Expert in Web Technologies and a seasoned Developer Evangelist. He is a passionate advocate for modern web technologies, helping people understand and unlock the latest & greatest features of web development.