Stephen Build ← Roadmap
0/0
Roadmap backend · Étape 5 sur 7

Relier ton front et ton back

T'as une to-do list qui ne se souvient de rien en dehors de ton navigateur, et une API qui sait tout garder mais que personne n'utilise. Dans ce tuto, tu les branches ensemble : la page parle au serveur, le serveur parle à la base. À la fin, t'as une vraie appli web, de bout en bout.

Durée 1 semaine Niveau étapes 1 à 4 du backend faites Projet la to-do branchée sur ton API Mis à jour

À la fin, tu sauras

Les missions

Tout se passe dans ton dossier api-taches, avec le serveur lancé par npm run dev et la page ouverte sur http://localhost:3000. Garde trois choses sous les yeux : la page, la console du navigateur et le terminal du serveur.

Mission 0

T'as deux moitiés d'appli. Le front : la to-do list de l'étape 4 du parcours frontend, qui range ses tâches dans localStorage (donc seulement dans ton navigateur). Le back : api-taches, qui range des tâches { id, text, done } dans une vraie base. Mêmes noms de champs, c'était voulu. Dans cette étape, on les branche ensemble.

Depuis l'étape 2, ton serveur sert tout ce qui est dans public/ grâce à express.static("public"). Remplace l'ancien public/index.html par les 3 fichiers de ta to-do :

api-taches
api-taches/
├── package.json
├── server.js         # express.static("public") est déjà là depuis l'étape 2
├── db.js
├── routes/
│   └── tasks.js
├── requests.http
├── data.db
└── public/
    ├── index.html    # la to-do de l'étape 4 (remplace l'ancien)
    ├── style.css
    └── script.js
public/index.html
<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Ma to-do list</title>
    <link rel="stylesheet" href="style.css">
    <script src="script.js" defer></script>
  </head>
  <body>
    <main>
      <h1>À faire</h1>
      <form id="todo-form">
        <label for="todo-input">Nouvelle tâche</label>
        <input id="todo-input" type="text" placeholder="Ex : finir la mission 6" autocomplete="off">
        <button type="submit">Ajouter</button>
      </form>
      <p id="todo-error" class="error" hidden></p>
      <ul id="todo-list"></ul>
    </main>
  </body>
</html>
public/style.css
.done {
  text-decoration: line-through;
  opacity: 0.6;
}

.error {
  color: crimson;
}

Pour public/script.js, copie pour l'instant celui de ta to-do (la version avec localStorage de la mission 6). Puis lance le serveur et ouvre http://localhost:3000, pas Live Server :

Terminal
# Dans le dossier api-taches
$ npm run dev
  • express.static("public") : quand le navigateur demande /, Express renvoie public/index.html. Puis /style.css et /script.js, que la page réclame à son tour.
  • La page et l'API ont maintenant la même adresse : localhost:3000. C'est ce qui va tout simplifier (on verra pourquoi à la mission 6).
  • Les tâches que t'avais avec Live Server n'apparaissent pas : localStorage est rangé par adresse de site, et localhost:3000 en est une nouvelle. Pas grave, on le jette juste après.
Erreur fréquente

Tu vois {"error":"Route introuvable"} au lieu de la page ? Soit tes fichiers ne sont pas dans public/, soit t'as lancé le serveur depuis un autre dossier : "public" est cherché à partir de l'endroit où tu tapes npm run dev. Place-toi dans api-taches.

Résultat attendu
Terminal
$ npm run dev

> api-taches@1.0.0 dev
> node --watch server.js

Serveur lancé sur http://localhost:3000
[14:32:05] GET /
[14:32:05] GET /style.css
[14:32:05] GET /script.js
localhost:3000

Sur http://localhost:3000, ta to-do s'affiche. Ton logger montre que c'est bien Express qui a envoyé les 3 fichiers (les heures changent chez toi).

Mission 1

