Chapter 13: Frontend Forms — Connecting JavaScript to Your API

Let’s Start With a Real Problem

You’ve built a genuinely solid API across the last twelve chapters. You open Swagger UI, click “Try it out,” and everything works beautifully. Then you build a simple HTML page with a signup form, open it in your browser, submit it… and your browser console explodes with a red error:

Access to fetch at 'http://127.0.0.1:8000/users/register' from origin
'http://127.0.0.1:5500' has been blocked by CORS policy

Nothing is actually broken. Your API works fine. Your JavaScript is fine. This is your browser, deliberately protecting the user, refusing to let a webpage from one address quietly call an API on another address without explicit permission. This chapter is about understanding exactly why that happens — and fixing it the right way — while building real forms that talk to your FastAPI backend using nothing but plain JavaScript.


What You’ll Learn

  • What CORS actually is, and why browsers enforce it
  • How to configure FastAPI’s CORSMiddleware correctly (and safely)
  • How to submit an HTML form to your API using JavaScript’s fetch(), without a page reload
  • How to display API validation errors directly in the UI
  • How to fetch and render a live list of data from your API, and refresh it after creating new items
  • Theory + hands-on assignment questions to lock in what you’ve learned

We’ll use plain HTML, CSS, and JavaScript — no frontend framework, no build tools, nothing to install beyond what’s already on your machine.


Theory: What Is CORS, and Why Does It Exist?

