Astro Landing Page Form: Netlify Serverless Contact Form

Rodney Lab - Apr 5 '22 - - Dev Community

☁️ 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

Astro Landing Page Form: Netlify Serverless Contact Form: Screenshot of app showing contact form on landing page.

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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';
---
Enter fullscreen mode Exit fullscreen mode
      <section class="card card-alt">
        <h2>Find out more</h2>
        <ContactForm client:load />
      </section>---
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Bots are created by Telegram's Bot Father — isn't that cute! Open up a new chat with @BotFather.
  2. 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 end bot and should only contain alphanumeric characters and underscores. I will use “Astro Landing Page Form Site” as the name and astro_landing_page_form_bot as the username. Bot Father will respond with the new API key for your bot make a note of this.
  3. 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

  1. 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” replace my_bot with the name of your bot.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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" }
        ]
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Form: Netlify Serverless Contact Form: Screenshot of Netlify console showing a link for the built site.

🙌🏽 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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .