Web development basics · Lesson 11 of 11 · about 35 minutes

How a real site is built

The whole track in one site: Juniper & Rye's pages, news, live prices, logins and pre-orders, put together and tested the way a release should be. One script checks everything the track taught, in a real browser: 13 end-to-end checks, accessibility on every page, a speed budget and a security sweep. Then two realistic mistakes, and the checks that caught them.

By the end you'll know

  • How the pieces from Lessons 1 to 10 fit into one Next.js site, and where each lives
  • What happens, step by step, when a signed-in customer opens their orders page
  • Where every setting and secret lives, locally and on Vercel
  • How to write one release check that runs the track's tests together
  • What kinds of mistakes those checks catch, with two real examples

Before you start

  • You need: Lessons 1 to 10 of this track. This lesson builds nothing new; it puts their parts together. Node.js 20 or later, Python 3, and Google Chrome.
  • Versions: Next.js 16.4.5, Supabase CLI 3.0.0, @supabase/ssr 0.12.7, Playwright 1.63.0, axe-core 4.14.0 and Lighthouse 13.6.0, November 2026.
  • The example: the finished Juniper & Rye site, a made-up bakery, with Ada and Ben, its made-up customers. Every run and line of output below is real.

The short version

  • A real site is a few simple parts with clear jobs: static pages, a page that refreshes from an API, a login, and a database that decides who sees what.
  • Every setting has one home. Public values can be NEXT_PUBLIC_; secrets are read by the server from its environment, and never appear in the build.
  • One script runs every test the track wrote. In 1 minute 45 seconds, it checked the pages, prices, logins, data privacy, accessibility, speed and secrets. All passed.
  • The checks earn their keep on mistakes. A new table without row level security, and a form field that lost its label, each failed the check immediately.

The map

The practice folder, lesson-11, has the finished site in site/, plus the supplier API, the database migration and the tests. The parts of the site, and where each came from:

