Sveltekit Protected Routes in SPA mode

Jul 26, 2024 Authentication Written by Vivek Shukla

I have been working on one of my project where I already had a separate backend server (not Sveltekit), so I thought it’s only logical that I should just use SPA mode on my frontend (Sveltekit) to avoid having another server.

Most common approach I came across when implementing protected routes is to use hooks with cookies or session on the server.

But I want to avoid server because of the following reasons:

  • It will most probably bottleneck my blazing fast backend server
  • Serverless platforms like Vercel, Netlify charges for function invocation, charges can skyrocket really fast
  • I don’t want to self-host my own Node server either

So after some tinkering and research I came to this solution, please only follow through if below conditions apply to you

  • You already have another backend
  • You are just integrating REST APIs into your Sveltekit frontend
  • You are fine with storing your auth credential in localStorage

Let’s first understand the flow, starting with Login:

  1. User authenticates himself
  2. You get a auth token in response
  3. Store the token in localStorage
  4. Take user to protected route and call whatever api you need to
  5. If any of the API you are calling responds with 401 then log user out
  6. If there is any change in token value in localStorage then check if token is valid

Basically we will have two kinds of route: public and private. Any route inside private will only be accessible for logged in user.

You can find the repository with all the code here.

Routes directory structure:

routes/
├── (private)
│   ├── +layout.svelte
│   └── +page.svelte
└── (public)
│   ├── +layout.svelte
    └── login
        └── +page.svelte

Auth

We are gonna create a new file lib/auth.ts to store our functions related to auth.

lib/auth.ts

import { writable } from 'svelte/store';
import { browser } from '$app/environment';

export const userToken = writable<string | null>(null);

export const updateUserToken = () => {
  browser && userToken.update((_) => localStorage.getItem('token') || null);
};

export const clearUserToken = () => {
  userToken.set(null);
  if (browser) {
    localStorage.removeItem('token');
    window.location.href = '/login';
  }
};

We are storing token in the localStorage but we also need to know whenever it updates, for this we are going to use Sveltekit stores.

Stores will enable us to subscribe to the changes in the token value, which we will use to re-authenticate the token.

export const userToken = writable<string | null>(null);

We would also need to update the value of the userToken whenever we navigate through our protected routes. And since this will be client only therefore we are checking if runtime environment is browser:

export const updateUserToken = () => {
  browser && userToken.update((_) => localStorage.getItem('token') || null);
};

Now for the logout function, this will be used whenever we get 401 in response or when user wants to logout.

export const clearUserToken = () => {
  userToken.set(null);
  if (browser) {
    localStorage.removeItem('token');
    window.location.href = '/login';
  }
};

Login Page

In login/+page.svelte

  • call your authentication api which will give you some kind of auth token
  • store it on the localStorage
  • redirect user to protected route, in this case it’s /
<script lang="ts">
  import { goto } from '$app/navigation';

  let email = '';
  let password = '';
  let message = '';

  function formSubmit(event: any) {
    event.preventDefault();
    fetch('/api/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, password }),
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        message = 'Invalid credential';
        throw new Error('Network response was not ok.');
      })
      .then((data) => {
        localStorage.setItem('token', data.token);
        goto('/');
      });
  }
</script>

<div>
  <form method="post" on:submit={formSubmit}>
    <fieldset>
      <label>
        Email
        <input
          type="email"
          bind:value={email}
          name="email"
          placeholder="Email"
          autocomplete="email"
        />
        <p style="color: red">{message}</p>
      </label>

      <label>
        Password
        <input type="password" bind:value={password} name="password" placeholder="password" />
        <p style="color: red">{message}</p>
      </label>
    </fieldset>

    <input type="submit" value="Login" />
  </form>
</div>

Protected Route

Now, for protected routes we want that only authenticated user can access those. That means just having token in localStorage is not enough, we must verify that by calling user detail api.

But just verifying once is not enough, what if user has logged out from other browser tab or manually deleted the localStorage contents.

For the above scenarios we can subscribe to page store, so that any time page changes we can call the updateUserToken function.

(private)/+layout.svelte

<script lang="ts">
  import { page } from '$app/stores';
  import { clearUserToken, updateUserToken, userToken } from '$lib/auth';
  import { onDestroy, onMount } from 'svelte';

  const unsubPage = page.subscribe((_) => {
    updateUserToken();
  });

  onMount(() => {
    return userToken.subscribe((token) => {
      if (token === null) {
        clearUserToken();
      } else {
        fetch('/api/user', {
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${token}`,
          },
        })
          .then((response) => {
            if (!response.ok) {
              clearUserToken();
            }
            return response.json();
          })
          .then((data) => {
            console.log(data);
          })
          .catch((error) => {
            console.error(error);
          });
      }
    });
  });

  onDestroy(() => {
    unsubPage();
  });
</script>

{#if $userToken}
  <slot />
{/if}

How to handle 401 in child pages

The +layout.svelte takes care of token validation when we first enter the protected route and also whenever there is any change in token value.

However we also need to handle the scenarios when the API request inside of any of those routes gives back HTTP 401 in response.

HTTP 401 is used as status code for Unauthorized access.

So, in your in your load function in +page.ts file you can do something like this to handle 401:

import { clearUserToken } from '$lib/auth';
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
  // dummy api which always returns 401 for testing purpose
  const response = await fetch('/api/401');
  if (response.status === 401) {
    clearUserToken();
  }
  return {};
};

Well not just in load function, even in your +page.svelte file if you are calling fetch on external api, you can handle 401 the same way.

Fixing UX

Our protected routes must work now but there is a bit of UX problem.

After logging in if we go to /login, we could still access the Login page, instead we should redirect user to home page.

We can add below +layoute.svelte inside of (public), so it will apply to whatever page we add there.

<script lang="ts">
  import { page } from '$app/stores';
  import { onDestroy, onMount } from 'svelte';
  import { updateUserToken, userToken } from '$lib/auth';

  const unsubPage = page.subscribe((_) => {
    updateUserToken();
  });

  onMount(() => {
    return userToken.subscribe((value) => {
      if (value !== null) {
        window.location.href = '/';
      }
    });
  });

  onDestroy(() => {
    unsubPage();
  });
</script>

<slot />

Since we are adding this to (public)/+layout.svelte, it will be applied to all the routes inside of it. It’s fine if it’s just authentication related pages like login, sign up or forgot password. But would not be okay if it’s other kinds of public pages like docs, help page, etc. So better use different group layout for those pages where you do not want redirects.

GitHub Repository