Users and Security

Form Actions


Learning Objectives

  • You know of form actions and can use them to handle form submissions in SvelteKit.
  • You know of Docker Networking and why a service within a Docker Network cannot access another service using “localhost”.
  • You know how to redirect the user to another page after a successful form submission.

In the previous chapter, we created the functionality for registering and authenticating users. The functionality was implemented with a form that used default method for sending the data, i.e. “GET”. The problem with this approach is that the data is sent in the URL, which is not secure.

In this chapter, we will implement the functionality with a form that uses the “POST” method for sending data.

When we create a form with the method “POST”, SvelteKit automatically captures the form submission, looking to handle the form on the server responsible for the client-side application. This is done by functionality called form actions.

Defining form actions

To define what happens on the server responsible for the client-side application when a form is submitted, we need to export a constant called actions from a file called +page.server.js, which is in the same folder as the page that contains the form.

The actions constant is an object where each key represents a form action’s name, and each value is the corresponding function that handles the form submission.

In our case, we have two form actions — one for logging in and one for registering a new user.

Create a file called +page.server.js to the folder src/routes/auth/[action] and place the following content to the file.

export const actions = {
  login: async ({ request }) => {
    const data = await request.formData();
    console.log("Received a login request with the following data.");
    console.log(data);

    return { message: "Thanks!" };
  },
  register: async ({ request }) => {
    const data = await request.formData();
    console.log("Received a register request with the following data.");
    console.log(data);

    return { message: "Thanks!" };
  },
};

Now, we have two form actions — one for logging in and one for registering a new user. The form actions are asynchronous functions that receive an object as a parameter. The object contains the request object, which is an instance of the Request class. The request object has an asynchronous method formData that returns a FormData object with the data that was submitted with the form.

Loading Exercise...

Form actions and form

To use the form actions in the +page.svelte, we remove the onsubmit attribute and add the action attribute to the form. The value of the action attribute is the name of the form action that should be called when the form is submitted, prefixed with ?/.

In addition, when using form objects, the +page.svelte receives a form object as a property that contains data returned from the form action.

The following example shows a modified version of our earlier form that now uses form actions. The key changes include receiving the form object as a property, removing the onsubmit attribute and the associated function for handling form submission, adding the action attribute, and showing the message from the form object if it exists.

<script>
  let { data, form } = $props();
</script>

<h2 class="text-xl pb-4">
  {data.action === "login" ? "Login" : "Register"} form
</h2>

{#if form?.message}
  <p class="text-xl">{form.message}</p>
{/if}

<form class="space-y-4" method="POST" action="?/{data.action}">
  <label class="label" for="email">
    <span class="label-text">Email</span>
    <input
      class="input"
      id="email"
      name="email"
      type="email"
      placeholder="Email"
    />
  </label>
  <label class="label" for="password">
    <span class="label-text">Password</span>
    <input class="input" id="password" name="password" type="password" />
  </label>
  <button class="w-full btn preset-filled-primary-500" type="submit">
    {data.action === "login" ? "Login" : "Register"}
  </button>
</form>

Now, when the form is submitted, the form action is called, and the message from the form action is shown on the page. In addition, we can see the data that was submitted with the form in the console.

Loading Exercise...

Form actions and API

Previously, the functionality for sending the form data to the authentication API was implemented in the Svelte component, and executed on the client. When using form actions, we move the functionality for sending the request to the authentication API to the server responsible for the client-side application.

That is, now, when a user submits a form, the form is first sent to the client-side server, where the form actions handle the request. From there, the data is sent to the authentication API. This is shown in Figure 1 below.

Fig 1. — Flow of submitting a form when using form actions and an API.
Form actions and API keys

One of the benefits of form actions is that they are run on the server responsible for the client-side application. This means that the form actions can access environment variables that are set in the server responsible for the client-side application. Thus, sensitive data, such as API keys, can be stored on the server, and they do not need to be exposed to the client.

The functionality for sending the form data to the authentication API is added to the form actions. The following outlines the basic functionality for sending the email and password to the API, and returning the response, which would then be available in the form property of the component that submitted the form.

import { PUBLIC_API_URL } from "$env/static/public";

const apiRequest = async (url, data) => {
  return await fetch(`${PUBLIC_API_URL}${url}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });
};

export const actions = {
  login: async ({ request }) => {
    const data = await request.formData();
    const response = await apiRequest(
      "/api/auth/login",
      Object.fromEntries(data),
    );
    return await response.json();
  },
  register: async ({ request }) => {
    const data = await request.formData();
    const response = await apiRequest(
      "/api/auth/register",
      Object.fromEntries(data),
    );
    return await response.json();
  },
};

However, when we try the example out in the walking skeleton, it fails. What is going on?

Docker and Networking

When we use the walking skeleton, the server responsible for the client-side application and the server responsible for the authentication API are run in their own Docker containers. When running the walking skeleton with Docker Compose, Docker Compose creates a network that connects the containers. Each container has its own address within the network, and the containers can communicate with each other using these addresses.

The address localhost, however, has a special meaning — it means that computer or the service itself. That is, when the server responsible for the client-side application uses localhost, it refers to itself. At the same time, we can access services in the Docker Network using localhost from the local machine, as the compose.yaml file binds ports of the services to our local machine (using the ports configuration).

On a high-level, the networking is visualized in Figure 2.

Fig 2. — High-level visualization of services running within the Docker Network, outlining why the server responsible for the client-side application cannot access the server responsible for the authentication API using the address “localhost”.

When services are configured in compose.yaml, the names of the services are used as the addresses for the services. That is, the server responsible for the client-side application can access the server responsible for the authentication API using the address server (the name of the service in the compose.yaml file), and the port 8000 (the port the server is running on). This is the address that should be used in the apiRequest function in the +page.server.js file.

Let’s add the internal API URL to the environment variables and modify the apiRequest function in the +page.server.js file to use the internal API URL instead of the public API URL.

First, modify .env.development of the client-side application to include the following line:

PUBLIC_INTERNAL_API_URL=http://server:8000

Then, modify the apiRequest function in the +page.server.js file to use the PUBLIC_INTERNAL_API_URL instead of PUBLIC_API_URL.

import { PUBLIC_INTERNAL_API_URL } from "$env/static/public";

const apiRequest = async (url, data) => {
  return await fetch(`${PUBLIC_INTERNAL_API_URL}${url}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });
};

