sveltekit-spa
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
More from linehaul-ai/linehaulai-claude-marketplace
geospatial-postgis-patterns
Implement geofences, spatial queries, real-time tracking, and mapping features in laneweaverTMS using PostGIS and PGRouting. Use when building location-based features, distance calculations, ETA predictions, or fleet visualization.
83quickbooks-online-api
Expert guide for QuickBooks Online API integration covering authentication, CRUD operations, batch processing, and best practices for invoicing, payments, and customer management.
61rbac-authorization-patterns
Provide patterns for implementing Role-Based Access Control and multi-tenant authorization in laneweaverTMS. Use when implementing user roles, permissions, tenant isolation, Echo authorization middleware, RLS policies for multi-tenant access, or JWT claims structure for freight brokerage applications.
61slack-block-kit
Build Slack Block Kit UIs for messages, modals, and Home tabs. Use when creating Slack notifications, interactive forms, bot responses, app dashboards, or any Slack UI. Covers blocks (Section, Actions, Input, Header), elements (Buttons, Selects, Date pickers), composition objects, and the slack-block-builder library.
44svelte-flow
Build node-based editors, interactive diagrams, and flow visualizations using Svelte Flow. Use when creating workflow editors, data flow diagrams, organizational charts, mindmaps, process visualizations, DAG editors, or any interactive node-graph UI. Supports custom nodes/edges, layouts (dagre, hierarchical), animations, and advanced features like proximity connect, floating edges, and contextual zoom.
35testcontainers-go
Use this skill when writing Go integration tests with Docker containers, using testcontainers-go modules (postgres, redis, kafka, etc.), setting up container-based test infrastructure, or configuring container networking and wait strategies. Covers 62+ pre-configured modules, cleanup patterns, and multi-container setups.
34