Step 11: Frontend + API Integration
What You'll Learn
- Create page loaders that access services via the IoC container
- Access loader data in Svelte components with $props()
- Handle loading and error states gracefully
- Build forms to create and modify data
- Refresh data after mutations with navigate()
What You'll Build
A fully interactive todo list with server-rendered data and form handling.
Understanding Page Loaders
Page loaders run on the server before your Svelte component renders. They can access your services directly via the IoC container, giving you full access to your business logic layer. This pattern ensures your pages are server-rendered with real data.
Create a main.load.ts file alongside your main.svelte file.
The loader function runs on every page request and returns data that becomes available
as component props.
Creating a Page Loader
Create a page loader that uses your service directly:
import container from "../../../server/container";
import TodosService from "../../../server/services/todos_service";
// Use 'any' type for the request parameter
type RequestData = any;
export default async function load(_request: RequestData) {
const service = await container.instance(TodosService);
try {
const todos = await service.getTodos();
return { todos, load_error: null };
} catch (error: any) {
return { todos: [], load_error: error.message || 'Failed to load todos' };
}
}Key points:
- Import your container and service directly
- Use
export default async function load - Use
type RequestData = anyto avoid server-only imports - Access query params via
request.query
Accessing Loader Data
In your Svelte component, use $props() to access the data returned by your loader:
<script lang="ts">
// Destructure the props returned by main.load.ts
let { todos, load_error } = $props();
</script>Displaying Data
Use Svelte's {#each} block to render your list of todos:
<ul class="space-y-2">
{#each todos as todo}
<li class="flex items-center gap-3 p-3 bg-white rounded-lg shadow">
<input
type="checkbox"
checked={Boolean(todo.completed)}
class="h-5 w-5"
/>
<span class={todo.completed ? 'line-through text-gray-400' : ''}>
{todo.title}
</span>
</li>
{/each}
</ul>Handling Loading and Error States
Always handle the case when data fails to load or when the list is empty:
{#if load_error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<p class="text-red-700">
Error: {load_error}
</p>
<button
onclick={() => window.location.reload()}
class="mt-2 text-red-600 underline"
>
Try again
</button>
</div>
{:else if todos.length === 0}
<div class="text-center py-8 text-gray-500">
<p>No todos yet. Create your first one!</p>
</div>
{:else}
<ul class="space-y-2">
{#each todos as todo}
{/* ... todo item ... */}
{/each}
</ul>
{/if}Create a Navigation Helper
A small helper keeps client-side refreshes simple and consistent:
export interface NavigateOptions {
replace?: boolean;
}
export function navigate(path: string, options?: NavigateOptions): void {
const { replace = false } = options ?? {};
if (replace) {
history.replaceState(null, "", path);
} else {
history.pushState(null, "", path);
}
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
}Adding a Form
Create a form to add new todos using Svelte's reactive state and fetch API:
import { navigate } from "../../lib/navigate";
// Form state
let newTitle = $state('');
let isSubmitting = $state(false);
let submitError = $state('');
async function handleSubmit(event: Event) {
event.preventDefault();
if (!newTitle.trim()) return;
isSubmitting = true;
submitError = '';
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
});
if (!response.ok) {
throw new Error('Failed to create todo');
}
newTitle = '';
// Refresh the page to re-run the loader
navigate(window.location.pathname + window.location.search);
} catch (error) {
submitError = error.message;
} finally {
isSubmitting = false;
}
}<form onsubmit={handleSubmit} class="flex gap-2 mb-6">
<input
type="text"
bind:value={newTitle}
placeholder="What needs to be done?"
disabled={isSubmitting}
class="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
disabled={isSubmitting || !newTitle.trim()}
class="px-4 py-2 bg-orange-600 text-white rounded-lg disabled:opacity-50"
>
{isSubmitting ? 'Adding...' : 'Add Todo'}
</button>
</form>
{#if submitError}
<p class="text-red-600 text-sm mb-4">{submitError}</p>
{/if}Refreshing After Mutations
After creating, updating, or deleting data, use navigate() to re-run
the page loader and refresh the displayed data:
import { navigate } from "../../lib/navigate";
async function deleteTodo(id: number) {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
// Re-run the loader to refresh the list
navigate(window.location.pathname + window.location.search);
}
async function toggleComplete(todo: Todo) {
await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !Boolean(todo.completed) })
});
navigate(window.location.pathname + window.location.search);
}Complete Example
Here's a complete main.svelte that brings together all the concepts:
<script lang="ts">
import { navigate } from "../../lib/navigate";
interface Todo {
id: number;
title: string;
completed: number;
}
let { todos, load_error }: { todos: Todo[], load_error: string | null } = $props();
let newTitle = $state('');
let isSubmitting = $state(false);
async function addTodo(event: Event) {
event.preventDefault();
if (!newTitle.trim() || isSubmitting) return;
isSubmitting = true;
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
});
newTitle = '';
isSubmitting = false;
navigate(window.location.pathname + window.location.search);
}
async function toggleTodo(todo: Todo) {
await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !Boolean(todo.completed) })
});
navigate(window.location.pathname + window.location.search);
}
async function deleteTodo(id: number) {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
navigate(window.location.pathname + window.location.search);
}
</script>
<div class="max-w-lg mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">My Todos</h1>
<form onsubmit={addTodo} class="flex gap-2 mb-6">
<input
type="text"
bind:value={newTitle}
placeholder="What needs to be done?"
class="flex-1 px-4 py-2 border rounded-lg"
/>
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg">
Add
</button>
</form>
{#if load_error}
<p class="text-red-600">{load_error}</p>
{:else if todos.length === 0}
<p class="text-gray-500">No todos yet!</p>
{:else}
<ul class="space-y-2">
{#each todos as todo}
<li class="flex items-center gap-3 p-3 bg-white rounded-lg shadow">
<input
type="checkbox"
checked={Boolean(todo.completed)}
onchange={() => toggleTodo(todo)}
/>
<span class={todo.completed ? 'line-through text-gray-400 flex-1' : 'flex-1'}>
{todo.title}
</span>
<button
onclick={() => deleteTodo(todo.id)}
class="text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</div>What's Next?
Your application now has a fully functional frontend that communicates with your API. In the final step, we'll prepare everything for production deployment.