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
CORSMiddlewarecorrectly (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 port5500in 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, includingContent-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 actualPOSTrequest, withJSON.stringify(payload)converting our JavaScript object into the JSON string the API expects.response.okistruefor any2xxstatus code, andfalseotherwise — a quick way to branch between success and error handling.- When validation fails, FastAPI’s
422error response contains adetailarray (you saw this shape back in Chapter 5) — the code checksArray.isArray(data.detail)to handle both that validation-error shape and simpler stringdetailmessages (like our409 Conflictfor duplicate usernames) with the same code path.
⚠️ Security note: Never use
allow_origins=["*"]together withallow_credentials=Truein 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:
- Open
http://127.0.0.1:5500/index.htmlin your browser (notfile://— using the actual server matters for realistic origin behavior). - Fill in the form and submit it. Confirm you see a green success message, and confirm the page never reloaded.
- Submit the form again with the same username — confirm you see the
409 Conflictmessage rendered in red, sourced directly from your API’s error response. - See the problem, then the fix: temporarily remove your own origin from the
originslist inmain.py(or comment out the wholeadd_middlewareblock), 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 anasyncfunction that fetches the current product list and rebuilds the table body from scratch:tableBody.innerHTML = ""clears out old rows, then aforloop 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
submithandler, after successfully posting the new product, we callawait 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’sprice: floatvalidation 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
- Open
http://127.0.0.1:5500/index.html— confirm the table starts empty. - Add a product through the form — confirm it appears in the table immediately, without a page reload.
- Add two or three more — confirm the table keeps growing correctly.
- 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_originsfor real projects — treat["*"]as a prototyping-only shortcut, never a production setting, especially onceallow_credentials=Trueis 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.okbefore assuming afetch()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_URLas 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
curlfirst, to isolate whether it’s a CORS issue or a genuine backend bug.
Official FastAPI Documentation References
- CORS (Cross-Origin Resource Sharing)
- Middleware
- Static Files
- MDN — Using Fetch
- MDN — Cross-Origin Resource Sharing (CORS)
Theory Questions
- Why does the browser block cross-origin requests by default, and who is actually enforcing that block — the client or the server?
- What is a CORS “preflight” request, and which HTTP method does the browser use to send it?
- Why is
allow_origins=["*"]combined withallow_credentials=Trueconsidered dangerous? - Why does the code check
response.okinstead of relying onfetch()to throw an error for a failed HTTP request? - Why does
loadProducts()clear and fully rebuild the table instead of just appending the newly created item to it?
Practical Assignment
- 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. - 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. - 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’soriginslist. Observe the exact error in your browser console, then fix it by updatingoriginsto 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
CORSMiddlewareresolves 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
