Tutorials / Build a REST API with C# and .NET · Lesson 32 of 34
Frontend
On this page
The First Web Page
The API can list, add, change and delete books, and it explains its refusals clearly. But the only ways to use it are curl and the test page, and both are tools for developers. The owner of the bookshop cannot be asked to type JSON into a terminal. The owner needs a web page: a table of books on the screen.
Our goal: a web page that asks the API for the books and shows them in a table.
Here is the route:
- See how the same app that answers with JSON can also hand out web pages.
- Write the page: plain HTML for the table, and a few lines of JavaScript that ask the API and fill it.
- Look at what happened with the browser's developer tools.
- Learn the one safety habit for putting data into a page.
- See what the page does when the API says no.
This lesson leaves out forms (adding, editing and deleting come in the next lesson), the report page, and everything about how the page looks. The page is ugly on purpose: no colours, no layout, no styles, only the browser's plain default look. The point is how a page talks to the API, and a design would hide that. It also leaves out frameworks and tests for the page, and a page that lives at a different address from the API.
Up to now you were the backend developer. In this lesson you put on the other hat: the frontend developer who receives the API, as described in how a team builds an API.
Where we are
This is the solution from the end of rules the API cannot see. One folder and one file are new, and Program.cs gets two lines:
BookShop/
src/
BookShop.Api/
Program.cs <- changes (two lines)
wwwroot/ <- NEW folder
index.html <- NEW
Only the files that change are listed. Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.
Why plain HTML and JavaScript
Front-end development is as big as back-end development. The challenges are different: layout and design, differences between browsers, keeping track of what the page shows, accessibility, and many tools. Because of this, many teams have separate back-end and front-end developers.
This course does not cover front-end development in depth. Instead, we use the bare minimum of a web page, so we can show one thing: how a web browser app consumes the API.
To see how big the front end is, look at what a front-end developer chooses and learns before the first page works:
- CSS, and then a choice of CSS framework: Bootstrap, Tailwind, or something else.
- JavaScript in depth: how the language really behaves, and how the browser runs it.
- TypeScript, a typed layer on top of JavaScript that most teams now expect.
- A framework: React, Angular, Vue, Svelte, and more. Each has its own way of thinking.
- Project structure and conventions: where files go, how they are named, how state is kept, which tools build and bundle it all.
Each of these has several competing answers, and the answers change every few years.
That is why this lesson keeps it simple. The page here uses plain HTML and vanilla JavaScript (JavaScript without a framework). There is nothing to install and no build step. Frameworks and single-page apps (SPAs, sites made of one page that redraws itself) are widely used, but each needs a course of its own.
Why the app hands out the page too
A web server answers requests, and the answer can be JSON or a web page. To the server both are just content, so the bookshop app can hand out the page as well as the data.
In many real projects the page is hosted separately from the API. Then there are two things to run and two addresses. The browser also asks the API for extra permission before it lets the page use it.
Here the page comes from the API itself, so there is one thing to run, one address, and no permission to set up.
We pick the first setup because it is the least to set up and explain. A separately hosted page is left out.
A page is another file the app can hand out
Since the first lesson, the picture has been one request and one response: a browser asks, a server answers. So far every answer was JSON. But a web page is only a text file written in a language called HTML, and a server can send a text file as easily as JSON. Our API is a web server, so it can hand out the page too.
Here is the whole plan in one picture:
Two things to notice:
- Two requests. The first fetches the page. The page then contains a small program, written in JavaScript, that makes the second request for the data. JavaScript runs in the visitor's browser, on the visitor's computer. It does not run on our server.
- One address. The page and the data come from the same app, so the page can ask for
/booksand nothing more. A page that lives at a different address from the API is possible, but the browser then wants extra permission from the API. We avoid that by serving the page from the API itself, and leave the other case out.
Let the app hand out files
By default, the app hands out nothing but answers from our controllers. We switch on a second job: handing out files from a folder. Two things to set up: the folder and the switch.
The folder is called wwwroot (short for "web root"). It is the one folder from which the app is allowed to give files to browsers. Anything outside it stays private. Create it inside the API project, with an index.html in it. From BookShop/:
mkdir src/BookShop.Api/wwwroot
In Visual Studio: right-click the BookShop.Api project, Add, New Folder, and name it wwwroot. Then right-click that folder, Add, New Item, and pick an HTML page named index.html. (These clicks are from the documentation, not run here.) Either way, give index.html one line for now:
<p>hello</p>
The name index.html is the habit of every web server: when an address names no file (just /), the server looks for index.html.
Do not double-click the file to open it from disk. A page opened that way has no server behind it, so an address such as /books would mean nothing to it.
Start the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio) and ask for the page:
curl -i http://localhost:5101/
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-ba1ac6e320c0b803cc1be122af76aa31-a0f61a63096f1ead-00"}
The file is there, but the app does not know it should give it out. (The body is the Problem Details shape from errors the professional way, on one line. Ignore the traceId for now.) Stop the app with Ctrl+C, and add the switch. Open src/BookShop.Api/Program.cs. The whole file is:
using BookShop.Data;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("BookShop");
builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<IBookService, BookService>();
builder.Services.AddScoped<IStockReportService, StockReportService>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseDefaultFiles(); // new
app.UseStaticFiles(); // new
app.MapControllers();
app.Run();
UseStaticFiles()is the switch. A static file is a file that is sent as it is, the same for everyone (a page, an image), in contrast to an answer that our code works out for each request. This line makes the app give out whatever is inwwwroot.UseDefaultFiles()is theindex.htmlhabit. It changes a request for/into a request for/index.html. It only rewrites the address;UseStaticFiles()then does the giving, so it must come second.- The order is the order a request meets them. Both lines sit before
MapControllers(), so a request for a file is answered before the controllers are tried.
Newer ASP.NET templates also offer an alternative, MapStaticAssets(), aimed at bigger sites. The two classic lines are enough for us, and they work the same everywhere.
Start the app again and ask for the page once more:
curl -i http://localhost:5101/
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/html
<p>hello</p>
(Some headers are left out.) A 200 and our one line. Open http://localhost:5101/ in a browser to see the word hello.
The browser's developer tools
From here on we want to see what a page does, and the page itself hides it. Every browser has developer tools for this. In Chrome, Edge and Firefox, press F12 (or right-click the page and choose Inspect). A panel opens with tabs. We use two:
- Console shows messages and errors from the page's JavaScript. When something fails silently on the page, the reason is usually here, in red.
- Network lists every request the page made, with its status, and lets you click one to read the response. Open the tab before loading the page, or reload afterwards.
They are for you, the developer. The visitor never needs them.
The page
Replace the contents of src/BookShop.Api/wwwroot/index.html with the whole page:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Books</title>
</head>
<body>
<h1>Books</h1>
<p id="message"></p>
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Price</th>
<th>Stock</th>
<th>Category</th>
</tr>
</thead>
<tbody id="book-rows"></tbody>
</table>
<!-- the script: asks the API for the books -->
<script>
async function loadBooks() {
const response = await fetch('/books');
if (!response.ok) {
document.getElementById('message').textContent =
'Could not load the books. The API answered ' + response.status + '.';
return;
}
const books = await response.json();
const rows = document.getElementById('book-rows');
rows.replaceChildren();
for (const book of books) {
const row = rows.insertRow();
row.insertCell().textContent = book.title;
row.insertCell().textContent = book.author;
row.insertCell().textContent = book.price.toFixed(2);
row.insertCell().textContent = book.stock;
row.insertCell().textContent = book.category;
}
}
loadBooks();
</script>
</body>
</html>
The page has two parts. The HTML says what is on the page: a heading, a message line and an empty table. The script, at the bottom, asks the API for the books and fills the table.
The HTML
HTML is a set of elements, each written between a start tag and an end tag, such as <h1>Books</h1>. They nest inside one another:
<head>holds facts about the page: its text encoding (utf-8, so that accented letters work) and its<title>, the text on the browser tab.<body>holds what the visitor sees.<h1>is the biggest heading,<p>is a paragraph.<table>is a table.<thead>holds the header row (<tr>is a row,<th>a header cell).<tbody>will hold the book rows, and we leave it empty, because the script will fill it.id="book-rows"gives an element a name. The script uses it to find the element. The empty<p id="message">is a place for a line of text, which we use at the end of this lesson.
The script
JavaScript looks a lot like C#, and the parts that matter here are few. Read it from the top:
async function loadBooks()is a method that can wait.asyncandawaitmean what they meant in async and await: while the request travels, the page stays alive.fetch('/books')sends aGETrequest to/booksat the page's own address. It is the browser's built-in way of making a request from a script, likecurlfrom inside the page. Once the answer arrives,awaithands it over asresponse.response.okistruefor the2xxstatuses, so!response.okmeans the API said no. We come back to this at the end.response.json()reads the body and turns the JSON into a JavaScript array of objects, like the conversion described in JSON. The properties keep the names from the JSON, in lowercase:book.title,book.price.documentis the page itself. It is a live model of the page that the browser builds, called the DOM, and a script can read and change it.document.getElementById('book-rows')finds the element with thatid.rows.replaceChildren()empties the body of the table. The first time it does nothing. We include it so that loading the list a second time (which the next lesson does) does not stack new rows under the old ones.for (const book of books)is aforeach. For each book,rows.insertRow()adds a table row, androw.insertCell()adds a cell to it..textContent = ...puts text in the cell.book.price.toFixed(2)writes the price with two decimals. JSON numbers have no trailing zeros, so the API sends34.5and44. A price looks wrong without them.loadBooks();on the last line runs the function when the page loads.
Refresh the browser. The table fills with the five books of our shop, in the order the API sends them (by title):
| Title | Author | Price | Stock | Category |
|---|---|---|---|---|
| Clean Code | Robert C. Martin | 34.50 | 7 | Programming |
| Refactoring | Martin Fowler | 44.00 | 3 | Programming |
| Salt Fat Acid Heat | Samin Nosrat | 29.00 | 4 | Cooking |
| The Joy of Cooking | Irma Rombauer | 32.50 | 2 | Cooking |
| The Pragmatic Programmer | Andrew Hunt | 39.90 | 12 | Programming |
(That is the page, drawn as a table here. Yours is plain and unstyled.) With the table empty, the page would show only the header row, and no error.
Now open the developer tools, go to Network and refresh. You see one row for the page itself and one for books, the script's request, each with its status (200). Click the books row and look for its response: it is the JSON you used to read in the terminal. This is the second request of the picture. The page has the same conversation as curl, made by a script.
Put data in as text, never as HTML
The cell lines use textContent. A shorter way exists, and it is tempting: build the whole row as a piece of HTML text and hand it over with innerHTML. Here is why we do not. First, add one more book to the shop, with a title that contains HTML:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"<b>Bold</b> Claims","author":"A. Writer","price":10,"stock":1,"category":"Test"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/6
{"id":6,"title":"<b>Bold</b> Claims","author":"A. Writer","price":10,"stock":1,"category":"Test"}
(Your id may differ.) The API accepted it, as it should: nothing in the rules forbids angle brackets in a title.
Case 1: textContent, the page as written. Refresh. The first row of the table is:
<b>Bold</b> Claims | A. Writer | 10.00 | 1 | Test
The title is shown exactly as it was typed, angle brackets included.
Case 2: innerHTML. Suppose the loop were written the short way instead:
const row = rows.insertRow();
row.innerHTML = '<td>' + book.title + '</td><td>' + book.author + '</td><td>'
+ book.price.toFixed(2) + '</td><td>' + book.stock + '</td><td>' + book.category + '</td>';
Run that and the same row is:
Bold Claims | A. Writer | 10.00 | 1 | Test
The brackets are gone and Bold is drawn in bold. The browser took the title for HTML, because that is what innerHTML means: "this text is HTML, build elements from it".
A bold word is harmless. But HTML can also carry code that runs in the visitor's browser. If a book title, or later any text a user typed, were pasted into the page as HTML, whoever wrote it could run code on every visitor's computer. This attack is called cross-site scripting (XSS), and it is one of the most common ways websites are broken. The habit that prevents it costs nothing:
⚠️ Put text from the API into the page with
textContent, so it is always text. UseinnerHTMLonly for HTML that you wrote yourself.
The API did not forbid the title, and it should not: a book really could be named with a bracket. The page is the place that decides how a value is shown, so the page is where the habit lives. Delete the test book again, using the id from your own output:
curl -i -X DELETE http://localhost:5101/books/6
HTTP/1.1 204 No Content
When the call fails
The script checks response.ok before it reads anything. Why? Because fetch does not treat an error status as a failure. It reports a failure only when it cannot reach the server at all. A 404 or a 500 is, for fetch, an answer like any other, and response.json() would then read the error body as if it were a list of books.
See it. In index.html, make a typo on purpose: change fetch('/books') to fetch('/book'), and refresh. The API has no /book, so it answers 404, and the page shows:
Could not load the books. The API answered 404.
The table stays empty and the message paragraph, the empty <p id="message"> from the start, now holds the sentence. Check the Network tab: the request to book is there, with its 404 in red. Change the typo back to /books.
The message is written for the owner, in plain words. The status number is there so that the owner can read it out to the developer. A failure where the server cannot be reached at all (the app is stopped, the network is down) makes fetch raise an error instead. We leave that case out; the Console tab would show it in red.
Where we ended
Compare with your solution. One folder and one file are new, and Program.cs gained two lines:
BookShop/
src/
BookShop.Api/
Program.cs UseDefaultFiles and UseStaticFiles added
wwwroot/
index.html the Books page: a table filled by a script
Recap
- A web page is a text file, and our app can hand it out like any other answer. The folder
wwwrootis the only one it gives files from. UseStaticFiles()switches the job on, andUseDefaultFiles()makes/mean/index.html. Both go beforeMapControllers(), in that order.- The page does two things: the HTML describes the page, and a script in the page asks the API for the data. The script runs in the visitor's browser.
- Page and API come from the same address, so the script can call
/bookswith no more than that. fetchmakes the request,response.json()reads the JSON, and the DOM (getElementById,insertRow,insertCell) is how a script changes the page.- The developer tools (F12), with the Console and Network tabs, show what a page really does.
- Put API text into the page with
textContent, neverinnerHTML, so that a value can never become code. fetchdoes not fail on a404or a500. Checkresponse.okand show a plain message.
Questions to think about
Each question has its answer folded away underneath. Guess first, then open it to check.
- Why can the page ask for just
/books, without a full address?
Show answer
The page and the API are served by the same app, so they share one address. A short address means "the same place the page came from".
- Who runs the JavaScript in the page: our server or the visitor's browser?
Show answer
The visitor's browser. The server only hands the script over. That is why the page's checks can never replace the API's.
- Why do we write book titles into the table with
textContentand notinnerHTML?
Show answer
textContent always treats the value as plain text. innerHTML would read it as HTML, so a title could become part of the page, or even run code in the visitor's browser.
- Why does the script check
response.ok?
Show answer
fetch counts a 404 or a 500 as an ordinary answer. Without the check, the script would try to read an error as a list of books.
- The page can only show books. What would it need in order to add one? (The next lesson answers this.)
Show answer
A form to type the five values, and a script that sends them to the API as JSON in a POST request.
Next lesson: the Books screen, where the same page learns to add, edit and delete books.