Premier branchement : au lieu de lire localStorage, la page demande la liste au serveur avec GET /api/tasks. C'est le même fetch que pour la météo, sauf que cette fois l'API, c'est toi qui l'as écrite.

Ajoute une ligne pour afficher l'état du chargement, entre le message d'erreur et la liste :

public/index.html
<p id="todo-error" class="error" hidden></p>
<p id="todo-status"></p>
<ul id="todo-list"></ul>

Puis remplace tout public/script.js :

public/script.js
const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const list = document.querySelector("#todo-list");
const errorMsg = document.querySelector("#todo-error");
const statusEl = document.querySelector("#todo-status");

// Plus de localStorage : les tâches viennent du serveur
let tasks = [];

function render() {
  list.textContent = "";

  for (const task of tasks) {
    const li = document.createElement("li");

    const label = document.createElement("label");
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = task.done;
    checkbox.addEventListener("change", () => {
      // Pour l'instant, rien ne part au serveur (mission 3)
      task.done = checkbox.checked;
      render();
    });

    const text = document.createElement("span");
    text.textContent = task.text;
    if (task.done) {
      text.classList.add("done");
    }
    label.append(checkbox, text);

    const del = document.createElement("button");
    del.type = "button";
    del.textContent = "Supprimer";
    del.addEventListener("click", () => {
      // Pour l'instant, rien ne part au serveur (mission 3)
      tasks = tasks.filter((t) => t.id !== task.id);
      render();
    });

    li.append(label, del);
    list.append(li);
  }
}

// Demande la liste des tâches à l'API
async function loadTasks() {
  statusEl.textContent = "Chargement...";
  const response = await fetch("/api/tasks");
  tasks = await response.json();
  statusEl.textContent = "";
  render();
}

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const text = input.value.trim();
  if (text === "") {
    errorMsg.textContent = "Écris une tâche avant de l'ajouter.";
    errorMsg.hidden = false;
    return;
  }

  // Pour l'instant, la tâche reste dans le navigateur (mission 2)
  errorMsg.hidden = true;
  tasks.push({ id: Date.now(), text: text, done: false });
  input.value = "";
  input.focus();
  render();
});

loadTasks();

La base est peut-être vide. Crée deux tâches avec ton fichier requests.http (ou curl), puis recharge la page :

requests.http
### Deux tâches pour tester le front
POST http://localhost:3000/api/tasks
Content-Type: application/json

{ "text": "Brancher le front sur l'API" }

###
POST http://localhost:3000/api/tasks
Content-Type: application/json

{ "text": "Relire le cours sur fetch" }
  • fetch("/api/tasks") : une adresse qui commence par / veut dire « sur le même serveur que la page ». Donc http://localhost:3000/api/tasks.
  • let tasks = [] : la liste part vide, puis loadTasks() la remplit avec la réponse du serveur. Plus de load(), plus de save().
  • « Chargement... » s'affiche pendant l'attente. En local c'est instantané, mais en ligne un appel peut prendre une seconde.
  • Ajouter, cocher et supprimer marchent encore, mais seulement dans la page : recharge et tout revient comme dans la base. On branche ça dans les deux missions suivantes.
Erreur fréquente

SyntaxError avec « is not valid JSON » dans la console : t'as ouvert la page avec Live Server. /api/tasks est alors demandé à Live Server, qui n'a pas d'API et répond une page d'erreur en texte, pas du JSON. Passe par http://localhost:3000.

Résultat attendu
Réponse de GET /api/tasks
[{"id":1,"text":"Brancher le front sur l'API","done":false},{"id":2,"text":"Relire le cours sur fetch","done":false}]
localhost:3000

Les deux tâches créées avec requests.http s'affichent dans la page, après un « Chargement... » très bref. Tes id peuvent être différents.

Mission 2

Pour créer une tâche, le front envoie un POST avec du JSON dans le corps, exactement comme ton fichier requests.http. Comme on va appeler l'API 4 fois (lire, créer, cocher, supprimer), on range le code commun dans une petite fonction api(). Remplace ta fonction loadTasks par ces deux-là :

