| name | sveltekit-spa |
| description | Comprehensive guide for building SvelteKit applications in SPA (Single Page Application) mode with client-side rendering only. Use when working on SvelteKit projects that use adapter-static with CSR, especially those with separate backends like Golang/Echo. Covers routing, page options, data loading, and proper SPA configuration while avoiding SSR features. |
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 guardsreferences/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-staticwith 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.jswith fallback page - Create
src/routes/+layout.tswithexport const ssr = falseandexport const prerender = false - Set up environment variables for API URL (
.envfile) - 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.jsadapter configuration - Add
ssr = falseandprerender = falseto root+layout.ts - Convert all
+page.server.jsfiles to+page.ts - Remove all
+server.jsAPI 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 routes404.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 = falseandprerender = falsein 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-staticconfigured with correct fallback - ✅
ssr = falsein 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 previewlocally 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 = falseandprerender = falsein root+layout.tsimmediately - Use load functions - Centralize data fetching in
+page.tsload 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 previewbefore 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.mdfor complex routing scenarios - See
references/backend-integration.mdfor detailed backend integration patterns