Sveltekit Protected Routes in SPA mode

Jul 26, 2024 Authentication Written by Vivek Shukla

[Nov 28, 2024] Update: Code and approach has been updated to suit Svelte 5.


In a static SvelteKit app, client-side “protected routes” are a UX pattern. They are not a security boundary.

That is worth saying before anything else, because the approach below is still useful if you are building a SvelteKit frontend in SPA mode against a separate backend. You just need the right mental model. Your frontend can redirect unauthenticated users away from certain pages. It cannot hide static routes or bundled code from anyone who cares to look. The real protection happens on your backend API.

What SPA mode means here

When people say “SPA mode” in SvelteKit, they usually mean a frontend compiled into static HTML, CSS, and JavaScript, then served without SvelteKit running any server-side auth checks.

In that setup every built asset is delivered publicly. Route groups like (private) help you organize code, but they do not make files private. Anything sensitive has to come from a backend that authenticates and authorizes the request. That includes file uploads: a static SPA can show a file picker, but the bytes have to land on a server that checks who is sending them. Serving those files is the same rule on the way back out.

So what follows is about auth-aware navigation in the client, with your backend doing the actual security work.

When this approach makes sense

Use this pattern only if all of these are true:

  • you already have a separate backend server
  • protected data is fetched from that backend after authentication
  • you are not relying on the frontend bundle itself to hide sensitive content
  • your backend validates auth on every protected API request

If you need server-enforced route protection for rendered content, use SvelteKit’s server features instead of pure SPA mode.

The most secure common setup is cookie-based authentication managed by your backend:

  • HttpOnly so JavaScript cannot read the cookie
  • Secure so it is sent only over HTTPS
  • SameSite=Lax or stricter, depending on your flow
  • CSRF protection for state-changing requests

The example below uses localStorage instead, because it is easier to demonstrate in a frontend-only article. Read it as a way to show the route-handling flow, not as the best security choice. If you can use backend-set cookies, use them. SvelteKit auth with HttpOnly cookies walks through that version step by step.

The flow

  1. User logs in through your backend API.
  2. Backend returns an auth token or sets an auth cookie.
  3. Frontend stores the auth state, or simply relies on the cookie.
  4. Frontend redirects the user into the authenticated area.
  5. Backend validates every protected API call.
  6. If the backend responds with 401, log the user out or send them to login.

Step 5 is the one that matters. The route redirect is convenience, the API check is the security control.

Example route structure

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

This keeps the authenticated and unauthenticated parts of the app apart. On a static deployment it does not mean everything under (private) is hidden from the public.

Disable SSR

For a client-rendered setup, disable SSR in the relevant layout files:

export const ssr = false;

That goes in (public)/+layout.ts and (private)/+layout.ts.

Auth helper

Create lib/auth.svelte.ts:

function authToken() {
	return {
		get token(): string | null {
			return localStorage.getItem('token') || null;
		},
		set token(value: string) {
			localStorage.setItem('token', value);
		},
		clear() {
			localStorage.removeItem('token');
			window.location.href = '/login';
		}
	};
}

export const userAuth = authToken();

It is a small wrapper around localStorage, nothing more. If your backend uses secure cookies you would not store the token this way, but the route-handling idea stays the same.

(public)/+layout.ts

If the user already appears logged in, check that state with the backend and redirect them away from auth pages like login or signup.

Group only auth-related pages here. Docs, blog, and other general public pages do not belong in this group, or authenticated users will get redirected away from them.

import { userAuth } from '$lib/auth.svelte.js';
import type { LayoutLoad } from './$types';

export const ssr = false;

export const load: LayoutLoad = async ({ fetch }) => {
	if (userAuth.token) {
		fetch('/api/user', {
			method: 'GET',
			headers: {
				'Content-Type': 'application/json',
				Authorization: `Bearer ${userAuth.token}`
			}
		})
			.then((response) => {
				if (!response.ok) {
					userAuth.clear();
				}
				return response.json();
			})
			.then((data) => {
				console.log(data);
			})
			.catch((error) => {
				console.error(error);
			});
	}
	return {};
};

That is a UX improvement, nothing more.

Login page

In login/+page.svelte, call your backend login API, store the returned token if you are using token storage, then send the user into the authenticated area.

<script lang="ts">
	import { goto } from '$app/navigation';
	import { userAuth } from '$lib/auth.svelte.js';

	let email = $state('');
	let password = $state('');
	let message = $state('');

	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) => {
				userAuth.token = data.token;
				goto('/');
			});
	}
</script>

<div>
	<form method="post" onsubmit={formSubmit}>
		<fieldset>
			<label>
				Email
				<input
					type="email"
					bind:value={email}
					name="email"
					placeholder="admin@example.com"
					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>

With cookies, your backend usually sets the cookie in the login response and the frontend never touches the token itself.

Protected route layout

For authenticated sections, check whether the user looks logged in. If they do, confirm it with the backend by calling a protected endpoint such as /api/user.

import { goto } from '$app/navigation';
import { userAuth } from '$lib/auth.svelte.js';
import type { LayoutLoad } from './$types';

export const ssr = false;

export const load: LayoutLoad = async ({ fetch }) => {
	if (!userAuth.token) {
		goto('/login');
	} else {
		fetch('/api/user', {
			method: 'GET',
			headers: {
				'Content-Type': 'application/json',
				Authorization: `Bearer ${userAuth.token}`
			}
		})
			.then((response) => {
				if (!response.ok) {
					userAuth.clear();
				}
				return response.json();
			})
			.then((data) => {
				console.log(data);
			})
			.catch((error) => {
				console.error(error);
			});
	}
	return {};
};

Two useful things happen here. Users with no auth state at all get redirected, and stale or invalid state gets caught by the backend. The layout still is not what protects your data. The endpoint is.

(private)/+layout.svelte

<script lang="ts">
	import { userAuth } from '$lib/auth.svelte.js';

	interface Props {
		children?: import('svelte').Snippet;
	}

	let { children }: Props = $props();
</script>

<div>
	<h2>PROTECTED ROUTE</h2>
	<br />
	<p><a href="/">Home</a></p>
	<br />
	<p><a href="/page2">Page2</a></p>
	<br />
	<p><a href="/unauthorized">This page give 401</a></p>
	<br />
	<button onclick={userAuth.clear}>Logout</button>
</div>

{@render children?.()}

Handle 401 everywhere

The layout check covers you when someone enters a protected section or refreshes the page. It does not cover a token or session that expires after that check, so you also need to handle 401 Unauthorized in API calls made from child pages, form actions, and component code.

import { userAuth } from '$lib/auth.svelte.js';
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
	const response = await fetch('/api/401');
	if (response.status === 401) {
		userAuth.clear();
	}
	return {};
};

The same applies anywhere else you call fetch. If a protected backend endpoint returns 401, clear the auth state and take the user back to login.

Summary

Client-side route guards in a static SvelteKit SPA are good for navigation and user experience, and that is all they are. Your backend has to authenticate and authorize every protected API request, and sensitive static content should never sit in the frontend bundle on the assumption that a route guard will hide it.

Keep those rules in mind and the pattern works well for a SvelteKit frontend paired with a separate backend API.

GitHub Repository