public/script.js
// Appelle l'API et renvoie le JSON, ou lance une erreur avec le message du serveur
async function api(path, options = {}) {
  const response = await fetch(path, {
    ...options,
    headers: { "Content-Type": "application/json", ...options.headers },
  });
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || "Erreur serveur");
  return data;
}

// Demande la liste des tâches à l'API
async function loadTasks() {
  statusEl.textContent = "Chargement...";
  tasks = await api("/api/tasks");
  statusEl.textContent = "";
  render();
}

Puis remplace tout le bloc form.addEventListener("submit", ...) :

public/script.js
form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const text = input.value.trim();
  if (text === "") {
    errorMsg.textContent = "Écris une tâche avant de l'ajouter.";
    errorMsg.hidden = false;
    return;
  }

  try {
    // Le serveur crée la tâche et la renvoie, avec son vrai id
    const task = await api("/api/tasks", {
      method: "POST",
      body: JSON.stringify({ text: text }),
    });
    tasks.push(task);
    errorMsg.hidden = true;
    input.value = "";
    input.focus();
    render();
  } catch (error) {
    // Le message vient du serveur, par exemple une erreur 400
    errorMsg.textContent = error.message;
    errorMsg.hidden = false;
  }
});
  • method: "POST" : sans lui, fetch fait un GET.
  • "Content-Type": "application/json" : prévient le serveur que le corps est du JSON. C'est grâce à cet en-tête que express.json() remplit req.body.
  • JSON.stringify(...) : le corps d'une requête est du texte, comme avec localStorage.
  • response.ok vaut true pour les codes 200 à 299. Sinon, api() lance une erreur avec le message error que ton serveur a renvoyé, et le catch l'affiche.
  • Plus de Date.now() : c'est la base qui choisit l'id. On ajoute au tableau la tâche renvoyée par le serveur, avec son vrai id.

Pour voir une erreur du serveur, le front bloque déjà les champs vides. Colle cette ligne dans la console du navigateur, puis clique sur Ajouter :

Console
document.querySelector("#todo-input").value = "a".repeat(201);

Le front vérifie pour le confort (un message immédiat), le serveur vérifie pour la sécurité : n'importe qui peut appeler ton API sans passer par ta page.

Erreur fréquente

Le serveur répond 400 « Le texte est obligatoire » alors que t'as bien écrit une tâche ? T'as oublié l'en-tête Content-Type : express.json() ignore alors le corps et req.body reste undefined. Avec api(), il est mis à chaque fois, c'est tout l'intérêt.

Demande à l'IA
Explique-moi ce que fait cette ligne JavaScript : headers: { 'Content-Type': 'application/json', ...options.headers }. Que font les trois points, et pourquoi on les place après le Content-Type ? N'écris pas de code à ma place.
Résultat attendu
Terminal (logger du serveur)
[14:40:12] GET /api/tasks
[14:40:31] POST /api/tasks
[14:41:02] POST /api/tasks
localhost:3000

Une tâche normale s'ajoute et reste là après un rechargement. Avec 201 caractères, c'est le serveur qui refuse (400) et son message s'affiche en rouge. Le texte exact est celui que t'as écrit à l'étape 3.

Mission 3

Même principe pour les deux autres actions. Cocher envoie un PATCH avec le nouvel état, supprimer envoie un DELETE. Remplace ta fonction render par celle-ci, et ajoute les deux fonctions en dessous :

public/script.js
function render() {
  list.textContent = "";

  for (const task of tasks) {
    const li = document.createElement("li");

    const label = document.createElement("label");
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = task.done;
    checkbox.addEventListener("change", () => toggleTask(task, checkbox));

    const text = document.createElement("span");
    text.textContent = task.text;
    if (task.done) {
      text.classList.add("done");
    }
    label.append(checkbox, text);

    const del = document.createElement("button");
    del.type = "button";
    del.textContent = "Supprimer";
    del.addEventListener("click", () => deleteTask(task));

    li.append(label, del);
    list.append(li);
  }
}

