home / skills / linehaul-ai / linehaulai-claude-marketplace / sveltekit-spa

sveltekit-spa skill

/plugins/sveltekit-spa

npx playbooks add skill linehaul-ai/linehaulai-claude-marketplace --skill sveltekit-spa

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
22.8 KB
---
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 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

```js
// 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

```ts
// 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
```svelte
<script>
	let { data } = $props(); // Data from +page.js load function
</script>

<h1>{data.title}</h1>
```

**+page.ts** - Client-side data loading
```ts
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
```svelte
<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
```

```ts
// 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:**

1. **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

2. **sessionStorage** (slightly better than localStorage)
   - Cleared when tab/window closes
   - Still vulnerable to XSS
   - Better for temporary sessions

3. **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:

```go
// 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:

```ts
// 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

```ts
// 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

```ts
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

```ts
// 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

```svelte
<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

```ts
// +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:

```ts
// 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

```js
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

```svelte
<!-- 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

```svelte
<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

```svelte
<script>
	import { navigating } from '$app/state';
	
	// Show loading indicator during navigation
</script>

{#if navigating}
	<div class="loading-bar">Loading...</div>
{/if}
```

## Error Pages

```svelte
<!-- 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

```js
// .env
VITE_API_URL=http://localhost:8080
VITE_PUBLIC_KEY=pk_test_...
```

```js
// 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

```ts
// 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

```svelte
<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

```svelte
<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

```bash
bun run build
```

This generates static files in the `build/` directory (or path specified in adapter config).

### Preview Locally

```bash
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

1. ✅ `adapter-static` configured with correct fallback
2. ✅ `ssr = false` in root layout
3. ✅ Backend API URLs configured via environment variables
4. ✅ CORS configured on backend for frontend origin
5. ✅ Build successful with no errors
6. ✅ Test locally with `bun run preview`
7. ✅ 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`:
```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:

```go
// 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:

```svelte
<!-- 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:

```svelte
<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:

```ts
// 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:

```ts
// 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:

```svelte
<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

1. **Tree-shake unused code** - Import only what you need
2. **Use lightweight alternatives** - Consider bundle size of dependencies
3. **Lazy load routes** - SvelteKit does this automatically
4. **Optimize images** - Use modern formats (WebP, AVIF)
5. **Enable compression** - Configure your hosting for gzip/brotli

## Best Practices

1. **Disable SSR and prerender early** - Set both `ssr = false` and `prerender = false` in root `+layout.ts` immediately
2. **Use load functions** - Centralize data fetching in `+page.ts` load functions with proper TypeScript types
3. **Handle errors gracefully** - Use error boundaries and proper error states
4. **Protect routes** - Implement authentication checks in layout load functions
5. **Use environment variables** - Never hardcode API URLs or keys
6. **Test locally** - Always test with `bun run preview` before deploying
7. **Configure CORS properly** - Ensure backend allows frontend origin
8. **Consider prerendering** - Prerender static pages for better initial load
9. **Use absolute API URLs** - Avoid relative paths when calling backend
10. **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