duhan
Blog

Authentication in Svelte

I wanted to write about authentication in Svelte. It has smaller community compared to React, so there are less resources you can find.

I assume you already have a Svelte project. You only need to install one package, which is svelte-spa-router. This package is so handy when it comes to code splitting and guarding your routes.

Let’s start with folder structure. Just create the files and stop panicking. We will write this files along the way. Make sure your structure similar to this:

src/
├─ api/
│ ├─ auth.ts
├─ components/
│ ├─ Header.svelte
├─ pages/
│ ├─ Home.svelte
│ ├─ Login.svelte
├─ routes/
│ ├─ router.svelte
├─ store/
│ ├─ auth.ts
├─ utils/
│ ├─ secureFetch.ts

Step 1: Setup Routing with svelte-spa-router

  1. Run npm install -D svelte-spa-router to add a hash router to your Svelte project.
  2. In src/routes/router.svelte, define your routes. Following snippet enables lazy loading and route guards.
<script>
  const routes = {
    "/": wrap({
      asyncComponent: () => import("../pages/Home.svelte"),
      conditions: [() => $authStore.user.isAuthenticated],
    }),
    "/login": wrap({
      asyncComponent: () => import("../pages/Login.svelte"),
    }),
  };
</script>
  1. In src/pages/Home.svelte, a simple greeting message would be lovely.
<script lang="ts"></script>
<main>
    <p>You shouldn't be here</p>
</main>

And for src/pages/Login.svelte, a simple login form is necessary. If user is authenticated, we are gonna redirect the user to home page with onMount. Otherwise, when form submits, it makes a call to authStore.login.

<script lang="ts">
  import { onMount } from "svelte";
  import { authStore } from "../store/auth";
  import { replace } from "svelte-spa-router";

  onMount(() => {
    if ($authStore.user.isAuthenticated) replace("/");
  });

  const handleSubmit = async (e: SubmitEvent) => {
    const data = new FormData(e.target as HTMLFormElement);
    const email = data.get("email") as string;
    const password = data.get("password") as string;
    await authStore.login(email, password);
  };
</script>

<main>
  <form on:submit|preventDefault="{handleSubmit}">
    <input type="text" placeholder="email" name="email" />
    <br />
    <input type="password" placeholder="password" name="password" />
    <br />
    <button type="submit">login</button>
  </form>
</main>

Step 2: Create the Authentication Store

  1. In src/store/auth.ts, use writable from Svelte to create an auth store. This store will manage user authentication states, like isAuthenticated, user, and token.
  2. Start with an initial state function that checks localStorage for existing authentication details, setting the initial user state accordingly.
type StoreUser = {
  id: number | null;
  email: string;
  isAuthenticated: boolean;
  token: string;
};

export type AuthState = {
  user: StoreUser;
  loading: boolean;
  error: string | null;
};

const initialState = (): AuthState => {
  const user = localStorage.getItem("user");
  const baseObj = {
    loading: false,
    error: null,
  };

  if (!user) {
    return {
      ...baseObj,
      user: {
        id: null,
        email: "",
        isAuthenticated: false,
        token: "",
      },
    };
  }

  const userObj: StoreUser = JSON.parse(user);
  const token = JSON.parse(user).token;

  return {
    ...baseObj,
    user: {
      id: userObj.id,
      email: userObj.email,
      isAuthenticated: !!token,
      token: token || "",
    },
  };
};
  1. Implement login and logout functions within the store to update the user’s authentication state. Following snippet contains loginApi, I will talk about it in a bit.