// Coche ou décoche : PATCH avec le nouvel état
async function toggleTask(task, checkbox) {
  try {
    const updated = await api(`/api/tasks/${task.id}`, {
      method: "PATCH",
      body: JSON.stringify({ done: checkbox.checked }),
    });
    // On remplace l'ancienne version de la tâche par celle du serveur
    tasks = tasks.map((t) => (t.id === updated.id ? updated : t));
    errorMsg.hidden = true;
    render();
  } catch (error) {
    errorMsg.textContent = error.message;
    errorMsg.hidden = false;
  }
}

// Supprime : DELETE, le serveur répond 204 sans corps
async function deleteTask(task) {
  try {
    await api(`/api/tasks/${task.id}`, { method: "DELETE" });
    tasks = tasks.filter((t) => t.id !== task.id);
    errorMsg.hidden = true;
    render();
  } catch (error) {
    errorMsg.textContent = error.message;
    errorMsg.hidden = false;
  }
}
  • `/api/tasks/${task.id}` : l'id dans l'adresse, comme /api/tasks/:id côté serveur, qui le lit avec req.params.id.
  • { done: checkbox.checked } : avec PATCH, on n'envoie que ce qui change. Le texte, le serveur le garde.
  • tasks.map(...) : on remplace la tâche par la version renvoyée par le serveur. C'est lui qui a le dernier mot.
  • DELETE répond 204 No Content, sans corps. D'où le if (response.status === 204) return null; dans api() : sans lui, response.json() planterait sur un corps vide.

Le schéma de l'étape 4 a changé : avant, c'était « modifier tasks, save(), render() ». Maintenant, c'est « demander au serveur, attendre sa réponse, mettre à jour tasks, render() ».

Erreur fréquente

« Tâche introuvable » au clic ? Une tâche affichée n'existe plus dans la base : tu l'as supprimée avec requests.http, ou t'as effacé data.db. Recharge la page pour repartir de ce que contient vraiment la base.

À toi

Ajoute sous la liste un bouton « Supprimer les tâches faites ». Deux façons : côté front, une boucle sur les tâches faites qui appelle DELETE pour chacune ; ou côté serveur, une nouvelle route DELETE /api/tasks?done=true qui supprime tout d'un coup avec une seule requête SQL et répond 204. Indice pour la boucle : for ... of avec un await dedans, puis un seul render() à la fin.

Résultat attendu
Terminal (logger du serveur)
[14:52:40] PATCH /api/tasks/1
[14:52:44] DELETE /api/tasks/2
[14:52:47] GET /api/tasks
localhost:3000

Tu coches la première tâche, tu supprimes la deuxième, tu recharges (le GET) : la page est identique. Ouvre data.db avec SQLite Viewer : la ligne 1 a done à 1, la ligne 2 a disparu.

Mission 4

En vrai, le réseau rame, le serveur plante, quelqu'un clique deux fois. Coupe ton serveur avec Ctrl + C et coche une tâche : la case reste cochée alors que rien n'est enregistré. On blinde tout ça. Voici le public/script.js complet, version finale :

public/script.js
const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const addBtn = form.querySelector("button");
const list = document.querySelector("#todo-list");
const errorMsg = document.querySelector("#todo-error");
const statusEl = document.querySelector("#todo-status");

let tasks = [];

// Appelle l'API et renvoie le JSON, ou lance une erreur avec le message du serveur
async function api(path, options = {}) {
  const response = await fetch(path, {
    ...options,
    headers: { "Content-Type": "application/json", ...options.headers },
  });
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.error || "Erreur serveur");
  return data;
}

function showError(error) {
  console.error(error);
  // fetch lui-même échoue (TypeError) quand le serveur ne répond pas
  errorMsg.textContent = error instanceof TypeError
    ? "Le serveur ne répond pas. Il est bien lancé ?"
    : error.message;
  errorMsg.hidden = false;
}