Now, the server responsible for the client-side application can access the server responsible for the authentication API using the address server and the port 8000.

After restarting the walking skeleton, the form actions should now work as expected.

Loading Exercise...

Redirecting on success

When the user has successfully registered, it would be nice to redirect the user to the login page. This can be done by throwing a redirect in the form action. The redirect function is imported from @sveltejs/kit.

Modify +page.server.js to redirect to the login page when the user has successfully registered. Below, we assume that the status code falls within the “ok” range if registration is successful.

import { redirect } from "@sveltejs/kit";

// ...
  register: async ({ request }) => {
    const data = await request.formData();
    const response = await apiRequest(
      "/api/auth/register",
      Object.fromEntries(data),
    );

    if (response.ok) {
      throw redirect(302, "/auth/login?registered=true");
    }

    return await response.json();
  },
// ...

Above, in the redirect, we also add a request parameter “registered” to the redirect. We can use the parameter on the login page to show a message that the user has successfully registered, and that the user should now log in to the system.

To access the query parameter in the page component, we modify the +page.js file to check for search parameters from the url — the url object is available in the object passed to the function. If the search parameter “registered” is present, we add a property registered to the params object.

import { error } from "@sveltejs/kit";

export const load = ({ params, url }) => {
  if (params.action !== "login" && params.action !== "register") {
    throw error(404, "Page not found.");
  }

  if (url.searchParams.has("registered")) {
    params.registered = true;
  }

  return params;
};

Now, we can modify the +page.svelte file to show a message when the user has successfully registered.

<script>
  let { data, form } = $props();
</script>

<h2 class="text-xl pb-4">
  {data.action === "login" ? "Login" : "Register"} form
</h2>

{#if form?.message}
  <p class="text-xl">{form.message}</p>
{/if}

{#if data.registered}
  <p class="text-xl">
    You have successfully registered. Please login to continue.
  </p>
{/if}

<form class="space-y-4" method="POST" action="?/{data.action}">
  <label class="label" for="email">
    <span class="label-text">Email</span>
    <input
      class="input"
      id="email"
      name="email"
      type="email"
      placeholder="Email"
    />
  </label>
  <label class="label" for="password">
    <span class="label-text">Password</span>
    <input class="input" id="password" name="password" type="password" />
  </label>
  <button class="w-full btn preset-filled-primary-500" type="submit">
    {data.action === "login" ? "Login" : "Register"}
  </button>
</form>

Now, when we try out the registration functionality of the application, the user is redirected to the login page after successfully registering, and a message is shown that indicates that the user has successfully registered.

Similarly, we can modify the application to redirect the user to another page after successfully logging in.

// ...
  login: async ({ request }) => {
    const data = await request.formData();
    const response = await apiRequest(
      "/api/auth/login",
      Object.fromEntries(data),
    );

    if (response.ok) {
      throw redirect(302, "/");
    }
    return await response.json();
  },
// ...

At this point, the user can register and log in, but the user is not yet tracked. In the next chapter, we will implement the functionality for tracking the user.

Loading Exercise...