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.
Table of Contents
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.
Recommended auth storage
The most secure common setup is cookie-based authentication managed by your backend:
HttpOnlycookies so JavaScript cannot read themSecurecookies so they are sent only over HTTPSSameSite=Laxor 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:
- User logs in using your backend API.
- Backend returns an auth token or sets an auth cookie.
- Frontend stores auth state or simply relies on the cookie.
- Frontend redirects the user into the authenticated area.
- Every protected API call is validated by the backend.
- 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