function render() {
  list.textContent = "";
  statusEl.textContent = tasks.length === 0 ? "Aucune tâche pour l'instant." : "";

  for (const task of tasks) {
    const li = document.createElement("li");

    const label = document.createElement("label");
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = task.done;
    checkbox.addEventListener("change", () => toggleTask(task, checkbox));

    const text = document.createElement("span");
    text.textContent = task.text;
    if (task.done) {
      text.classList.add("done");
    }
    label.append(checkbox, text);

    const del = document.createElement("button");
    del.type = "button";
    del.textContent = "Supprimer";
    del.addEventListener("click", () => deleteTask(task, del));

    li.append(label, del);
    list.append(li);
  }
}

async function loadTasks() {
  statusEl.textContent = "Chargement...";
  try {
    tasks = await api("/api/tasks");
    render();
  } catch (error) {
    statusEl.textContent = "";
    showError(error);
  }
}

async function toggleTask(task, checkbox) {
  checkbox.disabled = true;
  try {
    const updated = await api(`/api/tasks/${task.id}`, {
      method: "PATCH",
      body: JSON.stringify({ done: checkbox.checked }),
    });
    tasks = tasks.map((t) => (t.id === updated.id ? updated : t));
    errorMsg.hidden = true;
    render();
  } catch (error) {
    checkbox.checked = task.done; // on remet la case comme avant
    checkbox.disabled = false;
    showError(error);
  }
}

async function deleteTask(task, button) {
  button.disabled = true;
  try {
    await api(`/api/tasks/${task.id}`, { method: "DELETE" });
    tasks = tasks.filter((t) => t.id !== task.id);
    errorMsg.hidden = true;
    render();
  } catch (error) {
    button.disabled = false;
    showError(error);
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const text = input.value.trim();
  if (text === "") {
    errorMsg.textContent = "Écris une tâche avant de l'ajouter.";
    errorMsg.hidden = false;
    return;
  }

  addBtn.disabled = true;
  try {
    const task = await api("/api/tasks", {
      method: "POST",
      body: JSON.stringify({ text: text }),
    });
    tasks.push(task);
    errorMsg.hidden = true;
    input.value = "";
    render();
  } catch (error) {
    showError(error);
  } finally {
    addBtn.disabled = false;
    input.focus();
  }
});

loadTasks();
  • Deux sortes d'échec : le serveur répond une erreur (400, 404, 500), et api() la transforme en Error avec son message. Ou le serveur ne répond pas du tout : fetch lui-même lance une TypeError (« Failed to fetch »). showError distingue les deux.
  • addBtn.disabled = true : pas de double-clic qui crée deux fois la même tâche. finally réactive le bouton dans tous les cas, réussite ou erreur.
  • Même idée pour la case et le bouton Supprimer de chaque tâche.
  • Si le PATCH échoue, checkbox.checked = task.done remet la case dans l'état que le serveur connaît. La page ne ment jamais.
  • render() affiche « Aucune tâche pour l'instant. » quand la liste est vide : un écran blanc, on ne sait jamais si c'est vide ou cassé.

Teste les trois cas : serveur coupé pendant que la page est ouverte, tâche de 201 caractères, et réseau lent (onglet Réseau, menu « Pas de limitation » → « 3G lente ») pour voir « Chargement... » et le bouton grisé.

Erreur fréquente

Tu coupes le serveur et tu recharges la page : « Ce site est inaccessible ». Normal, c'est lui qui envoie aussi la page. Le message « Le serveur ne répond pas » sert quand le serveur tombe pendant que la page est ouverte.

Demande à l'IA
Dans mon front JavaScript, j'ai un try/catch autour d'un fetch. Explique-moi la différence entre un fetch qui échoue (TypeError: Failed to fetch) et une réponse avec un code 400 ou 500, et pourquoi fetch ne lance pas d'erreur tout seul pour un 404. N'écris pas mon code à ma place.
Résultat attendu
Console (serveur coupé)
PATCH http://localhost:3000/api/tasks/1 net::ERR_CONNECTION_REFUSED
TypeError: Failed to fetch
    at api (script.js:12:26)
    at toggleTask (script.js:75:27)
