Implementing Maintenance mode on a SvelteKit site

Implementing Maintenance mode on a SvelteKit site

More to consider than you think.

A maintenance mode that disables access to your site can be quite useful for scheduled downtime. The idea is simple, but when we take UX and different kind of HTTP request scenarios into account, we have quite a few things to tackle. Let's try to list them first:

Users browsing the site

For normal browsing (GET requests), a user-friendly message should be displayed. A thought could be to redirect users to a /maintenance route, but unexpected redirects is not a very nice UX. The route should not change, which means that we should use a common +layout.svelte component to display the message.

API access

If your site has an API defined with +server.ts files (docs here) these won't use a layout file, so they must be handled separately. It's probably unlikely that the API will be used for browsing, so a 503 Service Unavailable could be returned immediately, which is especially important in this case, since we don't want any API requests to go through and potentially modifying the database when we're doing maintenance!

Form data posting

This one is easy to overlook, but possibly the most important: A user posting form data to your site is very valuable, they could be finishing up a purchase, for example. So we need to treat these with the highest respect - if their data is lost in any way because of the maintenance, we've probably lost a customer.

This means that we need to:

  • Prevent redirections
  • Prevent clearing of the form
  • Display a message that they could try again soon.

SvelteKit has the nice feature of Progressive enhancement, which takes user agents with no javascript into account.

Unfortunately, without javascript the browser will reload the page, and unless we do some advanced checking for maintenance mode later in the request, and restoring the form data without any other action taken, there's not much to do here.

If javascript is enabled though, we can take advantage of the progressive enhancement to prevent data loss, and that's what we will focus on, given that the majority of the users should have javascript enabled. But before we look into that, let's see if we can make things simpler.

Simplifying based on request method

A simplification could be made by assuming that GET requests won't modify anything critical in the system. If the site collects advanced statistics this may be a faulty assumption, but noting that, we're left with a simpler use case:

  1. If GET - Display a friendly error message
  2. If not GET - Return a JSON error message

This works because we want to return a 503 status code in both cases, which will cover our three scenarios:

BrowsingAPI requestAJAX form posting
GETFriendly message503 statusN/A
Not GETN/A503 status503 status+JSON

Since HTTP status 503 means temporary unavailable, the API consumers should be able to handle that in both cases, even if they get a html page in the case of a GET request. Browsers will display the friendly message either way.

For form posting, returning HTML will mess up the client-side response, which expects JSON (we are ignoring the non-javascript users, as said) so we want to return something that the browser understands. SvelteKit has an ActionResult type which seems useful here. Let's piggy-back on that and add the use:enhance action, and we should probably be home free.

But haven't we forgotten something? How do we even put the site into maintenance mode?

Setting the site mode

We'd like to set some kind of MODE variable in the system, that will be read very early in the app and handled accordingly. This is not just about maintenance mode, but others as well. For example:

  • development mode will have debug info and tools enabled
  • staging mode will have source maps and design feedback tools enabled
  • production mode will have bug reporting tools and analytics enabled

There could be all sorts of combinations between these that must be configured depending on the mode.

With NodeJS, Vite and SvelteKit we have a couple of modes already available, but there are a few issues with them:

  • The NODE_ENV environment variable - but setting it to anything else than production or development could be problematic
  • Vite's MODE, available through import.meta.env.MODE - could be anything, but must be set at build-time, so we can't change it while the site is running.

It looks like we have no other choice than to use something else. You have probably figured out that environment variables is perfect for this, given their speed of access and the multiple ways they can be updated.

But what should it be called? Naming is always hard. Using MODE directly can create confusion with the Vite variable. So I'm going for APP_MODE, and will specify all valid values in a template .env file:

.env

# development, staging, production, maintenance
APP_MODE="development"

# development, production
NODE_ENV="development"