CORS (Cross-Origin Resource Sharing) is a browser security feature. By default, a webpage loaded from one origin (a combination of protocol + domain + port, e.g. http://127.0.0.1:5500) is not allowed to make requests to a different origin (e.g. http://127.0.0.1:8000) unless that other server explicitly says “this origin is allowed to talk to me.”

This isn’t your API being broken — it’s your browser protecting users from malicious websites silently making requests to banks, email providers, or other sensitive services using the user’s existing login session, without their knowledge.

For requests like POST with a JSON body, the browser often first sends an invisible preflight request — an OPTIONS request asking “would you accept a real request from my origin?” — before sending the actual request at all. FastAPI’s CORSMiddleware handles answering these preflight checks for you automatically, once configured.

⚠️ Important: CORS is enforced by the browser, not your API server. Tools like Swagger UI, curl, or Postman don’t enforce CORS at all — which is exactly why your API “worked fine” in Swagger UI but failed from your actual HTML page.


Program 1: A Registration Form With CORS Configured Correctly

Program Objective

Build a small, standalone FastAPI backend with a POST /users/register endpoint and proper CORS configuration, plus a separate plain HTML/JavaScript frontend — served from a different port, to genuinely trigger and then correctly solve a real CORS scenario — with a form that submits without reloading the page and displays validation errors inline.

Step-by-Step Implementation

Step 1: Create the backend project

uv init frontend-forms-demo
cd frontend-forms-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app frontend
touch app/__init__.py
frontend-forms-demo/
├── app/
│   ├── __init__.py
│   └── main.py
├── frontend/
│   └── index.html
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/main.py

from fastapi import FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr, Field
from typing import Annotated

app = FastAPI(title="Frontend Forms Demo", version="1.0.0")

# In development, list the exact origins your frontend runs on.
origins = [
    "http://127.0.0.1:5500",
    "http://localhost:5500",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

_fake_users_db: list[dict] = []
_next_id = 1


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr


@app.post("/users/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate):
    global _next_id

    if any(u["username"] == user_in.username for u in _fake_users_db):
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")

    new_user = {"id": _next_id, "username": user_in.username, "email": user_in.email}
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user

frontend/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Register — Frontend Forms Demo</title>
  <style>
    body { font-family: sans-serif; max-width: 400px; margin: 60px auto; }
    input { display: block; width: 100%; padding: 8px; margin-bottom: 12px; box-sizing: border-box; }
    button { padding: 10px 16px; cursor: pointer; }
    #message { margin-top: 16px; padding: 10px; border-radius: 4px; display: none; }
    .success { background: #d4edda; color: #155724; }
    .error { background: #f8d7da; color: #721c24; }
  </style>
</head>
<body>
  <h2>Create an Account</h2>

  <form id="register-form">
    <input type="text" id="username" placeholder="Username" required />
    <input type="email" id="email" placeholder="Email" required />
    <input type="password" id="password" placeholder="Password" required />
    <button type="submit">Register</button>
  </form>

  <div id="message"></div>

  <script>
    const API_BASE_URL = "http://127.0.0.1:8000";
    const form = document.getElementById("register-form");
    const messageBox = document.getElementById("message");

    function showMessage(text, type) {
      messageBox.textContent = text;
      messageBox.className = type;
      messageBox.style.display = "block";
    }

    form.addEventListener("submit", async (event) => {
      event.preventDefault(); // stop the browser's default full-page reload

      const payload = {
        username: document.getElementById("username").value,
        email: document.getElementById("email").value,
        password: document.getElementById("password").value,
      };

      try {
        const response = await fetch(`${API_BASE_URL}/users/register`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(payload),
        });

        const data = await response.json();

        if (!response.ok) {
          const detail = Array.isArray(data.detail)
            ? data.detail.map((err) => err.msg).join(", ")
            : data.detail;
          showMessage(`Error: ${detail}`, "error");
          return;
        }

        showMessage(`Welcome, ${data.username}! Your account was created.`, "success");
        form.reset();
      } catch (err) {
        showMessage("Could not reach the server. Is the API running?", "error");
      }
    });
  </script>
</body>
</html>

Detailed Code Explanation

  • origins = [...] lists the exact frontend addresses allowed to call this API. We’re serving the frontend on port 5500 in this program, so that’s what we whitelist.
  • app.add_middleware(CORSMiddleware, ...) is what actually solves the browser error from the introduction:
    • allow_origins=origins — only requests from these exact origins are allowed.
    • allow_credentials=True — allows cookies/authorization headers to be included in cross-origin requests (relevant once we add authentication in later chapters).
    • allow_methods=["GET", "POST"] — restricts which HTTP methods cross-origin requests can use; add more (PATCH, DELETE) as your API grows.
    • allow_headers=["*"] — allows any request headers, including Content-Type: application/json, which our form needs to send JSON.
  • In index.html, the JavaScript is deliberately simple and framework-free:
    • event.preventDefault() stops the browser’s default behavior of reloading the page on form submission — essential for a smooth, modern form experience.
    • fetch(...) sends the actual POST request, with JSON.stringify(payload) converting our JavaScript object into the JSON string the API expects.
    • response.ok is true for any 2xx status code, and false otherwise — a quick way to branch between success and error handling.
    • When validation fails, FastAPI’s 422 error response contains a detail array (you saw this shape back in Chapter 5) — the code checks Array.isArray(data.detail) to handle both that validation-error shape and simpler string detail messages (like our 409 Conflict for duplicate usernames) with the same code path.

⚠️ Security note: Never use allow_origins=["*"] together with allow_credentials=True in a real application — browsers actually block this combination for good reason, since it would let literally any website make authenticated requests on a logged-in user’s behalf. Always list your real, specific frontend origins in production.

Execution & Testing

Terminal 1 — start the backend:

uv run fastapi dev app/main.py

Terminal 2 — serve the frontend on a different port (this is what makes CORS relevant here):

cd frontend
python -m http.server 5500

Now:

  1. Open http://127.0.0.1:5500/index.html in your browser (not file:// — using the actual server matters for realistic origin behavior).
  2. Fill in the form and submit it. Confirm you see a green success message, and confirm the page never reloaded.
  3. Submit the form again with the same username — confirm you see the 409 Conflict message rendered in red, sourced directly from your API’s error response.
  4. See the problem, then the fix: temporarily remove your own origin from the origins list in main.py (or comment out the whole add_middleware block), restart the backend, and submit the form again. Open your browser’s developer console (F12) to see the exact CORS error from the introduction — then restore the configuration and confirm it works again.

Program 2: A Dynamic List That Fetches and Refreshes Data

Program Objective

Build a small, standalone FastAPI backend with GET /products/ and POST /products/ endpoints, plus a frontend page that loads the product list automatically on page load, lets the user add a new product through a form, and refreshes the list immediately after — all without a page reload.

Step-by-Step Implementation

Step 1: Create the backend project

uv init dynamic-list-demo
cd dynamic-list-demo
uv add fastapi --extra standard

Step 2: Create the project structure

mkdir -p app frontend
touch app/__init__.py
dynamic-list-demo/
├── app/
│   ├── __init__.py
│   └── main.py
├── frontend/
│   └── index.html
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/main.py

from fastapi import FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import Annotated

app = FastAPI(title="Dynamic List Demo", version="1.0.0")

origins = ["http://127.0.0.1:5500", "http://localhost:5500"]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

_fake_products_db: list[dict] = []
_next_id = 1


class ProductCreate(BaseModel):
    name: Annotated[str, Field(min_length=2, max_length=100)]
    price: Annotated[float, Field(gt=0)]


class ProductOut(BaseModel):
    id: int
    name: str
    price: float


@app.get("/products/", response_model=list[ProductOut])
def get_products():
    return _fake_products_db


@app.post("/products/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(product_in: ProductCreate):
    global _next_id
    new_product = {"id": _next_id, "name": product_in.name, "price": product_in.price}
    _fake_products_db.append(new_product)
    _next_id += 1
    return new_product

frontend/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Products — Dynamic List Demo</title>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 60px auto; }
    form { display: flex; gap: 8px; margin-bottom: 20px; }
    input { flex: 1; padding: 8px; }
    button { padding: 8px 14px; cursor: pointer; }
    table { width: 100%; border-collapse: collapse; }
    th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
  </style>
</head>
<body>
  <h2>Products</h2>

  <form id="product-form">
    <input type="text" id="name" placeholder="Product name" required />
    <input type="number" id="price" placeholder="Price" step="0.01" min="0.01" required />
    <button type="submit">Add</button>
  </form>

  <table>
    <thead>
      <tr><th>ID</th><th>Name</th><th>Price</th></tr>
    </thead>
    <tbody id="product-table-body"></tbody>
  </table>

  <script>
    const API_BASE_URL = "http://127.0.0.1:8000";
    const form = document.getElementById("product-form");
    const tableBody = document.getElementById("product-table-body");

    async function loadProducts() {
      const response = await fetch(`${API_BASE_URL}/products/`);
      const products = await response.json();

      tableBody.innerHTML = "";
      for (const product of products) {
        const row = document.createElement("tr");
        row.innerHTML = `<td>${product.id}</td><td>${product.name}</td><td>$${product.price.toFixed(2)}</td>`;
        tableBody.appendChild(row);
      }
    }

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

      const payload = {
        name: document.getElementById("name").value,
        price: parseFloat(document.getElementById("price").value),
      };

      await fetch(`${API_BASE_URL}/products/`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });

      form.reset();
      await loadProducts(); // refresh the table with the newly added product
    });

    loadProducts(); // load the initial list as soon as the page opens
  </script>
</body>
</html>

Detailed Code Explanation

  • loadProducts() is an async function that fetches the current product list and rebuilds the table body from scratch: tableBody.innerHTML = "" clears out old rows, then a for loop creates a new <tr> for each product using a template string.
  • loadProducts(); is called once immediately at the bottom of the script — this is what makes the table populate automatically the moment the page loads, without waiting for any user action.
  • Inside the form’s submit handler, after successfully posting the new product, we call await loadProducts() again — this is the key pattern for keeping a UI in sync with your backend: instead of trying to manually insert just the one new row into the table, we simply re-fetch the full, authoritative list from the API and re-render everything. It’s simpler to reason about and guarantees the UI never drifts out of sync with real data.
  • parseFloat(...) converts the text input’s string value into an actual number before sending it — HTML <input type="number"> still gives you a string in JavaScript, so this conversion matters for the API’s price: float validation to succeed.
  • Notice this backend’s CORS setup is identical to Program 1’s — this is a configuration you’ll copy into nearly every FastAPI project that has a separate frontend.

Execution & Testing

Terminal 1:

uv run fastapi dev app/main.py

Terminal 2:

cd frontend
python -m http.server 5500
  1. Open http://127.0.0.1:5500/index.html — confirm the table starts empty.
  2. Add a product through the form — confirm it appears in the table immediately, without a page reload.
  3. Add two or three more — confirm the table keeps growing correctly.
  4. Refresh the browser page (a real, full reload this time) — confirm the products are still there, since they’re being loaded fresh from the API’s GET /products/ on every page load, not stored in the browser itself.

Tips & Instructions for Developers

  • ✅ Always list your exact frontend origins in allow_origins for real projects — treat ["*"] as a prototyping-only shortcut, never a production setting, especially once allow_credentials=True is involved.
  • ✅ Use event.preventDefault() on every form you submit via JavaScript — forgetting it causes a jarring full-page reload that discards your fetch logic’s result.
  • ✅ Always check response.ok before assuming a fetch() call succeeded — a failed request still resolves the Promise; it doesn’t automatically throw an error the way you might expect.
  • ✅ Re-fetch and re-render the full list after a create/update/delete action, rather than trying to manually patch the DOM — it’s far less error-prone as your UI grows more complex.
  • ✅ Keep API_BASE_URL as a single constant at the top of your script — you’ll thank yourself when it’s time to point the frontend at a staging or production API URL.
  • ❌ Don’t hardcode API URLs in multiple places throughout your JavaScript — one typo and half your app silently breaks.
  • ❌ Don’t confuse a CORS error with your API actually being broken — always check whether the exact same request works fine through Swagger UI or curl first, to isolate whether it’s a CORS issue or a genuine backend bug.

Official FastAPI Documentation References


Theory Questions

  1. Why does the browser block cross-origin requests by default, and who is actually enforcing that block — the client or the server?
  2. What is a CORS “preflight” request, and which HTTP method does the browser use to send it?
  3. Why is allow_origins=["*"] combined with allow_credentials=True considered dangerous?
  4. Why does the code check response.ok instead of relying on fetch() to throw an error for a failed HTTP request?
  5. Why does loadProducts() clear and fully rebuild the table instead of just appending the newly created item to it?

Practical Assignment

  1. Add a loading state: Starting from Program 2’s project, show a “Loading products…” message while loadProducts() is running, and hide it once the table has rendered.
  2. Add delete functionality: Add a DELETE /products/{product_id} endpoint to the backend, then add a “Delete” button to each table row in the frontend that calls it and refreshes the list afterward.
  3. Trigger and fix a real CORS error: Starting from Program 1’s project, serve the frontend on a different port than 5500 (e.g. 5501) without updating the backend’s origins list. Observe the exact error in your browser console, then fix it by updating origins to match — and write down, in your own words, why the fix worked.

Summary

In this chapter, you learned:

  • What CORS is, why browsers enforce it, and how FastAPI’s CORSMiddleware resolves it correctly and safely
  • How to submit a form to your API using fetch() without a page reload
  • How to display API validation and error messages directly in your UI
  • How to fetch and render a live, API-backed list, and keep it in sync after creating new data

Scroll to Top