# Data: fetch, APIs and a backend

JSON, HTTP and fetch, CORS and API keys, and a Supabase database with logins and Row Level Security.

Multimedia & Creative Technology · 3.1 Extra Webdev · Self-study · version 0.1.0 · 2026-10-14

Tags: javascript, api, database, security, auth

Slides: <https://slides.mctantwerp.be/en/extra-webdev/06-data>

<!-- slide 2 -->

## After this lesson you can

- Read and write JSON, and turn it into a JavaScript object and back
- Read the docs of an API and build the right url, method and headers for a request
- Fetch data with async and await, check the status, and show loading, empty and error states on a page
- Explain why the browser blocks some requests, and what you can and cannot do about it
- Keep an API key out of your frontend with a small function on a server
- Store data in Supabase, behind a login and Row Level Security policies you tested yourself

> **Explanation:** The three assignments build on each other: a page with live data from a public API, then a guestbook in Supabase, then a key hidden behind a function. Keep a project folder open while you read and copy the code you like. Typing it yourself sticks better than reading it.

<!-- slide 3 -->

## Agenda

1. JSON
2. APIs and HTTP
3. fetch
4. Data on the page
5. Sending data
6. CORS
7. Keys and limits
8. Storing data
9. Supabase
10. Logins and Row Level Security

> **Explanation:** Chapters 1 to 5 are about other people's data: reading an API and showing it. Chapters 6 and 7 are the two walls you will hit as soon as you build something real: CORS and API keys. Chapters 8 to 10 are about storing your own data. If you know JSON and HTTP already, skim chapters 1 and 2, but do the quizzes.

<!-- slide 4 -->

## 1. JSON

> **Explanation:** Every API in this deck talks JSON, and so will nearly every API you use after it. You also meet it outside APIs: package.json, the settings of VS Code, the export of a database. Ten minutes on the rules saves you an hour of hunting for a missing comma.

<!-- slide 5 -->

**Data travels as text.**

A server cannot send your page a JavaScript object, only characters. JSON is the format nearly every API uses for those characters: it looks like a JavaScript object, and every programming language can read it.

> **Explanation:** JSON was described by Douglas Crockford in the early 2000s, as a lighter option than XML, which every API used back then. It won because a browser could read it with one function call. The official spec fits on one page: json.org. Every language has a parser for it, so a Python server and a JavaScript page understand each other without extra work.

<!-- slide 6 -->

### What JSON looks like

```json
{
  "name": "pikachu",
  "id": 25,
  "legendary": false,
  "types": ["electric"],
  "stats": { "hp": 35, "speed": 90 },
  "evolvesFrom": null
}
```

1. A string, always in double quotes. The key too.
2. A number, without quotes. No difference between whole numbers and decimals.
3. `true` or `false`, without quotes.
4. An array: a list between square brackets.
5. An object inside an object. Nesting goes as deep as the data needs.
6. `null` means no value. JSON has no `undefined`.

> **Explanation:** This is a slimmed down version of what PokéAPI really sends for Pikachu. The real answer is over 10,000 lines, which is normal: an API sends everything it has, and you pick the fields you need. The whole document is one value, usually an object or an array. Indentation and line breaks mean nothing to a parser, they are only there for you.

<!-- slide 7 -->

### Not quite JavaScript

|   | JSON | A JavaScript object |
| --- | --- | --- |
| **Keys** | Always in double quotes | Quotes optional |
| **Strings** | Double quotes only | Single, double or backticks |
| **Comma after the last item** | Syntax error | Fine |
| **Comments** | Not allowed | Fine |
| **Functions, undefined, Date** | Do not exist | Fine |

A date travels as a string, usually like `"2026-10-14T09:00:00Z"`. More on [Working with JSON](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/JSON) at MDN.

> **Explanation:** The trailing comma is the classic one: JavaScript allows it, JSON does not, and the error message points at the character after it. Dates are a pain: JSON has no date type, so APIs send a string, usually in ISO 8601 like 2026-10-14T09:00:00Z, and you turn it into a Date yourself with new Date(string). VS Code shows JSON mistakes with a red squiggle, so paste a response in a .json file when you doubt.

<!-- slide 8 -->

### JSON.parse and JSON.stringify

Guess each output before you run it. The third preset breaks on purpose: find the character that does it.

```js
const text = '{"name": "pikachu", "id": 25, "types": ["electric"]}';

const pokemon = JSON.parse(text);
console.log(typeof text, typeof pokemon);
console.log(pokemon.types[0]);

console.log(JSON.stringify(pokemon));
console.log(JSON.stringify(pokemon, null, 2));
```

*parse and stringify*

```js
const text = '{"name": "pikachu", "id": 25, "types": ["electric"]}';

const pokemon = JSON.parse(text);
console.log(typeof text, typeof pokemon);
console.log(pokemon.types[0]);

console.log(JSON.stringify(pokemon));
console.log(JSON.stringify(pokemon, null, 2));
```

*a date*

```js
const event = { title: 'Open day', start: new Date('2026-11-21T10:00:00Z') };

const text = JSON.stringify(event);
console.log(text);

const back = JSON.parse(text);
console.log(typeof back.start);
console.log(new Date(back.start).getFullYear());
```

*broken JSON*

```js
const text = '{"name": "pikachu", "id": 25,}';

const pokemon = JSON.parse(text);
console.log(pokemon.name);
```

*what gets lost*

```js
const user = {
  name: 'Lisa',
  nickname: undefined,
  greet() { return 'hi'; },
};

console.log(JSON.stringify(user));
```

> **Explanation:** JSON.parse turns text into an object, JSON.stringify does the opposite. The second and third argument of stringify (null and 2) only make it readable: two spaces of indentation. In the last preset, look at what is missing in the output: the function and the undefined field are gone without a warning, and the date turned into a string. That silent loss bites when you save an object in localStorage and read it back later.

<!-- slide 9 -->

### Which of these is valid JSON?

- A. `{ name: "Lisa" }`
- B. `{ "name": 'Lisa' }`
- C. `{ "name": "Lisa", "age": 21 }`
- D. `{ "name": "Lisa", }`

**Answer:** C. `{ "name": "Lisa", "age": 21 }`

Keys and strings both need double quotes, and nothing may follow the last item. The first one is a perfectly good JavaScript object, which is exactly why it is tempting: JSON looks like JavaScript but is stricter. The second fails on the single quotes, the fourth on the trailing comma.

> **Explanation:** You will paste JSON by hand more often than you think: a test body for a POST, a config file, a fixture. When an API answers 400 Bad Request to your POST, check the JSON first.

<!-- slide 10 -->

## 2. APIs and HTTP

> **Explanation:** Before you write a single fetch, you need the words: url, method, status, header. They come back in every API doc, every error message and every Network tab. They are the same whether the server runs Laravel, Supabase or something you never heard of.

<!-- slide 11 -->

### What an API is

1. Your page → The API, **GET /v1/forecast?latitude=51.22...**: Your code asks for one url. The query parameters say which place and which values.
2. The API → Their database, **Look up**: The API reads from its own data. You never see how, and you do not need to.
3. Their database → The API, **Rows**: The raw data comes back to the server.
4. The API → Your page, **200 OK + JSON**: A status code, a few headers and a body in JSON. Your code turns that into something on the page.

> **Explanation:** API stands for application programming interface: a way for one program to use another. The word is older than the web (a library has an API too), but on the web it usually means this: a server with urls that send data instead of a page. The page and the API can belong to different companies. Your page asks Open-Meteo for the weather, and Open-Meteo has no idea who you are.

<!-- slide 12 -->

### Anatomy of a url

| Part | Here | What it does |
| --- | --- | --- |
| **Protocol** | `https://` | How to talk. Always https for an API. |
| **Host** | `api.open-meteo.com` | Which server |
| **Path** | `/v1/forecast` | Which endpoint on that server |
| **Query parameters** | `?latitude=51.22&longitude=4.40` | Key and value pairs, joined with & |

