Step 11 of 12 92% complete

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:

src/ui/pages/todos/main.load.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 = any to 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:

src/ui/pages/todos/main.svelte
1
2
3
4
<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:

Displaying todos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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:

Conditional rendering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{#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:

src/ui/lib/navigate.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

Form handling script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 template
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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:

Mutation handlers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

src/ui/pages/todos/main.svelte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<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.

Troubleshooting

NoEgo

© 2025 NoEgo. All rights reserved.