SvelteKit SPA Mode
Guide for building SvelteKit applications in pure SPA/CSR mode with adapter-static , specifically optimized for projects with separate backends (e.g., Golang + Echo).
About This Skill
Type: Reference guide (flexible pattern)
This skill provides patterns and conventions for SvelteKit SPA development. The core requirements (SSR disabled, adapter-static configuration) are mandatory for SPA mode, but implementation details should be adapted to your project's needs.
💡 Additional Resources: This skill includes detailed reference documentation:
-
references/routing-patterns.md
-
Complex routing scenarios, nested layouts, route guards
-
references/backend-integration.md
-
Detailed API patterns, authentication flows, error handling
Core Concept
SvelteKit SPA mode creates a fully client-rendered single-page application. The entire app runs in the browser with a fallback HTML page that bootstraps the application for any route.
Key characteristics:
-
No server-side rendering (SSR disabled)
-
All routing handled client-side
-
Backend is separate (API-only)
-
Uses adapter-static with fallback page
-
Full SvelteKit routing capabilities without SSR complexity
Initial SPA Setup Checklist
When setting up a new SvelteKit project in SPA mode:
-
Install @sveltejs/adapter-static
-
Configure adapter in svelte.config.js with fallback page
-
Create src/routes/+layout.ts with export const ssr = false and export const prerender = false
-
Set up environment variables for API URL (.env file)
-
Configure CORS on backend API
-
Test build process with bun run build
-
Test locally with bun run preview
-
Verify routing works after page refresh
Migrating Existing SvelteKit Project to SPA Mode
If converting an existing SvelteKit project:
-
Install @sveltejs/adapter-static (remove other adapters)
-
Update svelte.config.js adapter configuration
-
Add ssr = false and prerender = false to root +layout.ts
-
Convert all +page.server.js files to +page.ts
-
Remove all +server.js API routes (move to backend)
-
Update load functions to use absolute API URLs with environment variables
-
Test all routes still work client-side
-
Update deployment configuration for static hosting
Project Configuration
Adapter Setup
// svelte.config.js import adapter from '@sveltejs/adapter-static';
export default { kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: '200.html', // or '404.html', 'index.html' depending on host precompress: false, strict: false // Set false since we're not prerendering }) } };
Fallback page selection:
-
200.html
-
For hosts like Surge that support catch-all routes
-
404.html
-
For hosts that serve 404.html for missing routes (GitHub Pages)
-
index.html
-
Avoid unless necessary (can conflict with prerendered pages)
Disable SSR Globally
// src/routes/+layout.ts export const ssr = false; export const prerender = false;
Critical: Both exports are required for SPA mode:
-
ssr = false
-
Disables server-side rendering (all rendering happens client-side)
-
prerender = false
-
Disables prerendering at build time (unless selectively enabled per route)
This configuration ensures the entire application runs as a pure SPA with client-side rendering only.
Routing in SPA Mode
SvelteKit's filesystem-based routing works identically in SPA mode. The only difference is that all routes render client-side.
Basic Route Structure
laneweaver-frontend/ ├── bun.lock ├── components.json ├── e2e/ # Playwright end-to-end tests │ └── demo.test.ts ├── eslint.config.js ├── package.json ├── playwright.config.ts ├── src/ │ ├── app.css # Global styles │ ├── app.d.ts # TypeScript declarations │ ├── app.html # HTML template │ ├── lib/ │ │ ├── assets/ │ │ │ └── favicon.svg │ │ ├── components/ │ │ │ └── ui/ # Reusable UI components │ │ ├── index.ts # Library exports │ │ └── utils.ts # Utility functions │ └── routes/ │ ├── +layout.svelte # Root layout (nav, etc.) │ ├── +layout.ts # SSR disable, shared data │ ├── +page.svelte # Home page │ ├── dashboard/ │ │ ├── +page.svelte # /dashboard │ │ ├── +layout.svelte # Dashboard layout │ │ └── [id]/ │ │ └── +page.svelte # /dashboard/:id │ └── api/ │ └── +server.ts # ❌ AVOID - Use backend API instead ├── static/ # Static assets (served as-is) ├── svelte.config.js ├── tsconfig.json └── vite.config.ts
Route Files
+page.svelte - Page component
<script> let { data } = $props(); // Data from +page.js load function </script>
<h1>{data.title}</h1>
+page.ts - Client-side data loading
export const ssr = false; // Optional if set in root layout export const prerender = false; // Optional if set in root layout
/** @type {import('./$types').PageLoad} */
export async function load({ fetch, params }) {
// Fetch from your backend API
const API_URL = import.meta.env.VITE_API_URL;
const res = await fetch(${API_URL}/api/items/${params.id});
return await res.json();
}
+layout.svelte - Shared layout
<script> let { children } = $props(); </script>
<nav> <a href="/">Home</a> <a href="/dashboard">Dashboard</a> </nav>
{@render children()}
Dynamic Routes
src/routes/ └── blog/ └── [slug]/ ├── +page.svelte # /blog/hello-world └── +page.ts # Load data for slug
// src/routes/blog/[slug]/+page.ts export const ssr = false; export const prerender = false;
/** @type {import('./$types').PageLoad} */
export async function load({ params, fetch }) {
const API_URL = import.meta.env.VITE_API_URL;
const response = await fetch(${API_URL}/api/blog/${params.slug});
if (!response.ok) {
throw error(404, 'Post not found');
}
return await response.json();
}
Security Considerations
Authentication Token Storage
⚠️ IMPORTANT: Many examples in this guide use localStorage for simplicity in demonstrating concepts, but this has significant security implications:
Risks:
-
Vulnerable to XSS (Cross-Site Scripting) attacks
-
Accessible to all JavaScript code on the page, including third-party scripts
-
Not automatically cleared on browser close
-
No built-in protection against CSRF attacks
Recommended alternatives for production:
HttpOnly Cookies (preferred)
-
Set by backend server
-
Not accessible to JavaScript (immune to XSS token theft)
-
Automatically sent with requests to same domain
-
Can be marked as Secure and SameSite
sessionStorage (slightly better than localStorage)
-
Cleared when tab/window closes
-
Still vulnerable to XSS
-
Better for temporary sessions
In-memory storage with session timeout
-
Store in component state or stores
-
Cleared on page refresh
-
Most secure for highly sensitive apps
Best practice: Use HttpOnly cookies with your backend API for authentication tokens. Reserve localStorage only for non-sensitive application state.
CORS Configuration
Ensure your backend properly configures CORS to only allow your frontend origin:
// Example: Golang + Echo e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"https://yourdomain.com"}, // Never use "*" in production AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{"Authorization", "Content-Type"}, AllowCredentials: true, // Required for cookies }))
Data Loading Patterns
Client-Side Load Function
Load functions in +page.ts run in the browser for SPA mode:
// src/routes/dashboard/+page.ts export const ssr = false; export const prerender = false;
/** @type {import('./$types').PageLoad} */ export async function load({ fetch, parent, url }) { // Access parent layout data const parentData = await parent();
// Fetch from backend API
const API_URL = import.meta.env.VITE_API_URL;
const response = await fetch(`${API_URL}/api/dashboard`, {
headers: {
'Authorization': `Bearer ${parentData.token}`
}
});
// Access URL search params
const filter = url.searchParams.get('filter');
return {
dashboardData: await response.json(),
filter
};
}
Authentication Token Flow
// src/routes/+layout.ts export const ssr = false; export const prerender = false;
/** @type {import('./$types').LayoutLoad} */ export async function load({ fetch }) { // Get token from localStorage or cookie // NOTE: See Security Considerations section for production-ready alternatives const token = localStorage.getItem('auth_token');
if (!token) {
return { user: null, token: null };
}
// Validate token with backend
const API_URL = import.meta.env.VITE_API_URL;
const response = await fetch(`${API_URL}/api/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
localStorage.removeItem('auth_token');
return { user: null, token: null };
}
return {
user: await response.json(),
token
};
}
Error Handling
import { error, redirect } from '@sveltejs/kit';
export const ssr = false; export const prerender = false;
/** @type {import('./$types').PageLoad} */ export async function load({ fetch, parent }) { const { token } = await parent();
if (!token) {
throw redirect(303, '/login');
}
const API_URL = import.meta.env.VITE_API_URL;
const response = await fetch(`${API_URL}/api/protected-resource`);
if (response.status === 401) {
throw redirect(303, '/login');
}
if (response.status === 404) {
throw error(404, 'Resource not found');
}
if (!response.ok) {
throw error(response.status, 'Failed to load resource');
}
return await response.json();
}
Backend Integration (Golang + Echo)
API Communication
// src/lib/api.ts const API_BASE = import.meta.env.VITE_API_URL;
export async function apiRequest(endpoint: string, options: RequestInit = {}) { // NOTE: See Security Considerations section for production-ready alternatives const token = localStorage.getItem('auth_token');
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || 'API request failed');
}
return response.json();
}
Form Submission
<script> import { apiRequest } from '$lib/api'; import { goto } from '$app/navigation';
let formData = $state({ email: '', password: '' });
let error = $state(null);
async function handleSubmit() {
try {
const result = await apiRequest('/api/auth/login', {
method: 'POST',
body: JSON.stringify(formData)
});
// NOTE: See Security Considerations section for production-ready alternatives
localStorage.setItem('auth_token', result.token);
goto('/dashboard');
} catch (e) {
error = e.message;
}
}
</script>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> <input type="email" bind:value={formData.email} /> <input type="password" bind:value={formData.password} /> {#if error} <p class="error">{error}</p> {/if} <button type="submit">Login</button> </form>
Page Options Reference
Available Options
// +page.ts or +layout.ts export const ssr = false; // Disable server-side rendering export const prerender = false; // Don't prerender this page export const csr = true; // Enable client-side rendering (default)
Important for SPA mode:
-
Always set ssr = false and prerender = false in root layout or individual pages
-
Keep csr = true (it's the default)
-
Both exports are required for pure SPA behavior
When to Prerender in SPA Mode
You can selectively prerender pages even in SPA mode:
// src/routes/about/+page.ts export const ssr = true; // Enable for prerendering export const prerender = true; // Prerender this page at build
This creates static HTML for /about while keeping other routes as SPA. Useful for:
-
Marketing pages
-
About pages
-
Terms of service
-
Any static content
Navigation
Programmatic Navigation
import { goto } from '$app/navigation';
// Navigate to route goto('/dashboard');
// Navigate with options goto('/search', { replaceState: true, // Replace history instead of push noScroll: true, // Don't scroll to top keepFocus: true, // Keep current focus state: { from: 'home' } // Pass state });
// Navigate with search params goto('/search?q=sveltekit&page=2');
Link Behavior
<!-- Standard navigation --> <a href="/dashboard">Dashboard</a>
<!-- Disable client-side routing for this link --> <a href="/external" data-sveltekit-reload>External Site</a>
<!-- Prefetch on hover --> <a href="/dashboard" data-sveltekit-preload-data="hover"> Dashboard </a>
<!-- Prefetch on viewport --> <a href="/dashboard" data-sveltekit-preload-data="viewport"> Dashboard </a>
State Management
URL State with $page
<script> import { page } from '$app/state';
// Access current route info
$effect(() => {
console.log(page.url.pathname); // Current path
console.log(page.params); // Route parameters
console.log(page.data); // Data from load functions
});
</script>
<div> Current path: {page.url.pathname} {#if page.params.id} Viewing ID: {page.params.id} {/if} </div>
Navigation State
<script> import { navigating } from '$app/state';
// Show loading indicator during navigation
</script>
{#if navigating} <div class="loading-bar">Loading...</div> {/if}
Error Pages
<!-- src/routes/+error.svelte --> <script> import { page } from '$app/state'; </script>
<div class="error-page"> <h1>{page.status}</h1> <p>{page.error?.message}</p> <a href="/">Go home</a> </div>
Environment Variables
// .env VITE_API_URL=http://localhost:8080 VITE_PUBLIC_KEY=pk_test_...
// Access in code const apiUrl = import.meta.env.VITE_API_URL; const publicKey = import.meta.env.VITE_PUBLIC_KEY;
Important: All env vars must be prefixed with VITE_ to be accessible in client-side code.
Common Patterns
Protected Routes
// src/routes/dashboard/+layout.ts import { redirect } from '@sveltejs/kit';
export const ssr = false; export const prerender = false;
/** @type {import('./$types').LayoutLoad} */ export async function load({ parent }) { const { user } = await parent();
if (!user) {
throw redirect(303, '/login');
}
return { user };
}
Data Fetching with Loading States
<script> import { onMount } from 'svelte';
let data = $state(null);
let loading = $state(true);
let error = $state(null);
onMount(async () => {
try {
const response = await fetch('/api/data');
data = await response.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
</script>
{#if loading} <p>Loading...</p> {:else if error} <p>Error: {error}</p> {:else if data} <div>{data.content}</div> {/if}
Pagination
<script> import { page } from '$app/state'; import { goto } from '$app/navigation';
let { data } = $props();
function goToPage(pageNum) {
const url = new URL(window.location.href);
url.searchParams.set('page', pageNum);
goto(url.pathname + url.search);
}
</script>
<div class="items"> {#each data.items as item} <div>{item.title}</div> {/each} </div>
<div class="pagination"> {#each Array(data.totalPages) as _, i} <button onclick={() => goToPage(i + 1)}> {i + 1} </button> {/each} </div>
Build and Deployment
Build Command
bun run build
This generates static files in the build/ directory (or path specified in adapter config).
Preview Locally
bun run preview
Directory Structure After Build
build/ ├── _app/ │ ├── immutable/ # Hashed JS/CSS chunks │ └── version.json ├── 200.html # Fallback page (your SPA entry) └── index.html # Root page (if prerendered)
Deployment Checklist
-
✅ adapter-static configured with correct fallback
-
✅ ssr = false in root layout
-
✅ Backend API URLs configured via environment variables
-
✅ CORS configured on backend for frontend origin
-
✅ Build successful with no errors
-
✅ Test locally with bun run preview
-
✅ Deploy build/ directory to static host
SvelteKit SPA vs Pure Vite
Choose SvelteKit SPA when you want:
-
File-based routing
-
Load functions for data fetching
-
Built-in error pages
-
Layouts and nested routes
-
Programmatic navigation with goto()
-
URL state management
Choose Pure Vite + Svelte when you want:
-
Manual routing (or no routing)
-
Complete control over bundle structure
-
Minimal framework overhead
-
Custom build configuration
SvelteKit SPA provides routing and data loading conventions while remaining fully client-side.
Troubleshooting
Issue: "Cannot access server-side modules"
Cause: Trying to use +page.server.ts in SPA mode.
Solution: Use +page.ts instead. All load functions run client-side in SPA mode.
Issue: "This page will be rendered on the server"
Cause: ssr = false and prerender = false not set.
Solution: Add both exports to root +layout.ts :
export const ssr = false; export const prerender = false;
Issue: 404 errors on refresh
Cause: Server doesn't serve fallback page for all routes.
Solution:
-
Verify adapter fallback configuration
-
Configure your static host to serve the fallback page for all routes
-
Test with bun run preview locally first
Issue: API CORS errors
Cause: Backend not configured to allow frontend origin.
Solution: Configure CORS on your Golang/Echo backend:
// In your Echo server e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"http://localhost:5173", "https://yourdomain.com"}, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{"Authorization", "Content-Type"}, }))
Issue: Environment variables not available
Cause: Variables not prefixed with VITE_ .
Solution: Rename all client-side env vars to start with VITE_ .
Performance Optimization
Prefetching Strategies
SvelteKit provides built-in prefetching for faster navigation:
<!-- Prefetch on hover (most common) --> <a href="/dashboard" data-sveltekit-preload-data="hover"> Dashboard </a>
<!-- Prefetch when link enters viewport --> <a href="/reports" data-sveltekit-preload-data="viewport"> Reports </a>
<!-- Prefetch immediately on page load --> <a href="/critical" data-sveltekit-preload-data="tap"> Critical Page </a>
Code Splitting
Leverage dynamic imports for large components:
<script> import { onMount } from 'svelte';
let HeavyComponent;
onMount(async () => {
// Load component only when needed
const module = await import('$lib/components/HeavyChart.svelte');
HeavyComponent = module.default;
});
</script>
{#if HeavyComponent} <svelte:component this={HeavyComponent} /> {/if}
Selective Prerendering
Prerender static pages for instant loading:
// src/routes/about/+page.ts export const prerender = true; export const ssr = true; // Enable for build-time rendering
Good candidates for prerendering:
-
Marketing pages
-
About/Terms/Privacy pages
-
Documentation
-
Blog posts (if content is static)
Request Deduplication
Prevent duplicate API calls in load functions:
// src/lib/cache.ts const cache = new Map<string, any>(); const pending = new Map<string, Promise<any>>();
export async function cachedFetch(url: string, options: RequestInit = {}) {
const key = ${url}:${JSON.stringify(options)};
// Return cached result
if (cache.has(key)) {
return cache.get(key);
}
// Return pending request
if (pending.has(key)) {
return pending.get(key);
}
// Make new request
const promise = fetch(url, options).then(r => r.json());
pending.set(key, promise);
try {
const result = await promise;
cache.set(key, result);
return result;
} finally {
pending.delete(key);
}
}
Loading State Optimization
Show instant feedback during navigation:
<script> import { navigating } from '$app/state'; </script>
{#if navigating} <div class="loading-bar" /> {/if}
<style> .loading-bar { position: fixed; top: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, #4f46e5, #06b6d4); animation: slide 1s ease-in-out infinite; }
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
</style>
Bundle Optimization Tips
-
Tree-shake unused code - Import only what you need
-
Use lightweight alternatives - Consider bundle size of dependencies
-
Lazy load routes - SvelteKit does this automatically
-
Optimize images - Use modern formats (WebP, AVIF)
-
Enable compression - Configure your hosting for gzip/brotli
Best Practices
-
Disable SSR and prerender early - Set both ssr = false and prerender = false in root +layout.ts immediately
-
Use load functions - Centralize data fetching in +page.ts load functions with proper TypeScript types
-
Handle errors gracefully - Use error boundaries and proper error states
-
Protect routes - Implement authentication checks in layout load functions
-
Use environment variables - Never hardcode API URLs or keys
-
Test locally - Always test with bun run preview before deploying
-
Configure CORS properly - Ensure backend allows frontend origin
-
Consider prerendering - Prerender static pages for better initial load
-
Use absolute API URLs - Avoid relative paths when calling backend
-
Handle loading states - Show feedback during data fetching
Related Documentation
For advanced patterns and additional context:
-
See references/routing-patterns.md for complex routing scenarios
-
See references/backend-integration.md for detailed backend integration patterns