A GET request is a url, so your browser can open it. [This one](https://api.open-meteo.com/v1/forecast?latitude=51.22&longitude=4.40&current=temperature_2m) answers with the temperature in Antwerp, as JSON.

> **Explanation:** The base url (protocol and host, sometimes with a version like /v1) stays the same for every request to one API. The docs usually start with it. Order of the query parameters does not matter. Some characters cannot go in a url as they are: a space becomes %20, an & inside a value becomes %26. Chapter 4 shows how URLSearchParams does that for you.

<!-- slide 13 -->

### Methods and REST

| Method | Example | Does | Body |
| --- | --- | --- | --- |
| **GET** | `GET /posts/1` | Reads. Changes nothing. | No |
| **POST** | `POST /posts` | Creates something new | Yes |
| **PUT** | `PUT /posts/1` | Replaces the whole thing | Yes |
| **PATCH** | `PATCH /posts/1` | Changes some fields | Yes |
| **DELETE** | `DELETE /posts/1` | Removes it | Usually not |

A REST API has one url per thing, and the method says what to do with it. More on [HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods).

> **Explanation:** PUT and PATCH get mixed up a lot, and plenty of APIs do not respect the difference. Read the docs of the API you use. REST is a style, not a standard: you will see APIs that do everything with POST, or that put the action in the url (/posts/1/publish). GraphQL is the other big style: one url, and the body of the request says what you want. You will not need it for this deck.

<!-- slide 14 -->

### Status codes

| Code | Means | You see it when |
| --- | --- | --- |
| **200, 201, 204** | OK, created, OK without a body | It worked |
| **301, 304** | Moved, not modified | The browser follows or caches, you rarely notice |
| **400, 422** | Bad request, invalid data | Your JSON or a field is wrong |
| **401, 403** | Not logged in, not allowed | A key or a login is missing, or does not give access |
| **404, 429** | Not found, too many requests | A typo in the url, or you hit the rate limit |
| **500, 503** | Server error, unavailable | Not your fault. Try again later. |

All of them on [HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status).

> **Explanation:** The first digit tells you whose problem it is. 4xx means fix your request: a typo in the url, a missing field, a wrong key. 5xx means the server broke, and trying again later may work. 422 is what Laravel sends when validation fails, with the errors per field in the body. 401 and 403 are often mixed up, even by APIs themselves: 401 says it does not know who you are, 403 says it knows you and the answer is no.

<!-- slide 15 -->

### Headers

*Request, then response*

```http
GET /api/v2/pokemon/pikachu HTTP/2
Host: pokeapi.co
Accept: application/json

HTTP/2 200
content-type: application/json; charset=utf-8
access-control-allow-origin: *

{"id":25,"name":"pikachu", ...}
```

| Header | Says |
| --- | --- |
| `Accept` | The format I want back |
| `Content-Type` | The format of this body |
| `Authorization` | Who I am: a key or a token |
| `Retry-After` | Wait this long before you try again |

> **Explanation:** This is the real exchange with PokéAPI, from curl -i. Header names are case insensitive: HTTP/2 writes them in lowercase. The Accept header asks for a format, Content-Type says which format the body has. Authorization carries a key or a login token, usually as Bearer followed by the token. The last response header is CORS, chapter 6 is about it.

<!-- slide 16 -->

### Reading API docs

- The base url, and whether there is a version in it like /v1
- Authentication: none, a key in a header or the url, or a login with OAuth
- The endpoints, with their parameters: which ones are required, which have a default
- An example response, so you know the shape of the JSON before you write code
- Limits and terms: requests per day, and whether commercial use is allowed
- CORS: whether a browser may call it, or only a server

Practice on the [Open-Meteo docs](https://open-meteo.com/en/docs), or find an API in the [public-apis list](https://github.com/public-apis/public-apis).

> **Explanation:** Open the Open-Meteo docs next to this list and find each item. Their docs even build the url for you with a form. Bad docs are a reason to pick another API: if you cannot find the limits or an example response, you will not find them when something breaks either. The public-apis list on GitHub has a column for Auth and one for CORS, which saves you a lot of trying.

<!-- slide 17 -->

### The Network tab

1. **Open DevTools**: Windows <kbd>F12</kbd>, Mac <kbd>Cmd</kbd>+<kbd>Option</kbd>+<kbd>I</kbd>, then the Network tab. Reload the page so it records from the start.
2. **Filter on Fetch/XHR**: Only the requests your JavaScript makes stay in the list.
3. **Click a request**: Headers shows the url, the method and the status. Response shows the body as it arrived.
4. **Read the status first**: Red is 4xx or 5xx. A request that is missing never left your code.

> **Explanation:** The Fetch/XHR filter hides images, CSS and scripts, so only the calls your code makes stay. Tick Preserve log when a page navigates away and you lose your requests. Right click a request for Copy as cURL or Copy as fetch: handy to replay it in a terminal or paste it into a bug report. Firefox and Safari have the same tab with nearly the same names.

<!-- slide 18 -->

### You send a valid key, for an invoice that belongs to another account. Which status code fits best?

- A. 401 Unauthorized
- B. 403 Forbidden
- C. 400 Bad Request
- D. 500 Internal Server Error

**Answer:** B. 403 Forbidden

The API knows who you are (the key is valid) and refuses: that is 403. 401 is tempting because of its name, but it means the API does not know who you are: no key, or a key that is wrong or expired. 400 is for a request that is malformed, and 500 means the server itself broke.

> **Explanation:** Some APIs answer 404 instead of 403 on purpose, so a stranger cannot even find out that a resource exists. GitHub does that for private repos. So a 404 on something you are sure exists can mean you are not allowed to see it.

<!-- slide 19 -->

## 3. fetch

> **Explanation:** fetch is built into every browser and into Node, so you need no library. jQuery's $.ajax and axios did the same job before fetch existed, and you will still see them in older code. The hard part is the waiting, so the first slide of this chapter is about that.

<!-- slide 20 -->

### Waiting for the network

Guess the order of the three lines before you run it.

```js
console.log('1. before');

fetch('https://pokeapi.co/api/v2/pokemon/pikachu')
  .then(() => console.log('3. the answer is in'));

console.log('2. after');
```

> **Explanation:** The answer is: 1, 2, 3. fetch starts the request and returns at once with a Promise, a placeholder for an answer that is not there yet. JavaScript does not wait for the network, it runs the next line. The function inside then runs later, when the response has arrived. If JavaScript did wait, your whole page would freeze for every request, like it does for alert().

<!-- slide 21 -->

### async and await

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  const response = await fetch(`${api}/pokemon/${name}`);
  const data = await response.json();

  console.log(data.name);
  console.log(data.weight);
}

showPokemon('pikachu');
```

1. `async` allows `await` inside this function.
2. `await` pauses this function until the response is in: the status and the headers, not the body yet.
3. `.json()` reads the body and parses it. That takes time too, so it gets its own `await`.
4. `data` is a normal object now. Use it like any other.
5. Calling the function starts it. The next line of your script does not wait for it.

> **Explanation:** await only pauses the function it is in. The rest of the page keeps running: buttons work, animations play. That is why await is only allowed inside a function marked async (or at the top level of a module, a script tag with type=module). An async function always returns a Promise itself, so whoever calls it can await it too, or ignore it.

<!-- slide 22 -->

### Your first fetch

Change the name, or load a preset. Look at the shape of the data before you use a field.

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  const response = await fetch(`${api}/pokemon/${name}`);
  const data = await response.json();

  console.log(data.name, '#' + data.id);
  console.log('weight', data.weight / 10, 'kg');
}

showPokemon('pikachu');
```

*pikachu*

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  const response = await fetch(`${api}/pokemon/${name}`);
  const data = await response.json();

  console.log(data.name, '#' + data.id);
  console.log('weight', data.weight / 10, 'kg');
}

showPokemon('pikachu');
```

*eevee*

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  const response = await fetch(`${api}/pokemon/${name}`);
  const data = await response.json();

  console.log(data.name, '#' + data.id);
  console.log('weight', data.weight / 10, 'kg');
}

showPokemon('eevee');
```

*the whole object*

```js
async function show() {
  const response = await fetch('https://pokeapi.co/api/v2/pokemon/ditto');
  const data = await response.json();
  console.log(Object.keys(data));
  console.log(data);
}

show();
```

*a list*

```js
async function list() {
  const response = await fetch('https://pokeapi.co/api/v2/pokemon?limit=5');
  const data = await response.json();
  console.log(data.count + ' in total');
  console.log(data.results.map((p) => p.name));
}

list();
```

> **Explanation:** The whole object is too big for this console, which only shows three levels deep. In a real browser, console.log(data) gives you a tree you can fold open: that is the fastest way to learn the shape of an API. weight is in hectograms in PokéAPI, which is why the code divides by 10. You only learn that kind of thing from the docs.

<!-- slide 23 -->

### A 404 is not an error

```js
const url = 'https://pokeapi.co/api/v2/pokemon/pikachuu';
const response = await fetch(url);

console.log(response.status);   // 404
console.log(response.ok);       // false

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
```