localhost:3000

Serveur coupé avec Ctrl + C, tu décoches : la case revient cochée et le message s'affiche. Le rouge dans la console est normal ici : c'est console.error qui garde une trace pour toi. Relance npm run dev, ça repart.

Mission 5

Quand ça ne marche pas, la vraie question est : c'est le front ou le back ? L'onglet Réseau des outils de développement (F12) répond en dix secondes : il montre chaque requête envoyée et ce que le serveur a répondu.

  1. Ouvre F12 → onglet Réseau (Network), et clique sur le filtre Fetch/XHR pour ne garder que les appels à l'API.
  2. Recharge, ajoute une tâche, coche-la, supprime-la. Une ligne apparaît à chaque fois.
  3. Clique sur une ligne. En-têtes : l'adresse, la méthode, le code d'état. Charge utile (Payload) : ce que le front a envoyé. Réponse : ce que le serveur a renvoyé.
  4. Clic droit sur une ligne → Copier → Copier en tant que cURL : tu rejoues la requête dans le terminal, sans navigateur.
  • La charge utile est fausse (un champ mal nommé, { texte: ... } au lieu de { text: ... }) : le bug est dans le front.
  • La charge utile est bonne mais la réponse est une erreur 500 : le bug est dans le back. Va lire le terminal du serveur, l'erreur détaillée y est.
  • Pas de ligne du tout : le fetch n'est jamais appelé. Regarde la console.
Erreur fréquente

Une ligne rouge en 404 avec {"error":"Route introuvable"} : l'adresse est fausse. Classique : /api/task au lieu de /api/tasks, ou un id qui vaut undefined (regarde la colonne Nom).

À toi

Ouvre ta to-do dans deux navigateurs différents (Chrome et Firefox, ou une fenêtre de navigation privée). Ajoute une tâche dans l'un, recharge l'autre : elle y est. Avec localStorage, c'était impossible. Ensuite, ajoute un bug exprès (texte au lieu de text dans le POST) et retrouve-le uniquement avec l'onglet Réseau, avant de le corriger.

Résultat attendu
Outils de développement > Réseau (filtre Fetch/XHR)
Nom      État   Type     Taille
tasks    200    fetch    0,3 ko
tasks    201    fetch    0,3 ko
1        200    fetch    0,3 ko
2        204    fetch    0,1 ko

▸ En-têtes         URL de requête : http://localhost:3000/api/tasks
                   Méthode de requête : POST
                   Code d'état : 201 Created
▸ Charge utile     {text: "Arroser les plantes"}
▸ Réponse          {"id":3,"text":"Arroser les plantes","done":false}

Une ligne par appel à l'API, avec la bonne méthode et le bon code. Les tailles et les id changent chez toi, et Chrome peut afficher les libellés en anglais (Headers, Payload, Response).

Mission 6

Tôt ou tard, tu verras le mot CORS en rouge dans la console. Autant le provoquer exprès pour comprendre. Pour le navigateur, une origine, c'est protocole + domaine + port. http://localhost:3000 et http://127.0.0.1:5500 sont deux origines différentes.

Par sécurité, une page ne peut lire les réponses que de sa propre origine, sauf si le serveur d'en face l'autorise. Sans cette règle, n'importe quel site que tu visites pourrait appeler l'API de ta banque avec ta session.

L'expérience : serveur lancé, ouvre public/index.html avec Live Server (port 5500). Comme à la mission 1, /api/tasks part vers Live Server. Alors donne l'adresse complète du serveur, juste pour le test :

public/script.js
// En haut du fichier, juste pour l'expérience
const API_URL = "http://localhost:3000";

