Skip to content

Consuming data: REST APIs and local sources

Exercise consuming-data-annex.pdf · PDF

Add a note called "Only here" to NoteBox. Now open the same address in a different browser. Where is your note? Not there. Open it on your phone: a third, different NoteBox.

localStorage belongs to one browser on one device. For a to-do list on your own phone, that is fine. For anything two people or two devices must share, the data has to live somewhere they can both reach: a server. Until now your app owned its data. From here on it asks for it, and it must be ready for the answer to be late, empty or "no".

What you will learn

  • Compare three places data can live: a JSON file in the project, localStorage, a REST API
  • Read an API contract: method, URL, body, status code
  • Request data with fetch and await, and check response.ok
  • Load a page's data before it is shown with a load function, and receive it as the data prop
  • Send data with POST, remove it with DELETE, and refresh what is on screen
  • Give a data screen its three states: data, empty, error
  • The trap: fetch does not fail on a 404

The restaurant

You, the page, sit at a table. You do not walk into the kitchen, which is the server. You read the menu, give your order to the waiter, and the waiter comes back with a plate or with "sorry, we're out of that". You wait in between. Sometimes the plate never comes.

In the restaurant On the web What it is
The menu API The list of requests a server accepts
Your order Request What the page asks for
The plate, or "sorry" Response What the server answers
The kitchen Server The program that holds the data

A REST API organises its menu around resources (here, notes). Each resource has an address, and a small set of verbs acts on it.

The verbs are called HTTP methods:

Method Means Typical answer
GET Give me this resource. Changes nothing 200 OK + the data
POST Create a new one, with the data I send 201 Created + the new item
PATCH Change part of an existing one 200 OK + the updated item
DELETE Remove this one 204 No Content

GET must never change anything. Browsers, caches and people all rely on that.

A status code sums up the answer in one number:

Code Means Examples
2xx It worked 200, 201, 204
4xx You asked wrongly 400 bad data, 404 not found
5xx The kitchen is on fire 500

The data travels as JSON: text that looks like JavaScript objects and that every language can read. It is text, not an object. JSON.parse turns it into an object, and JSON.stringify turns it back.

Who invented this

Roy Fielding named REST in his doctoral dissertation in 2000. Today, JSON is specified in RFC 8259 (2017), and HTTP methods and status codes in RFC 9110 (2022).

A JSON file

The simplest data source is a file in the project. Cut the four notes out of notes.svelte.js and into a JSON file. JSON is stricter than JavaScript: double quotes everywhere, and no trailing commas.

src/lib/seed.json
[
    { "id": "1", "title": "Shopping list", "body": "Milk x2, eggs (6), tomatoes, bread",
        "tag": "home", "pinned": true },
    { "id": "2", "title": "Maths homework", "body": "Exercises 4 to 9, page 112",
        "tag": "school", "pinned": false },
    { "id": "3", "title": "Passwords", "body": "Never write them here.",
        "tag": "none", "pinned": false },
    { "id": "4", "title": "Ideas for the app", "body": "Dark mode. Tags. Search.",
        "tag": "school", "pinned": false }
]

In the module, import seed from './seed.json'; replaces the array. The app works exactly as before.

Can the app add a note to that file? No. The file is baked into the app when it is built: instant, always there, and read-only.

Three places data can live

JSON file in the project localStorage REST API
Lives Inside the built app In this browser, on this device On a server
Changes? No: read-only Yes. Strings only, about 5 MB per site Yes, for everybody
Speed Instant Instant Takes time, can fail
Code import data from './x.json' getItem / setItem + JSON fetch + await, usually in load
Good for First data, fixed lists Preferences, drafts Shared or important data

The kitchen: an endpoint

You need a server, and SvelteKit can be one.

A file called +server.js is not a page but an endpoint: a URL that answers with data.

First, where the server keeps the notes:

src/lib/server/db.js
import seed from '../seed.json';

// Our "database": an array in the server's memory.
// It is reset every time the server restarts.
export const notes = structuredClone(seed);

A folder called server is off-limits to browser code. SvelteKit refuses to bundle it into a page.

Then the endpoint for the list. You export a function named after the HTTP method, and json() builds the response:

src/routes/api/notes/+server.js
import { json } from '@sveltejs/kit';
import { notes } from '#lib/server/db.js';