1. A typo in the name. The server answers, with a 404.
2. `fetch` does not throw. `ok` is true for 200 to 299, false for everything else.
3. So you throw yourself. From here on, a 404 behaves like any other error.

See [Response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) and [Using the Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch).

> **Explanation:** This surprises nearly everyone, and axios behaves differently (it does throw on a 4xx or 5xx), which adds to the confusion. fetch only rejects when there is no response at all: you are offline, the domain does not exist, or CORS blocks it. A 404 or a 500 is a response, so fetch is happy. Some APIs send an error message in the body of a 4xx, Open-Meteo does, so read the body before you throw if you want to show it.

<!-- slide 24 -->

### try and catch

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  try {
    const response = await fetch(`${api}/pokemon/${name}`);
    if (!response.ok) {
      throw new Error(`No Pokémon called ${name}`);
    }
    const data = await response.json();
    console.log(data.name);
  } catch (error) {
    console.error(error.message);
  }
}
```

1. Everything that can go wrong goes inside `try`.
2. A bad status: you throw, and JavaScript jumps to `catch`.
3. Only reached when the response was fine.
4. No network, a bad status or broken JSON: all three land here.

> **Explanation:** One try around the whole thing catches three kinds of trouble: no network (fetch throws a TypeError), a bad status (your own throw) and a body that is not valid JSON (.json() throws a SyntaxError). In a real page, the catch is where you show a message to the visitor instead of only logging it. Chapter 4 does that.

<!-- slide 25 -->

### When it goes wrong

Each preset breaks in another way. Compare what ends up in the console, and which errors the catch gets.

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  try {
    const response = await fetch(`${api}/pokemon/${name}`);
    if (!response.ok) {
      throw new Error(`No Pokémon called ${name} (HTTP ${response.status})`);
    }
    const data = await response.json();
    console.log(data.name, data.types.map((t) => t.type.name));
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

showPokemon('bulbasaur');
```

