Blog

Building a newsletter subscription form with SvelteKit form actions

SvelteKit is a first-party framework to build websites and applications with Svelte. In this post, we’ll learn how to create a custom newsletter component for your SvelteKit project, how to use environment variables in SvelteKit, and how to use SvelteKit’s form actions to progressively enhance our newsletter component.

Creating a newsletter component

If you want to follow along from scratch, create a new SvelteKit project before getting started.

npm create svelte@latest mailcoach-sveltekit
cd mailcoach-sveltekit
npm install
npm run dev

We’re going to create a generic <Newsletter /> that component we can embed anywhere in our app: as a CTA, in the footer… We’ll create our component in src/lib/components/Newsletter.svelte.

<script>
  let subscribed = false;
  let error = '';
</script>

<form>
  <label for="email">
    Email
    <input type="email" name="email" id="email" required>
  </label>
  <button type="Submit">
    Submit
  </button>
  {#if subscribed}
    <p>Thanks for subscribing!</p>
  {/if}
  {#if message}
    <p>Whoops! {message}</p>
  {/if}
</form>

Our component contains a text input so visitors can provide their email address, and a submit button. We’ll also declare variables to the result of the subscription later. We’ll store a boolean in subscribed to display a success message, and store the error message returned from the server in error.

Handle submissions with form actions

Form actions live in +page.server.js files, which run on the server. We’ll create a new routes/subscribe/+page.server.js file, so our subscribe action is available through a /subscribeendpoint. First we’ll declare an empty action, ready to process a request.

export const actions = {
  default: ({ request }) => {
    // …
  },
};

The <form> in our Newsletter component has an email field. In our action, we can access the FormData of the form that triggers a submission. Because formData needs to be awaited, we’ll make mark the action as async.

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    
    const email = formData.get('email');
  },
};

Next, we’ll use the fetch API to post the new subscriber via Mailcoach’s API, parse the response, and return a result object.

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    
    const response = await fetch('???', {
      method: 'POST',
      body: JSON.stringify({
        email: formData.get('email'),
      }),
      headers: {
        Authorization: 'Bearer: ???',
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    });
    
    const data = await response.json();

    return {
      subscribed: response.status === 200,
      error: response.status === 200 ? '' : data?.message,
    };
  },
};

When the Mailcoach API returns any other response code than 200, the user was not subscribed. In that case, we’ll also pass the API’s response message to the frontend.

There are still a few blanks we need to fill in: the URL we want to post to and our API token. Instead of hard coding them into the API request, we’ll set up a few environment variables in a new .env file in our project’s root directory.

MAILCOACH_API_TOKEN=your-token
MAILCOACH_API_ENDPOINT=https://[[your-domain]].mailcoach.app/api
MAILCOACH_EMAIL_LIST_UUID=your-email-list-uuid

Under the hood, Vite (which powers SvelteKit) will read the .env file and add it to the global process.env variable. SvelteKit exposes these environment variables through $env/static/private.

We don’t need to worry about exposing our credentials to the frontend. Anything in +page.server.jswill only be available to—and executed on—the server.

import {
  MAILCOACH_API_TOKEN,
  MAILCOACH_API_ENDPOINT,
  MAILCOACH_EMAIL_LIST_UUID,
} from '$env/static/private';

export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    
    const response = await fetch(`${MAILCOACH_API_ENDPOINT}/email-lists/${MAILCOACH_EMAIL_LIST_UUID}/subscribers`, {
      method: 'POST',
      body: JSON.stringify({
        email: formData.get('email'),
      }),
      headers: {
        Authorization: `Bearer ${MAILCOACH_API_TOKEN}`,
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    });
    
    const data = await response.json();

    return {
      subscribed: response.status === 200,
      error: response.status === 200 ? '' : data?.message,
    };
  },
};

Tying a form action to a form

Now that our form action is in place, we’ll bind it to the form of our newsletter component. First, we’ll point the form action and method to our SvelteKit server action.

<script>
  let subscribed = null;
  let error = '';
</script>

<form method="POST" action="/subscribe">
  <!-- … -->
</form>

Next, we’ll use SvelteKit’s enhance action to progressively enhance the form to submit with AJAX. With enhance, you can specify a callback tp execute code before the request gets sent, and after the response has been received.

<script>
  import { enhance } from '$app/forms';
  
  let subscribed = null;
  let message = '';
</script>

<form method="POST" action="/subscribe" use:enhance={() => {
  // Transform the request before sending…
  
  return async ({ result }) => {
    // Handle the response…
  };
}}>
  <!-- … -->
</form>

For our newsletter subscription, we don’t need to touch the request as the email field will already be included in the form’s data. We do however want to read the response and update our error and success state accordingly.

<script>
  import { enhance } from '$app/forms';
  
  let subscribed = null;
  let error = '';
</script>

<form method="POST" action="/subscribe" use:enhance={() => {
  return async ({ result }) => {
    subscribed = result.subscribed;
    error = result.error;
  };
}}>
  <!-- … -->
</form>

Progressive enhancement

As a cherry on the pie, we’re going to make sure our form behaves well when JavaScript is not available. Form actions are progressively enhanced by default. They run on the server, then display the page component defined for their URL (in our case, src/routes/subscribe/+page.svelte).

Since we don’t have a page component for the /subscribe URL yet, SvelteKit would throw a server error after processing the data. We’ll create a src/routes/subscribe/+page.svelte page.

When SvelteKit redirects a form action to a page, it will populate a form property and pass it to the page. First we’ll check if the form prop is populated. If it is, we know the user attempted to subscribe and we’ll display the result. If form is null, it’s a direct visit to /subscribe, and we’ll display our newsletter form instead.

<script>
  import Newsletter from "$lib/components/Newsletter.svelte";

  export let form;
</script>

{#if form}
  {#if form.subscribed}
    <p>Thanks for subscribing!</p>
  {:else}
    <p>Whoops! {form.message}</p>
  {/if}
{:else}
  <Newsletter />
{/if}

To summarize:

  • When JavaScript is enabled, our callback in use:enhance will hijack the form submission, send it with AJAX, and process the response in place.

  • When JavaScript is not available, the browser will make a traditional form request to /subscribe, SvelteKit will process the request on the server, and redirect to our subscribe page.

  • If a user would somehow land on /subscribe without submitting a form, the page will fall back to a generic subscription page.

With SvelteKit, we get the best of all worlds!

Ready to get started?