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.

Sveltev5© Arthur Bernier Jrrewritten · verified18 code blocks929 words

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 id
    • GET /todos/:id → object or 404 {"error":"not found"}
    • POST /todos {subject,details} → 201 object
    • PUT /todos/:id {subject,details} → 200 object
    • DELETE /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:
SH
npx sv create todofrontendcd todofrontendnpm installnpm run dev

Pick 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-auto targets a Node server; @sveltejs/adapter-static writes plain files to build/ that any static host can serve. The fallback option makes every route (including /post/3) load the same index.html, and ssr = false in the root layout tells SvelteKit to render in the browser.
SH
npm uninstall @sveltejs/adapter-autonpm install -D @sveltejs/adapter-static

vite.config.js

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

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 /todos itself. SvelteKit 3 reads .env files (Vite's .env.production is used by npm run build) and types the variables through src/env.js. static: true inlines 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)

ENV
VITE_API_URL=https://api-production-da990.up.railway.app

src/env.js

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. #lib is SvelteKit's alias for src/lib (it was $lib before SvelteKit 3). The requests are built from the origin: ${API}/todos and ${API}/todos/${id}.

src/lib/api.js

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):

TEXT
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 form

The 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

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

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

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

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

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

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

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

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:

SVELTE
<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)

SVELTE
<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#

See also