Connect the website to the API and deploy
Connect the Brew & Bean website to your order API: load the menu with fetch, submit real orders, handle loading and errors, then deploy the database, API and site and test the live shop on your phone.
This is the last lesson of the project. You have a website (cart and order form) and an API (menu, orders, database, tests), but they are strangers. Here you introduce them: the website will load the menu from the API and send the order to the API. Then you deploy the API and change the website to point at it, so anyone can place an order on the internet.
You need: the Brew & Bean website from the first project (folder brew-and-bean), the API folder brew-and-bean-api with its tests passing, Node.js, Git and a GitHub account. For deployment: free accounts on a host for the API (we show Render), a free Postgres (we show Neon) and the host you used for the site (we show Netlify).
The big picture
Three pieces, three settings
| Part | Where it runs | How it is told about the other |
|---|---|---|
| Website (React) | Netlify, in the customer's browser | VITE_API_URL: the address of the API, built into the site |
| API (FastAPI) | Render | CORS_ORIGINS: the website address that may call it |
| Database (Postgres) | Neon | DATABASE_URL: given to the API |
The browser downloads the website from Netlify. The website's JavaScript then calls the API on Render with fetch. The API talks to Postgres. The customer never talks to the database, and the website never contains a password.
What you will do
- 1Run both parts locally and see the problem the site has with an API.
- 2Prompt the AI to connect them.
- 3Review the changes.
- 4Add the API client, the menu loading and the real order submit.
- 5Test everything locally, including failures.
- 6Deploy the database and the API.
- 7Point the website at the live API, redeploy and test on your phone.
Step 1: Plan the change
Here is what changes in the website. Almost everything else stays the same.
- A new file src/api.js holds all calls to the API, so no component builds URLs itself.
- The menu is no longer a JavaScript array. A MenuProvider fetches it once and shares it, like CartProvider shares the cart.
- Loading and error states appear wherever the menu is shown. A website that depends on a server must handle "still loading" and "the server is down".
- Placing an order calls POST /orders, shows the server's confirmation and total, and keeps the cart if something goes wrong.
- The order form sends pickup as a code (asap, in_15, ...), matching the API contract.
I have a React + Vite site (plain JavaScript, react-router-dom, a CartContext that stores a list of { id, qty } and reads names and prices from a static menu array in src/data/menu.js). I now have a FastAPI backend with GET /menu and POST /orders (contract below).
Change the site so that:
1. The menu comes from GET /menu, loaded once by a MenuProvider (src/context/MenuContext.jsx) with status 'loading', 'ready' or 'error' and a retry function.
2. Pages that show the menu (Home, Menu, Cart) show a loading message, and an error message with a Try again button.
3. The order form sends the order to POST /orders using fetch, disables the button while sending, shows errors from the server, keeps the cart if the request fails, and shows the server's confirmation (order id, items, total, pickup time) on success.
4. All fetch calls live in src/api.js. The base URL comes from import.meta.env.VITE_API_URL with a default of http://localhost:8000.
5. The cart sends only item ids and quantities. Never prices.
6. Saved cart items that are not on the menu any more are removed once the menu has loaded.
Change as little as possible. No new libraries. Show every file that changes, in full.
API contract:
[paste API.md]
Current files:
[paste CartContext.jsx, Cart.jsx, OrderForm.jsx, OrderConfirmation.jsx, Menu.jsx, Home.jsx, main.jsx]What to expect: Expect new files api.js, MenuContext.jsx and a small status component, plus changes to six existing files. Check that no component calls fetch directly and no price is sent.
Step 2: Review the plan in the AI's answer
Review checklist
- Every network call goes through src/api.js.
- The request body has only customer_name, phone, pickup and items with item_id and qty.
- There is a loading state and an error state for the menu, and a way to retry.
- The Place order button is disabled while the request runs, so a double click cannot send two orders.
- The cart is cleared only after the server confirms the order.
- Errors shown to the customer are short and human. They do not show raw JSON or stack traces.
- No API key or secret in the front-end code.
- The API address comes from VITE_API_URL, not a hard-coded production address.
Step 3: The code
Here is a reference implementation. If your AI's version differs but meets the checklist, keep yours. Start with src/api.js, the only file that knows the API's address and how to talk to it.
// The address of the API. Set VITE_API_URL when you deploy.
const API_URL = (
import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
).replace(/\/$/, '')
async function request(path, options) {
let response
try {
response = await fetch(`${API_URL}${path}`, options)
} catch {
// fetch only throws when the request never reached the server.
throw new Error('Could not reach the shop. Check your internet and try again.')
}
if (!response.ok) {
let message = 'Something went wrong. Please try again.'
try {
const body = await response.json()
// 422 errors come as a list. Other errors come as a plain sentence.
if (typeof body.detail === 'string') {
message = body.detail
} else if (response.status === 422) {
message = 'Please check your details and try again.'
}
} catch {
// The body was not JSON. Keep the general message.
}
throw new Error(message)
}
return response.json()
}
export function getMenu() {
return request('/menu')
}
export function createOrder(order) {
return request('/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
})
}request() wraps fetch. It turns network failures and server errors into one Error with a readable message. getMenu and createOrder are the only functions components use.
fetch has a surprise: it does not throw on a 404 or 422. It only throws when the request could not be made at all (server down, no internet). So the code checks response.ok and handles both kinds of failure.
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { getMenu } from '../api.js'
const MenuContext = createContext(null)
export function MenuProvider({ children }) {
const [menu, setMenu] = useState([])
const [status, setStatus] = useState('loading') // 'loading' | 'ready' | 'error'
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let cancelled = false
getMenu()
.then((items) => {
if (cancelled) return
setMenu(items)
setStatus('ready')
})
.catch(() => {
if (!cancelled) setStatus('error')
})
// If this effect runs again, ignore the answer of the old request.
return () => {
cancelled = true
}
}, [attempt])
const value = useMemo(
() => ({
menu,
status,
retry: () => {
setStatus('loading')
setAttempt((n) => n + 1)
},
}),
[menu, status],
)
return <MenuContext.Provider value={value}>{children}</MenuContext.Provider>
}
// eslint-disable-next-line react-refresh/only-export-components
export function useMenu() {
const context = useContext(MenuContext)
if (!context) {
throw new Error('useMenu must be used inside <MenuProvider>')
}
return context
}Loads the menu once when the app starts. The cancelled flag stops an old request from overwriting newer state. React's StrictMode runs effects twice in development, which is why this matters.
// Shows a message while the menu loads, or a retry button if it failed.
// Shows nothing once the menu is ready.
export default function MenuStatus({ status, retry }) {
if (status === 'loading') {
return (
<p role="status">
Loading the menu... The first visit can take a little longer.
</p>
)
}
if (status === 'error') {
return (
<div role="alert">
<p>We could not load the menu. Check your internet and try again.</p>
<button type="button" className="btn" onClick={retry}>
Try again
</button>
</div>
)
}
return null
}A small component used by Home, Menu and Cart. It shows nothing when the menu is ready.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { CartProvider } from './context/CartContext.jsx'
import { MenuProvider } from './context/MenuContext.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<MenuProvider>
<CartProvider>
<App />
</CartProvider>
</MenuProvider>
</BrowserRouter>
</StrictMode>,
)MenuProvider goes outside CartProvider, because the cart needs the menu to know names and prices.
// The menu items now come from the API (GET /menu).
// Only the category list for the filter buttons stays here.
export const categories = ['all', 'coffee', 'tea', 'bakery', 'food']
export const categoryLabels = {
all: 'All',
coffee: 'Coffee',
tea: 'Tea',
bakery: 'Bakery',
food: 'Food',
}Delete the old menu array. Only the category list for the filter buttons stays, because it is about how the page looks and not about the shop's products.
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react'
import { useMenu } from './MenuContext.jsx'
const STORAGE_KEY = 'brew-and-bean-cart'
const MAX_QTY = 20
const CartContext = createContext(null)
// The cart is a list of { id, qty }. Names and prices come from the menu (the API).
function cartReducer(cart, action) {
switch (action.type) {
case 'add': {
const existing = cart.find((line) => line.id === action.id)
if (!existing) {
return [...cart, { id: action.id, qty: 1 }]
}
return cart.map((line) =>
line.id === action.id
? { ...line, qty: Math.min(line.qty + 1, MAX_QTY) }
: line,
)
}
case 'increase':
return cart.map((line) =>
line.id === action.id
? { ...line, qty: Math.min(line.qty + 1, MAX_QTY) }
: line,
)
case 'decrease':
return cart
.map((line) =>
line.id === action.id ? { ...line, qty: line.qty - 1 } : line,
)
.filter((line) => line.qty > 0)
case 'remove':
return cart.filter((line) => line.id !== action.id)
case 'clear':
return []
case 'prune': {
// Drop lines for items that are no longer on the menu.
const kept = cart.filter((line) => action.ids.includes(line.id))
return kept.length === cart.length ? cart : kept
}
default:
return cart
}
}
// Read the saved cart, ignoring anything that is missing or invalid.
function loadSavedCart() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY))
if (!Array.isArray(saved)) return []
return saved.filter(
(line) =>
typeof line.id === 'string' &&
Number.isInteger(line.qty) &&
line.qty > 0 &&
line.qty <= MAX_QTY,
)
} catch {
return []
}
}
export function CartProvider({ children }) {
const { menu, status } = useMenu()
const [cart, dispatch] = useReducer(cartReducer, [], loadSavedCart)
// Once the menu has loaded, remove saved items that no longer exist.
useEffect(() => {
if (status === 'ready') {
dispatch({ type: 'prune', ids: menu.map((item) => item.id) })
}
}, [status, menu])
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cart))
} catch {
// Storage can be blocked (private mode). The cart still works in memory.
}
}, [cart])
const value = useMemo(() => {
// Until the menu has loaded we cannot know names and prices, so no lines yet.
const lines = cart
.map((line) => ({
...line,
item: menu.find((item) => item.id === line.id),
}))
.filter((line) => line.item)
return {
lines,
totalItems: cart.reduce((sum, line) => sum + line.qty, 0),
totalPrice: lines.reduce((sum, line) => sum + line.qty * line.item.price, 0),
addItem: (id) => dispatch({ type: 'add', id }),
increase: (id) => dispatch({ type: 'increase', id }),
decrease: (id) => dispatch({ type: 'decrease', id }),
removeItem: (id) => dispatch({ type: 'remove', id }),
clearCart: () => dispatch({ type: 'clear' }),
}
}, [cart, menu])
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}
// eslint-disable-next-line react-refresh/only-export-components
export function useCart() {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart must be used inside <CartProvider>')
}
return context
}Changes: it imports useMenu, loadSavedCart no longer needs the menu, a new prune action removes saved items that are no longer sold, and lines are built from the menu that was loaded.
In the cart, lines combines the saved { id, qty } with the menu item. Before the menu has loaded there are no matching items, so lines is empty. That is why totalItems counts from the cart and why the Cart page waits for the menu.
import useDocumentTitle from '../hooks/useDocumentTitle.js'
import { useState } from 'react'
import MenuCard from '../components/MenuCard.jsx'
import MenuStatus from '../components/MenuStatus.jsx'
import { useMenu } from '../context/MenuContext.jsx'
import { categories, categoryLabels } from '../data/menu.js'
export default function Menu() {
useDocumentTitle('Menu')
const { menu, status, retry } = useMenu()
const [category, setCategory] = useState('all')
const visibleItems =
category === 'all' ? menu : menu.filter((item) => item.category === category)
return (
<section className="container page">
<h1>Our menu</h1>
<div className="filters" role="group" aria-label="Filter by category">
{categories.map((name) => (
<button
key={name}
type="button"
className={name === category ? 'chip chip-active' : 'chip'}
aria-pressed={name === category}
onClick={() => setCategory(name)}
>
{categoryLabels[name]}
</button>
))}
</div>
<MenuStatus status={status} retry={retry} />
<ul className="menu-grid">
{visibleItems.map((item) => (
<li key={item.id}>
<MenuCard item={item} />
</li>
))}
</ul>
</section>
)
}Changes: it gets the menu from useMenu and shows MenuStatus above the list.
In src/pages/Home.jsx make the same two changes: replace import { menu } from '../data/menu.js' with imports of MenuStatus and useMenu, add const { menu, status, retry } = useMenu() as the first line after useDocumentTitle(), and put <MenuStatus status={status} retry={retry} /> under the heading "Customer favourites". The rest of the page is unchanged.
import useDocumentTitle from '../hooks/useDocumentTitle.js'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import OrderForm from '../components/OrderForm.jsx'
import OrderConfirmation from '../components/OrderConfirmation.jsx'
import { createOrder } from '../api.js'
import MenuStatus from '../components/MenuStatus.jsx'
import { useCart } from '../context/CartContext.jsx'
import { useMenu } from '../context/MenuContext.jsx'
import { formatPrice } from '../utils/format.js'
export default function Cart() {
useDocumentTitle('Your cart')
const { lines, totalPrice, increase, decrease, removeItem, clearCart } =
useCart()
const { status: menuStatus, retry } = useMenu()
const [confirmation, setConfirmation] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState('')
async function handlePlaceOrder(details) {
setSubmitting(true)
setSubmitError('')
try {
// Send only ids and quantities. The server works out names and prices.
const order = await createOrder({
customer_name: details.name,
phone: details.phone,
pickup: details.pickup,
items: lines.map(({ id, qty }) => ({ item_id: id, qty })),
})
setConfirmation(order)
clearCart()
} catch (error) {
// Keep the cart so the customer can try again.
setSubmitError(error.message)
} finally {
setSubmitting(false)
}
}
if (confirmation) {
return <OrderConfirmation order={confirmation} />
}
if (menuStatus !== 'ready') {
return (
<section className="container page">
<h1>Your cart</h1>
<MenuStatus status={menuStatus} retry={retry} />
</section>
)
}
if (lines.length === 0) {
return (
<section className="container page empty">
<h1>Your cart is empty</h1>
<p>Add something from the menu and it will show up here.</p>
<Link to="/menu" className="btn">
Browse the menu
</Link>
</section>
)
}
return (
<section className="container page">
<h1>Your cart</h1>
<div className="cart-layout">
<div>
<ul className="cart-list">
{lines.map(({ id, qty, item }) => (
<li key={id} className="cart-line">
<span className="cart-line-name">{item.name}</span>
<div className="qty">
<button
type="button"
className="qty-btn"
aria-label={`Decrease quantity of ${item.name}`}
onClick={() => decrease(id)}
>
-
</button>
<span>{qty}</span>
<button
type="button"
className="qty-btn"
aria-label={`Increase quantity of ${item.name}`}
onClick={() => increase(id)}
>
+
</button>
</div>
<span className="cart-line-price">
{formatPrice(item.price * qty)}
</span>
<button
type="button"
className="link-button"
aria-label={`Remove ${item.name} from cart`}
onClick={() => removeItem(id)}
>
Remove
</button>
</li>
))}
</ul>
<div className="cart-total">
<span>Total</span>
<span>{formatPrice(totalPrice)}</span>
</div>
<button type="button" className="link-button" onClick={clearCart}>
Empty the cart
</button>
</div>
<OrderForm
onSubmit={handlePlaceOrder}
submitting={submitting}
submitError={submitError}
/>
</div>
</section>
)
}The important part is handlePlaceOrder: send, wait, then show the server's order or an error. try, catch and finally make sure the button is released in both cases.
import { useState } from 'react'
// value is what the API expects. label is what the customer reads.
const pickupOptions = [
{ value: 'asap', label: 'As soon as possible' },
{ value: 'in_15', label: 'In 15 minutes' },
{ value: 'in_30', label: 'In 30 minutes' },
{ value: 'in_60', label: 'In 1 hour' },
]
function validate({ name, phone }) {
const errors = {}
if (name.trim().length < 2) {
errors.name = 'Enter your name (at least 2 letters).'
}
if (!/^[6-9]\d{9}$/.test(phone.trim())) {
errors.phone = 'Enter a 10-digit mobile number starting with 6, 7, 8 or 9.'
}
return errors
}
export default function OrderForm({ onSubmit, submitting, submitError }) {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [pickup, setPickup] = useState(pickupOptions[0].value)
const [errors, setErrors] = useState({})
function handleSubmit(event) {
event.preventDefault()
if (submitting) return
const found = validate({ name, phone })
setErrors(found)
const firstInvalid = ['name', 'phone'].find((field) => found[field])
if (firstInvalid) {
document.getElementById(firstInvalid).focus()
return
}
onSubmit({ name: name.trim(), phone: phone.trim(), pickup })
}
return (
<form className="order-form" onSubmit={handleSubmit} noValidate>
<h2>Your details</h2>
<div className="field">
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p className="error" id="name-error">
{errors.name}
</p>
)}
</div>
<div className="field">
<label htmlFor="phone">Mobile number</label>
<input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
maxLength={10}
value={phone}
onChange={(event) => setPhone(event.target.value)}
aria-invalid={Boolean(errors.phone)}
aria-describedby={errors.phone ? 'phone-error' : undefined}
/>
{errors.phone && (
<p className="error" id="phone-error">
{errors.phone}
</p>
)}
</div>
<div className="field">
<label htmlFor="pickup">Pickup time</label>
<select
id="pickup"
name="pickup"
value={pickup}
onChange={(event) => setPickup(event.target.value)}
>
{pickupOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{submitError && (
<p className="error" role="alert">
{submitError}
</p>
)}
<button type="submit" className="btn" disabled={submitting}>
{submitting ? 'Placing order...' : 'Place order'}
</button>
</form>
)
}Changes: pickup options have a value for the API and a label for people, the form shows submitError, and the button is disabled while sending.
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { formatPrice } from '../utils/format.js'
// "order" is exactly what the API sent back after POST /orders.
export default function OrderConfirmation({ order }) {
const headingRef = useRef(null)
// Move focus to the heading so screen readers announce the confirmation.
useEffect(() => {
headingRef.current.focus()
}, [])
const pickupTime = new Date(order.pickup_time).toLocaleTimeString('en-IN', {
hour: 'numeric',
minute: '2-digit',
})
return (
<section className="container page">
<div className="confirmation">
<h1 ref={headingRef} tabIndex={-1}>Thank you, {order.customer_name}!</h1>
<p>
Your order will be ready for pickup at <strong>{pickupTime}</strong>{' '}
at our Indiranagar shop.
</p>
<ul className="summary-list">
{order.items.map((line) => (
<li key={line.item_id}>
<span>
{line.qty} x {line.name}
</span>
<span>{formatPrice(line.price * line.qty)}</span>
</li>
))}
</ul>
<p className="summary-total">
<span>Total</span>
<span>{formatPrice(order.total)}</span>
</p>
<p className="muted">
Order number: <strong>{order.id}</strong>. Pay at the counter when
you collect.
</p>
<Link to="/menu" className="btn">
Back to the menu
</Link>
</div>
</section>
)
}Now it shows exactly what the server returned. The pickup time is formatted in the customer's local time, and the demo note is gone.
Why show the server's total and not the cart's? The server's number is the real one. If the two ever differ (for example, a price changed while the customer had the page open), the customer sees what they will really pay. In a real project you would also show a message when the totals differ.
Step 4: Run both and test locally
You need two terminals. In the first, start the API with a key set. In the second, start the website.
# Terminal 1: in brew-and-bean-api (virtual environment active)
export ADMIN_KEY=dev-admin-key # PowerShell: $env:ADMIN_KEY = "dev-admin-key"
uvicorn app.main:app --reloadListens on http://localhost:8000.
# Terminal 2: in brew-and-bean
npm run devListens on http://localhost:5173, the address the API allows by default in CORS_ORIGINS.
Local test checklist
- Open http://localhost:5173/menu. The eight items appear (after a brief Loading message).
- Add two Espressos and a Butter Croissant. The cart total is Rs 390.
- Fill the form and place the order. The confirmation shows an order number and a pickup time.
- Open http://localhost:8000/docs, run GET /orders/{id} with that order number. It returns your order.
- Stop the API (Ctrl+C), reload /menu. You see the error message and a Try again button. Start the API again and click Try again. The menu appears.
- With the API stopped, try to place an order. You see an error under the button and the cart is still full.
- Type the phone number 12345 in the form. The form itself rejects it, and nothing is sent to the API.
The CORS error
If the menu never loads and the browser console (F12, Console tab) says the request was blocked by CORS policy, the API did not allow the website's address. Check that you opened the website at exactly http://localhost:5173 (not 127.0.0.1) or set CORS_ORIGINS to the address in the browser bar. CORS is a browser rule: curl and the /docs page work even when CORS is wrong, which is why this confuses people.
Changing the API address
To use a different API address locally, create a file .env.local in the website folder with one line, VITE_API_URL=http://localhost:8001, and restart npm run dev. Vite reads variables only when it starts. Files ending in .local are ignored by Git in the standard Vite template: check your .gitignore.
Step 5: Deploy the database and the API
Hosting services change their names, menus and free plans often. The steps below describe what to do in general and what we saw at the time of writing (2026). If a button has moved, look for the same idea, or ask your AI to explain the host's current screen.
Deploy the API (Render and Neon)
- 1Create a free Postgres database on a provider such as Neon. Copy its connection string. It looks like postgresql://user:password@host/dbname?sslmode=require. Treat it like a password.
- 2Put the API in its own GitHub repository (commands below). The repository must contain app/, requirements.txt and .gitignore. It must not contain brew.db, .venv or any secret.
- 3Create a new Web Service on Render from that repository. Choose Python as the language and the Free instance type.
- 4Build command: pip install -r requirements.txt
- 5Start command: uvicorn app.main:app --host 0.0.0.0 --port $PORT (the host tells your program which port to use in the PORT variable, and --host 0.0.0.0 lets traffic in from outside the machine).
- 6Add three environment variables: DATABASE_URL (the Neon string), ADMIN_KEY (a long random text, see below) and CORS_ORIGINS (your Netlify address, for example https://brew-and-bean-yourname.netlify.app, with no slash at the end). If the build fails because of the Python version, set the Python version in the host's settings (Render documents a PYTHON_VERSION variable) to a 3.10 or newer release.
- 7Deploy. When the log says the service is live, open https://your-api-name.onrender.com/menu. You should see the menu JSON.
git init
git add .
git status
git commit -m "Brew & Bean order API"
git branch -M main
git remote add origin https://github.com/YOUR-USERNAME/brew-and-bean-api.git
git push -u origin mainRun git status before the commit and check that .venv, brew.db and .env are not listed. Create the empty repository on GitHub first, and use your own username.
python -c "import secrets; print(secrets.token_urlsafe(32))"Prints a long random text. Use it as ADMIN_KEY on the host and keep a copy in your password manager. Never commit it, and never use it as a VITE_ variable.
Free means it sleeps
A free API service on many hosts goes to sleep when nobody has used it for a while, and the first request after that can take many seconds (sometimes about a minute) while it wakes up. That is why the website shows "Loading the menu... The first visit can take a little longer." For a real shop you would pay for an always-on plan. Check the host's current free-plan terms before you share the link.
All the settings in one place
| Setting | Where | Value |
|---|---|---|
| DATABASE_URL | Render (API) | The Postgres connection string |
| ADMIN_KEY | Render (API) | Long random text |
| CORS_ORIGINS | Render (API) | https://your-site.netlify.app (no trailing slash, exact address) |
| VITE_API_URL | Netlify (website) | https://your-api-name.onrender.com (no trailing slash) |
Step 6: Point the website at the live API
Vite reads VITE_ variables at build time and bakes their values into the JavaScript files. So the website must be rebuilt after you set the variable, and changing it in the host later does nothing until you deploy again. Remember also that anything in a front-end build is public: the API address is fine to publish, but never put the admin key here.
Deploy the website
- 1In Netlify, open your site's settings and find Environment variables.
- 2Add VITE_API_URL with your API address, for example https://brew-and-bean-api.onrender.com, with no slash at the end.
- 3Trigger a new deploy so the site is rebuilt (for example Deploys, then Trigger deploy). Pushing a small commit also works.
- 4Open your Netlify address on your phone. The menu should load, maybe slowly the first time.
- 5Place an order. You should see the confirmation with an order number.
- 6Open https://your-api-name.onrender.com/docs, run GET /orders/{id} with that number, and see the order.
Now play the shop owner. Staff use the live /docs page to move the order along. Open PATCH /orders/{order_id}/status, enter the order id, type your ADMIN_KEY in the x-admin-key box and send {"status": "preparing"}. The order's status changes. A later GET shows it. Building a proper staff screen is a good next project.
curl -X PATCH https://your-api-name.onrender.com/orders/ORDER_ID/status -H "Content-Type: application/json" -H "X-Admin-Key: YOUR_ADMIN_KEY" -d '{"status":"ready"}'The same thing from the command line. Replace the three capitalised parts.
When it does not work
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Menu shows the error and Try again | Wrong VITE_API_URL, API asleep or crashed | Open the API address plus /menu in a new tab. If that works, check VITE_API_URL and redeploy the site. |
| Console says blocked by CORS policy | CORS_ORIGINS does not match the website address exactly | Use the exact address with https and no trailing slash. Save, wait for the API to restart. |
| Site works locally but calls localhost when deployed | VITE_API_URL was not set before the build | Set it in the host and redeploy the website. |
| Mixed content error | The https website calls an http API address | Use the https address of the API. |
| API deploy fails to start | Wrong start command, missing PORT, or old Python | Read the log. Use --host 0.0.0.0 --port $PORT and a Python version of 3.10 or newer. |
| 500 error on /orders in production | DATABASE_URL missing or wrong | Check the environment variables and the log. Look for connection or authentication errors. |
| Orders vanish after a while | The API is still using SQLite on a temporary disk | DATABASE_URL is not set on the host. |
| First request takes ages | Free service waking up | Wait. Consider an always-on plan for a real business. |
My React website (on [host]) calls my FastAPI API (on [host]). Something fails. I will give you what I see in three places. Tell me what is most likely wrong, in order of likelihood, and for each cause tell me how to check it in under a minute. Do not guess beyond the evidence.
1. Browser console and Network tab (request URL, status, error):
[paste]
2. The API host's log around the same time:
[paste]
3. My settings (hide secrets): VITE_API_URL = [...], CORS_ORIGINS = [...], the website address = [...]
What to expect: A good answer separates network, CORS, configuration and code problems, and starts with the cheapest check. Never paste real passwords, connection strings or admin keys into an AI chat.
Security check before you share the link
Before sharing
- ADMIN_KEY is long, random and set only on the API host.
- CORS_ORIGINS lists only your website, not *.
- No secret is in the website code or in any VITE_ variable.
- .env files, brew.db and .venv are not in either GitHub repository.
- The Postgres connection string is only in the API host's settings.
- There is no endpoint that lists all orders or phone numbers.
- You told your friends it is a demo and not a real shop. Do not collect real customers' phone numbers unless you are ready to protect them. India's DPDP Act applies to personal data.
The /docs page is public. For a learning project that is fine and helpful. In a real business you may hide it with FastAPI(docs_url=None). Also remember that the API has no rate limiting yet: a stranger could place thousands of fake orders. Adding limits is a good stretch goal.
How this comes up in interviews
"How does your frontend talk to your backend?" is one of the most common questions for full-stack roles. You can now answer with specifics: fetch calls in one API module, loading and error states, environment variables for the base URL (build-time in Vite), CORS on the server and why it exists, secrets only on the server, and a hosted database. Being able to describe the deploy flow (Git push, build, environment variables) is a strong signal for junior candidates. Add the live link and the two GitHub repositories to your resume.
Try it: Add an order status page
Add a route /order/:id to the website that calls GET /orders/{id} and shows the status (received, preparing, ready, picked up). Link to it from the confirmation page and refresh it every 15 seconds while the order is not finished. Which new function goes in api.js?
Try it: Stretch: a staff page
Build a small staff page that sends PATCH /orders/{id}/status. Where should the admin key come from? Think about why it cannot be a VITE_ variable, and sketch an approach that keeps the key safe.
Try it: Stretch: a custom domain and rate limits
Ask your AI how to add rate limiting to the POST /orders endpoint, review its suggestion with the same checklist you used all project, add a test for it and deploy.
Key takeaways
- A front end and a back end meet through one API module, an environment variable for the address, and CORS on the server.
- fetch does not throw on 4xx or 5xx. Check response.ok, and handle loading and error states in the UI.
- VITE_ variables are baked in at build time and are public. Rebuild after changing them. Secrets stay on the server.
- Deploy in order: database, API (with its settings), then the website pointed at the API.
- Free hosts sleep and have changing terms. Say so in the UI and check current plans.
- You built this with AI the professional way: contract first, review each answer, test, then ship.
Check yourself
Finishing the quiz marks the lesson as done.
1. The website works on localhost but after deploying it still calls http://localhost:8000. What is the most likely cause?
2. In the browser console you see "blocked by CORS policy", but the API's /docs page works fine. What does that tell you?
3. Why does fetch need an explicit check of response.ok?
4. Why must the admin key never be placed in a VITE_ environment variable?
5. Why does the cart clear only after the server confirms the order?
6. The first request to your free-hosted API takes about a minute. What is the sensible response?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.