// Dans api(), on colle l'adresse du serveur devant le chemin
const response = await fetch(API_URL + path, {

Recharge : erreur CORS dans la console, et ton message « Le serveur ne répond pas » s'affiche (pour fetch, une requête bloquée ressemble à un serveur éteint, seule la console dit la vraie cause). Pourtant ton terminal affiche une ligne OPTIONS /api/tasks : le navigateur a d'abord demandé au serveur « t'acceptes cette origine ? » (la requête de « pré-vérification »), et sans réponse claire, il a bloqué.

Solution 1, celle qu'on utilise : servir le front depuis Express. Même origine, pas de CORS, rien à configurer. C'est exactement ce que tu fais depuis la mission 0.

Solution 2, quand le front et le back vivent vraiment à deux adresses (un front sur Vercel, une API ailleurs) : le serveur autorise l'origine du front avec le paquet cors.

Terminal
$ npm install cors
server.js
import express from "express";
import cors from "cors";
// ... tes autres imports

const app = express();

// Autorise UNE origine précise à appeler l'API
app.use(cors({ origin: "http://127.0.0.1:5500" }));

app.use(express.json());
// ... la suite ne change pas
  • origin : l'adresse exacte du front autorisé, sans / à la fin. Chaque réponse porte alors l'en-tête Access-Control-Allow-Origin.
  • CORS est une règle du navigateur. curl et REST Client ne sont pas concernés : c'est pour ça que tes tests marchaient.
  • cors() sans option autorise le monde entier. À éviter dès qu'il y aura des comptes (étape 6).

Une fois l'erreur vue et corrigée, reviens à la solution 1 : enlève API_URL de script.js, les deux lignes cors de server.js, et désinstalle le paquet :

Terminal
$ npm uninstall cors
Erreur fréquente

L'erreur CORS reste avec cors installé ? Compare l'origine dans le message de la console avec ton origin : localhost et 127.0.0.1 ne sont pas la même chose pour le navigateur, et un / en trop à la fin suffit à tout bloquer.

Demande à l'IA
Explique-moi la politique de même origine et CORS dans le navigateur, avec une analogie simple. Pourquoi une requête bloquée par CORS arrive quand même jusqu'à mon serveur Express ? Et pourquoi curl n'est jamais bloqué ?
Résultat attendu
Console (Live Server, sans cors)
Access to fetch at 'http://localhost:3000/api/tasks' from origin 'http://127.0.0.1:5500'
has been blocked by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Terminal (avec cors)
[15:10:03] OPTIONS /api/tasks
[15:10:03] GET /api/tasks

Sans le paquet cors, l'erreur ci-dessus. Avec, le serveur répond d'abord à la question du navigateur (OPTIONS), puis le vrai GET passe et tes tâches s'affichent sur le port 5500. Ensuite, tu reviens à la solution 1.

Le projet

La to-do branchée sur ton API

Ta to-do de l'étape 4 frontend devient une vraie appli : chaque action passe par ton API et finit dans data.db. Refais les branchements des missions 1 à 4 sans regarder le code : repars du script.js avec localStorage, et remplace chaque save() par le bon appel à l'API. Si tu bloques, ouvre l'onglet Réseau avant de relire la mission.

Ton projet est validé quand :

Bonus

Rends le texte d'une tâche modifiable : un double-clic sur le texte le remplace par un champ, Entrée envoie un PATCH avec { text }, et une erreur 400 du serveur s'affiche. Puis ajoute des filtres « Toutes / À faire / Faites » qui appellent GET /api/tasks?done=false ou ?done=true.

Pour aller plus loin

Étape 5 bouclée ?

Si ta to-do survit à un rechargement, à un autre navigateur et à un serveur coupé, t'as construit une appli complète, du bouton jusqu'à la base. Problème : n'importe qui voit et efface les tâches de tout le monde. Prochaine étape : des comptes, des mots de passe et des sessions, pour que chacun ait ses propres tâches.

Étape 6 : Comptes et sécurité →