export function GET() {
    return json(notes);
}

Type localhost:5173/api/notes in the address bar. Raw JSON: this is what a page will receive. Now ask from the browser's console, as a page would:

const response = await fetch('/api/notes');
response.status;          // 200
await response.json();    // the array

fetch sends a request and returns a promise of a response: a value that is not ready yet.

The waiter has left, but has not come back. await waits for the response, and await response.json() waits again while the body is read. Forget an await and you are holding an empty tray.

One note needs a second endpoint, with the "house number" from Routing and navigation. error() builds a failure response:

src/routes/api/notes/[id]/+server.js
import { error, json } from '@sveltejs/kit';
import { notes } from '#lib/server/db.js';

export function GET({ params }) {
    const note = notes.find((item) => item.id === params.id);
    if (!note) error(404, 'Note not found');

    return json(note);
}

export function DELETE({ params }) {
    const index = notes.findIndex((item) => item.id === params.id);
    if (index === -1) error(404, 'Note not found');

    notes.splice(index, 1);

    return new Response(null, { status: 204 });
}

Keep DevTools → Network open and try /api/notes/2 and /api/notes/999 in the address bar. You should see 200 and 404. This tab is where you debug every data problem from now on.

load: ask before the page is shown

When should the list page ask for the notes? Before it is shown, so the user never sees a page with a hole in it.

A load function, in a +page.js next to the page, fetches what the page needs before the page is drawn.

flowchart LR
    U["<b>User opens /</b>"] --> L["<b>load</b> in +page.js<br>the order"]
    L -->|"GET /api/notes"| S["<b>+server.js</b><br>the kitchen"]
    S -->|"200 + JSON"| L
    L -->|"returns { notes }"| P["<b>+page.svelte</b><br>receives data"]
    S -.->|"4xx or 5xx"| E["<b>+error.svelte</b><br>the apology"]
src/routes/+page.js
import { error } from '@sveltejs/kit';

export async function load({ fetch }) {
    const response = await fetch('/api/notes');

    if (!response.ok) {
        error(response.status, 'Could not load the notes.');
    }

    return { notes: await response.json() };
}

Use the fetch that load receives as a parameter: SvelteKit then knows this load depends on that URL, which you will need later. And since NoteBox has ssr = false, this load runs in the browser.

response.ok is true for any 2xx status.

Careful: fetch does not fail on a 404

fetch only fails by itself if the network fails. A 404 or a 500 is a normal response with ok set to false: a "no" from the waiter is still an answer. You must check.

Whatever load returns arrives in the page as the prop data. Two lines change in the list page:

src/routes/+page.svelte (script)
<script>
    let { data } = $props();

    let query = $state('');

    let visible = $derived(
        data.notes.filter((note) => note.title.toLowerCase().includes(query.toLowerCase()))
    );
</script>

Reload with the Network tab open: one request, notes, status 200, and the cards appear. Open the second browser: the same notes. The problem from the top of this page is fixed.

Careful: why not fetch inside an $effect?

It works, but the page first shows empty and then jumps, you must invent your own loading and error flags, and navigation cannot wait for it. load gives you all of that for free.

When it fails

The note page follows the same pattern, with the parameter:

src/routes/notes/[id]/+page.js
import { error } from '@sveltejs/kit';

export async function load({ fetch, params }) {
    const response = await fetch(`/api/notes/${params.id}`);

    if (response.status === 404) {
        error(404, 'Note not found');
    }
    if (!response.ok) {
        error(response.status, 'Could not load the note.');
    }

    return { note: await response.json() };
}