*found*

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  try {
    const response = await fetch(`${api}/pokemon/${name}`);
    if (!response.ok) {
      throw new Error(`No Pokémon called ${name} (HTTP ${response.status})`);
    }
    const data = await response.json();
    console.log(data.name, data.types.map((t) => t.type.name));
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

showPokemon('bulbasaur');
```

*typo: 404*

```js
const api = 'https://pokeapi.co/api/v2';

async function showPokemon(name) {
  try {
    const response = await fetch(`${api}/pokemon/${name}`);
    if (!response.ok) {
      throw new Error(`No Pokémon called ${name} (HTTP ${response.status})`);
    }
    const data = await response.json();
    console.log(data.name, data.types.map((t) => t.type.name));
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

showPokemon('bulbasaurr');
```

*no server*

```js
const api = 'https://pokeapi.invalid/api/v2';

async function showPokemon(name) {
  try {
    const response = await fetch(`${api}/pokemon/${name}`);
    if (!response.ok) {
      throw new Error(`No Pokémon called ${name} (HTTP ${response.status})`);
    }
    const data = await response.json();
    console.log(data.name, data.types.map((t) => t.type.name));
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

showPokemon('bulbasaur');
```

*no try and catch*

```js
const api = 'https://pokeapi.invalid/api/v2';

async function showPokemon(name) {
  const response = await fetch(`${api}/pokemon/${name}`);
  const data = await response.json();
  console.log(data.name);
}

showPokemon('bulbasaur');
```

> **Explanation:** The last preset has no try and catch. The error does not disappear: it becomes an unhandled rejection, which you see in red in the console, while your page sits there showing nothing. pokeapi.invalid uses the .invalid top level domain, which is reserved and never resolves, so it is a safe way to simulate a server that is down.

<!-- slide 26 -->

### then or await

#### With then

```js
fetch(url)
  .then((response) => response.json())
  .then((data) => {
    console.log(data.name);
  })
  .catch((error) => {
    console.error(error.message);
  });
```

#### With await

```js
try {
  const response = await fetch(url);
  const data = await response.json();
  console.log(data.name);
} catch (error) {
  console.error(error.message);
}
```

> **Explanation:** Before async and await (2017) everybody wrote then chains, and plenty of docs and Stack Overflow answers still do. They do exactly the same: await is a nicer way to write a then. Mixing both in one function works but reads badly, so pick one. The rest of this deck uses await.

<!-- slide 27 -->

### What does `console.log(fetch(url))` print?

- A. The JSON of the response
- B. A Promise
- C. undefined
- D. The status code

**Answer:** B. A Promise

fetch returns a Promise right away, before the network has answered. You get the response by awaiting that Promise, and the JSON by awaiting response.json() on top of that. The JSON is the tempting answer, because that is what you wanted. Even with await, you would get a Response object, not the JSON.

> **Explanation:** You will see Promise { <pending> } in your own console sooner or later. It almost always means a missing await. The second most common bug is the opposite: an await inside a function that is not async, which is a syntax error.

<!-- slide 28 -->

## 4. Data on the page

> **Explanation:** A console.log is not a website. This chapter puts the data on a page, and deals with the moments where there is no data yet, no data at all, or no data because something broke. That is where a page by a student and a page by a professional differ the most.

<!-- slide 29 -->

### Four states

- **Loading**: The request is on its way. Show that something is happening, or the page looks broken.
- **Error**: No network, a 4xx or a 5xx. Say what went wrong in human words, and what to do about it.
- **Empty**: It worked, and there is nothing. An empty array is an answer, not a bug.
- **Data**: The case every tutorial shows. Only one out of four.

> **Explanation:** On a fast connection the loading state lasts 50 milliseconds and you never see it. Your visitor on a train does. DevTools can simulate that: in the Network tab, switch No throttling to Slow 4G. An empty state is not an error: a search with no results is a normal answer, and it deserves a sentence that says so instead of a blank page.

<!-- slide 30 -->

### Weather in Antwerp

Watch loading turn into the weather. Then send latitude 999 and read what the API answers.

```html
<h1>Weather in Antwerp</h1>
<p id="status">Loading...</p>

<script>
  const api = 'https://api.open-meteo.com/v1/forecast';
  const status = document.querySelector('#status');

  async function loadWeather() {
    const params = new URLSearchParams({
      latitude: 51.22,
      longitude: 4.40,
      current: 'temperature_2m,wind_speed_10m',
      timezone: 'Europe/Brussels',
    });

    try {
      const response = await fetch(`${api}?${params}`);
      const data = await response.json();
      if (!response.ok) throw new Error(data.reason);

      const now = data.current;
      status.textContent = `${now.temperature_2m} °C, wind ${now.wind_speed_10m} km/h`;
    } catch (error) {
      status.textContent = 'No weather right now: ' + error.message;
    }
  }

  loadWeather();
</script>
```

*Antwerp*

```html
<h1>Weather in Antwerp</h1>
<p id="status">Loading...</p>

<script>
  const api = 'https://api.open-meteo.com/v1/forecast';
  const status = document.querySelector('#status');

  async function loadWeather() {
    const params = new URLSearchParams({
      latitude: 51.22,
      longitude: 4.40,
      current: 'temperature_2m,wind_speed_10m',
      timezone: 'Europe/Brussels',
    });

    try {
      const response = await fetch(`${api}?${params}`);
      const data = await response.json();
      if (!response.ok) throw new Error(data.reason);

      const now = data.current;
      status.textContent = `${now.temperature_2m} °C, wind ${now.wind_speed_10m} km/h`;
    } catch (error) {
      status.textContent = 'No weather right now: ' + error.message;
    }
  }

  loadWeather();
</script>
```

*Reykjavík*

```html
<h1>Weather in Reykjavík</h1>
<p id="status">Loading...</p>

<script>
  const api = 'https://api.open-meteo.com/v1/forecast';
  const status = document.querySelector('#status');

  async function loadWeather() {
    const params = new URLSearchParams({
      latitude: 64.15,
      longitude: -21.94,
      current: 'temperature_2m,wind_speed_10m',
      timezone: 'Europe/Brussels',
    });

    try {
      const response = await fetch(`${api}?${params}`);
      const data = await response.json();
      if (!response.ok) throw new Error(data.reason);

      const now = data.current;
      status.textContent = `${now.temperature_2m} °C, wind ${now.wind_speed_10m} km/h`;
    } catch (error) {
      status.textContent = 'No weather right now: ' + error.message;
    }
  }

  loadWeather();
</script>
```

*latitude 999*

```html
<h1>Weather in nowhere</h1>
<p id="status">Loading...</p>

<script>
  const api = 'https://api.open-meteo.com/v1/forecast';
  const status = document.querySelector('#status');

  async function loadWeather() {
    const params = new URLSearchParams({
      latitude: 999,
      longitude: 4.40,
      current: 'temperature_2m,wind_speed_10m',
      timezone: 'Europe/Brussels',
    });

    try {
      const response = await fetch(`${api}?${params}`);
      const data = await response.json();
      if (!response.ok) throw new Error(data.reason);

      const now = data.current;
      status.textContent = `${now.temperature_2m} °C, wind ${now.wind_speed_10m} km/h`;
    } catch (error) {
      status.textContent = 'No weather right now: ' + error.message;
    }
  }

  loadWeather();
</script>
```

> **Explanation:** URLSearchParams builds the query string for you, and encodes what needs encoding: a space, an &, an accent. A city typed by a user, like 'Sint-Niklaas & Lokeren', would break a url you glue together yourself. Open-Meteo sends an error message in the body of a 400, under reason, so the code reads the body before it checks ok. Another API may do that differently: check the docs.

<!-- slide 31 -->

### A list, or nothing

Switch to user 99: the API answers 200 with an empty array, and the page has to say so.

```html
<h1>Posts</h1>
<p id="status">Loading...</p>
<ul id="posts"></ul>

<script>
  const api = 'https://jsonplaceholder.typicode.com';
  const status = document.querySelector('#status');
  const list = document.querySelector('#posts');

  async function loadPosts(userId) {
    const response = await fetch(`${api}/posts?userId=${userId}`);
    const posts = await response.json();

    if (posts.length === 0) {
      status.textContent = 'This user has not posted anything yet.';
      return;
    }

    status.textContent = posts.length + ' posts';
    for (const post of posts.slice(0, 4)) {
      const item = document.createElement('li');
      item.textContent = post.title;
      list.append(item);
    }
  }

  loadPosts(1);
</script>
```

*user 1*

```html
<h1>Posts</h1>
<p id="status">Loading...</p>
<ul id="posts"></ul>

<script>
  const api = 'https://jsonplaceholder.typicode.com';
  const status = document.querySelector('#status');
  const list = document.querySelector('#posts');

  async function loadPosts(userId) {
    const response = await fetch(`${api}/posts?userId=${userId}`);
    const posts = await response.json();

    if (posts.length === 0) {
      status.textContent = 'This user has not posted anything yet.';
      return;
    }

    status.textContent = posts.length + ' posts';
    for (const post of posts.slice(0, 4)) {
      const item = document.createElement('li');
      item.textContent = post.title;
      list.append(item);
    }
  }

  loadPosts(1);
</script>
```

*user 99*

```html
<h1>Posts</h1>
<p id="status">Loading...</p>
<ul id="posts"></ul>

<script>
  const api = 'https://jsonplaceholder.typicode.com';
  const status = document.querySelector('#status');
  const list = document.querySelector('#posts');

  async function loadPosts(userId) {
    const response = await fetch(`${api}/posts?userId=${userId}`);
    const posts = await response.json();

    if (posts.length === 0) {
      status.textContent = 'This user has not posted anything yet.';
      return;
    }

    status.textContent = posts.length + ' posts';
    for (const post of posts.slice(0, 4)) {
      const item = document.createElement('li');
      item.textContent = post.title;
      list.append(item);
    }
  }

  loadPosts(99);
</script>
```

> **Explanation:** JSONPlaceholder has ten fake users with ten posts each, so user 99 has none. The API answers 200 with an empty array, which is correct: the question was fine, the answer is nothing. createElement plus textContent is a bit more typing than building one big HTML string, and the next slide shows why it is worth it.

<!-- slide 32 -->

### textContent, not innerHTML

#### Runs whatever is in the data

```js
// post.title is '<img src=x onerror=alert(1)>'
item.innerHTML = post.title;
```

#### Shows it as text

```js
// the same title, shown as it is
item.textContent = post.title;
```

> **Explanation:** This is called cross-site scripting, XSS. Data from an API is text somebody else wrote: a post title, a username, a product review. With innerHTML the browser reads it as HTML, so an img tag with an onerror attribute runs JavaScript in your page, with access to everything your page can do. With textContent the same string shows up as text, angle brackets and all. When you really need HTML from an API, use a sanitizer like DOMPurify first.

<!-- slide 33 -->

### Task: A page with live data

Homework, in a folder of your own with an index.html and a script.js. Stuck? Slides try and catch, Four states and Weather in Antwerp, and MDN's Using the Fetch API.

1. Pick a public API without a key that allows CORS, and read its docs first
2. Build the url with URLSearchParams, from something the visitor types or picks
3. Fetch on a button click or a form submit, with async, await, response.ok and try and catch
4. Show all four states on the page: loading, error, empty and the data, with textContent
5. Done when a wrong input shows a clear message, and the console stays free of red

> **Explanation:** Stuck on the first step? Open the url of your API in the browser first and look at the JSON. If you cannot get the data in a browser tab, your code will not get it either. Use the public-apis list and pick one with Auth: No and CORS: Yes. Good ones besides the two in this deck: the Art Institute of Chicago, the Rick and Morty API, and iRail for Belgian trains.

<!-- slide 34 -->

## 5. Sending data

> **Explanation:** Reading is half of it. A contact form, a like button, a booking: as soon as the visitor does something, your page sends data to an API. Same fetch, three extra options.

<!-- slide 35 -->

### POST with a JSON body

```js
const url = 'https://jsonplaceholder.typicode.com/posts';

const response = await fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Hello', userId: 1 }),
});

const created = await response.json();
console.log(response.status, created);
```

1. Without a method, fetch sends a GET.
2. Tells the server the body is JSON.
3. The body is a string. `JSON.stringify` makes one of your object.
4. The server answers with the new post and its id.

> **Explanation:** Content-Type tells the server how to read the body. Leave it out and fetch sends your string as text/plain, and many servers then ignore the body completely. JSON.stringify is the other half: a body must be a string (or FormData, or a file). Hand fetch an object and it turns it into the text [object Object]. A good API answers a POST with 201 Created and the new object, including the id the server gave it.

<!-- slide 36 -->

### Create, change, delete

Compare the status codes of the presets. The last one forgets something: find out what the server got.

```js
const url = 'https://jsonplaceholder.typicode.com/posts';

async function send() {
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Hello', userId: 1 }),
    });

    console.log('status', response.status);
    console.log(await response.json());
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

send();
```

*POST*

```js
const url = 'https://jsonplaceholder.typicode.com/posts';

async function send() {
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Hello', userId: 1 }),
    });

    console.log('status', response.status);
    console.log(await response.json());
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

send();
```

*PATCH*

```js
const url = 'https://jsonplaceholder.typicode.com/posts/1';

async function send() {
  try {
    const response = await fetch(url, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Changed' }),
    });

    console.log('status', response.status);
    console.log(await response.json());
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

send();
```

*DELETE*

```js
const url = 'https://jsonplaceholder.typicode.com/posts/1';

async function send() {
  try {
    const response = await fetch(url, {
      method: 'DELETE',
    });

    console.log('status', response.status);
    console.log(await response.json());
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

send();
```

*no stringify*

```js
const url = 'https://jsonplaceholder.typicode.com/posts';

async function send() {
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: { title: 'Hello', userId: 1 },
    });

    console.log('status', response.status);
    console.log(await response.json());
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

send();
```

> **Explanation:** JSONPlaceholder fakes every write: it answers like a real API, but nothing is saved, so a GET afterwards still shows the old post. Perfect for practice, useless for a real app: chapter 9 has a real database. The last preset forgets JSON.stringify. The server gets the text [object Object], cannot parse it and answers 500 with an error message that is not JSON, so response.json() throws a SyntaxError on top. Two errors for one mistake.

<!-- slide 37 -->

## 6. CORS

> **Explanation:** Sooner or later you see this in red in your console: blocked by CORS policy. Most students then search for a way to switch it off. You cannot, and once you know why, you know what to do instead.

<!-- slide 38 -->

**The browser guards the door.**

A script may only read answers from its own origin: protocol, host and port together. `https://mysite.be` and `https://api.mysite.be` are two origins. Another origin has to say it is fine, with a header.

> **Explanation:** Without this rule, any site you visit could fetch your webmail or your bank in the background, with your cookies, and read the answer. The same-origin policy stops the reading, not the sending: the request can still reach the server. That is why CORS protects users, not servers. A server that wants to block requests needs its own checks.

<!-- slide 39 -->

### How CORS works

1. Your page → The browser, **fetch(url, { method: POST })**: Your code asks. The browser sees another origin and a JSON body.
2. The browser → The API, **OPTIONS (preflight)**: The browser asks first, with `Origin: https://mysite.be` and the method it wants to use.
3. The API → The browser, **204 + Allow headers**: `Access-Control-Allow-Origin` with your origin or a star, plus the allowed methods and headers.
4. The browser → The API, **POST with the JSON**: Only now the real request goes out.
5. The API → The browser, **201 + Allow-Origin**: The response carries the header again.
6. The browser → Your page, **Response, or TypeError**: Header present: your code gets the response. Header missing: your code only gets a TypeError, and the reason goes to the console.

> **Explanation:** A simple request (a GET without special headers) goes out at once, and the browser checks the answer. A POST with a JSON body, a PUT, a DELETE or an Authorization header gets a preflight first: an OPTIONS request that asks whether the real one is allowed. In the Network tab you then see two requests for one fetch. The star allows every origin, which is what public APIs like PokéAPI send.

<!-- slide 40 -->

### Blocked

example.com answers fine, it just sends no CORS header. Compare what your code gets from each.

```js
async function check(url) {
  try {
    const response = await fetch(url);
    console.log('Got it:', response.status);
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

check('https://pokeapi.co/api/v2/pokemon/mew');
```

*PokéAPI*

```js
async function check(url) {
  try {
    const response = await fetch(url);
    console.log('Got it:', response.status);
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

check('https://pokeapi.co/api/v2/pokemon/mew');
```

*example.com*

```js
async function check(url) {
  try {
    const response = await fetch(url);
    console.log('Got it:', response.status);
  } catch (error) {
    console.error(error.name + ': ' + error.message);
  }
}

check('https://example.com');
```

> **Explanation:** In a real page, Chrome writes the reason in the console: has been blocked by CORS policy, No Access-Control-Allow-Origin header is present. Your code never sees that text, on purpose: a script may not learn anything about a server that did not allow it. So when fetch gives a TypeError, look in the console of DevTools before you blame the network. This sandbox runs on an origin called null, which PokéAPI accepts because it allows every origin.

<!-- slide 41 -->

### Fixing a CORS error

- The server has to send the header. Nothing in your frontend can switch CORS off.
- Your own API: add the headers there, with your origin. Every framework has a setting for it.
- Somebody else's API without CORS was not built for browsers. Call it from a function on a server.
- `mode: 'no-cors'` hides the error and gives you an empty response you cannot read
- A browser extension that disables CORS only fixes your own laptop, not your visitors'

[CORS at MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)

> **Explanation:** Laravel has a config/cors.php for this, Supabase sends the headers for its own API already. An API without CORS headers usually means its makers expect it to be called from a server, often because it needs a secret key. That leads straight to the next chapter: the fix for a missing CORS header and the fix for a secret key are the same small function on a server.

<!-- slide 42 -->

### A request works in curl and in Postman, but fails in your page with a CORS error. Why?

- A. The API is down for browsers
- B. Only browsers enforce CORS
- C. Your page needs https
- D. curl sends a secret key along

**Answer:** B. Only browsers enforce CORS

CORS is a rule of the browser, to protect the person using it. curl and Postman are not a browser, they read any answer. The server answered your page too, but without the header the browser kept the answer away from your code. So the API is not down, and nothing secret was missing: a header on the server side was.

> **Explanation:** Postman, Insomnia and the REST Client extension in VS Code do not enforce CORS either, so they are great for testing an API and useless for testing CORS. Test that in a real page.

<!-- slide 43 -->

## 7. Keys and limits

> **Explanation:** Most good APIs want a key: maps, AI models, payments, weather with more detail. A key identifies you, and often your credit card. This chapter is about where that key may live, and about the limits that come with it.

<!-- slide 44 -->

### Everything in your frontend is public

*.env and main.js*

```js
VITE_WEATHER_KEY=f3a9c1d7e2

const key = import.meta.env.VITE_WEATHER_KEY;
fetch(`https://api.example.com/?key=${key}`);
```

```console
$ npm run build
$ grep -o "f3a9c1d7e2" dist/assets/*.js
dist/assets/index-B7xq2.js:f3a9c1d7e2
```

A key in your JavaScript sits in the browser of every visitor: in the bundle, and in the Network tab. A `.env` file and a build step do not change that.

**[Accounts and secrets](https://slides.mctantwerp.be/en/general/07-accounts-and-secrets)**: Keeping .env out of git, and what to do when a key leaks.

> **Explanation:** Vite only puts variables that start with VITE_ in your code, exactly so you do not leak the rest by accident. But that prefix means: this is public. People run bots that search GitHub and public websites for keys, and a key for an AI API can cost you hundreds of euros in a night. If a secret key ever ended up in your frontend or in git, revoke it and make a new one. Deleting the commit is not enough.

<!-- slide 45 -->

### A function in between

1. Your page → Your function, **GET /functions/v1/weather?city=Ghent**: Your page calls your own function. No key in the url, none in the headers.
2. Your function → The API, **GET ...?q=Ghent&appid=KEY**: The function reads the key from an environment variable and calls the API.
3. The API → Your function, **200 + JSON**: The answer goes to the function, not to the browser.
4. Your function → Your page, **200 + JSON**: The function passes the data on. The Network tab shows your function, never the key.

> **Explanation:** The function is tiny: it takes the request from your page, adds the key, calls the API and passes the answer back. It runs on a server you do not have to manage: Supabase Edge Functions, Netlify Functions, Vercel Functions or Cloudflare Workers all work. Because the function calls the API and not the browser, CORS of the API no longer matters either. Your function sends its own CORS headers to your page.

<!-- slide 46 -->

### A proxy in an Edge Function

*supabase/functions/weather/index.ts*

```ts
const api = 'https://api.openweathermap.org/data/2.5/weather';
const cors = { 'Access-Control-Allow-Origin': '*' };

Deno.serve(async (req) => {
  const city = new URL(req.url).searchParams.get('city') ?? 'Antwerp';
  const key = Deno.env.get('WEATHER_KEY');

  const params = new URLSearchParams({ q: city, appid: key });
  const res = await fetch(`${api}?${params}`);

  const body = await res.text();
  return new Response(body, { status: res.status, headers: cors });
});
```

1. Runs for every request to the url of the function.
2. Only the city comes from the visitor. The key comes from the server.
3. The function builds the url itself, so nobody adds parameters of their own, and calls the API with the same fetch as in the browser.
4. Passes status and body on, with a CORS header for your page.

> **Explanation:** Edge Functions run on Deno, so they use the standard Request and Response objects of the web, the same fetch you know. Deno.env.get reads a secret you set with the CLI. The function only passes city on, so a visitor cannot use your key for other endpoints. In a real project, also restrict Access-Control-Allow-Origin to your own domain instead of a star.

<!-- slide 47 -->

### Secrets on the server

```console
$ npx supabase functions new weather
$ npx supabase secrets set WEATHER_KEY=f3a9c1d7e2
$ npx supabase functions deploy weather --no-verify-jwt
```

*In your page*

```js
const response = await fetch('https://abcd1234.supabase.co/functions/v1/weather?city=Ghent');
```

The key lives in the project on Supabase: not in git, not in the browser. See [Edge Function secrets](https://supabase.com/docs/guides/functions/secrets).

> **Explanation:** You need the Supabase CLI (npx supabase works without installing it) and a project linked with supabase link. --no-verify-jwt makes the function public: without it, Supabase only lets requests with a valid login token through. Public is fine for weather, not for a function that costs real money per call: then leave the check on, or add your own limit. On Netlify or Vercel the idea is the same: an environment variable in the dashboard, a function that reads it.

<!-- slide 48 -->

### Which key goes where

| Key | Public? | Where |
| --- | --- | --- |
| Supabase publishable, `sb_publishable_...` | Yes, by design | Frontend. RLS decides what it may do. |
| Supabase secret, `sb_secret_...` | Never | Server only. It skips every RLS policy. |
| Stripe publishable, `pk_...` | Yes | Frontend, for the payment form |
| Stripe secret, `sk_...` | Never | Server only |
| OpenAI, Anthropic, Mistral | Never | Server only. Every call costs money. |
| Google Maps JavaScript | Yes, if restricted | Frontend, locked to your domain |

> **Explanation:** The difference is what the key can do on its own. A publishable key only says which project you mean, and something else decides what it may do: RLS policies in Supabase, a list of allowed domains for Google Maps. A secret key can do everything it was made for, by itself. The legacy names still show up in tutorials: anon for the publishable key, service_role for the secret key. Supabase plans to retire those by the end of 2026.

<!-- slide 49 -->

### Where does it go

**1. The Supabase publishable key, in a Vite app that reads and writes notes**

- **In .env with the VITE_ prefix** ✓
- In an Edge Function secret
- Nowhere, the app works without it

The supabase-js client in the browser needs it on every request, and it is made to be public. What keeps the notes safe is RLS, not the key.

**2. An OpenAI key for a chatbot on your portfolio**

- In .env with the VITE_ prefix
- **In a function on a server, as a secret** ✓
- In localStorage after the first visit

Every call costs money, and a key in the frontend is readable by everyone. The chatbot calls your function, the function calls OpenAI. localStorage is still the frontend.

**3. The Supabase secret key, for an admin page only you use**

- In the admin page, it is only you
- **In an Edge Function that checks you are the admin** ✓
- In a hidden input field

Only you open the page, but the JavaScript of the page is public, and the secret key skips every policy. A function on the server holds the key and checks who is asking first.

**4. A free weather key, 1,000 calls a day**

- In the frontend, it is free
- **In a function on a server** ✓
- In the url of the page

Free is not the same as public. Anyone who copies it uses up your 1,000 calls, and your page stops working for the rest of the day. A function keeps it out of sight.

**5. The database password of your Supabase project**

- In .env with the VITE_ prefix
- In an Edge Function secret
- **In your password manager, not in the app** ✓

Your app talks to the database through the Data API with a key, it never needs the password. You only use it for a direct connection from a tool like TablePlus, so it lives in your password manager.

> **Explanation:** The question behind every case is the same: what can a stranger do with this key, and who pays for it? A free key is still yours: somebody who copies it eats your daily quota, and your page stops working for everyone else.

<!-- slide 50 -->

### Rate limits

| API | Limit | Also |
| --- | --- | --- |
| **Open-Meteo, free** | 600 a minute, 5,000 an hour, 10,000 a day | Non-commercial use only |
| **PokéAPI** | No hard limit | Fair use: cache what you fetch, or get banned |
| **JSONPlaceholder** | Generous | Fake data, for testing only |

Over the limit, an API answers [429 Too Many Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429), often with a `Retry-After` header that says how long to wait.

> **Explanation:** The numbers are from the docs on 14 October 2026, and limits change, so check them before you build on an API. PokéAPI removed its rate limit when it moved to static hosting, but asks you to cache, and bans IP addresses that abuse it. A 429 often comes with a Retry-After header in seconds. Many APIs also send headers like X-RateLimit-Remaining, so you can see the limit coming.

<!-- slide 51 -->

### Staying under the limit

- Cache answers that do not change every second, with the time you fetched them
- Fetch on submit, or after the visitor stops typing. Not on every keystroke.
- Ask for a list in one request, instead of one request per item in a loop
- On a 429, wait as long as Retry-After says before you try again
- Your own proxy needs a limit too. A public function with your key is a free key for anyone.

> **Explanation:** Caching is the big one. The weather for Antwerp does not change every second, so keep the answer for ten minutes in a variable or in localStorage, with the time you fetched it. A search field that fetches on every keystroke sends ten requests for one word: wait until the visitor stops typing for 300 milliseconds (debounce) or until they press Enter.

<!-- slide 52 -->

## 8. Storing data

> **Explanation:** Up to now the data belonged to somebody else. From here on it is yours: a list of favourites, a guestbook, a high score. The first question is always the same: does only this visitor need it, or does somebody else have to see it too?

<!-- slide 53 -->

### localStorage

```js
const favourites = ['pikachu', 'eevee'];
localStorage.setItem('favourites', JSON.stringify(favourites));

const saved = JSON.parse(localStorage.getItem('favourites') ?? '[]');

localStorage.removeItem('favourites');
```

1. Only strings go in, so the array becomes JSON first.
2. Read it back and parse it. A key that does not exist gives `null`, so start from an empty list.
3. Gone. The visitor can also clear it themselves, any time.

See [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) at MDN. `sessionStorage` works the same, but forgets everything when the tab closes.

> **Explanation:** localStorage only stores strings, which is why JSON is back: stringify on the way in, parse on the way out. getItem gives null for a key that does not exist, so the ?? '[]' gives you an empty list the first time. The sandboxes in this deck cannot use localStorage (they run without an origin of their own), so test this in the DevTools console of any page. The Application tab in Chrome shows what is stored, per origin.

<!-- slide 54 -->

### Good for, not for

#### localStorage is fine for

- Preferences: dark mode, language, a layout
- A cache of API answers, with a time on it
- A draft of a form, so a reload does not wipe it
- Favourites, when one device is enough

#### Use a database for

- Anything another visitor or another device has to see
- Anything you cannot afford to lose: it lives in one browser
- More than a few megabytes
- Secret keys. Every script on the page reads it.

> **Explanation:** Every script on your page can read localStorage, including an analytics snippet or a library that got hacked. That is why a login token in localStorage is debated: supabase-js does it by default, many security people prefer a cookie the JavaScript cannot read. For this deck: fine for a token from a well-known library, never for a secret key. The limit is around 5 MB per origin in most browsers.

<!-- slide 55 -->

### Three ways to a backend

|   | Supabase | Firebase | A Laravel API |
| --- | --- | --- | --- |
| **Data** | Postgres, tables and SQL | Firestore, documents | MySQL, Postgres or SQLite |
| **Who checks access** | RLS policies, in the database | [Security Rules](https://firebase.google.com/docs/rules) | Your controllers and policies, in PHP |
| **The browser talks to** | The database, through their API | The database, through their SDK | Your server only |
| **Good when** | A frontend app that needs SQL and logins | Realtime mobile apps, Google tools | Business logic, or the project runs Laravel |

> **Explanation:** A guestbook, a leaderboard, a booking: as soon as one visitor has to see what another wrote, the data lives in a database behind an API. You build that API yourself (a Laravel app) or rent one (Supabase, Firebase). Firebase is older, from Google, stores documents instead of tables and has its own rule language. Supabase is Postgres, so what you learn there works in every SQL database. The rest of this deck uses Supabase.

<!-- slide 56 -->

## 9. Supabase

> **Explanation:** Supabase is open source and you could host it yourself, but nearly everybody uses their cloud. Lovable Cloud runs on it too. Make a free account now (log in with GitHub) and a project, so you can follow along in the dashboard.

<!-- slide 57 -->

### What you get

- **Postgres**: A real SQL database, with tables, relations and constraints. A table editor in the dashboard, or SQL.
- **The Data API**: Every table you allow gets a REST endpoint on its own. The JS client talks to it.
- **Auth**: Sign up and log in with a password, a magic link, or GitHub and Google.
- **Storage and functions**: Files like avatars and uploads, and Edge Functions for code that needs a secret.

Free plan on 14 October 2026: two active projects, 500 MB of database, paused after a week without use. See [pricing](https://supabase.com/pricing).

> **Explanation:** The Data API is PostgREST, an open source project: it reads your tables and makes a REST endpoint for each one. supabase-js is a friendly layer over those urls, so everything it does, you could do with fetch too. The free plan numbers are from the pricing page on 14 October 2026. The pause after a week is the one that bites students: a project for an assignment can be asleep on the day you present it. Waking it up takes a click in the dashboard.

<!-- slide 58 -->

### A table, in SQL

```sql
create table public.notes (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid()
    references auth.users on delete cascade,
  body text not null check (char_length(body) <= 500),
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

grant select, insert, delete on public.notes to authenticated;
```

1. The database numbers the rows itself.
2. The id of the logged in user, filled in by the database.
3. The database refuses an empty note or one that is too long.
4. RLS on: from now on no row goes out unless a policy allows it.
5. Opens the table to logged in users of the Data API. RLS still decides per row.

> **Explanation:** Run this in the SQL editor of the dashboard. The table editor works too, and it switches RLS on for you, but SQL is something you can save, review and run again. The grant is new: since 30 May 2026 a new project no longer opens new tables to the Data API on its own, and existing projects follow on 30 October 2026. Without the grant, every query fails with permission denied, error 42501. A tutorial from before that date will not mention it.

<!-- slide 59 -->

### The client

```console
$ npm install @supabase/supabase-js
```

*.env*

```bash
VITE_SUPABASE_URL=https://abcd1234.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
```

*src/supabase.js*

```js
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
);
```

See the [JavaScript reference](https://supabase.com/docs/reference/javascript/introduction).

**[npm and Vite](https://slides.mctantwerp.be/en/extra-webdev/02-npm-and-vite)**: npm install and .env in a Vite project.

> **Explanation:** No Vite? Import the client straight from a CDN in a script tag with type=module: import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'. The url and the publishable key are both in the dashboard of your project, under the API key settings. A VITE_ prefix is fine here, because this key is meant to be public. Older projects show an anon key there too: it works the same, but use the publishable key in new code.

<!-- slide 60 -->

### Reading rows

```js
const { data, error } = await supabase
  .from('notes')
  .select('id, body, created_at')
  .order('created_at', { ascending: false })
  .limit(20);

if (error) {
  console.error(error.message);
} else {
  console.log(data);
}
```

1. The table and the columns, like `select ... from notes` in SQL.
2. Newest first, at most 20.
3. No throw: you get `data` or `error`, and you check which one.

Filters like `.eq()` and `.ilike()` in the [select reference](https://supabase.com/docs/reference/javascript/select).

> **Explanation:** This is the biggest difference with fetch: supabase-js does not throw when the database says no. You always get an object with data and error, and one of the two is null. A try and catch around it catches a network failure, but a denied query slips through, so check error every time. Filters read like SQL: .eq('user_id', id) is where user_id = id, .ilike('body', '%pizza%') searches without caring about capitals. The select reference lists them all.

<!-- slide 61 -->

### Inserting a row

```js
const { data, error } = await supabase
  .from('notes')
  .insert({ body: 'Buy more coffee' })
  .select()
  .single();
```

1. No `user_id`: the database fills it in from the login.
2. Asks for the new row back, with its id and date.
3. One object instead of an array with one object.

See [insert](https://supabase.com/docs/reference/javascript/insert), and `update` and `delete` next to it.

> **Explanation:** Without .select(), an insert gives data: null even when it worked, which confuses everybody once. .single() turns the array of one row into the object itself. You can insert an array of objects too, for several rows in one request. The same pattern works for update and delete, with a filter like .eq('id', 12) so you do not change every row the policies allow.

<!-- slide 62 -->

### Your select gives `data: []` and `error: null`, while the dashboard shows 40 rows. Most likely cause?

- A. The publishable key is wrong
- B. RLS is on, and no policy lets this user read
- C. The table was never granted to the Data API
- D. The network is down

**Answer:** B. RLS is on, and no policy lets this user read

RLS filters rows silently: a row you may not see is left out, and an empty list is a perfectly valid answer. The other three would give an error instead. A wrong key gives a 401, a missing grant gives permission denied (42501), and no network makes the request fail.

> **Explanation:** When you debug this, run the same query in the SQL editor of the dashboard. That editor runs as the owner of the database, which skips RLS. Rows there and none in your app means a policy is missing, not data.

<!-- slide 63 -->

## 10. Logins and Row Level Security

> **Explanation:** This is the chapter that matters most if you ever put a Supabase app in front of real people. Lovable apps leaked data because of exactly this, and so did apps written by hand. Read it slowly, then test your own app the way the last slides show.

<!-- slide 64 -->

### Logging in

```js
await supabase.auth.signUp({ email, password });
await supabase.auth.signInWithPassword({ email, password });

const { data: { user } } = await supabase.auth.getUser();

await supabase.auth.signOut();
```

1. Makes an account. Supabase mails a confirmation link first.
2. Logs in. From now on the client sends a token along with every query.
3. Who is logged in right now, or `null`.
4. Throws the token away.

Every call returns `{ data, error }` again. See [password based auth](https://supabase.com/docs/guides/auth/passwords).

> **Explanation:** On a hosted project, sign up sends a confirmation mail by default, and the user can only log in after clicking it. Turn that off in the auth settings while you test, and on again before real users come. The client keeps the session in localStorage and refreshes the token for you. getUser asks the Auth server whether the token is still valid, so use it when it matters who is logged in.

<!-- slide 65 -->

**RLS is the only lock.**

The browser talks to your database directly, with a key everybody can copy. No server of yours checks anything in between. What the policies allow, anyone with curl can do.

> **Explanation:** In a Laravel app, your controller decides what a user may see, on your server. In a Supabase app there is no server of yours in between: the browser talks to the database directly. That is what makes it fast to build, and it is why a missing policy is a leak, not a bug. The Lovable deck in this course shows what happened to apps that forgot.

<!-- slide 66 -->

### Who is asking

1. Browser → Data API, **GET /rest/v1/notes**: Two headers: `apikey` with the publishable key, and `Authorization: Bearer` with Lisa's login token.
2. Data API → Postgres, **select as authenticated**: The API checks the token and runs the query with the role authenticated. No token means the role anon.
3. Postgres, **Policies, per row**: `auth.uid()` reads Lisa's id from the token. Every row is checked against the select policies, the rest is left out.
4. Postgres → Browser, **200 + her rows**: Only Lisa's notes. Without a matching policy: an empty list, no error.

> **Explanation:** The token is a JWT, a signed piece of JSON with the user id in it. Postgres trusts it because Supabase signed it, and auth.uid() reads the id from it. Without a token, the request runs as the role anon. That is why policies say to authenticated or to anon: the role decides which policies even apply.

<!-- slide 67 -->

### Policies

```sql
create policy "read own notes" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "add own notes" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "delete own notes" on public.notes
  for delete to authenticated
  using ((select auth.uid()) = user_id);
```

1. Logged in users see the rows where `user_id` is their own id.
2. A new row must carry their own id. A row for somebody else is refused.
3. They delete only their own rows. A delete on another row matches nothing.

See [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) in the Supabase docs.

> **Explanation:** Wrapping auth.uid() in a select is a performance trick from the Supabase docs: Postgres then runs it once per query instead of once per row. Same result, and it matters on a table with thousands of rows. Policies of the same kind add up: if any select policy allows a row, the user sees it. So one sloppy policy is enough to open a table.

<!-- slide 68 -->

### using and with check

| Clause | Checks | For |
| --- | --- | --- |
| `using` | Rows that already exist: which ones you may see, change or delete | `select`, `update`, `delete` |
| `with check` | The new version of a row: what you may write | `insert`, `update` |

A row that fails `using` is skipped without a word. A row that fails `with check` gives an error.

> **Explanation:** An update needs both: using picks which rows you may change, with check decides what they may look like afterwards. Without the with check, a user could update their own note and set user_id to somebody else's id, and hand them a note. An update also needs a select policy on the same rows, or the update finds nothing to change. A failed with check gives an error, a row filtered by using just does not count: zero rows, no error.

<!-- slide 69 -->

### Public read, own write

```sql
grant select on public.messages to anon, authenticated;
grant insert on public.messages to authenticated;

create policy "anyone reads" on public.messages
  for select to anon, authenticated
  using (true);

create policy "logged in users write their own" on public.messages
  for insert to authenticated
  with check ((select auth.uid()) = user_id);
```

`using (true)` is fine for data that is public anyway, like a guestbook. On a table with personal data, it is a leak.

> **Explanation:** A guestbook is public on purpose, so using (true) is the right policy for reading it. The visitor without a login needs the grant to anon as well, or the Data API refuses before RLS is even asked. The same policy on a table with email addresses or phone numbers is exactly the leak from the Lovable deck. Ask per table: would I put every row of this on a public web page?

<!-- slide 70 -->

### How it goes wrong

- RLS off on one table: the publishable key reads and writes all of it
- `using (true)` on personal data, because the query returned nothing without it
- The secret key in the frontend: it skips every policy
- A view on a protected table: views skip RLS, unless created with `security_invoker`
- An insert policy without `with check`, so the browser picks the `user_id`
- Testing only as yourself, never as a second user and never without a login

> **Explanation:** The view one surprises experienced developers: a view runs with the rights of whoever created it, usually the owner, who skips RLS. Since Postgres 15 you can create it with security_invoker = true, so the policies of the table apply. The Security Advisor in the Supabase dashboard flags several of these, a table without RLS among them. Like Lovable's scan, it finds missing locks, not wrong ones.

<!-- slide 71 -->

### Test it like a stranger

```console
$ export URL=https://abcd1234.supabase.co KEY=sb_publishable_...
$ curl "$URL/rest/v1/notes?select=*" -H "apikey: $KEY"
{"code":"42501","message":"permission denied for table notes"}
$ curl -X POST "$URL/rest/v1/notes" -H "apikey: $KEY" -H "Content-Type: application/json" -d '{"body":"hi"}'
{"code":"42501","message":"permission denied for table notes"}
```

Without a login: nothing to read, nothing to write. Then log in as a second user and go after the notes of the first.

**[Lovable](https://slides.mctantwerp.be/en/extra-webdev/11-lovable)**: The same policies, written by an agent, and what happened to the apps where nobody checked them.

> **Explanation:** Get the publishable key from the JavaScript of your own app, the way a stranger would: the Network tab, any request to supabase.co, the apikey header. The two curl commands are what anybody on the internet can do. Both fail here because the table is only granted to logged in users, before RLS is even asked. For the second user, log in as them in another browser, copy their token from the Authorization header of a request, and try to read or delete the notes of the first. If that works, a policy is wrong.

<!-- slide 72 -->

### Which of these can go in your Vite .env with the VITE\_ prefix?

- A. `sb_publishable_...`
- B. `sb_secret_...`
- C. The database password
- D. Your OpenAI key

**Answer:** A. `sb_publishable_...`

Everything with VITE\_ ends up in the JavaScript of every visitor. The publishable key is made for that: RLS decides what it may do. The secret key is tempting because it makes every query work, and that is the problem: it skips every policy. The database password is never needed in an app, and an OpenAI key costs you money per call.

> **Explanation:** Supabase refuses a secret key that comes from a browser: it looks at the User-Agent header and answers 401. Handy as a seat belt, useless as protection: anybody who copies the key from your bundle uses it with curl, and then it works. A leaked secret key gets deleted in the dashboard, right away.

<!-- slide 73 -->

### Task: A guestbook in Supabase

Homework, with a free Supabase project. Stuck? Slides A table in SQL, Policies, Public read, own write and Test it like a stranger, and the Supabase docs on Row Level Security.

1. Make a project and a messages table in SQL, with RLS on and the grants you need
2. Policies: anyone reads, logged in users add messages under their own id and delete only their own
3. A page with a login form, the list of messages and a form to add one, with all four states
4. Test without a login and as two users, in the app and with curl
5. Done when a stranger can read but not write, and user B cannot delete a message of user A

> **Explanation:** Start with the SQL from the slides A table, in SQL and Public read, own write, adapted to a messages table with a body and a user_id. Use the esm.sh import if you do not want Vite yet. Switch off email confirmation in the auth settings while you test, so you can make two accounts quickly. Stuck on an empty list? The quiz after Inserting a row tells you where to look.

<!-- slide 74 -->

### Task: Hide a key

Homework, with the page from the first assignment or a new one. Stuck? Slides A proxy in an Edge Function and Secrets on the server, and the Supabase docs on Edge Functions.

1. Pick an API that needs a key, and make a free key
2. Write an Edge Function that adds the key and passes only the parameters you allow
3. Set the key as a secret, deploy the function, and call it from your page
4. Search your built JavaScript and the Network tab for the key
5. Done when the page works and the key shows up nowhere in the browser or in git

> **Explanation:** OpenWeatherMap has a free key and is the API on the slides, but any API with a key works. A new OpenWeatherMap key can take a while to become active, so make it first. If the function answers 401, check the secret with npx supabase secrets list. If your page gets a CORS error from your own function, the header in your Response is missing.

## Glossary

- **JSON**: JavaScript Object Notation: a text format for data that nearly every API uses. Looks like a JavaScript object, with stricter rules: double quotes everywhere, no comments, no functions. (slide 6)
- **API**: Application programming interface. On the web: a server with urls that answer with data, usually JSON, instead of a page. (slide 11)
- **Endpoint**: One url of an API that does one thing, like /v1/forecast or /posts/1. The docs of an API are mostly a list of endpoints. (slide 12)
- **Query parameter**: A key and a value after the ? in a url, like ?latitude=51.22. Filters or configures what an endpoint sends back. (slide 12)
- **REST**: A way to design an API: one url per thing (a resource), and the HTTP method says what to do with it. GET /posts reads, POST /posts creates. (slide 13)
- **HTTP method**: The verb of a request: GET reads, POST creates, PUT and PATCH change, DELETE removes. (slide 13)
- **Status code**: The three digit number at the start of every response. 2xx went fine, 4xx is a mistake in the request, 5xx is a problem on the server. (slide 14)
- **Header**: A line of metadata on a request or a response, like Content-Type or Authorization. Not part of the body. (slide 15)
- **Promise**: An object that stands for a value that comes later. fetch returns one right away, the response arrives when the network is done. (slide 20)
- **async and await**: await pauses an async function until a Promise has its value, without blocking the page. Only allowed inside a function marked async, or in a module. (slide 21)
- **response.ok**: True when the status is between 200 and 299. fetch does not throw on a 404 or a 500, so you check this yourself. (slide 23)
- **CORS**: Cross-Origin Resource Sharing: headers a server sends to tell the browser that a page from another origin may read its response. Without them the browser blocks your code. (slide 39)
- **Origin**: Protocol, host and port of a page together, like https://mysite.be. Two urls that differ in one of the three are two origins. (slide 38)
- **Preflight**: The OPTIONS request a browser sends first, before a POST with JSON or a request with an Authorization header, to ask the server whether the real request is allowed. (slide 39)
- **Proxy**: A small function on a server that calls an API for your frontend, so the key stays on the server. A serverless or edge function is the usual way. (slide 45)
- **Rate limit**: The number of requests an API accepts per minute, hour or day. Go over it and you get 429 Too Many Requests. (slide 50)
- **localStorage**: Key and value storage in the browser, per origin, for strings only. Stays after a reload, but only on that one browser. (slide 53)
- **Supabase**: A hosted Postgres database with an API on top, plus logins, file storage and edge functions. Your frontend talks to it directly. (slide 57)
- **Publishable key**: The Supabase key that goes in your frontend, sb_publishable\_ followed by characters. Called the anon key before. Public by design, RLS decides what it may do. (slide 48)
- **Row Level Security**: Postgres rules per table that decide, row by row, who may read or write it. In a Supabase app it is the only thing between a stranger with your publishable key and your data. (slide 67)

## Links and sources

### In the deck

- [MDN: Working with JSON](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/JSON) (slide 7)
- [This one](https://api.open-meteo.com/v1/forecast?latitude=51.22&longitude=4.40&current=temperature_2m) (slide 12)
- [MDN: HTTP request methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods) (slide 13)
- [MDN: HTTP response status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status) (slide 14)
- [Open-Meteo: weather forecast API](https://open-meteo.com/en/docs) (slide 16)
- [Public APIs](https://github.com/public-apis/public-apis) (slide 16): A list per topic, with columns for Auth and CORS
- [MDN: Response.ok](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) (slide 23)
- [MDN: Using the Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) (slide 23)
- [MDN: Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) (slide 41)
- [Supabase: Edge Function secrets](https://supabase.com/docs/guides/functions/secrets) (slide 47)
- [MDN: 429 Too Many Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429) (slide 50)
- [MDN: localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) (slide 53)
- [Firebase Security Rules](https://firebase.google.com/docs/rules) (slide 55)
- [Supabase: pricing](https://supabase.com/pricing) (slide 57): Free plan checked on 2026-10-14
- [Supabase: JavaScript client](https://supabase.com/docs/reference/javascript/introduction) (slide 59)
- [Supabase: select](https://supabase.com/docs/reference/javascript/select) (slide 60): Filters like eq, ilike, gte and in
- [Supabase: insert](https://supabase.com/docs/reference/javascript/insert) (slide 61)
- [Supabase: password-based auth](https://supabase.com/docs/guides/auth/passwords) (slide 64)
- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) (slide 67)

### Sources

- [MDN: async function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)
- [MDN: How to use promises](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Async_JS/Promises)
- [MDN: An overview of HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview)
- [MDN: HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers)
- [MDN: URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams)
- [PokéAPI documentation](https://pokeapi.co/docs/v2): No key, no rate limit, a fair use policy that asks you to cache
- [Open-Meteo: terms](https://open-meteo.com/en/terms): Free for non-commercial use, 600 calls a minute and 10,000 a day, checked on 2026-10-14
- [JSONPlaceholder guide](https://jsonplaceholder.typicode.com/guide/): Fake writes: POST, PATCH and DELETE answer, nothing is saved
- [Supabase: API keys](https://supabase.com/docs/guides/api/api-keys): Publishable and secret keys, anon and service_role are the legacy names
- [Supabase: securing your API](https://supabase.com/docs/guides/api/securing-your-api)
- [Supabase: tables not exposed to the Data API automatically](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically): New projects since 30 May 2026, existing projects from 30 October 2026
- [Supabase: Edge Functions](https://supabase.com/docs/guides/functions)
- [Laravel Sanctum](https://laravel.com/docs/13.x/sanctum)