Path in site/What it doesLesson
app/layout.js, app/globals.cssEvery page's frame: header, nav, a skip link, main, footer; one stylesheet2, 4, 10
app/page.jsHome: the hero image and the latest news4, 9
content/news/*.mdx, lib/news.js, app/news/News posts as files, drafts hidden in production5
app/components/mdx.jsComponents posts can use, including a live Price5, 6
lib/bakehouse.js, app/menu/Prices from the supplier's API, with a timeout and checks; an error page6
proxy.js, lib/supabase/Login cookies, refreshed and checked7
app/login/Sign in and out, with server actions7, 10
app/orders/Your pre-orders, and an accessible form to add one7, 10
vercel.jsonTells Vercel this is a Next.js project8

Outside site/: supabase/migrations/ holds the pre-orders table with row level security and its two policies, written in one migration this time; bakehouse_api.py is Lesson 6's made-up supplier; tests/ has the checks.

A few things changed from the earlier lessons, because the parts now meet:

  • The menu comes from the API, not a file. Lesson 4's data/menu.js is gone.
  • The Price in news posts is live: an async server component that asks the supplier. That had a side effect, below.
  • The pre-order form's items come from the supplier, and the server checks every submitted item against the same list, so a tampered form can't order something that doesn't exist.

Running it all

(Your machine)

BASH
cd lesson-11
npm install
SUPABASE_EXPERIMENTAL_STACK=1 npx supabase init
npx supabase start -x realtime,storage,functions,studio,mail,analytics,pooler
python3 bakehouse_api.py &
node seed.mjs
chmod +x make-env.sh check-all.sh tests/security.sh
./make-env.sh
cd site
npm install
npm run build
npm start
  • npm install in the lesson folder installs the CLI and test tools; in site/, the site's packages.
  • supabase init and supabase start set up the local database and Auth, as in Lesson 7. start applies the migration in supabase/migrations/ itself.
  • python3 bakehouse_api.py & starts the supplier API in the background. (& runs it without holding the terminal; stop it later with kill %1.)
  • node seed.mjs signs up Ada and Ben with one pre-order each.
  • chmod +x makes the scripts runnable; ./make-env.sh writes site/.env.local.
  • npm run build and npm start serve the site on port 3011. Leave it running.

The build's route table shows every kind of page from the track in one place:

TEXT
Route (app)           Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /login
├ ○ /menu                     1m      1y
├ ○ /news
├   /news/[slug]
│ ├ ● /news/rye-week          1m      1y
│ └ ● /news/new-oven          1m      1y
└ ƒ /orders


ƒ Proxy (Middleware)
  • ○ / and ○ /news: static, built once.
  • ○ /menu with 1m: static, rebuilt in the background at most once a minute (Lesson 6).
  • ● /news/rye-week and /news/new-oven: one page per post, built at build time. The draft, holiday-orders, isn't listed: drafts are left out of production builds (Lesson 5).
  • ƒ /login and ƒ /orders: per request, because they read cookies (Lesson 7).

The surprise is the 1m on the news posts. Nobody asked for posts to refresh. But both posts use <Price>, which fetches with revalidate: 60, and a page refreshes as often as the most frequent fetch inside it. That's usually what you want, since the price in a post stays current, but it's a reminder to read the route table after every change.

One request, start to finish

Ada is signed in and opens /orders. Here's everything that happens, and which lesson it came from:

  1. Her browser sends GET /orders with her login cookie (Lessons 1 and 7).
  2. proxy.js runs first. It reads the cookie, refreshes the token if it's close to expiring, and checks it with getClaims(). A missing or forged token would be redirected to /login here (Lesson 7).
  3. The orders page, a server component, checks again with getClaims(). Both checks are cheap, and a page shouldn't rely on the proxy alone.
  4. Two requests go out at once, with Promise.all: to Supabase for pre-orders, carrying Ada's token, and to the supplier for today's bakes, carrying the supplier's key (Lesson 6).
  5. Postgres applies row level security. The query has no where user_id = ...; the policy returns only rows whose user_id matches the token's user (Lesson 7).
  6. The server renders HTML with her orders and the form, and sends it (Lesson 4).
  7. The form is a client component. Its JavaScript makes the Reserve button submit through a server action and show the result, in a role="status" or role="alert" that screen readers announce (Lessons 3 and 10).
  8. When she reserves, the server action checks the item against the supplier's list and the date's format, then inserts with her token. RLS's with check makes sure it's saved in her name (Lessons 6 and 7).

Notice what the browser is trusted with: nothing. The cookie is checked twice, the form's contents are checked once more on the server, and the database makes the final decision about whose rows are whose.

Where the settings live

SettingWho may see itLocallyOn Vercel
NEXT_PUBLIC_SUPABASE_URLAnyonesite/.env.localProduction: the real project; Preview: a test project
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYAnyone; limited by RLSsite/.env.localSame split
BAKEHOUSE_URLThe serversite/.env.localProduction and Preview
BAKEHOUSE_API_KEYThe server onlysite/.env.localA secret; a test key for Preview
Supabase secret keyNot used by this siteKept out entirely: nothing here needs to bypass RLS

site/.env.local is in .gitignore, so none of these are committed. The security check below confirms the supplier key isn't in the build at all: the server reads it from the environment each time it runs.

The release check

check-all.sh runs everything the track tested, against the running site. It resets the local database and re-seeds Ada and Ben first, so every run starts from the same data:

(Your machine)

BASH
cd lesson-11
./check-all.sh
TEXT
== End to end
PASS  / loads (HTTP 200): "Sourdough, rye and pastries, baked every morning"
PASS  /menu loads (HTTP 200): "Today's bakes"
PASS  /news loads (HTTP 200): "News from the bakery"
PASS  /news/rye-week loads (HTTP 200): "Rye week is back"
PASS  menu lists the supplier's 3 bakes
PASS  news post shows dark rye's live price, $9.00
PASS  draft post is hidden in production (HTTP 404)
PASS  signed out, /orders sends you to /login
PASS  wrong password shows an announced error
PASS  Ada sees only her own pre-order: Dark rye 2026-11-14
PASS  Ada reserves a country sourdough; she now has 2
PASS  a made-up item is refused by the server: "Choose one of today's bakes."
PASS  Ben sees only his: Cardamom bun 2026-11-14
all end-to-end checks passed

== Accessibility
PASS  /: 0 WCAG problems
PASS  /menu: 0 WCAG problems
PASS  /news: 0 WCAG problems
PASS  /news/rye-week: 0 WCAG problems
PASS  /login: 0 WCAG problems
PASS  /orders: 0 WCAG problems

== Speed budget
PASS  /: score 97, LCP 1.3 s, CLS 0.000, TBT 194 ms
PASS  /menu: score 95, LCP 1.3 s, CLS 0.000, TBT 245 ms
PASS  /news/rye-week: score 96, LCP 1.3 s, CLS 0.000, TBT 217 ms

== Security
PASS  no braces MDX would swallow in news posts
PASS  no supplier key or Supabase secret key in browser files
info  the supplier key is in 0 build file(s) in all: the server reads it from the environment when it runs
PASS  Supabase advisor: no security errors

== Summary
PASS  End to end
PASS  Accessibility
PASS  Speed budget
PASS  Security

It took 1 minute 45 seconds. What each part does:

  • End to end (tests/e2e.cjs) uses the site in a real browser with Playwright, as a visitor, as Ada and as Ben. The most important lines are the last three: Ada and Ben each see only their own orders, and the server refuses a tampered form. To tamper, the test adds a fake option to the form's dropdown in the page, the way someone could in developer tools:
JS
await ada.locator("#item").evaluate((sel) => sel.add(new Option("50 croissants", "50 croissants", true, true)));
  • Accessibility (tests/a11y.cjs) runs Lesson 10's axe scan on every page, including /orders signed in as Ada, where a scan of the public pages would never reach.
  • Speed budget (tests/speed.mjs) runs Lesson 9's Lighthouse check as a phone on slow 4G, and fails a page if LCP is over 2.5 seconds, CLS over 0.1 or TBT over 300 ms. Like any lab test, its numbers move a little between runs, which is why the limits sit at "good", not at today's numbers.
  • Security (tests/security.sh) runs Lesson 5's brace scanner on the posts, searches the browser files for either secret, and runs Lesson 7's Supabase advisor.

Each test exits with an error code when it fails, and check-all.sh does too, so the same script can stop a deploy.

Catching mistakes

A check that always passes proves little. Two realistic mistakes:

A table without row level security. A developer adds customer reviews in a hurry. The migration in mistakes/reviews.sql creates the table and forgets RLS. Applied, then checked:

(Your machine)

BASH
cp mistakes/reviews.sql supabase/migrations/20261108000002_reviews.sql
npx supabase migration up
tests/security.sh
TEXT
PASS  no braces MDX would swallow in news posts
PASS  no supplier key or Supabase secret key in browser files
info  the supplier key is in 0 build file(s) in all: the server reads it from the environment when it runs
FAIL  Supabase advisor found security errors

The advisor said why:

TEXT
"detail": "Table `public.reviews` is public, but RLS has not been enabled."

Lesson 7 showed what that means: anyone with the page's public key could read, change and delete every review. Caught before it shipped. (Remove the file and run npx supabase db reset to undo it.)

A form field that lost its label. During a redesign, someone replaces the pickup date's <label> with a <p>, because it was easier to style. It looks the same. After a rebuild, the accessibility check:

TEXT
PASS  /: 0 WCAG problems
PASS  /menu: 0 WCAG problems
PASS  /news: 0 WCAG problems
PASS  /news/rye-week: 0 WCAG problems
PASS  /login: 0 WCAG problems
FAIL  /orders: 1 WCAG problems label

Only on /orders, the page you have to sign in to see. A scan of the public pages would have passed.

How the finished site fits together, and the checks that guard it. Select the diagram to enlarge it.

Putting it on the internet

Lesson 8's steps apply unchanged, with two additions:

  • A hosted Supabase project instead of the local one. Run the migration against it (npx supabase link, then npx supabase db push --remote: in CLI 3, database commands target the local stack unless you add --remote), and set its URL and publishable key in Vercel. Give previews a separate test project, so testing never touches real customers.
  • The supplier's real URL and key as Vercel environment variables, the key as a secret.

Then the release check can point at a preview deployment before you promote it. That last step wasn't run for this lesson; everything above ran locally.

In real systems

  • Run the release check in CI on every pull request. GitHub Actions can start a local Supabase stack, build the site and run check-all.sh; a failing check blocks the merge. That's the habit that makes all the other lessons stick.
  • Watch production, too. Checks before release don't see what real visitors do. Collect errors (Vercel's logs, or a tool like Sentry), field speed data (Lesson 9) and uptime. The Monitoring track covers alerts.
  • Back up the database, and test a restore. Supabase's paid plans include backups; whatever you use, a backup you've never restored is a guess.
  • Keep dependencies current. Every version in this track is pinned. Tools like Dependabot or Renovate open pull requests for updates, and the release check tells you if one breaks something.
  • Write down how to roll back, with Lesson 8's promote step included, before you need it.

Spot the problem

Work out what's wrong before you read the explanation.

"All the tests pass, and customers can see each other's orders." A team's site has end-to-end tests that sign in as a test customer, add an order, and check it appears. Every test passes on every release. A customer reports seeing a stranger's order. What's wrong? The tests only ever used one customer. A test that signs in as one person and sees their own order passes whether or not anyone else's orders are visible too. This lesson's tests sign in as Ada and as Ben, and check each sees exactly their own: one row each, and the right one. The fix: test with at least two users, and assert what each must not see, not just what they should; then check row level security on the table, which is probably missing or too broad. Then check: does signing in as the second test customer show only their rows?

Keep this

Before every releaseHowLesson
Read the route tablenpm run build: ○, ●, ƒ and Revalidate as intended4, 6
Pages work, as visitors and as each kind of userEnd-to-end tests with two customers7, 11
Every page is accessible, signed in tooaxe on every page; keyboard and screen reader by hand10
Fast on a phoneLighthouse with a budget9
No secrets in the browser; RLS on every tableSearch the build; Supabase advisor6, 7
Safe to release, and to undoPreview first; know rollback and promote8

Where you are now

That's the track. You started with what a browser does with 661 bytes of HTML, and you've built a site with static and live pages, content from files, data from an API, logins, private data per customer, and checks for speed, accessibility and security, and you know how to deploy it and roll it back. More than any one technique, the habit to keep is the one every lesson used: change one thing, measure what really happened, and trust the measurement over the documentation, the tool's guess or your own expectation.

Check yourself

  1. Why did the news posts get a 1m Revalidate in the route table?

  2. When Ada opens /orders, what decides which rows she gets?

  3. Where is the supplier's API key in the built site?

  4. Why does the end-to-end test sign in as both Ada and Ben?

  5. Which check caught the reviews table without row level security?

  6. The pickup date lost its label. Why did only /orders fail?

Key terms

  • Release check: a script that runs every test that matters before a release, and fails if any does.
  • End-to-end test: a test that uses the whole running site in a real browser, as a person would.
  • Test data (seeding): known accounts and records created before tests, so every run starts the same.
  • Budget: a limit, like LCP under 2.5 seconds, that fails the check when it's crossed.
  • CI (continuous integration): running the build and checks automatically on every change, usually on every pull request.
  • Environment: where a copy of the site runs (local, preview, production), each with its own settings.

Go deeper