☁️ Serverless Astro
Here we will see how you can set up an Astro landing page form. Astro builds static sites, which are typically faster and more secure. Using serverless functions you can add functionality traditionally handled by a backend yet keep the Astro speed. When it comes to serverless there are a number of options, writing code in JavaScript, Rust or other languages and also in terms of the platform the code runs on. Netlify offers hassle-free setup and we will stick with JavaScript in case this is your first time trying serverless.
As just hinted, serverless functions let you run traditional back-end operations without having to configure and maintain a back end server. Typically they are cheap to run and can easily scale up to handle high demand. The main trade-off is spin-up time, the time between the request being received and the cloud server being ready to start working on the request. This is falling all the time and in fact is not critical for our use case.
🧱 What we’re Building
We will use Astro and Svelte to create a single page site; a landing page for a book launch. The focus is on how to integrate serverless functions with Astro so we will keep the site fairly basic. This makes this an ideal tutorial to follow if you are just getting started with Astro. We will add a contact form and see how you can link that up to Netlify serverless functions. The serverless function will use a Telegram bot to send a message to a Telegram private chat. This can make the app more convenient to use, as site admins can have access to messages via the Telegram mobile app as well as while they are at their desk.
Netlify makes the serverless function available via an endpoint on the same domain the site is hosted on. We will invoke the Netlify function by sending a REST POST
request (containing form data in the body). If this all sounds interesting, then let’s get going!
⚙️ Astro Setup
We are going to use Svelte to create the contact form and interface with the serverless function, so need to add the Svelte integration as we set up Astro. Let’s do that now from the Terminal:
mkdir astro-contact-form && cd $_
pnpm init astro
pnpm install
pnpm astro add svelte
pnpm run dev
Choose the Minimal app template and accept suggested config when prompted. Once that’s all done, open up your browser just to check we’re good to go. The CLI will tell you where the local dev server is running, this will be http://localhost:3000 if there is not already something running on port 3000
. Don’t expect too much (yet)! If all is working well, you will just see the word “Astro” in the browser window.
Take a look at astro.config.mjs
in the project root folder. Astro has added the Svelte integration for you!
🏡 Astro Landing Page Home Page
We will start with the home page. The markup will all be done in Astro and we will only use Svelte for the contact form. Replace the content in src/pages/index.astro
:
---
---
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Astro Contact Form: using Netlify Serverless Functions</title>
</head>
<body>
<header class="header-container">
<h1>Astro Landing Page: using Serverless Functions</h1>
</header>
<main class="main-container">
<section class="card">
<h2>New book is launching soon:</h2>
<ul>
<li>Why you should be using NewTech</li>
<li>How to leverage latest NewTech features,</li>
<li>10 Step plan for your business</li>
</ul>
</section>
<section class="card card-alt">
<h2>Find out more</h2>
<p>Contact form will go here </p>
</section>
</main>
</body>
</html>
Add some optional styling to make it look a little nicer:
<style>
/* hind-regular - latin */
@font-face {
font-family: Hind;
font-style: normal;
font-weight: var(--font-weight-normal);
src: local(''), url('/fonts/hind-v15-latin-regular.woff2') format('woff2'),
url('/fonts/hind-v15-latin-regular.woff') format('woff');
}
/* hind-700 - latin */
@font-face {
font-family: Hind;
font-style: normal;
font-weight: var(--font-weight-bold);
src: local(''), url('/fonts/hind-v15-latin-700.woff2') format('woff2'),
url('/fonts/hind-v15-latin-700.woff') format('woff');
}
:global(html) {
background-color: var(--colour-light);
font-family: var(--font-body);
accent-color: var(--colour-brand);
}
:global(body) {
margin: 0;
font-weight: var(--font-weight-normal);
}
:global(h1, h2) {
font-family: var(--font-heading);
}
:global(h1) {
font-size: var(--font-size-6);
font-weight: var(--font-weight-bold);
}
:global(h2) {
font-size: var(--font-size-5);
font-weight: var(--font-weight-bold);
}
:global(form) {
display: flex;
flex-direction: column;
width: min(32rem, 100%);
margin-left: auto;
margin-right: auto;
color: var(--colour-dark);
}
:global(input, textarea) {
width: 100%;
text-indent: var(--spacing-2);
padding: var(--spacing-1) var(--spacing-0);
margin: var(--spacing-0) var(--spacing-0) var(--spacing-5);
border: var(--spacing-px) solid var(--colour-theme);
border-radius: var(--spacing-1);
font-size: var(--font-size-3);
background-color: var(--colour-light);
}
:global(textarea) {
padding: var(--spacing-2) var(--spacing-0);
resize: none;
}
:global(button) {
background-color: var(--color-brand);
background-image: var(--colour-brand-gradient);
border: var(--spacing-px-2) solid var(--colour-light);
border-radius: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-3);
font-weight: var(--font-weight-bold);
color: var(--colour-light);
cursor: pointer;
}
:global(.screen-reader-text) {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
width: 1px;
overflow: hidden;
position: absolute !important;
word-wrap: normal !important;
}
:global(.error-text) {
color: var(--colour-brand);
}
:global(:root) {
--colour-theme: hsl(334 43% 17%); /* dark purple */
--colour-brand: hsl(332 97% 43%); /* dogwood rose */
--colour-alt: hsl(201 11% 41%); /* cadet */
--colour-light: hsl(0 0% 99%); /* cultured */
--colour-dark: hsl(245 100% 15%); /* midnight blue */
--colour-brand-gradient: linear-gradient(
45deg,
hsl(332deg 97% 36%) 0%,
hsl(332deg 97% 37%) 21%,
hsl(332deg 97% 38%) 30%,
hsl(332deg 97% 38%) 39%,
hsl(332deg 97% 39%) 46%,
hsl(332deg 97% 40%) 54%,
hsl(332deg 97% 41%) 61%,
hsl(332deg 97% 42%) 69%,
hsl(332deg 97% 42%) 79%,
hsl(332deg 97% 43%) 100%
);
--colour-alt-gradient: linear-gradient(
45deg,
hsl(204deg 11% 44%) 0%,
hsl(204deg 11% 46%) 21%,
hsl(204deg 10% 48%) 30%,
hsl(204deg 10% 49%) 39%,
hsl(204deg 10% 51%) 46%,
hsl(204deg 10% 53%) 54%,
hsl(204deg 10% 55%) 61%,
hsl(204deg 10% 56%) 69%,
hsl(204deg 10% 58%) 79%,
hsl(205deg 11% 60%) 100%
);
--spacing-0: 0;
--spacing-px: 1px;
--spacing-px-2: 2px;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-12: 3rem;
--spacing-18: 4.5rem;
--max-width-wrapper: 48rem;
--font-size-root: 16px;
--font-size-3: 1.563rem;
--font-size-5: 2.441rem;
--font-size-6: 3.052rem;
--font-weight-normal: 400;
--font-weight-bold: 700;
--font-heading: 'Hind';
--font-body: 'Hind';
/* CREDIT: https://www.joshwcomeau.com/shadow-palette/ */
--shadow-color: 0deg 0% 58%;
--shadow-elevation-medium: -1px 1px 1.4px hsl(var(--shadow-color) / 0.51),
-2.7px 2.7px 3.7px -1.2px hsl(var(--shadow-color) / 0.43),
-7.6px 7.6px 10.5px -2.3px hsl(var(--shadow-color) / 0.36),
-20px 20px 27.6px -3.5px hsl(var(--shadow-color) / 0.29);
}
:global(input:focus, textarea:focus) {
border-color: var(--colour-brand);
}
.header-container {
display: grid;
background-color: var(--colour-alt);
background-image: var(--colour-alt-gradient);
box-shadow: var(--shadow-elevation-medium);
color: var(--colour-light);
height: var(--spacing-24);
place-content: center;
}
.main-container {
display: flex;
flex-direction: column;
color: var(--colour-light);
width: min(100% - var(--spacing-12), var(--max-width-wrapper));
margin: var(--spacing-18) auto;
padding: var(--spacing-12);
font-size: var(--font-size-3);
}
.main-content {
display: flex;
flex-direction: column;
margin: var(--spacing-6);
padding: var(--spacing-2) var(--spacing-12) var(--spacing-6);
background-color: var(--colour-light);
border-radius: var(--spacing-1);
color: var(--colour-dark);
}
.card {
border: var(--spacing-px-2) solid var(--colour-theme);
box-shadow: var(--shadow-elevation-medium);
background-color: var(--colour-theme);
background-image: var(--colour-brand-gradient);
padding: var(--spacing-0) var(--spacing-6) var(--spacing-6);
margin: var(--spacing-0) var(--spacing-0) var(--spacing-12);
border-radius: var(--spacing-2);
}
.card-alt {
border-color: var(--colour-dark);
background-color: var(--colour-alt);
background-image: var(--colour-alt-gradient);
color: var(--colour-light);
}
</style>
We are using self-hosted fonts here. You can download the fonts from google-webfonts-helper. Unzip and move the four files to a new public/fonts
folder.
Astro Aliases
If that’s all working, we will move on to the contact form component. Astro lets you define aliases for folders within your project. This can be more convenient than writing something like import ContactFrom from '../../components/ContactForm.svelte'
.
First create a src/components
folder then edit tsconfig.json
in the project root folder, so it looks like this:
{
"compilerOptions": {
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"$components/*": ["src/components/*"]
}
},
"include": ["src/**/*.ts"]
}
Now add he component to the Home Page (it won’t work until we actually create and define the Svelte component).
---
import ContactForm from '$components/ContactForm.svelte';
---
<section class="card card-alt">
<h2>Find out more</h2>
<ContactForm client:load />
</section>---
The client:load
directive in line 26 is an Astro hydration parameter. We use load
here to tell Astro always to hydrate the ContactForm
component, making it interactive by letting its JavaScript load. This makes sense here as the form will probably be in view when the page loads, not to mention that it is a main feature of the page. If we had a contact form which was JavaScript heavy and far down the page (so initially out of view) we could instruct Astro to hydrate it only when visible, using client:visible
. This would improve user experience, optimising loading the visible content faster.
Contact Form
Typically you will add client-side and serverless-side form input validation. To keep the code lean and stop the post getting too long, we will not add this here though. Let me know if you would like to see some possible ways to do this; I could write a separate post.
Create src/components/ContactForm.svelte
and add the content below. You see we can add additional scoped styling within Astro Svelte components. Remove this style
block if you are skipping styling.
<script>
let botField = '';
let name = '';
let email = '';
let message = '';
let serverState;
$: submitting = false;
function handleServerResponse(ok, msg) {
serverState = { ok, msg };
}
const handleSubmit = async () => {
console.log({ email, name, message });
try {
submitting = true;
await fetch(`/.netlify/functions/contact-form-message`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
botField,
email,
name,
message,
}),
});
submitting = false;
handleServerResponse(true, '');
} catch (error) {
console.error(`Eror: ${error}`);
submitting = false;
handleServerResponse(
false,
'Unable to send your message at the moment. Please try again later.',
);
}
};
</script>
<form on:submit|preventDefault={handleSubmit} class="form-container">
<input aria-hidden="true" type="hidden" name="bot-field" bind:value={botField} />
<div>
<span class="screen-reader-text"><label for="name">Name</label></span>
<input bind:value={name} required id="name" placeholder="Name" title="Name" type="text" />
</div>
<div>
<span class="screen-reader-text"><label for="email">Email</label></span>
<input
bind:value={email}
required
id="email"
placeholder="blake@example.com"
title="Email"
type="email"
/>
</div>
<div>
<span class="screen-reader-text"><label for="message">Message</label></span>
<textarea
bind:value={message}
required
id="message"
rows={6}
placeholder="Write your message here..."
title="Message"
type="text"
/>
</div>
<div class="button-container">
<button type="submit" disabled={submitting}> Send</button>
</div>
{#if serverState}<p class={!serverState.ok ? 'errorMsg' : ''}>
{serverState.msg}
</p>{/if}
</form>
<style>
.button-container {
display: flex;
width: 100%;
margin: var(--spacing-2);
justify-content: flex-end;
}
</style>
We add very basic bot detection in the form of a honeypot field (line 45
). This is not displayed in, or announced by, the browser but a bot would find it and might be tempted to fill it out. So any time we see a response with this field filled out, we can assume a bot filled it out. For a production app you might consider using a spam detection service like Akismet or Cloudflare bot detection HTTP headers. Captchas might also be suitable in some cases.
The form fields use Svelte input bindings, this is a little different to (and simpler than) what you might be used to if you come from a React background. Let me know if a separate post or video on Svelte form and input bindings would be useful.
Although we later add the Axios package for use in the serverless function, the fetch API helps us out here with actually reaching the serverless function. We send a JSON POST
request to /.netlify/functions/contact-form-message
. For this to work, we need to create the serverless function with a file name contact-form-message.js
. We will do that next!
🌥 Serverless Function
First we will add some Netlify configuration to the project. Create netlify.toml
in the project’s root folder and add this content:
[build]
command = "npm run build"
functions = "netlify/functions"
publish = "dist"
Notice the publish directory is dist
which is where Astro outputs your build site to. Next we can create the functions folder: netlify/functions
then add contact-form-message.js
. If you prefer TypeScript, change the extension and also add the @netlify/functions
package. You can import types (only if you are working in TypeScript) adding import type { Handler } from '@netlify/functions';
to the top of this file.
import axios from 'axios';
const { TELEGRAM_BOT_API_TOKEN, TELEGRAM_BOT_CHAT_ID } = process.env;
async function notifyViaTelegramBot({ honeyBotFlaggedSpam, name, email, message }) {
try {
const data = JSON.stringify(
{
honeyBotFlaggedSpam,
name,
email,
message,
},
null,
2,
);
const text = `Contact form message: ${data}`;
await axios({
url: `https://api.telegram.org/bot${TELEGRAM_BOT_API_TOKEN}/sendMessage`,
method: 'POST',
data: {
chat_id: TELEGRAM_BOT_CHAT_ID,
text,
},
});
return { successful: true };
} catch (error) {
let message;
if (error.response) {
message = `Telegram server responded with non 2xx code: ${error.response.data}`;
} else if (error.request) {
message = `No Telegram response received: ${error.request}`;
} else {
message = `Error setting up telegram response: ${error.message}`;
}
return { successful: false, error: message };
}
}
export async function handler({ body, httpMethod }) {
try {
if (httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed',
};
}
const data = JSON.parse(body);
const { botField, email, name, message } = data;
const { error: telegramError } = await notifyViaTelegramBot({
honeyBotFlaggedSpam: botField !== '',
email,
name,
message,
});
if (telegramError) {
return {
statusCode: 400,
body: telegramError,
};
}
return { statusCode: 200, body: 'Over and out.' };
} catch (error) {
return {
statusCode: 400,
body: `Handler error: ${error}`,
};
}
}
export default handler;
As mentioned earlier, we use Axios here to contact Telegram servers, relaying the contact message to our private chat. We can add it as a project dependency now:
pnpm add axios
Let’s set up a Telegram bot so we can get the environment variables needed to wire this up.
🤖 Telegram Bot
The process for getting Telegram API credentials is quite simple, just follow step by step and you will have API keys in a couple of minutes.
- Bots are created by Telegram's Bot Father — isn't that cute! Open up a new chat with @BotFather.
- You interact with bots in Telegram by typing commands which begin with a
/
in the chat window. Create a new bot using the command/newbot
. Bot Father will ask you for a name and then a username. The name can be anything but the username needs to endbot
and should only contain alphanumeric characters and underscores. I will use “Astro Landing Page Form Site” as the name andastro_landing_page_form_bot
as the username. Bot Father will respond with the new API key for your bot make a note of this. - Next we need to create a new group chat and add the bot to it (you can also add anyone whom you want to receive bot messages). From the Telegram menu select New Group. Enter a name for the group when prompted then in the Add Members window type the username of your bot.
Retreive Chat ID
- We’re almost done. Next we need to get the ID for this new group chat so we can send messages to it from the Netlify Serverless Function. From the group chat, send a message to the bot by typing the following command as a message “
/my_id @my_bot
” replacemy_bot
with the name of your bot. - In the terminal use curl to see the bot’s updates. Remember to replace
123456789:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQq
with the API key you got earlier:
curl -L https://api.telegram.org/bot123456789:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQq/getUpdates
If you don’t have curl on your machine, just paste the link into your browser instead. If you are working on a shared machine, be sure to clear the link from the browser history as it contains an API key.
You will get a response back something like this:
{
"ok": true,
"result": [
{
"update_id": 741497477,
"message": {
"message_id": 2,
"from": {
"id": 1234567890,
"is_bot": false,
"first_name": "Rodney",
"last_name": "Lab",
"username": "askRodney"
},
"chat": {
"id": -123456789,
"title": "Astro Landing Page Form Site",
"type": "group",
"all_members_are_administrators": true
},
"date": 1623667295,
"text": "/my_id @astro_landing_page_form_bot",
"entities": [
{ "offset": 0, "length": 6, "type": "bot_command" },
{ "offset": 7, "length": 29, "type": "mention" }
]
}
}
]
}
Ok this is just some JSON. It contains two ids, although we just need one. The first is the message ID. We don’t need this one. The second, within the chat object, starts with a “-
”, this is the chat ID we need, including the “-
”.
We have all the API data we need to proceed. Let's carry on by setting up or function.
Netlify Environment Variables
Netlify has the easiest way for handling environment. You can add them manually using the web console, though I prefer the CLI. If you want to try my way, add the Telegram credentials to a new .env
file:
TELEGRAM_BOT_API_TOKEN="123456789:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQq"
TELEGRAM_BOT_CHAT_ID="-123456789"
Notice you should not prefix these with PUBLIC_
as you would normally do for Astro environment variables. This is because we do not need to expose the credentials on the front end, just for the serverless function. In fact another advantage of using serverless functions is the added security of not needing to expose credentials to the client. Next you will need to install the Netlify CLI globally on your machine, if you do not already have it installed:
pnpm add -g netlify-cli
Next, commit your project to GitHub, GitLab or whichever service you use and set it up as you normally do. Be sure to add .env
to the .gitignore
file so the credentials do not end up in your remote repo. If this is your first time using Netlify, follow the step-by-step deploy instructions. Once the project is set up, just run the following commands from the project folder:
netlify init
netlify env:import .env
Just follow prompts to the right project and this will take the variables from the .env
file and add them to your project on Netlify's servers. There is not a massive convenience pickup as we only have two variable here, but for larger projects it is definitely worth the effort. Learn more about managing Netlify environment variables from Netlify.
You might need to rebuild the site once you have added the environment variables.
💯 Astro Landing Page Contact Form: Testing
Everything should be working now, so let’s test it. The Netlify console will give you a link to the freshly built site. Follow the link and complete the contact form. Open up your Telegram app and you should have a message in the group chat you created.
🙌🏽 Astro Landing Page Contact Form: Wrapping Up
In this post we have had an introduction to Astro and seen:
- how to set up an Astro Svelte project,
- how to use Netlify serverless functions to provide “back-end” functionality to your Astro app,
- a convenient and efficient way to manage environment variables in your Netlify project.
The full code for the app is available in the Astro demo repo on Rodney Lab GitHub.
I hope you found this article useful and am keen to hear how you plan to use the Astro code in your own projects.
🙏🏽 Astro Landing Page Contact Form: Feedback
Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on Astro as well as SvelteKit. Also subscribe to the newsletter to keep up-to-date with our latest projects.