The page loses its imports of the module. In the markup, note becomes data.note, and the {#if note} ... {:else} goes away: if the note does not exist, load stops before the page is drawn.

src/routes/notes/[id]/+page.svelte
<script>
    import { goto } from '$app/navigation';

    let { data } = $props();

    async function remove() {
        const response = await fetch(`/api/notes/${data.note.id}`, { method: 'DELETE' });
        if (response.ok) goto('/');
    }
</script>

<svelte:head>
    <title>{data.note.title} · NoteBox</title>
</svelte:head>

<p><a href="/">&lt; Notes</a></p>

<article>
    <h1>{data.note.title}</h1>
    <p class="tag">{data.note.tag}{data.note.pinned ? ' · pinned' : ''}</p>
    <p>{data.note.body}</p>
    <button onclick={remove}>Delete</button>
</article>

Type /notes/999: a bare "404 Note not found". When load calls error(), SvelteKit shows +error.svelte instead of the page. Make your own:

src/routes/+error.svelte
<script>
    import { page } from '$app/state';
</script>

<h1>{page.status}</h1>
<p>{page.error.message}</p>
<p><a href="/">Back to the notes</a></p>

It sits inside the layout, so the header is still there and there is a way out. This is Nielsen's heuristic number 9: help users recognise, diagnose and recover from errors.

Now break it on purpose. In api/notes/+server.js, make GET start with error(500, 'Database is down'); (and import error next to json). Reload /: you get the error page, with the message from load. Remove the line.

POST: sending data

The list now reads from the server, but /new still adds to localStorage. Until you finish this section, adding a note will look broken. That is expected.

Add this to src/routes/api/notes/+server.js, and keep import { error, json } from '@sveltejs/kit'; at the top:

export async function POST({ request }) {
    const data = await request.json();
    const title = String(data.title ?? '').trim();

    if (title.length < 3 || title.length > 40) {
        error(400, 'The title must have 3 to 40 characters.');
    }

    const note = {
        id: String(Date.now()),
        title,
        body: String(data.body ?? ''),
        tag: String(data.tag ?? 'none'),
        pinned: Boolean(data.pinned)
    };
    notes.push(note);

    return json(note, { status: 201 });
}

You already validated the title in the form. Why again here? Because the form is your door, and the endpoint is a door anyone can knock on. Prove it from the console:

await fetch('/api/notes', { method: 'POST', body: '{"title":"x"}' });

The status is 400. The server validates everything, always; the form's validation is for the user's comfort. And the server chooses the id, never the client.

Now the page's side:

src/routes/new/+page.svelte
<script>
    import { goto } from '$app/navigation';
    import NoteForm from '#lib/NoteForm.svelte';

    let problem = $state('');

    async function save(data) {
        const response = await fetch('/api/notes', {
            method: 'POST',
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify(data)
        });

        if (!response.ok) {
            const body = await response.json();
            problem = body.message;
            return;
        }

        goto('/');
    }
</script>

<svelte:head>
    <title>New note · NoteBox</title>
</svelte:head>

<div class="narrow">
    <h1>New note</h1>
    <NoteForm onsave={save} />
    {#if problem}
        <p class="problem" role="alert">The note was not saved: {problem}</p>
    {/if}
</div>

<style>
    .narrow { max-width: 358px; }
    .problem { color: var(--nb-danger); }
</style>

A fetch that sends has three parts: the method, a header saying "this is JSON", and the body as text. NoteForm is, again, untouched.

Save a note with the Network tab open. Two lines appear: notes POST 201, then notes GET 200. Who asked for the second one? goto('/') opened the list, and its load ran. Click each line and read Headers, Payload and Response.

Now the module has no job left. Delete src/lib/notes.svelte.js.

Careful: did you do the /tags/[tag] exercise?

That page still imports the module, so deleting it breaks the page. First give it its own +page.js: fetch all the notes, filter them in load, and return { tag, notes }. Then read data.tag and data.notes in the page.

Refreshing after a change

After a DELETE or a POST that stays on the same page, data is out of date. Who owns the notes now? The server. So the page asks again; it does not guess or edit its own copy. Two copies of the truth always end up different.

invalidate(url), from $app/navigation, tells SvelteKit that what this URL returned is out of date. Every load that fetched it runs again, and data updates.

await invalidate('/api/notes');

The URL must be the same one that load passed to fetch.

Your turn: delete from the list

The exercise in the handout adds these to the list page:

  • Under each card, a text button "Delete" plus the note's title (13 px, danger colour, no border). It is a sibling of the card link, not inside it.
  • On click, it sends DELETE /api/notes/{id}. If the answer is ok, it runs await invalidate('/api/notes'), and the card disappears with no page reload.
  • With no notes, an empty state: "No notes here. Write the first one." with a link to /new.
  • In the Network tab: one DELETE with status 204, then one GET with status 200.

That completes the three states of a data screen: data, empty and error.

If something fails, check these first:

What you see What to check
Clicking Delete also opens the note The button is inside the <a>. Interactive things do not nest
Clicking Delete throws an error in the console You wrote onclick={remove(note.id)}: that is a call, not a function. Use onclick={() => remove(note.id)}. If notes went missing, restart npm run dev to get them back
The list refreshes before the delete A missing await before fetch or invalidate
It works on / but not elsewhere fetch('api/notes/' + id) has no leading slash
You remove the card from data.notes by hand The server owns the notes. Ask again with invalidate
You use location.reload() It works, but it throws away the whole app's state to redraw four cards

Restart your dev server and the deleted notes come back. Why? Memory dies with the process. A real server would use a database on disk.

Then do it in your own app: put your main data in src/lib/<something>.json, serve it from a GET endpoint, show it through load, and add +error.svelte. If your app creates things, add the POST.

An API does not have to be SvelteKit

An API is often a separate project in another language, such as Java with Spring. The page does not care: a contract is a contract. That is why you use load + fetch here. It is the pattern that survives when the server is somebody else's.

Key takeaways

  • An API is a menu of requests, and a REST API organises it around resources with GET, POST, PATCH and DELETE.
  • Status codes: 2xx it worked, 4xx you asked wrongly, 5xx the server failed. JSON is text.
  • A JSON file is read-only, localStorage is one browser on one device, and an API is shared but slow and fallible.
  • fetch returns a promise. await it, check response.ok, then await response.json(). A 404 does not make it fail.
  • load gets a page's data before it is shown, the page receives it as data, and error() leads to +error.svelte.
  • The server owns the data and validates everything. After a change, ask again with invalidate.

Quick check

1. You want a list of 50 European countries that never changes. Which data source fits best?
  • A JSON file in the project
  • The localStorage of the browser
  • A POST request to a server
  • A variable inside an $effect

A JSON file is instant, always there and read-only, which is ideal for fixed lists. localStorage is for things that change per device, and a server adds waiting and failures you do not need here.

2. Which HTTP method should never change anything on the server?
  • POST
  • PATCH
  • GET
  • DELETE

GET only reads. Browsers, caches and people rely on that. POST creates, PATCH changes part of a resource and DELETE removes it.

3. A load function calls fetch and then response.json(). The server answers 500, and the page breaks in a confusing way instead of showing the error page. What is missing?
  • A second fetch to the same URL
  • A check of response.ok before reading
  • A try around the <script> of the page
  • A POST instead of the GET request

A 500 is a normal response: a "no" from the waiter is still an answer, so nothing stops by itself. You must check response.ok and call error() when it is false, and SvelteKit then shows +error.svelte.

4. A student writes const notes = fetch('/api/notes').json();. Why does it fail?
  • A GET request cannot return a JSON body
  • The URL needs the full address of the server
  • The method must be written in the options
  • fetch returns a promise, so it needs await

fetch gives a promise of a response. You await the response, check it, and then await response.json(), because reading the body takes time too.

5. Your form already checks that a title has at least 3 characters. Why check it again in the endpoint?
  • Because the browser forgets the rule after the first request
  • Because JSON cannot carry strings shorter than three characters
  • Because anyone can send a request without using your form
  • Because SvelteKit refuses any endpoint that has no validation

The form is your door, and the endpoint is a door anyone can knock on, even from the console. The form's validation is for comfort. The server validates everything, always.

6. After a successful DELETE, how should the list page update what it shows?
  • Remove the item from data.notes with splice
  • Call invalidate with the URL that load fetched
  • Call location.reload() to reload the entire app
  • Write the new list into localStorage by hand

The server owns the data, so the page asks again. invalidate reruns every load that fetched that URL, and data updates with no page reload.

7. A cinema app shows sessions from an API, and the API is sometimes down. Pick the two things the session screen needs.
  • A check of response.ok in load, calling error() when it is false
  • A message for the day with no sessions, not just an empty space
  • A fetch with no await, so the page never has to wait
  • A copy of every session typed into the markup, in case of failure

A data screen has three states: data, empty and error. Checking response.ok sends failures to +error.svelte, and an empty list deserves its own message. Typed copies are a second truth that goes stale.

You have every piece now: components, state, forms, layout, routes and data. Next, you put them together in your own app: Workshop: from Figma to app.