Why would a page call your own API?
So far the server has built whole pages: every click on a link fetched fresh HTML. That works well, and for many pages it is the right choice. Sometimes, though, you want part of a page to change without reloading everything: a sighting appears in the list the moment you add it, a price updates when you change a size, a booking form shows free times as you pick a date.
For that, the page sends a small request to your API with fetch(), gets JSON back, and updates the page with JavaScript. The server sends the page once and then only data. The page and the API live on the same server here, which keeps things simple: same address, no extra permissions needed.
The split also changes how you think about the server. The API becomes the single source of truth, and the page is one of its clients. Anything the page can do, someone with the request bar can do too, so every important rule, such as which fields are required, still lives on the server. The page’s checks only make the form friendlier.
What happens when the page loads?
Open the Browser view. The server answers GET / with an HTML page whose list says Loading…. The browser shows it and runs the script at the bottom. The script calls fetch('/api/sightings'), a second request to the same server. The API answers with a JSON array, and the script turns each sighting into an <li>.
Now open the Requests view. You’ll see two exchanges: the page, then the API call marked fetch(). That second request is the one to read when a page shows the wrong data, because it tells you exactly what the server sent the script.
async function load() {
const res = await fetch('/api/sightings'); // ask the API
const rows = await res.json(); // read the JSON body
list.replaceChildren(...rows.map((s) => { // rebuild the list
const li = document.createElement('li');
li.textContent = s.bird + ', ' + s.place;
return li;
}));
}Why are there two json() calls?
They live on different sides. On the server, res.json(sightings) turns the array into JSON text and sends it with Content-Type: application/json. In the page, fetch() gives you a response object, and its .json() method reads the body and turns the text back into an array. Both happen to be called res in this code, but one is Express’s response and the other is the browser’s. Keeping that straight saves a lot of confusion when you read the two files side by side.
The page’s .json() returns a promise, which is why it needs await. If the body isn’t valid JSON, for example an HTML error page, it throws a SyntaxError in the browser. When the list stays empty, check the type of the fetch() answer in the Requests view first.
How does a page send JSON with fetch()?
A plain fetch(url) sends a GET. To send data, pass a second argument with three things: the method, a header that says the body is JSON, and the body itself as a string.
const res = await fetch('/api/sightings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bird: form.get('bird'), place: form.get('place') })
});method: 'POST'makes it a POST. Without it, fetch tries a GET, and a GET with a body throws aTypeErrorbefore anything is sent.headers: { 'Content-Type': 'application/json' }tells the server how to read the body.express.json()only reads requests with that header.body: JSON.stringify({ ... })turns your object into JSON text. Passing the object itself sends the useless string[object Object].


Why doesn’t fetch() throw on a 404 or 400?
fetch() only rejects when it gets no answer at all, for example when the network is down. A 404 or a 500 is still an answer, so the promise resolves normally. Check res.ok, which is true for statuses from 200 to 299, before you treat the response as a success.
For a form, that means: if res.ok, clear the form and reload the list; if not, read the JSON error the server sent and show it near the form. Your server’s 400 answers with clear messages, from the validation lesson, are exactly what the page needs here.
if (res.ok) {
e.target.reset();
load();
} else {
const problem = await res.json();
note.textContent = problem.error;
}Why use textContent instead of innerHTML?
The sightings come from people. If a member types <img src=x onerror=alert(1)> as a bird name and your script writes it with innerHTML, the browser treats it as HTML and runs the code for every visitor. That is cross-site scripting, and data from your own API is no exception: the API passes along whatever was saved.
textContent puts the text in as text, so angle brackets show up as angle brackets. Build elements with document.createElement() and fill them with textContent, as the starting script does.
The server should still validate what it accepts, and escape wherever it builds HTML itself. Treat the page’s textContent as the last layer, and the API’s checks on what it accepts as the first. If another page or app ever uses the same API, it gets the same protection without having to remember it.
How does the practice server run the page’s script?
The Browser view runs the page’s JavaScript and passes its fetch() calls to your server, like a real browser would. The request bar and Check my work don’t run scripts, they only read what the server sends. That is why the checks look at two things: the API’s answers, and the script text inside the page. If both are right, the page works in the Browser view.
While a request is on its way, the page should say so. Disable the button after the first click and enable it again when the answer arrives, or a double click sends two identical sightings. On your own computer, open http://localhost:3000 and use the browser’s developer tools. The Network panel lists each fetch() call with its status and body, the same information as the Requests view here.
What do AI tools get wrong with fetch()?
Look for a missing Content-Type header on POST requests, which leaves req.body empty on the server; a missing await before res.json(), which gives you a promise instead of data; no res.ok check, so error pages are treated as success; and innerHTML with API data. Also check the URLs. Generated code often calls http://localhost:3000/api/... in full, which breaks the moment the site is hosted anywhere else. A path starting with / always goes to the server that sent the page.
Watch the order of events, too. A script that calls load() and then immediately reads the list will find it empty, because load() hasn’t finished yet. Anything that depends on the data belongs after the await, inside the same async function, or in a function it calls.
Field notes
- A page can ask your server for JSON and fill in the list itself, without loading a new page.
res.json()on the server sends JSON, andres.json()in the page reads it back.- To send JSON, give
fetch()a method, aContent-Type: application/jsonheader and a stringified body. fetch()doesn't fail on a 404 or 400, so checkres.okyourself.
Easy to get wrong
Without the header, the browser marks the body as plain text, express.json() ignores it and req.body is undefined on the server.
fetch() returns a promise, and so does .json(). Each needs await before you can use its result.
innerHTML treats saved text as HTML, so a crafted bird name can run code in every visitor’s browser.
A full localhost address only works on your own computer. A path starting with / goes to whichever server sent the page.
Questions people ask
Should I build pages on the server or with fetch()?
Build on the server when the page is mostly reading, since it loads fast, works without JavaScript and is easy for search engines. Use fetch() for the parts that change while someone is on the page, like adding a sighting. Many sites mix both.
Can a page call an API on a different site?
Only if that API allows it. Browsers block a page from reading answers from another origin unless the server sends CORS headers. A later lesson covers this. Calls to your own server need nothing extra.
Where should the page’s HTML live in a real project?
Usually in a file under public, served with express.static(). Here it sits in server.js so you can edit the page and the API in one place.