Astro just recently released their Astro Actions, which is pretty awesome.
In this tutorial, we will create an email capture form to showcase this feature. Email capture is beneficial in many ways, including enabling subscriber lists, enabling targeted marketing, boosting sales and conversions, enhancing customer relationships, offering cost-effective marketing, driving traffic and engagement, and so on.
Here it is in action.
We will start by building out our Astro App, and finally, We will connect to Strapi 5 and use the release candidate.
Besides the fact that Astro is fantastic, here are some of the reasons we are using Astro:
That is right, we will write vanilla JavaScript and TypeScript.
We can find all the details of Astro setup, but we are going to start with this simple command.
Note: first, create a folder where we will want to store this project on your local computer.
npm create astro@latest
The command above, installs the latest Astro project.
We will be asked where we would like to create our app. We will generate it in the root of your current folder and call it frontend
.
astro Launch sequence initiated.
dir Where should we create your new project? ./frontend
Then, it will ask if we want to include sample files. This is the recommended option, and we are going to choose it.
tmpl How would you like to start your new project?
● Include sample files (recommended)
○ Use blog template
○ Empty
Then choose yes
for the remainder options.
ts Do you plan to write TypeScript?
Yes
use How strict should TypeScript be?
Strict
deps Install dependencies?
Yes
git Initialize a new git repository?
Yes
██████ Project initializing...
■ Template copied
■ TypeScript customized
▶ Dependencies installing with npm...
╭─────╮ Houston:
│ ◠ ◡ ◠ Good luck out there, astronaut! 🚀
╰─────╯
Nice, we now have your local project running.
To start your project, navigate to your frontend
folder and run the command below:
yarn dev
Or:
npm run dev
Navigate to http://localhost:4321
, and we should see the following.
Now that Astro is running let's do one more thing before diving into our tutorial: install Tailwind CSS support. With Astro, it is easy.
We can find the details here, but we are going to use this handy command:
npx astro add tailwind
This will handle all of the magic automatically. We will see the following; follow the prompts.
➜ frontend git:(main) ✗ npx astro add tailwind
✔ Resolving packages...
Astro will run the following command:
If we skip this step, we can always run it yourself later
╭──────────────────────────────────────────────────────────╮
│ npm install @astrojs/tailwind@^5.1.0 tailwindcss@^3.4.4 │
╰──────────────────────────────────────────────────────────╯
✔ Continue? … yes
✔ Installing dependencies...
Astro will generate a minimal ./tailwind.config.mjs file.
✔ Continue? … yes
Astro will make the following changes to your config file:
╭ astro.config.mjs ─────────────────────────────╮
│ import { defineConfig } from 'astro/config'; │
│ │
│ import tailwind from "@astrojs/tailwind"; │
│ │
│ // https://astro.build/config │
│ export default defineConfig({ │
│ integrations: [tailwind()] │
│ }); │
╰───────────────────────────────────────────────╯
✔ Continue? … yes
success Added the following integration to your project:
- @astrojs/tailwind
Now that everything is all set, let's get started. If new to Astro, we can learn the basics by checking out the following blog series on Astro : Astro & Strapi Website Tutorial: Part 1 - Intro to Astro.
Excellent; now that our app is ready, let's get started.
We will start by building out our submission form. We will keep it simple.
Navigate to the src/pages/index.astro
inside your Astro project. Replace all of the code with the following.
1---
2// This runs on the server when the site is built
3import Layout from "../layouts/Layout.astro";
4---
5
6<!-- This is the HTML that gets sent to the browser -->
7<Layout title="Home">
8 <div class="sm:px-8 m-24 md:m-28">
9 <div class="mx-auto w-full max-w-7xl lg:px-8 flex justify-center">
10 <div class="flex flex-col gap-16"></div><div
11 class="space-y-10 lg:pl-16 xl:pl-24"
12 >
13 <div
14 class="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40"
15 >
16 <h2
17 class="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100"
18 >
19 <svg
20 viewBox="0 0 24 24"
21 fill="none"
22 stroke-width="1.5"
23 stroke-linecap="round"
24 stroke-linejoin="round"
25 aria-hidden="true"
26 class="h-6 w-6 flex-none"
27 ><path
28 d="M2.75 7.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
29 class="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
30 ></path><path
31 d="m4 6 6.024 5.479a2.915 2.915 0 0 0 3.952 0L20 6"
32 class="stroke-zinc-400 dark:stroke-zinc-500"></path></svg
33 ><span class="ml-3">Stay up to date</span>
34 </h2><p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
35 Get notified when I publish something new, and unsubscribe at any
36 time.
37 </p>
38
39 <!-- Add you form here -->
40 </div>
41 </div>
42 </div>
43 </div>
44</Layout>
45
46<style>
47 /* This is where you can add your scoped CSS styles */
48</style>
49
50<script>
51 // This is where you can add your client-side JavaScript code
52</script>
Layout
ComponentNotice that we are importing our Layout
component at the top; let's navigate into it and replace the code within the <body>
tags with the following.
1<body class="flex min-h-screen bg-zinc-50 dark:bg-black">
2 <div class="flex w-full">
3 <div class="relative flex w-full flex-col">
4 <main class="flex-auto"><slot /></main>
5 </div>
6 </div>
7</body>
Remove all the styles between the <styles>
tags; we won't need them since we installed Tailwind.
Now, we run yarn dev,
and we should see the following.
Before we create your form component. Let's quickly revisit our Layout
component.
The big takeaway is that we use the Layout
component as a wrapper that takes in children. We need to use Astro's <slot />
tag to render the children.
We can learn more details here
In our case it is the Layout.astro
.
1---
2interface Props {
3 title: string;
4}
5
6const { title } = Astro.props;
7---
8
9<!doctype html>
10<html lang="en">
11 <head>
12 <meta charset="UTF-8" />
13 <meta name="description" content="Astro description" />
14 <meta name="viewport" content="width=device-width" />
15 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
16 <meta name="generator" content={Astro.generator} />
17 <title>{title}</title>
18 </head>
19 <body class="flex min-h-screen bg-zinc-50 dark:bg-black">
20 <div class="flex w-full">
21 <div class="relative flex w-full flex-col">
22 <main class="flex-auto">
23 <slot /> <!-- children will go here -->
24 </main>
25 </div>
26 </div>
27 </body>
28</html>
When we import our Layout
component, everything we place between the <Layout></Layout>
will be rendered via the <slot/>
tag.
Here is our example in our index.astro
page.
1<Layout title="Home">
2 <div class="sm:px-8 m-24 md:m-28">
3 <!-- rest of our code -->
4 </div>
5</Layout>
Astro components are modular, reusable pieces of code representing user interface parts, like headers, footers, cards, etc.
Similar to frameworks like React or Vue, Astro uses a component-based architecture, allowing developers to break down the UI into manageable, reusable parts.
Astro components can be written using various frontend frameworks, such as React, Vue, Svelte, or plain HTML and JavaScript, which we will do in this Astro tutorial.
This flexibility allows developers to choose the best tool for their needs or mix and match frameworks within the same project.
Astro uses an "islands architecture," where only interactive components (islands) are hydrated and made interactive on the client side. This approach minimizes JavaScript sent to the browser, improving performance.
Let's build our form component.
Navigate to your component
folder and create a file called EmailSignUp.astro.
And add the following code.
1<form class="mt-6" id="email-form">
2 <div class="w-full flex">
3 <input
4 id="email"
5 name="email"
6 type="email"
7 placeholder="Email address"
8 aria-label="Email address"
9 class="min-w-0 flex-auto appearance-none rounded-md border border-zinc-900/10 bg-white px-3 py-[calc(theme(spacing.2)-1px)] shadow-md shadow-zinc-800/5 placeholder:text-zinc-400 focus:border-teal-500 focus:outline-none focus:ring-4 focus:ring-teal-500/10 sm:text-sm dark:border-zinc-700 dark:bg-zinc-700/[0.15] dark:text-zinc-200 dark:placeholder:text-zinc-500 dark:focus:border-teal-400 dark:focus:ring-teal-400/10"
10 />
11
12 <button
13 class="inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70 ml-4 flex-none"
14 type="submit"
15 >
16 Join
17 </button>
18 </div>
19</form>
This is where all of our magic will happen. But first, navigate to your index.astro
file, and let's import the component and add it to our code.
1import EmailSignUp from "../layouts/EmailSignUp.astro";
And add the following.
1<EmailSignUp />
The updated code should look like the following.
1---
2// This runs on the server when the site is built
3import Layout from "../layouts/Layout.astro";
4import EmailSignUp from "../layouts/EmailSignUp.astro";
5---
6
7<!-- This is the HTML that gets sent to the browser -->
8<Layout title="Home">
9 <div class="sm:px-8 m-24 md:m-28">
10 <div class="mx-auto w-full max-w-7xl lg:px-8 flex justify-center">
11 <div class="flex flex-col gap-16"></div><div
12 class="space-y-10 lg:pl-16 xl:pl-24"
13 >
14 <div
15 class="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40"
16 >
17 <h2
18 class="flex text-sm font-semibold text-zinc-900 dark:text-zinc-100"
19 >
20 <svg
21 viewBox="0 0 24 24"
22 fill="none"
23 stroke-width="1.5"
24 stroke-linecap="round"
25 stroke-linejoin="round"
26 aria-hidden="true"
27 class="h-6 w-6 flex-none"
28 ><path
29 d="M2.75 7.75a3 3 0 0 1 3-3h12.5a3 3 0 0 1 3 3v8.5a3 3 0 0 1-3 3H5.75a3 3 0 0 1-3-3v-8.5Z"
30 class="fill-zinc-100 stroke-zinc-400 dark:fill-zinc-100/10 dark:stroke-zinc-500"
31 ></path><path
32 d="m4 6 6.024 5.479a2.915 2.915 0 0 0 3.952 0L20 6"
33 class="stroke-zinc-400 dark:stroke-zinc-500"></path></svg
34 ><span class="ml-3">Stay up to date</span>
35 </h2><p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
36 Get notified when I publish something new, and unsubscribe at any
37 time.
38 </p>
39
40 <!-- Add you form here -->
41 <EmailSignUp />
42 </div>
43 </div>
44 </div>
45 </div>
46</Layout>
47
48<style>
49 /* This is where you can add your scoped CSS styles */
50</style>
51
52<script>
53 // This is where you can add your client-side JavaScript code
54</script>
If our restart your app, we will see the following.
But clicking the button won't do anything. That is okay. We will fix this in the next section using vanilla Javascript. What?!! Vanilla Javascript. Who does that?
Yes, let's see if we still remember how to do it. Sometimes, using a framework is overkill, especially if we have a static site and need one form to capture email.
Of course, I am joking. We can use React here if we like, but that is the beauty of Astro: It gives us the option to choose.
What's incredible about Astro is that we can add Javascript directly via the <script>
tag; we can either write all of the code there or in a separate file and then import it within the script tag.
Add the following to our EmailSignUp.astro
after our HTML code.
1<script>
2 import { actions } from "astro:actions";
3 import { isInputError } from "astro:actions";
4
5 function clearPreviousMessage(id: string) {
6 const messageElement = document.getElementById(id);
7 if (messageElement) {
8 messageElement.remove();
9 }
10 }
11
12 function addMessageElement(
13 message: string,
14 type: "error" | "success",
15 element: HTMLElement
16 ) {
17 const p = document.createElement("p");
18
19 p.id = "message";
20 p.className = `message ${type === "success" ? "text-teal-300" : "text-pink-300"} mt-2 px-2`;
21 p.innerText = message;
22 form.appendChild(p);
23 p.innerText = message;
24 element.appendChild(p);
25 }
26
27 function clearInput(id: string) {
28 const emailInput = document.getElementById(id) as HTMLInputElement;
29 if (emailInput) emailInput.value = "";
30 }
31
32 function renderMessage(error: any, data: any, form: HTMLFormElement) {
33 if (error && isInputError(error)) {
34 const message = error.fields.email && error.fields.email[0];
35 addMessageElement(message || "", "error", form);
36 } else {
37 if (data?.strapiErrors) {
38 const message = data?.strapiErrors.message;
39 addMessageElement(message, "error", form);
40 } else {
41 const message = "Form submitted, thank you.";
42 clearInput("email");
43 addMessageElement(message, "success", form);
44 }
45 }
46 }
47
48 const form = document.getElementById("email-form") as HTMLFormElement;
49
50 async function handleFormSubmit(e: Event) {
51 e.preventDefault();
52
53 const formData = new FormData(form);
54
55 const { data, error } = await actions.email.safe(formData);
56
57 clearPreviousMessage("message");
58 renderMessage(error, data, form);
59 }
60
61 form.addEventListener("submit", handleFormSubmit);
62</script>
Look at the beautiful vanilla Javascript. If it has been a while, that's okay. This is one of the reasons we started using Astro: so I can have the opportunity to write some vanilla Javascript. And if I need more, I can always bring in a React component.
Let's break down each part of the code above.
1import { actions } from "astro:actions";
2import { isInputError } from "astro:actions";
This is where we will import our actions, we are yet to create them, but this is something we will do next.
clearPreviousMessage
function:1function clearPreviousMessage(id: string) {
2 const messageElement = document.getElementById(id);
3 if (messageElement) {
4 messageElement.remove();
5 }
6}
This function removes a message element from the DOM if it exists. It is used to clear any previous messages before displaying a new one.
addMessageElement
function:1function addMessageElement(
2 message: string,
3 type: "error" | "success",
4 element: HTMLElement
5) {
6 const p = document.createElement("p");
7
8 p.id = "message";
9 p.className = `message ${
10 type === "success" ? "text-teal-300" : "text-pink-300"
11 } mt-2 px-2`;
12 p.innerText = message;
13 form.appendChild(p);
14 p.innerText = message;
15 element.appendChild(p);
16}
This function creates a new <p>
element with a specified message and style (either error or success) and appends it to a given HTML element.
clearInput
function:1function clearInput(id: string) {
2 const emailInput = document.getElementById(id) as HTMLInputElement;
3 if (emailInput) emailInput.value = "";
4}
This function clears the value of an input field identified by its id.
renderMessage
function1function renderMessage(error: any, data: any, form: HTMLFormElement) {
2 if (error && isInputError(error)) {
3 const message = error.fields.email && error.fields.email[0];
4 addMessageElement(message || "", "error", form);
5 } else {
6 if (data?.strapiErrors) {
7 const message = data?.strapiErrors.message;
8 addMessageElement(message, "error", form);
9 } else {
10 const message = "Form submitted, thank you.";
11 clearInput("email");
12 addMessageElement(message, "success", form);
13 }
14 }
15}
This function determines whether an error or success message should be displayed based on the presence of error and data parameters. It uses addMessageElement
to display the appropriate message.
email-form
and casts it as an HTMLFormElement
.1const form = document.getElementById("email-form") as HTMLFormElement;
handleFormSubmit
function:1async function handleFormSubmit(e: Event) {
2 e.preventDefault();
3
4 const formData = new FormData(form);
5
6 const { data, error } = await actions.email.safe(formData);
7
8 clearPreviousMessage("message");
9 renderMessage(error, data, form);
10}
This function handles the form submission event. It prevents the default form submission, gathers form data, and sends it using actions.email.safe()
. It then clears any previous messages and calls renderMessage
to display the appropriate message based on the response.
1form.addEventListener("submit", handleFormSubmit);
Adds an event listener to the form that triggers handleFormSubmit
function when the form is submitted.
Before we can test your form, we need to create an Astro Action.
Finally, let's examine how to use Astro's actions. This is an experimental feature, so we must first enable it. To learn more about Astro's actions, check out the following post.
Astro.config.mjs
FileOur first step is to update our Astro.config.mjs
file with the following.
1import { defineConfig } from "astro/config";
2
3import tailwind from "@astrojs/tailwind";
4
5// https://astro.build/config
6export default defineConfig({
7 integrations: [tailwind()],
8 output: "hybrid", // or 'server'
9 experimental: {
10 actions: true,
11 },
12});
actions
FolderNow, let's create a new folder in the src
directory called actions,
add the index.ts
file, and paste it into the following code.
1import { defineAction, z } from "astro:actions";
2
3export const server = {
4 email: defineAction({
5 accept: "form",
6 input: z.object({
7 email: z
8 .string({ message: "This field has to be filled." })
9 .email("This is not a valid email."),
10 }),
11
12 handler: async (formData) => {
13 // do something here
14 console.log(formData);
15 },
16 }),
17};
Here, we define a simple Astro action; what is fantastic is that we can do validation via zod. We can learn about Form Validation In TypeScipt Projects Using Zod and React Hook Form.
If we were to submit our form with any email, our action would return the zod validation error. Which is awesome.
But if we provide a valid email, we can see that we can console log our email.
Nice. We are now getting our email. Next, let's set up Strapi 5 and connect everything.
Did you know we recently released the Strapi 5 "release candidate"? This is your chance to take it for a spin and help us improve with your feedback.
You can check out the new docs here.
It is worthy to note that Astro also has it documentation for Strapi integration!
Let's start with the following command. We will use the --quickstart
flag to get us started with the SQLite database.
npx create-strapi@rc backend --quickstart
Create a free account on Strapi Cloud and benefit from:
- ✦ Blazing-fast ✦ deployment for your projects
- ✦ Exclusive ✦ access to resources to make your project successful
- An ✦ Awesome ✦ community and full enjoyment of Strapi's ecosystem
Start your 14-day free trial now!
? Please log in or sign up.
Login/Sign up
❯ Skip
When creating your project, we will be prompted to sign in to Strapi Cloud; we will skip this option for now.
But whenever we want to deploy our project quickly, Strapi Cloud is the way to go. We can learn more here.
Once all the dependencies are installed, your app will start, and we will see the following screen.
Once we log in, we will be greeted with the dashboard.
To proceed with our Astro/Strapi integration let's first create a collection type to store the emails that will be captured by our form.
Navigate to the Content-Type Builder
and click Create new collection type
.
We fill out the following fields. Set the Display Name
as Email Signup
.
Then click on continue.
We will add one text field called email
and make it unique
.
Finally, we must enable the endpoint to allow us to create entries. We can do this by navigating to Settings > Users Permission > Roles > Public, selecting our collection type Email Signup
, and checking the create check box.
Please save your changes. Now that this is done, we can post a request to the following URL path: https://localhost:1337/api/email-signups
.
Nice; we have to finish up the code in our Astro project to make the request to Strapi and pass the email contact form data.
In our Astro project, navigate to our actions
folder and examine the index.ts
file.
At the top, let's define a function that will call Strapi API using the following code:
1export async function mutateData(method: string, path: string, payload?: any) {
2 const baseUrl = import.meta.env.PUBLIC_STRAPI_URL || "http://localhost:1337";
3 const url = new URL(path, baseUrl);
4
5 const authToken = false;
6
7 const headers: any = {
8 "Content-Type": "application/json",
9 };
10
11 if (authToken) {
12 headers["Authorization"] = `Bearer ${authToken}`;
13 }
14
15 try {
16 const response = await fetch(url.href, {
17 method: method,
18 headers,
19 body: JSON.stringify({ ...payload }),
20 });
21 const data = await response.json();
22 return data;
23 } catch (error) {
24 console.log("error", error);
25 throw error;
26 }
27}
The above function is a general function that I use in many projects to make requests.
We are not using authentication, so I hardcoded it to false
. Still, we can extend the functionality to add authenticated requests via JWT token. We can learn more about it in this blog post: Guide on Authenticating Requests with the REST API.
Now that we have our function, we can use it in our action to make a POST request to our Strapi endpoint.
Inside our handler function, let's make the following change.
1 handler: async (formData) => {
2 // insert comments in db
3 console.log(formData);
4
5 const payload: Payload = {
6 data: {
7 email: formData.email,
8 },
9 };
10
11 const responseData = await mutateData("POST", "/api/email-signups", payload);
12
13 if (!responseData) {
14 return {
15 strapiErrors: null,
16 message: "Ops! Something went wrong. Please try again.",
17 };
18 }
19
20 if (responseData.error) {
21 return {
22 strapiErrors: responseData.error,
23 message: "Failed to Register.",
24 };
25 }
26
27 return {
28 message: "Form submitted, thank you.",
29 data: responseData,
30 strapiErrors: null,
31 };
32 },
After we call the mutateData
function, we check to see if the request fails and for Strapi-specific API errors.
For instance, we set the email
field as unique
. That means when the user tries to add their email more than once in the contact form, we will return a Strapi error saying that the email has to be unique. We will see this in action in just a moment.
The completed code should look like the following.
1import { defineAction, z } from "astro:actions";
2
3export async function mutateData(method: string, path: string, payload?: any) {
4 const baseUrl = import.meta.env.PUBLIC_STRAPI_URL || "http://localhost:1337";
5 const url = new URL(path, baseUrl);
6
7 const authToken = false;
8
9 const headers: any = {
10 "Content-Type": "application/json",
11 };
12
13 if (authToken) {
14 headers["Authorization"] = `Bearer ${authToken}`;
15 }
16
17 try {
18 const response = await fetch(url.href, {
19 method: method,
20 headers,
21 body: JSON.stringify({ ...payload }),
22 });
23 const data = await response.json();
24 return data;
25 } catch (error) {
26 console.log("error", error);
27 throw error;
28 }
29}
30
31interface Payload {
32 data: {
33 email: string;
34 };
35}
36
37export const server = {
38 email: defineAction({
39 accept: "form",
40 input: z.object({
41 email: z
42 .string({ message: "This field has to be filled." })
43 .email("This is not a valid email."),
44 }),
45
46 handler: async (formData) => {
47 // insert comments in db
48 console.log(formData);
49
50 const payload: Payload = {
51 data: {
52 email: formData.email,
53 },
54 };
55
56 const responseData = await mutateData(
57 "POST",
58 "api/email-signups",
59 payload
60 );
61
62 if (!responseData) {
63 return {
64 strapiErrors: null,
65 message: "Ops! Something went wrong. Please try again.",
66 };
67 }
68
69 if (responseData.error) {
70 return {
71 strapiErrors: responseData.error,
72 message: "Failed to Register.",
73 };
74 }
75
76 return {
77 message: "Form submitted, thank you.",
78 data: responseData,
79 strapiErrors: null,
80 };
81 },
82 }),
83};
Make sure that your Strapi and Astro apps are running, and let's test out our form.
Nice, everything is working as expected. We can submit our contact form, but we see an error if we try to submit the form with an already submitted email.
In this Astro tutorial, we've integrated Astro Actions with a vanilla JavaScript email capture form, seamlessly connecting it to Strapi 5.
Following these steps, you should understand how to set up an Astro project, implement and use Astro Actions, and integrate them with Strapi for backend functionality.
Astro's flexibility allows you to write clean, performant code without relying heavily on frameworks, making it a great choice for content-rich websites.
Using Astro's "Islands" architecture and integrating with tools like Strapi, you can create fast, SEO-friendly sites with dynamic content capabilities.
Hopefully this guide has been helpful and that you feel empowered to create more advanced and interactive web applications using Astro and Strapi. Happy coding!
You can find the project GitHub repo here.
If you have additional Strapi questions. Come hang out with us for Strapi Open Office hours.
Join us at 4 AM CST (9:00 AM GMT) for our new early bird session. Perfect for our global community members!
Remember our regular session at 12:30 PM CST (6:30 PM GMT). It's an excellent time for an afternoon break and chat!