const createAuthStore = () => {
  const { subscribe, set, update } = writable<AuthState>(initialState());

  // if user logs out in one tab and another tab is open, this will keep them in sync
  const syncLogin = (e: StorageEvent) => {
    if (e.key === "user") {
      if (e.newValue) {
        const user = JSON.parse(e.newValue);
        set({
          ...initialState(),
          user: {
            id: user.id,
            email: user.email,
            isAuthenticated: true,
            token: user.token,
          },
        });
      } else {
        set({ ...initialState() });
        push("/login");
      }
    }
  };

  window.addEventListener("storage", syncLogin);

  return {
    subscribe,
    login: async (email: string, password: string) => {
      if (!email || !password) {
        update((state) => ({
          ...state,
          error: "Please provide an email and password",
        }));
        return;
      }
      update((state) => ({ ...state, loading: true }));
      try {
        const res = await loginApi(email, password);
        update((state) => ({
          ...state,
          user: { ...res.user, isAuthenticated: true },
          loading: false,
          error: null,
        }));
        localStorage.setItem("user", JSON.stringify(res.user));
        push("/profile");
      } catch (error) {
        const err = error as Error;
        update((state) => ({
          ...state,
          error: err.message,
          loading: false,
        }));
      }
    },
    logout: () => {
      localStorage.removeItem("user");
      set({ ...initialState() });
      push("/login");
    },
    destroy: () => {
      window.removeEventListener("storage", syncLogin);
    },
  };
};

So far so good, now let’s talk about the API part.

Step 3: Create the API Layer

  1. In src/utils/secureFetch.ts, create a function that wraps the native fetch API. This function will automatically add the authentication token to API requests, ensuring they are authenticated, or you can just use axios intercepters if you want.
import { get } from "svelte/store";
import { authStore, type AuthState } from "../store/auth";

interface FetchOptions extends RequestInit {
  headers?: HeadersInit;
}

export async function secureFetch(
  url: string,
  options: FetchOptions = {}
): Promise<Response> {
  const authState: AuthState = get(authStore);
  const headers = new Headers(options.headers || {});

  if (authState.user.token) {
    headers.append("Authorization", `Bearer ${authState.user.token}`);
  }

  const mergedOptions: FetchOptions = { ...options, headers };
  const res = await fetch(url, mergedOptions);

  if (!res.ok && (res.status === 401 || res.status === 403)) {
    authStore.logout();
    throw new Error("Unauthorized: Invalid or expired token");
  }

  return res;
}
  1. In src/auth.ts, import the secureFetch function that we wrote earlier. And implement login/logout functions. Such as:
import { secureFetch } from "../utils/secureFetch";

type User = {
  id: number;
  email: string;
  token: string;
};

interface AuthApiResponse {
  message: string;
}

interface LoginResponse extends AuthApiResponse {
  isAuthenticated?: boolean;
  user: User;
}

export const loginApi = async (
  email: string,
  password: string
): Promise<LoginResponse> => {
  const res = await fetch("loginURL", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email, password }),
  });
  const data = (await res.json()) as LoginResponse;
  if (!res.ok) {
    throw new Error(data.message);
  }
  return { message: data.message, user: data.user };
};

export const logoutApi = async (): Promise<AuthApiResponse> => {
  const res = await secureFetch("logoutURL", {
    method: "POST",
  });
  const data = (await res.json()) as AuthApiResponse;
  if (!res.ok) {
    throw new Error(data.message);
  }
  return { message: data.message };
};

Now we are able to make requests to server. At last, in src/components/Header.svelte:

<script lang="ts">
  import { authStore } from "../store/auth";
  import { logoutApi } from "../api/auth";
  import { link } from "svelte-spa-router";

  let error: string | null = null;

  const handleLogout = async () => {
    try {
      await logoutApi();
      authStore.logout();
    } catch (err) {
      const _err = err as Error;
      error = _err.message;
    }
  };

  const routeConfig = [
    {
      name: "Home",
      path: "/",
      isProtected: true,
    },
    {
      name: "Login",
      path: "/login",
      isProtected: false,
    },
  ];
</script>

<nav>
  {#each routeConfig as route}
    {#if $authStore.user.isAuthenticated}
        {#if route.isProtected}
          <a use:link href="{route.path}">{route.name}</a>
        {/if}
        {:else if !route.isProtected}
          <a use:link href="{route.path}">{route.name}</a>
    {/if}
  {/each}
  {#if $authStore.user.isAuthenticated}
    <button on:click="{handleLogout}">Logout</button>
  {/if}
</nav>

I hope this blog post helps you to understand how to implement authentication in Svelte. If you have any questions, feel free to ask. I will be happy to help you. I have tried to make it without you to install any other dependency, remember how much dependency you add, you have to deal with those made-up dependency abstractions. Please write your own code. It’s fun.

You can find the whole code at github