Skip to content

Web (SvelteKit)

Web Architecture

All Manacore web applications use SvelteKit 2 with Svelte 5 (runes mode) and Tailwind CSS.

Project Structure

apps/{project}/apps/web/
├── src/
│ ├── app.html # HTML template
│ ├── app.css # Global styles (Tailwind)
│ ├── lib/
│ │ ├── components/ # Reusable components
│ │ │ ├── ui/ # Base UI components
│ │ │ └── features/ # Feature-specific components
│ │ ├── stores/ # Svelte stores
│ │ ├── services/ # API clients, auth
│ │ ├── utils/ # Helper functions
│ │ └── types/ # TypeScript types
│ └── routes/
│ ├── +layout.svelte # Root layout
│ ├── +page.svelte # Home page
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ └── register/
│ └── (app)/ # App route group (protected)
│ ├── +layout.svelte
│ ├── dashboard/
│ └── settings/
├── static/ # Static assets
├── svelte.config.js
├── vite.config.ts
└── tailwind.config.js

Svelte 5 Runes

State Management

<script lang="ts">
// Reactive state
let count = $state(0);
// Derived values
let doubled = $derived(count * 2);
// Deep reactive objects
let user = $state({
name: 'John',
settings: { theme: 'dark' }
});
// Effects (side effects on state change)
$effect(() => {
console.log('Count changed:', count);
// Cleanup function (optional)
return () => console.log('Cleanup');
});
function increment() {
count++;
}
</script>
<button onclick={increment}>
Count: {count} (doubled: {doubled})
</button>

Props

<script lang="ts">
interface Props {
title: string;
count?: number;
onUpdate?: (value: number) => void;
}
let { title, count = 0, onUpdate }: Props = $props();
</script>
<h1>{title}</h1>
<p>Count: {count}</p>
<button onclick={() => onUpdate?.(count + 1)}>Update</button>

Bindings

<script lang="ts">
let value = $state('');
let checked = $state(false);
</script>
<input bind:value />
<input type="checkbox" bind:checked />

Routing

Route Groups

Use route groups for layout organization:

routes/
├── (marketing)/ # Public pages
│ ├── +layout.svelte # Marketing layout
│ ├── +page.svelte # Landing page
│ └── pricing/
├── (auth)/ # Auth pages (no sidebar)
│ ├── +layout.svelte
│ ├── login/
│ └── register/
└── (app)/ # Protected app pages
├── +layout.svelte # App layout with sidebar
├── +layout.server.ts # Auth check
└── dashboard/

Layout Data

// routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(302, '/login');
}
return {
user: locals.user,
};
};

Page Data

// routes/(app)/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
import { api } from '$lib/services/api';
export const load: PageServerLoad = async ({ locals }) => {
const stats = await api.getStats(locals.user.id);
return {
stats,
};
};
<!-- routes/(app)/dashboard/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Dashboard</h1>
<p>Total items: {data.stats.total}</p>

API Integration

API Client

src/lib/services/api.ts
import { PUBLIC_API_URL } from '$env/static/public';
class ApiClient {
private baseUrl = PUBLIC_API_URL;
private token: string | null = null;
setToken(token: string) {
this.token = token;
}
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
async getUsers() {
return this.fetch<User[]>('/api/v1/users');
}
async createUser(data: CreateUserDto) {
return this.fetch<User>('/api/v1/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
}
export const api = new ApiClient();

Stores

Use Svelte stores for global state:

src/lib/stores/auth.ts
import { writable, derived } from 'svelte/store';
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
}
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
user: null,
token: null,
loading: true,
});
return {
subscribe,
setUser: (user: User, token: string) => {
update((state) => ({ ...state, user, token, loading: false }));
},
logout: () => {
set({ user: null, token: null, loading: false });
},
setLoading: (loading: boolean) => {
update((state) => ({ ...state, loading }));
},
};
}
export const auth = createAuthStore();
export const isAuthenticated = derived(auth, ($auth) => !!$auth.user);

Components

Base Component Pattern

src/lib/components/ui/Button.svelte
<script lang="ts">
import { cn } from '$lib/utils';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
class?: string;
onclick?: () => void;
}
let {
variant = 'primary',
size = 'md',
disabled = false,
class: className,
onclick,
...rest
}: Props = $props();
const variants = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
ghost: 'bg-transparent hover:bg-gray-100',
};
const sizes = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
};
</script>
<button
class={cn(
'rounded-md font-medium transition-colors',
variants[variant],
sizes[size],
disabled && 'opacity-50 cursor-not-allowed',
className
)}
{disabled}
{onclick}
{...rest}
>
<slot />
</button>

Utility Function

src/lib/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

Forms

Form Actions

routes/settings/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name') as string;
if (!name || name.length < 2) {
return fail(400, { error: 'Name must be at least 2 characters' });
}
await api.updateUser(locals.user.id, { name });
return { success: true };
},
};
routes/settings/+page.svelte
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" action="?/updateProfile" use:enhance>
<input name="name" required minlength="2" />
{#if form?.error}
<p class="text-red-500">{form.error}</p>
{/if}
<button type="submit">Save</button>
</form>

Best Practices

  1. Use runes - Always use $state, $derived, $effect
  2. Type everything - Use TypeScript for all components
  3. Server-side data loading - Use +page.server.ts for API calls
  4. Form actions - Use SvelteKit form actions for mutations
  5. Tailwind - Use utility classes, avoid custom CSS
  6. Component composition - Build small, reusable components