In this blog post, we will learn how to create a custom Astro Loader for Strapi using Content Layer API.
In Astro we are able to use Content Layer API to reference our project's data.
Which makes it easier to work with local content (Markdown, MDX,JSON, etc.) in Astro.
But in the past it was only limited to local files.
You can read more about it here
But with the new recent update, we are now able to use it to create custom loaders to fetch data from any API.
Which is what we will be doing in this blog post.
We will be creating a custom loader to fetch data from Strapi API.
Strapi is a headless CMS that allows us to create custom APIs.
We will be using Strapi 5 to manage our content that we can access via an API that will be used by our custom Astro Loader to fetch our data.
Let's take a look at how we can accomplish this.
Here is the repo for this blog post: Strapi + Astro
It includes the completed code for you to try, but in this post we will go through the important parts, building our custom loader and fetching our data from Strapi.
To get started, create a new folder for our project and initialize a new Strapi project with the following command:
npx create-strapi-app@rc server
We are using the latest Strapi 5 release candidate at the time of writing this post.
After release, (September 23rd 2024) you will be able to install Strapi 5 with the following command:
npx create-strapi-app@latest server
You will be prompted with a few questions:
? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? Yes
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
We will be using the default database (sqlite) and select yes
for example structure & data.
This will create a new Strapi project with content and collection types for us to work with.
Once Strapi is installed, change directory into the server
folder and start Strapi with the following command:
yarn develop
This will start our Strapi server on port 1337.
You will be greeted with the Strapi welcome screen.
Go ahead and create your first Strapi Admin User.
Once done, you will be redirected to the Strapi dashboard.
Navigate to the Strapi's Content Manager for the Articles
collection type.
You should see the following article that were created for us.
Now that we have our Articles setup, let's enable the API on our Articles collection type. To allow us to fetch our data from our API.
You can do so in Settings -> Users & Permissions Plugin -> Roles -> Public
By default, the Public role will have the Find action enabled for all collection types.
This was enable for us when we created our project and said yes
to include example data.
If you tried to make an API request to fetch our articles and don't see the data, make sure that you have published your articles.
Navigate to the Articles collection type and select all the articles and publish them.
Now if you make a request to http://localhost:1337/api/articles
you should see the following response.
1{
2 "data": [
3 {
4 "id": 6,
5 "documentId": "u93xyi2h6hy8axyid416wgzs",
6 "title": "A bug is becoming a meme on the internet",
7 "description": "How a bug on MySQL is becoming a meme on the internet",
8 "slug": "a-bug-is-becoming-a-meme-on-the-internet",
9 "createdAt": "2024-09-18T12:38:13.369Z",
10 "updatedAt": "2024-09-18T12:38:13.369Z",
11 "publishedAt": "2024-09-18T12:55:54.535Z",
12 "locale": null
13 },
14 {
15 "id": 7,
16 "documentId": "tpxdu1lmktawx78i9c0pak3b",
17 "title": "Beautiful picture",
18 "description": "Description of a beautiful picture",
19 "slug": "beautiful-picture",
20 "createdAt": "2024-09-18T12:38:13.556Z",
21 "updatedAt": "2024-09-18T12:38:13.556Z",
22 "publishedAt": "2024-09-18T12:55:54.535Z",
23 "locale": null
24 },
25 {
26 "id": 8,
27 "documentId": "v6b829wo4k0jfitfpzevl5tx",
28 "title": "The internet's Own boy",
29 "description": "Follow the story of Aaron Swartz, the boy who could change the world",
30 "slug": "the-internet-s-own-boy",
31 "createdAt": "2024-09-18T12:38:13.161Z",
32 "updatedAt": "2024-09-18T12:38:13.161Z",
33 "publishedAt": "2024-09-18T12:55:54.535Z",
34 "locale": null
35 },
36 {
37 "id": 9,
38 "documentId": "k1uos9ptvchyqnhaze4k3pgt",
39 "title": "This shrimp is awesome",
40 "description": "Mantis shrimps, or stomatopods, are marine crustaceans of the order Stomatopoda.",
41 "slug": "this-shrimp-is-awesome",
42 "createdAt": "2024-09-18T12:38:13.215Z",
43 "updatedAt": "2024-09-18T12:38:13.215Z",
44 "publishedAt": "2024-09-18T12:55:54.535Z",
45 "locale": null
46 },
47 {
48 "id": 10,
49 "documentId": "nu2qbzm7bocsvy28s175qf73",
50 "title": "What's inside a Black Hole",
51 "description": "Maybe the answer is in this article, or not...",
52 "slug": "what-s-inside-a-black-hole",
53 "createdAt": "2024-09-18T12:38:13.596Z",
54 "updatedAt": "2024-09-18T12:38:13.596Z",
55 "publishedAt": "2024-09-18T12:55:54.535Z",
56 "locale": null
57 }
58 ],
59 "meta": {
60 "pagination": {
61 "page": 1,
62 "pageSize": 25,
63 "pageCount": 1,
64 "total": 5
65 }
66 }
67}
We are not getting all of our data, which is expected, to prevent over fetching data from our API we need to tell Strapi specifically what items we would like to fetch.
For instance if you take a look at our post you will notice that we have cover
image, author
and category
relations and blocks
dynamic zone.
You can learn more about Strapi's populate and filtering here: Demystifying Strapi's Populate & Filtering
And how you can set up default populate for your collection types via middleware here: Route-Based Middleware to Handle Default Population Query Logic
We will set up default populate via middleware for this example, which will be a great learning opportunity for us.
And is a more secure way to prevent users from fetching any extra data that they don't need.
In Strapi, we have access to CLI that will allow us to generate a middleware.
To do so, run the following command in the root of our Strapi project:
npx strapi generate
You will be prompted with a few questions:
➜ server git:(main) ✗ npx strapi generate
? Strapi Generators middleware - Generate a middleware for an API
? Middleware name populate-article
? Where do you want to add this middleware? Add middleware to an existing API
? Which API is this for? article
✔ ++ /api/article/middlewares/populate-article.ts
We will be adding our middleware to the article
API.
This will create a new file in api/article/middlewares/populate-article.ts
.
Let's open the file and add the following code:
1/**
2 * `populate-article` middleware
3 */
4
5const populate = {
6 populate: {
7 cover: {
8 fields: ["url", "alternativeText", "name", "width", "height"],
9 },
10 blocks: {
11 on: {
12 "shared.media": {
13 populate: {
14 file: {
15 fields: ["url", "alternativeText", "name", "width", "height"],
16 },
17 },
18 },
19
20 "shared.slider": {
21 populate: {
22 files: {
23 fields: ["url", "alternativeText", "name", "width", "height"],
24 },
25 },
26 },
27
28 "shared.quote": {
29 populate: true,
30 },
31
32 "shared.rich-text": {
33 populate: true,
34 },
35 },
36 },
37 },
38};
39
40import type { Core } from "@strapi/strapi";
41
42export default (config, { strapi }: { strapi: Core.Strapi }) => {
43 console.log("populate-article middleware");
44 // Add your own logic here.
45 return async (ctx, next) => {
46 ctx.query = {
47 ...ctx.query,
48 ...populate,
49 };
50 strapi.log.info("In populate-article middleware.");
51
52 await next();
53 };
54};
In the code above, we are defining a populate object that will be used to populate our articles data.
We are using the populate
property to tell Strapi to populate our articles data with the following options:
cover
: Populate the cover
relation with the following fields: url
, alternativeText
, name
, width
, height
.blocks
: Populate the blocks
relation with the following options:on
: Populate the blocks
relation with the following options:shared.media
: Populate the shared.media
relation with the following fields: url
, alternativeText
, name
, width
, height
.shared.slider
: Populate the shared.slider
relation with the following fields: url
, alternativeText
, name
, width
, height
.shared.quote
: Populate the shared.quote
relation with the following fields: url
, alternativeText
, name
, width
, height
.Now, that we have our middleware setup, we will need to reference it in our API route.
To do so, open api/article/routes/article.ts
and add the following code:
1/**
2 * article router.
3 */
4
5import { factories } from "@strapi/strapi";
6
7export default factories.createCoreRouter("api::article.article", {
8 config: {
9 find: {
10 middlewares: ["api::article.populate-article"],
11 },
12 findOne: {
13 middlewares: ["api::article.populate-article"],
14 },
15 },
16});
Now that we have our middleware and our route setup, we can start our Strapi server and make a request to our API to fetch our articles.
yarn develop
You can make a request to http://localhost:1337/api/articles
to fetch our articles.
You should see the following response:
1{
2 "data": [
3 {
4 "id": 6,
5 "documentId": "u93xyi2h6hy8axyid416wgzs",
6 "title": "A bug is becoming a meme on the internet",
7 "description": "How a bug on MySQL is becoming a meme on the internet",
8 "slug": "a-bug-is-becoming-a-meme-on-the-internet",
9 "createdAt": "2024-09-18T12:38:13.369Z",
10 "updatedAt": "2024-09-18T12:38:13.369Z",
11 "publishedAt": "2024-09-18T12:55:54.535Z",
12 "locale": null,
13 "cover": {
14 "id": 7,
15 "documentId": "sipkbxby7bn82supbvuxe9ms",
16 "url": "/uploads/a_bug_is_becoming_a_meme_on_the_internet_f4e50f5260.jpeg",
17 "alternativeText": "An image uploaded to Strapi called a-bug-is-becoming-a-meme-on-the-internet",
18 "name": "a-bug-is-becoming-a-meme-on-the-internet",
19 "width": 3628,
20 "height": 2419
21 },
22 "blocks": [
23 {
24 "__component": "shared.rich-text",
25 "id": 12,
26 "body": "## Probant \n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. \n\n## Abit sua\n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. "
27 },
28 {
29 "__component": "shared.quote",
30 "id": 7,
31 "title": "Thelonius Monk",
32 "body": "You've got to dig it to dig it, you dig?"
33 },
34 {
35 "__component": "shared.media",
36 "id": 7,
37 "file": {
38 "id": 4,
39 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
40 "url": "/uploads/coffee_art_e195daffa4.jpeg",
41 "alternativeText": "An image uploaded to Strapi called coffee-art",
42 "name": "coffee-art",
43 "width": 5824,
44 "height": 3259
45 }
46 },
47 {
48 "__component": "shared.rich-text",
49 "id": 13,
50 "body": "## Spatiantia astra \n\nFoeda, medio silva *errandum*: onus formam munere. Mutata bibulis est auxiliare arces etiamnunc verbis virgineo Priamidas illa Thescelus, nam fit locis lucis auras. Exitus hospes gratulor ut pondere [speslimite](http://www.curas.io/figuram); quid habent, Avernales faciente de. Pervenit Ino sonabile supplex cognoscenti vires, Bacchumque errat miserarum venandi dignabere dedisti. Discrimina iuncosaque virgaque tot sine superest [fissus](http://quos.org/sitet.aspx). Non color esset potest non sumit, sed vix arserat. Nisi immo silva tantum pectusque quos pennis quisquam artus!"
51 },
52 {
53 "__component": "shared.slider",
54 "id": 6,
55 "files": [
56 {
57 "id": 4,
58 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
59 "url": "/uploads/coffee_art_e195daffa4.jpeg",
60 "alternativeText": "An image uploaded to Strapi called coffee-art",
61 "name": "coffee-art",
62 "width": 5824,
63 "height": 3259
64 },
65 {
66 "id": 5,
67 "documentId": "jbal0z30dzdz1r430z397lv0",
68 "url": "/uploads/coffee_beans_40fe668559.jpeg",
69 "alternativeText": "An image uploaded to Strapi called coffee-beans",
70 "name": "coffee-beans",
71 "width": 5021,
72 "height": 3347
73 }
74 ]
75 }
76 ]
77 },
78 {
79 "id": 7,
80 "documentId": "tpxdu1lmktawx78i9c0pak3b",
81 "title": "Beautiful picture",
82 "description": "Description of a beautiful picture",
83 "slug": "beautiful-picture",
84 "createdAt": "2024-09-18T12:38:13.556Z",
85 "updatedAt": "2024-09-18T12:38:13.556Z",
86 "publishedAt": "2024-09-18T12:55:54.535Z",
87 "locale": null,
88 "cover": {
89 "id": 8,
90 "documentId": "qxfy8ecrp9qn6hn4cj3jli6e",
91 "url": "/uploads/beautiful_picture_c01396b54b.jpeg",
92 "alternativeText": "An image uploaded to Strapi called beautiful-picture",
93 "name": "beautiful-picture",
94 "width": 3824,
95 "height": 2548
96 },
97 "blocks": [
98 {
99 "__component": "shared.rich-text",
100 "id": 14,
101 "body": "## Probant \n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. \n\n## Abit sua\n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. "
102 },
103 {
104 "__component": "shared.quote",
105 "id": 8,
106 "title": "Thelonius Monk",
107 "body": "You've got to dig it to dig it, you dig?"
108 },
109 {
110 "__component": "shared.media",
111 "id": 8,
112 "file": {
113 "id": 4,
114 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
115 "url": "/uploads/coffee_art_e195daffa4.jpeg",
116 "alternativeText": "An image uploaded to Strapi called coffee-art",
117 "name": "coffee-art",
118 "width": 5824,
119 "height": 3259
120 }
121 },
122 {
123 "__component": "shared.rich-text",
124 "id": 15,
125 "body": "## Spatiantia astra \n\nFoeda, medio silva *errandum*: onus formam munere. Mutata bibulis est auxiliare arces etiamnunc verbis virgineo Priamidas illa Thescelus, nam fit locis lucis auras. Exitus hospes gratulor ut pondere [speslimite](http://www.curas.io/figuram); quid habent, Avernales faciente de. Pervenit Ino sonabile supplex cognoscenti vires, Bacchumque errat miserarum venandi dignabere dedisti. Discrimina iuncosaque virgaque tot sine superest [fissus](http://quos.org/sitet.aspx). Non color esset potest non sumit, sed vix arserat. Nisi immo silva tantum pectusque quos pennis quisquam artus!"
126 },
127 {
128 "__component": "shared.slider",
129 "id": 7,
130 "files": [
131 {
132 "id": 4,
133 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
134 "url": "/uploads/coffee_art_e195daffa4.jpeg",
135 "alternativeText": "An image uploaded to Strapi called coffee-art",
136 "name": "coffee-art",
137 "width": 5824,
138 "height": 3259
139 },
140 {
141 "id": 5,
142 "documentId": "jbal0z30dzdz1r430z397lv0",
143 "url": "/uploads/coffee_beans_40fe668559.jpeg",
144 "alternativeText": "An image uploaded to Strapi called coffee-beans",
145 "name": "coffee-beans",
146 "width": 5021,
147 "height": 3347
148 }
149 ]
150 }
151 ]
152 },
153 {
154 "id": 8,
155 "documentId": "v6b829wo4k0jfitfpzevl5tx",
156 "title": "The internet's Own boy",
157 "description": "Follow the story of Aaron Swartz, the boy who could change the world",
158 "slug": "the-internet-s-own-boy",
159 "createdAt": "2024-09-18T12:38:13.161Z",
160 "updatedAt": "2024-09-18T12:38:13.161Z",
161 "publishedAt": "2024-09-18T12:55:54.535Z",
162 "locale": null,
163 "cover": {
164 "id": 3,
165 "documentId": "vi863t425htqu69292k7bgr3",
166 "url": "/uploads/the_internet_s_own_boy_35bc7356b4.jpeg",
167 "alternativeText": "An image uploaded to Strapi called the-internet-s-own-boy",
168 "name": "the-internet-s-own-boy",
169 "width": 1200,
170 "height": 707
171 },
172 "blocks": [
173 {
174 "__component": "shared.rich-text",
175 "id": 16,
176 "body": "## Probant \n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. \n\n## Abit sua\n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. "
177 },
178 {
179 "__component": "shared.quote",
180 "id": 9,
181 "title": "Thelonius Monk",
182 "body": "You've got to dig it to dig it, you dig?"
183 },
184 {
185 "__component": "shared.media",
186 "id": 9,
187 "file": {
188 "id": 4,
189 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
190 "url": "/uploads/coffee_art_e195daffa4.jpeg",
191 "alternativeText": "An image uploaded to Strapi called coffee-art",
192 "name": "coffee-art",
193 "width": 5824,
194 "height": 3259
195 }
196 },
197 {
198 "__component": "shared.rich-text",
199 "id": 17,
200 "body": "## Spatiantia astra \n\nFoeda, medio silva *errandum*: onus formam munere. Mutata bibulis est auxiliare arces etiamnunc verbis virgineo Priamidas illa Thescelus, nam fit locis lucis auras. Exitus hospes gratulor ut pondere [speslimite](http://www.curas.io/figuram); quid habent, Avernales faciente de. Pervenit Ino sonabile supplex cognoscenti vires, Bacchumque errat miserarum venandi dignabere dedisti. Discrimina iuncosaque virgaque tot sine superest [fissus](http://quos.org/sitet.aspx). Non color esset potest non sumit, sed vix arserat. Nisi immo silva tantum pectusque quos pennis quisquam artus!"
201 },
202 {
203 "__component": "shared.slider",
204 "id": 8,
205 "files": [
206 {
207 "id": 4,
208 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
209 "url": "/uploads/coffee_art_e195daffa4.jpeg",
210 "alternativeText": "An image uploaded to Strapi called coffee-art",
211 "name": "coffee-art",
212 "width": 5824,
213 "height": 3259
214 },
215 {
216 "id": 5,
217 "documentId": "jbal0z30dzdz1r430z397lv0",
218 "url": "/uploads/coffee_beans_40fe668559.jpeg",
219 "alternativeText": "An image uploaded to Strapi called coffee-beans",
220 "name": "coffee-beans",
221 "width": 5021,
222 "height": 3347
223 }
224 ]
225 }
226 ]
227 },
228 {
229 "id": 9,
230 "documentId": "k1uos9ptvchyqnhaze4k3pgt",
231 "title": "This shrimp is awesome",
232 "description": "Mantis shrimps, or stomatopods, are marine crustaceans of the order Stomatopoda.",
233 "slug": "this-shrimp-is-awesome",
234 "createdAt": "2024-09-18T12:38:13.215Z",
235 "updatedAt": "2024-09-18T12:38:13.215Z",
236 "publishedAt": "2024-09-18T12:55:54.535Z",
237 "locale": null,
238 "cover": {
239 "id": 6,
240 "documentId": "zbwb0no52euzikfwek2mogy0",
241 "url": "/uploads/this_shrimp_is_awesome_f1e228f3fb.jpeg",
242 "alternativeText": "An image uploaded to Strapi called this-shrimp-is-awesome",
243 "name": "this-shrimp-is-awesome",
244 "width": 1200,
245 "height": 630
246 },
247 "blocks": [
248 {
249 "__component": "shared.rich-text",
250 "id": 18,
251 "body": "## Probant \n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. \n\n## Abit sua\n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. "
252 },
253 {
254 "__component": "shared.quote",
255 "id": 10,
256 "title": "Thelonius Monk",
257 "body": "You've got to dig it to dig it, you dig?"
258 },
259 {
260 "__component": "shared.media",
261 "id": 10,
262 "file": {
263 "id": 4,
264 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
265 "url": "/uploads/coffee_art_e195daffa4.jpeg",
266 "alternativeText": "An image uploaded to Strapi called coffee-art",
267 "name": "coffee-art",
268 "width": 5824,
269 "height": 3259
270 }
271 },
272 {
273 "__component": "shared.rich-text",
274 "id": 19,
275 "body": "## Spatiantia astra \n\nFoeda, medio silva *errandum*: onus formam munere. Mutata bibulis est auxiliare arces etiamnunc verbis virgineo Priamidas illa Thescelus, nam fit locis lucis auras. Exitus hospes gratulor ut pondere [speslimite](http://www.curas.io/figuram); quid habent, Avernales faciente de. Pervenit Ino sonabile supplex cognoscenti vires, Bacchumque errat miserarum venandi dignabere dedisti. Discrimina iuncosaque virgaque tot sine superest [fissus](http://quos.org/sitet.aspx). Non color esset potest non sumit, sed vix arserat. Nisi immo silva tantum pectusque quos pennis quisquam artus!"
276 },
277 {
278 "__component": "shared.slider",
279 "id": 9,
280 "files": [
281 {
282 "id": 4,
283 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
284 "url": "/uploads/coffee_art_e195daffa4.jpeg",
285 "alternativeText": "An image uploaded to Strapi called coffee-art",
286 "name": "coffee-art",
287 "width": 5824,
288 "height": 3259
289 },
290 {
291 "id": 5,
292 "documentId": "jbal0z30dzdz1r430z397lv0",
293 "url": "/uploads/coffee_beans_40fe668559.jpeg",
294 "alternativeText": "An image uploaded to Strapi called coffee-beans",
295 "name": "coffee-beans",
296 "width": 5021,
297 "height": 3347
298 }
299 ]
300 }
301 ]
302 },
303 {
304 "id": 10,
305 "documentId": "nu2qbzm7bocsvy28s175qf73",
306 "title": "What's inside a Black Hole",
307 "description": "Maybe the answer is in this article, or not...",
308 "slug": "what-s-inside-a-black-hole",
309 "createdAt": "2024-09-18T12:38:13.596Z",
310 "updatedAt": "2024-09-18T12:38:13.596Z",
311 "publishedAt": "2024-09-18T12:55:54.535Z",
312 "locale": null,
313 "cover": {
314 "id": 9,
315 "documentId": "o40rnm9dss25bk1p8ybg43oh",
316 "url": "/uploads/what_s_inside_a_black_hole_5415177b52.jpeg",
317 "alternativeText": "An image uploaded to Strapi called what-s-inside-a-black-hole",
318 "name": "what-s-inside-a-black-hole",
319 "width": 800,
320 "height": 466
321 },
322 "blocks": [
323 {
324 "__component": "shared.rich-text",
325 "id": 20,
326 "body": "## Probant \n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. \n\n## Abit sua\n\nse Lorem markdownum negat. Argo *saxa* videnda cornuaque hunc qui tanta spes teneas! Obliquis est dicenti est salutat ille tamen iuvenum nostrae dolore. - Colores nocituraque comitata eripiunt - Addit quodcunque solum cui et dextram illis - Nulli meus nec extemplo ille ferebat pressit Se blandita fulvae vox gravem Pittheus cesserunt sanguine herbis tu comitum tenuit. Sui in ruunt; Doridaque maculosae fuissem! Et loqui. "
327 },
328 {
329 "__component": "shared.quote",
330 "id": 11,
331 "title": "Thelonius Monk",
332 "body": "You've got to dig it to dig it, you dig?"
333 },
334 {
335 "__component": "shared.media",
336 "id": 11,
337 "file": {
338 "id": 4,
339 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
340 "url": "/uploads/coffee_art_e195daffa4.jpeg",
341 "alternativeText": "An image uploaded to Strapi called coffee-art",
342 "name": "coffee-art",
343 "width": 5824,
344 "height": 3259
345 }
346 },
347 {
348 "__component": "shared.rich-text",
349 "id": 21,
350 "body": "## Spatiantia astra \n\nFoeda, medio silva *errandum*: onus formam munere. Mutata bibulis est auxiliare arces etiamnunc verbis virgineo Priamidas illa Thescelus, nam fit locis lucis auras. Exitus hospes gratulor ut pondere [speslimite](http://www.curas.io/figuram); quid habent, Avernales faciente de. Pervenit Ino sonabile supplex cognoscenti vires, Bacchumque errat miserarum venandi dignabere dedisti. Discrimina iuncosaque virgaque tot sine superest [fissus](http://quos.org/sitet.aspx). Non color esset potest non sumit, sed vix arserat. Nisi immo silva tantum pectusque quos pennis quisquam artus!"
351 },
352 {
353 "__component": "shared.slider",
354 "id": 10,
355 "files": [
356 {
357 "id": 4,
358 "documentId": "ql3knuf4jpvy7f1dwki6rfj1",
359 "url": "/uploads/coffee_art_e195daffa4.jpeg",
360 "alternativeText": "An image uploaded to Strapi called coffee-art",
361 "name": "coffee-art",
362 "width": 5824,
363 "height": 3259
364 },
365 {
366 "id": 5,
367 "documentId": "jbal0z30dzdz1r430z397lv0",
368 "url": "/uploads/coffee_beans_40fe668559.jpeg",
369 "alternativeText": "An image uploaded to Strapi called coffee-beans",
370 "name": "coffee-beans",
371 "width": 5021,
372 "height": 3347
373 }
374 ]
375 }
376 ]
377 }
378 ],
379 "meta": {
380 "pagination": {
381 "page": 1,
382 "pageSize": 25,
383 "pageCount": 1,
384 "total": 5
385 }
386 }
387}
Nice, now that we have our articles data, and before we set up our Astro project, we have one more thing to do.
When creating our Astro Loader, we would want it to infer the types from our Strapi API.
But at the moment, Strapi provide a SDK that allows us to do that. That is in the works, but not yet ready.
So we will have get the Strapi Schema via a plugin that I created. If you are interseted on how I did this, let me know in the comments.
But we will just install it via NPM.
We are going to install the following package:
yarn add get-strapi-schema
Once we have it installed, we neet to navigate to our config
folder and create a folder called plugins
if it doesn't already exist. And add the following code to enable the plugin.
1export default () => ({
2 "get-strapi-schema": {
3 enabled: true,
4 },
5});
Now restart Strapi and we should be able to get the schema. We can do this by navigating to the following URL in our browser witht the name of the collection type.
1 http://localhost:1337/get-strapi-schema/schema/article
You should see a JSON response.
1{
2 "kind": "collectionType",
3 "collectionName": "articles",
4 "info": {
5 "singularName": "article",
6 "pluralName": "articles",
7 "displayName": "Article",
8 "description": "Create your blog content"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "pluginOptions": {},
14 "attributes": {
15 "title": {
16 "type": "string"
17 },
18 "description": {
19 "type": "text",
20 "maxLength": 80
21 },
22 "slug": {
23 "type": "uid",
24 "targetField": "title"
25 },
26 "cover": {
27 "type": "media",
28 "multiple": false,
29 "required": false,
30 "allowedTypes": ["images", "files", "videos"]
31 },
32 "author": {
33 "type": "relation",
34 "relation": "manyToOne",
35 "target": "api::author.author",
36 "inversedBy": "articles"
37 },
38 "category": {
39 "type": "relation",
40 "relation": "manyToOne",
41 "target": "api::category.category",
42 "inversedBy": "articles"
43 },
44 "blocks": {
45 "type": "dynamiczone",
46 "components": [
47 "shared.media",
48 "shared.quote",
49 "shared.rich-text",
50 "shared.slider"
51 ]
52 },
53 "createdAt": {
54 "type": "datetime"
55 },
56 "updatedAt": {
57 "type": "datetime"
58 },
59 "publishedAt": {
60 "type": "datetime",
61 "configurable": false,
62 "writable": true,
63 "visible": false
64 },
65 "createdBy": {
66 "type": "relation",
67 "relation": "oneToOne",
68 "target": "admin::user",
69 "configurable": false,
70 "writable": false,
71 "visible": false,
72 "useJoinTable": false,
73 "private": true
74 },
75 "updatedBy": {
76 "type": "relation",
77 "relation": "oneToOne",
78 "target": "admin::user",
79 "configurable": false,
80 "writable": false,
81 "visible": false,
82 "useJoinTable": false,
83 "private": true
84 },
85 "locale": {
86 "writable": true,
87 "private": false,
88 "configurable": false,
89 "visible": false,
90 "type": "string"
91 },
92 "localizations": {
93 "type": "relation",
94 "relation": "oneToMany",
95 "target": "api::article.article",
96 "writable": false,
97 "private": false,
98 "configurable": false,
99 "visible": false,
100 "unstable_virtual": true,
101 "joinColumn": {
102 "name": "document_id",
103 "referencedColumn": "document_id",
104 "referencedTable": "articles"
105 }
106 }
107 },
108 "apiName": "article",
109 "globalId": "Article",
110 "uid": "api::article.article",
111 "modelType": "contentType",
112 "__schema__": {
113 "collectionName": "articles",
114 "info": {
115 "singularName": "article",
116 "pluralName": "articles",
117 "displayName": "Article",
118 "description": "Create your blog content"
119 },
120 "options": {
121 "draftAndPublish": true
122 },
123 "pluginOptions": {},
124 "attributes": {
125 "title": {
126 "type": "string"
127 },
128 "description": {
129 "type": "text",
130 "maxLength": 80
131 },
132 "slug": {
133 "type": "uid",
134 "targetField": "title"
135 },
136 "cover": {
137 "type": "media",
138 "multiple": false,
139 "required": false,
140 "allowedTypes": ["images", "files", "videos"]
141 },
142 "author": {
143 "type": "relation",
144 "relation": "manyToOne",
145 "target": "api::author.author",
146 "inversedBy": "articles"
147 },
148 "category": {
149 "type": "relation",
150 "relation": "manyToOne",
151 "target": "api::category.category",
152 "inversedBy": "articles"
153 },
154 "blocks": {
155 "type": "dynamiczone",
156 "components": [
157 "shared.media",
158 "shared.quote",
159 "shared.rich-text",
160 "shared.slider"
161 ]
162 }
163 },
164 "kind": "collectionType"
165 },
166 "modelName": "article",
167 "actions": {},
168 "lifecycles": {}
169}
This is what our loader will use to generate the types.
Now that our Strapi project is setup, let's setup our Astro project.
You can learn more about Astro here: Astro but I will walk you through the steps to get a basic project setup.
We will get started by installing Astro with the following command:
npm create astro@latest
You will be asked a series of questions, here is what I chose:
astro Launch sequence initiated.
dir Where should we create your new project? ./client
tmpl How would you like to start your new project? Empty
ts Do you plan to write TypeScript? Yes / strict
deps Install dependencies? Yes
git Initialize a new git repository? Yes
Once the project is created, we can started by navigating to the project directory called client
and running the following command yarn dev
to start the dev server.
You should see the following screen when navigating to http://localhost:4321/
Inside our Astro project, let's naviagate to our src
folder and create a new folder called content
with a config.ts
file.
Inside our config.ts
file, we will add the following code:
1import { defineCollection, z } from "astro:content";
2import { strapiLoader } from "../strapi-loader";
3
4// Define the Strapi posts collection
5// This sets up a custom loader for Strapi content
6const strapiPostsLoader = defineCollection({
7 loader: strapiLoader({ contentType: "article" }),
8});
9
10// Export the collections to be used in Astro
11export const collections = {
12 strapiPostsLoader,
13};
14
15// TODO: Implement the strapiLoader function in @/strapi-loader
In the file we just created, we are importing our strapi-loader
and defining a collection for our Strapi posts.
We have not implemented our strapi-loader
yet, but we will do that in a bit.
But to recap so far, we are using the defineCollection
function to define a collection for our Strapi posts.
We are passing our strapiLoader
function the contentType of article
.
This will allow us to fetch our Strapi data inside of our Astro components.
And finally we are exporting our collections to be used in Astro. Which we will do in a bit.
But first, in the src
folder, let's create a new file called strapi-loader.ts
.
And add the following code:
1import { z } from "astro:content";
2import type { Loader } from "astro/loaders";
3import type { ZodTypeAny, ZodObject } from "zod";
4
5// Configuration constants
6const STRAPI_BASE_URL =
7 import.meta.env.STRAPI_BASE_URL || "http://localhost:1337";
8const SYNC_INTERVAL = 60 * 1000; // 1 minute in milliseconds
9
10/**
11 * Creates a Strapi content loader for Astro
12 * @param contentType The Strapi content type to load
13 * @returns An Astro loader for the specified content type
14 */
15
16export function strapiLoader({ contentType }: { contentType: string }): Loader {
17 return {
18 name: "strapi-posts",
19
20 load: async function (this: Loader, { store, meta, logger }) {
21 const lastSynced = meta.get("lastSynced");
22
23 // Avoid frequent syncs
24 if (lastSynced && Date.now() - Number(lastSynced) < SYNC_INTERVAL) {
25 logger.info("Skipping Strapi sync");
26 return;
27 }
28
29 logger.info("Fetching posts from Strapi");
30
31 try {
32 // Fetch and store the content
33 const data = await fetchFromStrapi(`/api/${contentType}s`);
34 const posts = data?.data;
35
36 if (!posts || !Array.isArray(posts)) {
37 throw new Error("Invalid data received from Strapi");
38 }
39
40 // Get the schema
41 const schemaOrFn = this.schema;
42 if (!schemaOrFn) {
43 throw new Error("Schema is not defined");
44 }
45 const schema =
46 typeof schemaOrFn === "function" ? await schemaOrFn() : schemaOrFn;
47 if (!(schema instanceof z.ZodType)) {
48 throw new Error("Invalid schema: expected a Zod schema");
49 }
50
51 type Post = z.infer<typeof schema>;
52
53 store.clear();
54 posts.forEach((post: Post) => store.set({ id: post.id, data: post }));
55
56 meta.set("lastSynced", String(Date.now()));
57 } catch (error) {
58 logger.error(
59 `Error loading Strapi content: ${(error as Error).message}`
60 );
61 throw error;
62 }
63 },
64
65 schema: async () => {
66 const data = await fetchFromStrapi(
67 `/get-strapi-schema/schema/${contentType}`
68 );
69 if (!data?.attributes) {
70 throw new Error("Invalid schema data received from Strapi");
71 }
72 return generateZodSchema(data.attributes);
73 },
74 };
75}
76
77/**
78 * Maps Strapi field types to Zod schema types
79 * @param type The Strapi field type
80 * @param field The field configuration object
81 * @returns A Zod schema corresponding to the Strapi field type
82 */
83function mapTypeToZodSchema(type: string, field: any): ZodTypeAny {
84 const schemaMap: Record<string, () => ZodTypeAny> = {
85 string: () => z.string(),
86 uid: () => z.string(),
87 media: () =>
88 z.object({
89 allowedTypes: z.array(z.enum(field.allowedTypes)),
90 type: z.literal("media"),
91 multiple: z.boolean(),
92 url: z.string(),
93 alternativeText: z.string().optional(),
94 caption: z.string().optional(),
95 width: z.number().optional(),
96 height: z.number().optional(),
97 }),
98 richtext: () => z.string(),
99 datetime: () => z.string().datetime(),
100 relation: () =>
101 z
102 .object({
103 relation: z.literal(field.relation),
104 target: z.literal(field.target),
105 configurable: z.boolean().optional(),
106 writable: z.boolean().optional(),
107 visible: z.boolean().optional(),
108 useJoinTable: z.boolean().optional(),
109 private: z.boolean().optional(),
110 })
111 .optional(),
112 boolean: () => z.boolean(),
113 number: () => z.number(),
114 array: () => z.array(mapTypeToZodSchema(field.items.type, field.items)),
115 object: () => {
116 const shape: Record<string, ZodTypeAny> = {};
117 for (const [key, value] of Object.entries(field.properties)) {
118 if (typeof value === "object" && value !== null && "type" in value) {
119 shape[key] = mapTypeToZodSchema(value.type as string, value);
120 } else {
121 throw new Error(`Invalid field value for key: ${key}`);
122 }
123 }
124 return z.object(shape);
125 },
126 text: () => z.string(),
127 dynamiczone: () => z.array(z.object({ __component: z.string() })),
128 };
129
130 return (schemaMap[type] || (() => z.any()))();
131}
132
133/**
134 * Generates a Zod schema from Strapi content type attributes
135 * @param attributes The Strapi content type attributes
136 * @returns A Zod object schema representing the content type
137 */
138function generateZodSchema(attributes: Record<string, any>): ZodObject<any> {
139 const shape: Record<string, ZodTypeAny> = {};
140 for (const [key, value] of Object.entries(attributes)) {
141 const { type, ...rest } = value;
142 shape[key] = mapTypeToZodSchema(type, rest);
143 }
144 return z.object(shape);
145}
146
147/**
148 * Fetches data from the Strapi API
149 * @param path The API endpoint path
150 * @param params Optional query parameters
151 * @returns The JSON response from the API
152 */
153async function fetchFromStrapi(
154 path: string,
155 params?: Record<string, string>
156): Promise<any> {
157 const url = new URL(path, STRAPI_BASE_URL);
158
159 if (params) {
160 Object.entries(params).forEach(([key, value]) => {
161 url.searchParams.set(key, value);
162 });
163 }
164
165 try {
166 const response = await fetch(url.href);
167 if (!response.ok) {
168 throw new Error(`Failed to fetch from Strapi: ${response.statusText}`);
169 }
170 return response.json();
171 } catch (error) {
172 console.error(`Error fetching from Strapi: ${(error as Error).message}`);
173 throw error; // Re-throw the error for the caller to handle
174 }
175}
176
177// Ensure the required environment variable is set
178function checkEnvironmentVariables() {
179 if (!STRAPI_BASE_URL) {
180 throw new Error("STRAPI_BASE_URL environment variable is not set");
181 }
182}
183
184// Ensure environment variables are set before proceeding
185checkEnvironmentVariables();
There is a lot going on in this file, but we will break it down.
Let's break down the main components of our strapi-loader.ts
file:
strapiLoader
function:
This is the main export of our module. It creates and returns an Astro loader for Strapi content. It handles fetching data from Strapi, storing it, and managing the sync process.
mapTypeToZodSchema
function:
This function maps Strapi field types to corresponding Zod schema types. It's crucial for generating a Zod schema that matches the structure of our Strapi content.
generateZodSchema
function:
Using the mapTypeToZodSchema
function, this generates a complete Zod schema from the Strapi content type attributes. This schema is used to validate and type our data.
fetchFromStrapi
function:
A utility function that handles making HTTP requests to the Strapi API. It constructs the URL, sends the request, and handles any errors.
checkEnvironmentVariables
function:
This function ensures that the necessary environment variables (like STRAPI_BASE_URL
) are set before the loader runs.
The loader works as follows:
SYNC_INTERVAL
).This loader allows us to seamlessly integrate Strapi content into our Astro project, with type safety provided by Zod schemas that are dynamically generated based on our Strapi content structure.
When building a loader, it does not have to be this complex. And the 3 basic ingredients you need are, name, load, and schema.
Now that we have our loader, and we are aleady refferencing it in our config.ts
file, we can go and see how we can use it in our Astro components.
Before we start, we need to make one quick change in our Astro project.
For our Astro components I will be using tailwind, so let's install it with the following command:
npx astro add tailwind
Click yes
to all the questions.
Now that we have our tailwind installed, we can go and create our first Astro component.
But quick note, if you are using Astro 4, we will need to enable the new Astro Integration for Content Collections.
To do this, we need to update our astro.config.mjs
file to the following:
1// @ts-check
2import { defineConfig } from "astro/config";
3import tailwind from "@astrojs/tailwind";
4
5// https://astro.build/config
6export default defineConfig({
7 experimental: {
8 contentLayer: true,
9 },
10 integrations: [tailwind()],
11});
Let's navigate to our src/pages
folder and inside the index.astro
file, let's add the following code:
1---
2import { getCollection } from "astro:content";
3import { Image } from "astro:assets";
4
5const STRAPI_BASE_URL =
6 import.meta.env.STRAPI_BASE_URL || "http://localhost:1337";
7const strapiPosts = await getCollection("strapiPostsLoader");
8---
9
10<section class="container mx-auto px-4 py-8">
11 <h1 class="text-4xl font-bold mb-6">Strapi Blog Section</h1>
12
13 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
14 {
15 strapiPosts.map((post) => {
16 const { title, description, slug, cover } = post.data;
17 return (
18 <div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
19 <div class="p-6">
20 <Image
21 src={STRAPI_BASE_URL + cover.url}
22 alt={title}
23 width={300}
24 height={200}
25 class="w-full h-auto mb-4 rounded-md"
26 />
27 <h2 class="text-2xl font-bold mb-2 text-gray-800">{title}</h2>
28 <p class="text-gray-600 mb-4">{description}</p>
29 </div>
30 </div>
31 );
32 })
33 }
34 </div>
35</section>
In the code above we are able to access and use our custom loader via the getCollection
function.
Notice thate we are passing the name of our loader strapiPostsLoader
to the getCollection
function.
This is what we defined in our config.ts
file inside the contetn
folder.
Now restart your Strapi and Astro project and navigate to http://localhost:4321/
and you should see the following screen:
Nice, we are ready to build our Strapi Astro Loader.
Nice, we are now able to fetch and display our Strapi content in our Astro project.
Awesome, today we learned how to create a custom Astro Loader to fetch and display Strapi content.
With what we learned here today, you can build your own custom loaders for your own projects.
Thank you for reading this far.
Once you create a loader, you can push it to NPM and share it with the community.
This is what I did with my Strapi Astro Loader.
You can find it here: Strapi Astro Loader
GitHub Repo: Strapi Astro Loader
This is still work in progress and contributions are welcome.
So let's install the loader with the following command and use it in our Astro project.
npm install strapi-community-astro-loader
Now let's update the config.ts
file in our content
folder to the following:
1// import { strapiLoader } from "@/strapi-loader";
2import { strapiLoader } from "strapi-community-astro-loader"
Restart your Astro project and you should see the same results as we had before.
Learn more about building custom Astro loaders here
Thank you for reading this far. I hope you found this tutorial helpful. And I will see you in the next one.