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
fetchandawait, and checkresponse.ok - Load a page's data before it is shown with a
loadfunction, and receive it as thedataprop - Send data with
POST, remove it withDELETE, and refresh what is on screen - Give a data screen its three states: data, empty, error
- The trap:
fetchdoes 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.
[
{ "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.jsis not a page but an endpoint: a URL that answers with data.
First, where the server keeps the notes:
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:
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
fetchsends 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:
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
loadfunction, in a+page.jsnext 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"]
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:
<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:
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.
<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="/">< 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:
<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:
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:
<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. Everyloadthat fetched it runs again, anddataupdates.
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 runsawait 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,PATCHandDELETE. - Status codes: 2xx it worked, 4xx you asked wrongly, 5xx the server failed. JSON is text.
- A JSON file is read-only,
localStorageis one browser on one device, and an API is shared but slow and fallible. fetchreturns a promise.awaitit, checkresponse.ok, thenawait response.json(). A 404 does not make it fail.loadgets a page's data before it is shown, the page receives it asdata, anderror()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
localStorageof the browser - A
POSTrequest 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
fetchto the same URL - A check of
response.okbefore reading - A
tryaround the<script>of the page - A
POSTinstead of theGETrequest
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
GETrequest cannot return a JSON body - The URL needs the full address of the server
- The method must be written in the options
-
fetchreturns a promise, so it needsawait
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.noteswithsplice - Call
invalidatewith the URL thatloadfetched - Call
location.reload()to reload the entire app - Write the new list into
localStorageby 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.okinload, callingerror()when it is false - A message for the day with no sessions, not just an empty space
- A
fetchwith noawait, 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.