(NODE_ENV is undefined as default, so we'll set that one too.)

Finally, we're ready to code!

Starting on top with hooks

Catching requests early is best done through src/hooks.server.ts in your SvelteKit app. This is where we want to respond with a 503 and a friendly message in HTML or JSON format, depending on request method. Let's dig in! The code is available on Stackblitz.

src/hooks.server.ts

import { env } from '$env/dynamic/private';
import type { Handle } from '@sveltejs/kit';
import type { ActionResult } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  if (env.APP_MODE !== 'maintenance') return resolve(event);

  // Non-GET requests should not pass through.
  if (event.request.method !== 'GET') {
    const error = { status: 503, statusText: 'Maintenance mode.' };
    const actionResult: ActionResult = { type: 'error', error };
    return new Response(JSON.stringify(actionResult), {
      ...error,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  // For GET requests, +layout.svelte will show a friendly error,
  // so we should render the response and make sure that maintenance 
  // mode is respected there as well.
  const response = await resolve(event);

  // Replace appropriate parts of the response with 503.
  return new Response(response.body, {
    headers: response.headers,
    status: 503,
    statusText: 'Maintenance mode'
  });
};

Displaying the message in +layout.svelte

As said, we need to use +layout.svelte to display the message, but there is a problem: We can't access APP_MODE directly from there since it's a server-side variable only. And we probably don't want to expose that value to the world, so we need a +layout.server.ts file to read and transform that value:

src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';

export const load: LayoutServerLoad = async () => {
  switch (env.APP_MODE) {
    case 'staging':
    case 'production':
    case 'development':
      return { maintenance: false };

    case 'maintenance':
      return { maintenance: true };

    default:
      throw new Error('Invalid app mode!');
  }
};

This helps us ensure that APP_MODE is set to a correct value. The returned data structure can also be used for other cases, for example adding external javascript in certain modes. Using it is very simple:

src/routes/+layout.svelte

<script lang="ts">
  import type { LayoutData } from './$types';
  export let data: LayoutData;
</script>

{#if data.maintenance}
  <h1>Maintenance mode</h1>
  <p>We'll be back in a few minutes.</p>
{:else}
  <slot />
{/if}

With this, the users can simply reload the site when the message appears, instead of being redirected somewhere else and losing the current URL.

So we have covered API usage and browsing. Now let's tackle the more difficult, but very important part - form posting.

Preventing data loss at forms

We all know how annoying this can be, so it must be prevented. Let's test it by setting up a simple form on the default page, with a form action:

src/routes/+page.svelte

<script lang="ts">
  import type { ActionData } from './$types';
  import { enhance, applyAction } from '$app/forms';
  import { page } from '$app/stores';

  export let form: ActionData;
</script>

<form method="POST" use:enhance>
  <p>Name: <input name="name" type="text" value={form?.name ?? ""} /></p>
  {#if form?.errors?.name}
    <p>Name must be at least 4 characters long.</p>
  {/if}

  <p><button>Submit</button></p>
</form>

src/routes/+page.server.ts

import type { Actions } from './$types';
import { invalid } from '@sveltejs/kit';

export const actions: Actions = {
  default: async (event) => {
    // For testing:
    // throw new Error('Simulating error/maintenance mode')

    const data = await event.request.formData();

    const values = {
      name: data.get('name')?.toString() ?? ""
    };

    const errors = {
      name: values.name.length < 4
    };

    if (Object.values(errors).some((e) => e)) {
      return invalid(400, { ...values, errors });
    }

    return { status: 'success' };
  }
};

This should work as expected, but if we uncomment the throw new Error line, or set APP_MODE=maintenance (which is difficult without making a production build, hence the throw shortcut), we're directly taken to the error page, losing all form data.

This means that we have to use the callback for use:enhance to avoid redirection. Change the form element to this:

src/routes/+page.svelte

<form method="POST" use:enhance={() => {
  return async ({form, result}) => {
    if(result.type !== "error") {
      if(result.type === 'success') form.reset()
      applyAction(result)
    }
    else applyAction({
      type: 'invalid',
      status: Math.floor(result.error.status) || 500
    })
  }
}}>

This will update the form as usual unless an error occurs, in which case it will treat the result as an invalid state, which will not render the error page. The Math.floor trickery is to ensure an integer for the status value (thanks Sanchithasr).

Try it out, and you'll see that we're not redirected anymore, but there is nothing notifying the user that the site is down. Fortunately we have $page.status available that can be used for this. We could even use the $page.form to display a success message while we're at it. Add this above the form:

src/routes/+page.svelte

{#if $page.status >= 500}
  <p><b>Server error, please wait a bit and try again.</b></p>
{:else if $page.form && $page.status == 200}
  <p>Form posted successfully!</p>
{/if}

Final notes

It was quite a bit of work to have a maintenance mode that will cause the least amount of annoyance for our users. The use:enhance part could be factored out into its own function so it can be used on all forms, but I think it its default behavior should be not to "redirect" to an error page, since staying on the same page without reload is one of the points of using use:enhance! I've posted an issue about it on Github, so we'll see what the kind SvelteKit team says.

About API requests: In case you really want to return JSON with API requests instead of the layout html, hopefully your API uses a route like /api/..., which mean that you can test for that in hooks.server.ts:

if (event.request.method !== 'GET' || event.routeId.startsWith('/api/')

A final thing that we haven't tackled is to make the form status message disappear when re-submitting the data. Again, it's nice UX to make the message disappear/change while waiting for the response, but I haven't found a way to handle this without a separate variable on the page, which would be nice to avoid. Let me know if you have a way of solving this.

We're at the end, so thanks for reading, I hope you can find this useful. Thoughts, comments, suggestions, please let me know!