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.


This post originally needed an important clarification: in a static SvelteKit app, client-side “protected routes” are a UX pattern, not a security boundary.

If you are building a SvelteKit frontend in SPA mode with a separate backend, this approach can still be useful. But you need the right mental model:

  • your frontend can redirect unauthenticated users away from certain pages
  • your frontend cannot truly hide static routes or bundled code from users
  • the real protection must happen on your backend API

If that distinction is clear, the rest of this article will make sense.

What this article is actually about

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

In that setup:

  • all built frontend assets are delivered publicly
  • route groups like (private) help organize code, but they do not make files private
  • any sensitive data must come from a backend that performs authentication and authorization checks

So this article is about handling auth-aware navigation in the client while relying on your backend for actual security.

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 cookies so JavaScript cannot read them
  • Secure cookies so they are sent only over HTTPS
  • SameSite=Lax or stricter depending on your flow
  • CSRF protection for state-changing requests

This post shows a localStorage example because it is easier to demonstrate in a frontend-only article. That is for teaching the route-handling flow, not because it is the best security choice.

If you can use backend-set cookies, prefer that.

The flow

At a high level:

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

The important part is step 5. The route redirect is only 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 structure is useful for organizing authenticated and unauthenticated parts of the app.

It does not mean everything in (private) is hidden from the public on a static deployment.

Disable SSR

If you want a client-rendered SPA setup, disable SSR in the relevant layout files:

export const ssr = false;

You can place that 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();

This is just a small helper around localStorage.

Again, if your backend uses secure cookies, you would not store the token this way. The rest of the route-handling idea still applies.

(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.

Only group auth-related pages here. Do not put general public pages like docs or blog pages in this group, otherwise authenticated users may 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 {};
};

This improves UX. It is not the mechanism that secures your application.

Login page

In login/+page.svelte:

  • call your backend login API
  • store the returned token if you are using token storage
  • redirect the user to 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>

In a cookie-based setup, your backend would usually set the cookie in the login response and the frontend would not manually store the token.

Protected route layout

For authenticated sections, check whether the user appears logged in. If they do, validate that state 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 {};
};

This does two useful things:

  • redirects users who obviously have no auth state
  • validates stale or invalid auth state with the backend

But even here, remember the frontend layout is not what protects your data. The backend 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

Your layout check helps when entering a protected section or refreshing the page.

You still need to handle 401 Unauthorized in API calls made inside child pages, form actions, or component code. This is important because a token or session can expire after the initial route check.

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 idea applies anywhere else you call fetch. If a protected backend endpoint returns 401, clear auth state and take the user back to login.

Final takeaway

In a static SvelteKit SPA:

  • client-side route guards are useful for navigation and user experience
  • they are not a true security layer
  • your backend must authenticate and authorize every protected API request
  • do not place sensitive static content in the frontend bundle and assume route guards will hide it

If you keep those rules in mind, this pattern works well for a SvelteKit frontend paired with a separate backend API.

GitHub Repository