FRONTEND · Svelte
Svelte: consume the API
Scaffold a SvelteKit app with `npx sv create`, load the todo list in a load function, and build list / show / create / edit / delete with Svelte 5 runes and file-based routes.
Prerequisites#
- A deployed Todos API from one of the backend guides. Every backend serves the same contract (no trailing slashes):
GET /health→{"ok":true}GET /todos→[{id,subject,details}]ordered by idGET /todos/:id→ object or 404{"error":"not found"}POST /todos{subject,details}→ 201 objectPUT /todos/:id{subject,details}→ 200 objectDELETE /todos/:id→ 204- blank/missing fields → 400
{"error":"…"} - CORS allows any origin. Objects contain exactly
id(integer),subject,details.
- Node.js 22.17 or newer (SvelteKit 3's minimum; Node 24 is the current LTS).
Pick any backend#
All eight are live and interchangeable:
Every one serves the same contract, so the env var below can point at any of them. Each repo ships a check.mjs you can run against your own deploy: node check.mjs https://your-api.up.railway.app.
Setup#
We use SvelteKit, the official application framework for Svelte. It runs on Vite, gives you file-based routing and a load function per page, and is what npx sv create scaffolds today. (The 2021 version used Snowpack, which was discontinued in 2022.)
- Generate a new project and start the dev server:
npx sv create todofrontendcd todofrontendnpm installnpm run devPick the minimal template when asked, choose JavaScript with JSDoc comments (or TypeScript), and skip the add-ons. Open http://localhost:5173.
- Make the build a static site. The scaffold's
adapter-autotargets a Node server;@sveltejs/adapter-staticwrites plain files tobuild/that any static host can serve. Thefallbackoption makes every route (including/post/3) load the sameindex.html, andssr = falsein the root layout tells SvelteKit to render in the browser.
npm uninstall @sveltejs/adapter-autonpm install -D @sveltejs/adapter-staticvite.config.js
import adapter from "@sveltejs/adapter-static";import { sveltekit } from "@sveltejs/kit/vite";import { defineConfig } from "vite"; export default defineConfig({ plugins: [ sveltekit({ compilerOptions: { // Force runes mode for the project, except for libraries. Can be removed in svelte 6. runes: ({ filename }) => filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, // Static output: every route is served by the same index.html and // rendered in the browser (see src/routes/+layout.js). adapter: adapter({ fallback: "index.html" }), }), ],});src/routes/+layout.js
// Render everything in the browser: the build is a static site with no server.export const ssr = false;- Declare the API URL as a public environment variable. It holds the origin only (no path, no trailing slash); the code adds
/todositself. SvelteKit 3 reads.envfiles (Vite's.env.productionis used bynpm run build) and types the variables throughsrc/env.js.static: trueinlines the value at build time, which a static site needs, and the schema function supplies a local fallback when the variable is unset.
.env.production (the real URL of the backend you picked above; this one is the Rust / Axum API)
VITE_API_URL=https://api-production-da990.up.railway.appsrc/env.js
import { defineEnvVars } from "@sveltejs/kit/env"; export const variables = defineEnvVars({ // The origin of the Todos API, with no path and no trailing slash. // public: true makes it importable in browser code; static: true inlines // the build-time value, which a static site needs. VITE_API_URL: { public: true, static: true, schema: (value) => value || "http://localhost:8080", },});- Add a tiny API module.
#libis SvelteKit's alias forsrc/lib(it was$libbefore SvelteKit 3). The requests are built from the origin:${API}/todosand${API}/todos/${id}.
src/lib/api.js
import { VITE_API_URL as API } from "$app/env/public"; const headers = { "Content-Type": "application/json" }; // `fetch` is passed in so load functions can use SvelteKit's fetchexport async function getTodos(fetch = globalThis.fetch) { const response = await fetch(`${API}/todos`); if (!response.ok) throw new Error(`GET ${API}/todos failed: ${response.status}`); return response.json();} export function createTodo(todo) { return fetch(`${API}/todos`, { method: "POST", headers, body: JSON.stringify(todo) });} export function updateTodo(id, todo) { return fetch(`${API}/todos/${id}`, { method: "PUT", headers, body: JSON.stringify(todo) });} export function deleteTodo(id) { return fetch(`${API}/todos/${id}`, { method: "DELETE" });}Create our routes#
In SvelteKit a page is a folder under src/routes with a +page.svelte inside; [id] in a folder name is a dynamic segment. Create these files (empty for now):
src/routes/├─ +layout.js ssr = false (from the step above)├─ +layout.svelte shared header and "new todo" button├─ +page.js loads the list├─ +page.svelte / all posts├─ post/[id]/+page.svelte /post/:id one post├─ new/+page.svelte /new create form└─ edit/[id]/+page.svelte /edit/:id edit formThe layout and loading the todos#
The layout wraps every page. {@render children()} is where the current page renders (Svelte 5's replacement for <slot />).
src/routes/+layout.svelte
<script> let { children } = $props();</script> <div class="app"> <h1>Our Todos</h1> <a href="/new"><button>Make New Todo</button></a> <main> {@render children()} </main></div> <style> .app { text-align: center; }</style>Instead of fetching in onMount and starting with undefined, the list page gets its data from a sibling +page.js load function. SvelteKit waits for it before rendering the page, and the result arrives in the page as the data prop. (With ssr = false it runs in the browser; with server rendering enabled it would run on the server for the first visit.)
src/routes/+page.js
import { getTodos } from "#lib/api.js"; export async function load({ fetch }) { return { todos: await getTodos(fetch) };}Because post/[id] and edit/[id] need the same list, give them the same loader by re-exporting it:
src/routes/post/[id]/+page.js and src/routes/edit/[id]/+page.js
export { load } from "../../+page.js";AllPosts#
Loop through the todos and link to each one. The (todo.id) after the each block is the key.
src/routes/+page.svelte
<script> let { data } = $props(); // { todos } from +page.js</script> <div> {#each data.todos as todo (todo.id)} <article> <a href="/post/{todo.id}"><h1>{todo.subject}</h1></a> <h2>{todo.details}</h2> </article> {/each}</div>SinglePost#
Find the post whose id matches the [id] segment. page.params comes from $app/state; $derived recomputes when either the params or the data change.
src/routes/post/[id]/+page.svelte
<script> import { page } from "$app/state"; let { data } = $props(); const post = $derived(data.todos.find((p) => String(p.id) === page.params.id));</script> {#if post} <div> <h1>{post.subject}</h1> <h2>{post.details}</h2> <a href="/"><button>Back to Main</button></a> </div>{:else} <p>Not found.</p>{/if}Form#
One form component, used by both the new and edit pages. It takes the initial values, a label and a submit handler as props; $state holds the field values and bind:value keeps the inputs in sync.
src/lib/TodoForm.svelte
<script> let { initial = { subject: "", details: "" }, buttonLabel = "Create Todo", onsave } = $props(); let subject = $state(initial.subject); let details = $state(initial.details); async function handleSubmit(event) { event.preventDefault(); await onsave({ subject, details }); }</script> <form onsubmit={handleSubmit}> <input type="text" placeholder="subject" bind:value={subject} /> <input type="text" placeholder="details" bind:value={details} /> <input type="submit" value={buttonLabel} /></form>The new page creates and goes back to the list. goto is SvelteKit's programmatic navigation; refreshAll: true makes the list page's load run again on the way in (and discards any list SvelteKit preloaded while you hovered a link).
src/routes/new/+page.svelte
<script> import { goto } from "$app/navigation"; import { createTodo } from "#lib/api.js"; import TodoForm from "#lib/TodoForm.svelte"; async function save(todo) { await createTodo(todo); await goto("/", { refreshAll: true }); }</script> <TodoForm onsave={save} />The edit page pre-fills the form from the matching post and PUTs { subject, details } to /todos/:id on submit.
src/routes/edit/[id]/+page.svelte
<script> import { goto } from "$app/navigation"; import { page } from "$app/state"; import { updateTodo } from "#lib/api.js"; import TodoForm from "#lib/TodoForm.svelte"; let { data } = $props(); const post = $derived(data.todos.find((p) => String(p.id) === page.params.id)); async function save(todo) { await updateTodo(post.id, todo); await goto("/", { refreshAll: true }); }</script> {#if post} <TodoForm initial={post} buttonLabel="Edit Todo" onsave={save} />{:else} <p>Not found.</p>{/if}Add the edit link to the single-post page:
<a href="/edit/{post.id}"><button>Edit the Todo</button></a>Last, a delete button in SinglePost#
src/routes/post/[id]/+page.svelte (final version)
<script> import { goto } from "$app/navigation"; import { page } from "$app/state"; import { deleteTodo } from "#lib/api.js"; let { data } = $props(); const post = $derived(data.todos.find((p) => String(p.id) === page.params.id)); async function remove() { await deleteTodo(post.id); await goto("/", { refreshAll: true }); }</script> {#if post} <div> <h1>{post.subject}</h1> <h2>{post.details}</h2> <a href="/"><button>Back to Main</button></a> <a href="/edit/{post.id}"><button>Edit the Todo</button></a> <button onclick={remove}>Delete Todo</button> </div>{:else} <p>Not found.</p>{/if}Done: full CRUD on the frontend. npm run build writes the static site to build/; serve that folder with any static file server that falls back to index.html (for example npx serve -s build).
The finished example#
It is pointed at the Rust / Axum API (VITE_API_URL=https://api-production-da990.up.railway.app); to point it at any other backend from the table above, change the value in .env.production and rebuild. On Railway the repo adds serve as a dependency with "start": "serve -s build -l $PORT", so npm run build then npm start is the whole deploy.
Resources to Learn More#
- Svelte 5 runes:
$state,$derived,$props - SvelteKit routing and loading data
- SvelteKit environment variables
- adapter-static (static sites and the
fallbackoption) - Migrating to SvelteKit 3 (what changed from the version most tutorials still show)
- The interactive Svelte tutorial
See also