sveltekit-spa

Guide for building SvelteKit applications in pure SPA/CSR mode with adapter-static , specifically optimized for projects with separate backends (e.g., Golang + Echo).

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "sveltekit-spa" with this command: npx skills add linehaul-ai/linehaulai-claude-marketplace/linehaul-ai-linehaulai-claude-marketplace-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 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 &#x26;&#x26; { '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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

geospatial-postgis-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

rbac-authorization-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

quickbooks-online-api

No summary provided by upstream source.

Repository SourceNeeds Review
General

slack-block-kit

No summary provided by upstream source.

Repository SourceNeeds Review