← Architecture
Client and server Where truth lives

Client–server architecture

The browser asks. The server decides.

Every app you have shipped has a browser, a server, and a line between them that you may never have drawn on purpose. When you ask an AI to build something, it draws that line for you. Let’s ask for a small word game, see where the line ended up, and open devtools.

TypeScriptGoOne word game, two servers, two recorded builds.

01 / The prompt

“Build me a daily word game.”

You ask for a five-letter word game: six guesses, a streak, and a leaderboard. What comes back works. You play, the tiles turn green, your streak ticks up. Nothing in those first ten minutes tells you where today’s answer lives, or who decided that you won.

Those two questions are this whole lesson. There is a version of the game where the page downloads every answer and the browser tells the server how the game went. It is quick to build and needs one small endpoint. It is also roughly what the original Wordle did with its answers: the full list, in daily order, sat in the JavaScript the page loaded, and people read upcoming words out of it (a secondary write-up: Taq Karim walks through it).

The same shape shows up where the stakes are higher. In 2025 a researcher disclosed that apps generated by the Lovable platform let the browser query their Supabase databases with the public key while row-level security policies were missing or too loose. Names, email addresses, API keys, and payment records were readable (CVE-2025-48757). The database did what each request asked, because nothing had decided who was allowed to ask.

Nobody typed “trust the browser” into a prompt. It is what you get when the prompt says nothing about the line.

02 / Name the shape

The browser asks. The server decides.

Client–server architecture splits an app into a client, which asks, and a server, which holds what everyone shares and answers. In a web app the client is the browser. It runs on the player’s machine, and the player can read and change anything in it: the JavaScript, the requests, the responses.

So the rule fits in two sentences.

Anything the browser has, the user has. Anything the browser sends is a request, not a fact.

Here is who should own each piece of the word game.

Who owns each piece of state
StateOwnerWhy
Today’s answerServerIf the browser has it, the player has it.
Whether a guess is a real wordBothThe browser checks so typing feels quick. The server checks because the browser’s check can be skipped.
Tile colorsServerWorking them out needs the answer.
Attempts used todayServerA browser can say it is on attempt 1 forever.
Who is playingServerA name typed into a request is a claim. A session the server issued is not.
Streak and leaderboardServerOther players see them.
Letters being typedBrowserNobody else needs them, and they have to feel instant.

Words to put in a prompt or a review

Source of truth
The one place a value is decided and stored.
Trust boundary
The line past which input is something to check, not something to obey.
Secret
Never leaves the server, in any file or response.
Session
How the server knows who is asking. The server issues it; a body cannot.
Round trip
One request and its reply: the price of asking.
Optimistic UI
Showing what the browser already knows before the reply arrives.
When the browser talks to the database directlySupabase, Firebase, and row-level security

Some stacks skip your own server. The browser holds a public key and queries the database itself. The rule still holds; the server’s decision moves into the database, as policies. Supabase’s documentation says it plainly: “A table in an exposed schema without RLS is readable and writable by any role with a grant on it” (Row Level Security). The key is public by design. The policy is the server.

03 / Follow one guess

Watch the same game cross two different lines.

First the trusting build: the page arrives with the answers, and the browser reports its own win. Then the server build: the browser sends an attempt number and a word, and nothing else. Step through at your own pace, or open Try it and be the player with devtools open.

Client and server

Who decides whether a guess counts?

Trusting build

Browser

Holds
Nothing yet
Shows
Loading today’s puzzle

Server

Holds
All 30 answers
Last decision
Waiting for a page request
Leaderboard · day 2
  1. No wins yet

Server → browser{ answers: [crane, slate, plumb, … 30 words] }

01/ 04
The browser holds it

The page arrives with every answer.

The browser asks for the puzzle and gets the whole list: crane, slate, plumb, … 30 words. Today’s is slate.

Reduced motion: choose a scene to see its completed state.

Read this scene

The browser asks for the puzzle and gets the whole list: crane, slate, plumb, … 30 words. Today’s is slate.

Trusting build. The browser holds: Nothing yet. It shows: Loading today’s puzzle. The server holds: All 30 answers. Its last decision: Waiting for a page request. Leaderboard: no wins yet.

In flight from the server: { answers: [crane, slate, plumb, … 30 words] }

Watch restarts the story when you come back. Step through keeps your step. Try it starts both servers fresh each time you open it.

04 / Read the shape

The server owns the rule. The browser owns the asking.

Basic form is the rule itself: color five tiles. In the wild wraps it in a server that decides whose game this is, which attempt comes next, and what a retry means. At the call site the two sides meet: a browser that can only ask, and a door that only believes the session.

Notice what guess takes: a player and a day it was handed by trusted code, and a raw body it trusts for exactly two fields. That signature is the architecture.

Tile colors for one guess: exact letters first, then letters the answer still has spare. Any side could run this function. Only the server can call it for real, because it needs the answer.

TypeScriptReading
game.ts
export type Tile = 'hit' | 'present' | 'miss';

// Exact letters first. Then each other letter is present only while the
// answer still has an unmatched copy of it.
export function scoreGuess(answer: string, guess: string): Tile[] {
	const tiles: Tile[] = ['miss', 'miss', 'miss', 'miss', 'miss'];
	const unmatched = new Map<string, number>();
	for (let i = 0; i < 5; i++) {
		if (guess[i] === answer[i]) tiles[i] = 'hit';
		else unmatched.set(answer[i], (unmatched.get(answer[i]) ?? 0) + 1);
	}
	for (let i = 0; i < 5; i++) {
		const left = unmatched.get(guess[i]) ?? 0;
		if (tiles[i] === 'hit' || left === 0) continue;
		tiles[i] = 'present';
		unmatched.set(guess[i], left - 1);
	}
	return tiles;
}
GoAlongside
game.go
type Tile string

const (
	Hit     Tile = "hit"
	Present Tile = "present"
	Miss    Tile = "miss"
)

// ScoreGuess colors one guess. Exact letters first; then each other letter is
// present only while the answer still has an unmatched copy of it.
func ScoreGuess(answer, guess string) []Tile {
	tiles := []Tile{Miss, Miss, Miss, Miss, Miss}
	unmatched := map[byte]int{}
	for i := range 5 {
		if guess[i] == answer[i] {
			tiles[i] = Hit
		} else {
			unmatched[answer[i]]++
		}
	}
	for i := range 5 {
		if tiles[i] == Hit || unmatched[guess[i]] == 0 {
			continue
		}
		tiles[i] = Present
		unmatched[guess[i]]--
	}
	return tiles
}
The behavior these examples promiseChecked by 13 shared scenarios in TypeScript and Go
  • The body must be a JSON object with an integer attempt from 1 to 6 and a five-letter word. Anything else is bad-request. Other fields are ignored, including won, streak, and player.
  • A word not on the list is not-a-word and uses no attempt. An attempt that is not the next one is wrong-attempt.
  • Sending a counted attempt again with the same word returns the stored tiles with replayed: true. The same attempt with a different word is conflict.
  • The answer appears in a reply only once the game is won or lost. After that, new attempts are game-over.
  • A win the day after a win adds to the streak; any other win starts it at 1; a loss resets it to 0.

The expectations were written from these rules rather than copied from either implementation, and the TypeScript and Go tests both check every one.

Reading the TypeScriptunknown, private fields, and a union

JSON.parse returns unknown in spirit, so parseGuess narrows it field by field before anything uses it. Private # fields keep the answers and players unreachable from outside the class. GuessResponse is a union on status, so a caller has to handle a rejection before it can read tiles.

Reading the GoDecoding into a map, and MarshalJSON

Decoding into map[string]any lets the server look at two fields and ignore the rest. JSON numbers arrive as float64, so the attempt is checked for a whole number. GuessResponse writes its own JSON so a rejection has no tile fields at all. http.MaxBytesReader caps how much of a body the server will read.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types game.ts with Node 22.18 or later. For Go, save game.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/client-server-architecture

go 1.23
⬜⬜🟩⬜🟩
⬜⬜🟩⬜🟩 (already counted)
Not counted: bad-request
🟩🟩🟩🟩🟩 won: crane
streak 1 · leaderboard ada in 2

05 / Review the agent’s diff

“Tiles appear instantly now.”

Speed is a fair thing to want, and a round trip per guess is a real cost. Read what this change sends to the browser before you decide.

The agent’s pull request

“Tiles now appear instantly instead of after the round trip. All 22 tests pass.”

// server/routes/puzzle.ts
			export function getPuzzle(day: number) {
			(removed)  return { day, length: 5 };
			(added)  return { day, length: 5, answer: answerFor(day) };
			}
			
			// public/board.ts
			(removed)const reply = await submitGuess(send, attempt, word);
			(removed)paint(reply.tiles);
			(added)paint(scoreGuess(puzzle.answer, word)); // instant
			(added)void submitGuess(send, attempt, word);
			
You are reviewing this change. What do you do?

06 / How it fails

A network adds ways to fail that a function call never had.

Once the server decides, every guess crosses a network. Here is each way that crossing can go wrong, what the player should see, and what the server in this lesson does.

Failure modes of one guess request
What goes wrongWhat the player seesWhat the server does
SlowThe letters stay on the row; the colors wait.Nothing special. The attempt counts when the request arrives.
Unreachable“Not counted yet, try again,” with the row kept.Nothing was recorded, so a retry is a first try.
Tampered withNothing, if they are honest.Reads the attempt and the word, ignores the rest, and rejects a body without them.
Sent twiceOne row.Returns the stored tiles for the second copy and counts nothing new.
Counted, but the reply is lostThe retry shows the same tiles.Same as sent twice: the attempt is already stored.
Two tabs, two words, one attemptThe second tab is told to reload.Rejects the second word as a conflict.

The last three rows are the same idea: a retry must not count twice. The browser makes that possible by naming the attempt, and the server by storing what that attempt already produced. Idempotency gets its own lessons, Idempotency and at-least-once and Retry, backoff, and idempotency.

07 / Is it worth it?

You pay one round trip per guess. Here is what it buys.

The trusting build is faster to write and faster to play. Hold both builds up against the kinds of change every app eventually gets.

The same four changes, made to each build
ChangeTrusting buildServer decides
A second client, such as a mobile appRewrite the scoring, streak, and answer list in the app. Each client can lie differently.The app sends the same two fields. The rules stay in one place.
Swap the storage for a real databaseA server change.A server change. No difference here.
Change a rule, such as a hard modeShip it to every browser. A page left open keeps the old rule.One deploy. An old page gets rejections it can show.
A second team, looking for cheatingThey can only read claims.Every attempt is recorded and can be checked.

Before you move rules to the server, write down what you will measure, and what result you would accept, so the change is judged by something other than how it felt:

  • Time per guess at the 95th percentile, from the browser’s point of view. This is the cost you are adding.
  • Rejected requests, by reason. A sudden rise in conflict or wrong-attempt is a client bug, not an attacker.
  • Results no honest game produces, such as the share of wins in one guess. Take it before the change and after.

This page did not run the game for real players, so it has no numbers to give you. The point is to have picked the questions before the answers arrive.

08 / Ask for it

Two prompts, two builds, one checker.

We sent two agents the same request for this game at the same time, both running Claude Sonnet. One prompt described the game. The other added an Architecture block: the server is the source of truth, no answer reaches the browser, request bodies are untrusted, a retry counts once, and server-only code lives in server/. Then a script started each build and asked it the same six questions.

What the checker found, run 2026-09-13, when today’s answer was “zesty”
QuestionPlain promptArchitecture prompt
An answer in anything the browser receives before it playsNoneNone
A body claiming a win, with no guess in itRefused (400)Refused (400)
The same guess request sent twiceCounted twiceCounted once; twice if the browser makes a new guess id
A second name loses on purpose, then the real name guessesRevealed “zesty”; the real name won in 1Revealed “zesty”; the real name won in 1
Another browser sends guesses in a player’s nameUsed 6 of that player’s 6 attemptsUsed 6 of that player’s 6 attempts
A player named __proto__The server process exited (code 1)Error 500; the server kept running

The plain prompt did not ship the answer. Both builds scored every guess on the server and refused a claimed win. Agents have read about a lot of word games, and the answer in the bundle is a famous mistake.

The architecture block bought two differences the checker can see. A guess request sent twice counts once. And a player name the code did not expect gets an error, where in the plain build it stopped the whole server for everyone.

Then look at the two rows where both builds failed. A second name can lose on purpose to see the answer, and the real name wins in one guess. Any browser can use up anyone’s attempts. Neither prompt asked who is asking. Both said “Players identify themselves by typing a name,” and both agents built exactly that: the typed name became the identity.

server.ts · vague prompt
if (req.method === 'POST' && pathname === '/api/guess') {
  let body: any;
  try {
    body = await readJsonBody(req);
  } catch {
    return sendJson(res, 400, { error: 'invalid-json' });
  }
  const name = typeof body.player === 'string' ? body.player.trim() : '';
  const rawGuess = typeof body.guess === 'string' ? body.guess.trim().toLowerCase() : '';
  if (!name) return sendJson(res, 400, { error: 'missing-player' });
  if (rawGuess.length !== WORD_LENGTH || !/^[a-z]+$/.test(rawGuess)) {
    return sendJson(res, 400, { error: 'invalid-format' });
  }
  if (!VALID_GUESSES.has(rawGuess)) {
    return sendJson(res, 400, { error: 'not-in-word-list' });
  }

  const day = getDayNumber();
  const answer = getWordForDay(day);
  const player = getOrCreatePlayer(name);
  try {
    const entry = applyGuess(player, day, rawGuess, answer);
server/index.ts · literate prompt
const record = body as Record<string, unknown>;
const name = record?.name;
const guessId = record?.guessId;
const rawWord = record?.word;

if (!isNonEmptyString(name) || name.trim().length > 40) {
  sendJson(res, 400, { error: "name must be 1-40 characters" });
  return;
}
if (!isNonEmptyString(guessId) || guessId.trim().length > 100) {
  sendJson(res, 400, { error: "guessId is required" });
  return;

That sentence reads like a feature. It is an architecture decision, and it went in unexamined. The line that closes it is the one both prompts lacked: the server knows who is asking from a session it issued, and never takes a player name from a request body.

How the runs were made and checkedOne run each, recorded as written
  • Both agents received the prompts word for word, in fresh contexts, at the same time. Neither was told about the other, this lesson, or the checker. The only differences were the Architecture block and the output folder.
  • The files each agent wrote are kept byte for byte, with checksums, beside this lesson’s examples. The checker restores them into a temporary folder and starts a fresh server for every question.
  • The checker’s first run reported the answer “hover” in the plain build’s stylesheet. It was the CSS :hover selector. The check was fixed to skip pseudo-classes, and both runs are kept.
  • The __proto__ name is a plain JavaScript pitfall: the plain build stored players in an object keyed by the typed name, so that name found an existing object with no game history, and the unhandled error ended the process.
  • This is one sample of each prompt, not a measurement of a model. Another run could land differently. What it does show is that a prompt produced a build, and a check could say what that build actually does.

09 / Hold it there

Put the line where a check can say no.

A prompt gets you a build once. The next change, yours or an agent’s, can move the line back without anyone noticing. Three kinds of check keep it where you put it.

  1. The framework’s own door

    SvelteKit refuses to let code that reaches the browser import $lib/server, *.server.ts files, or private environment variables, and prints the import chain that got there. Its docs note one gap: the check is off under Vitest (server-only modules). In Next.js, import 'server-only' turns importing that module into a Client Component into a build error, and environment variables without the NEXT_PUBLIC_ prefix become empty strings in the browser (Server and Client Components).

  2. An import rule an agent cannot argue with

    Where the framework has no door, draw one: browser folders never import server folders. This site holds itself to the same kind of rule, where only server code may import the database client. Enforcement layer runs rules like this against real code and records what they catch, and Architecture as rules writes them from one declaration.

    .dependency-cruiser.cjs
    // .dependency-cruiser.cjs
    module.exports = {
    	forbidden: [
    		{
    			name: 'browser-never-imports-server',
    			comment: 'Answers and storage stay on the server. Browser code may not import them.',
    			severity: 'error',
    			from: { path: '^public/' },
    			to: { path: '^server/' }
    		}
    	]
    };
  3. A check on what actually ships

    Import rules see imports. They do not see an answer pasted into a string. So check the output too: fetch what the server sends a browser and search it for anything that must stay behind. The checker in section 08 does exactly that, and the lesson’s own tests assert that no reply contains the answer while a game is still being played.

    check-runs.mjs
    // A word preceded by a colon is a CSS pseudo-class such as :hover, not an answer.
    // Run 1 of this checker reported "hover" in a stylesheet for exactly that reason.
    const answersIn = (text) => ANSWERS.filter((word) => new RegExp(`(?<![:\\w-])${word}(?![\\w-])`, 'i').test(text));
    // A leak is today's answer, or enough of the list that it cannot be chance.
    const leakIn = (text) => {
    	const found = answersIn(text);
    	return found.includes(today) || found.length >= 5 ? found : [];
    };
    
Your form validation already draws this lineEvery form you validate twice already follows this rule. Optimistic UI is where it gets tested.

Where it already is in your components

You have written this form. The username field checks its pattern as you type, so the mistake shows up straight away, and the server checks the same pattern again when the form is submitted, along with the one thing only it can know: whether someone already has that name. The browser’s check is for the person typing. The server’s check is for everyone else.

Your framework draws the same line in its file names. In SvelteKit, a +page.server.ts load runs only on the server, while a +page.ts load can run in the browser too. In the Next.js App Router, 'use client' marks a boundary, and everything that file imports ships to the browser. When you decide which file a query goes in, you are deciding which side of the line it lives on.

When you have to own it

Now the word game’s board, with the round trip showing. Waiting for the server before showing anything feels broken, so the row shows the typed letters at once and marks them pending. That is optimistic UI, and it is only honest about what the browser knows: the letters, not their colors. When the reply comes back, the server’s tiles replace the pending row. When the server says no, the row goes away and the message says why.

One detail does the heavy lifting. A row still waiting keeps its attempt number, so the Retry button asks about the same attempt, and the server from section 04 answers with the stored result instead of counting a second guess.

A sign-up form. The browser checks the username pattern while you type; the server checks it again, plus whether the name is taken, and its answer is the one that counts.

ReactAlready in your code
SignUpForm.tsx
import { useState, type FormEvent } from 'react';

// The browser checks what it can, for speed. The server checks again and has
// the final say, because anyone can skip this form and call the endpoint.
const pattern = /^[a-z0-9_]{3,20}$/;

export default function SignUpForm() {
	const [name, setName] = useState('');
	const [serverError, setServerError] = useState<string | null>(null);
	const [pending, setPending] = useState(false);
	const hint = name === '' || pattern.test(name) ? null : '3–20 lowercase letters, digits, or _';

	async function submit(event: FormEvent) {
		event.preventDefault();
		if (hint) return;
		setPending(true);
		setServerError(null);
		const response = await fetch('/api/signup', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ name })
		});
		setPending(false);
		// The server runs the same pattern, plus the check only it can make:
		// whether someone already has this name.
		if (!response.ok) setServerError((await response.json()).message);
	}

	return (
		<form onSubmit={submit}>
			<label htmlFor="name">Username</label>
			<input
				id="name"
				value={name}
				onChange={(event) => setName(event.target.value)}
				aria-describedby="name-hint"
			/>
			<p id="name-hint">{hint ?? serverError}</p>
			<button disabled={pending || hint !== null}>Create account</button>
		</form>
	);
}

10 / Make the call

Let the browser decide only what is the player’s own.

Keeping logic in the browser is fine when the only person a lie can hurt is the person telling it: a single-player puzzle with no leaderboard, a draft that has not been sent, a theme preference. Local-first apps go further and let each device hold a full copy, with rules for merging copies later. That is a deliberate design with its own lesson, Local-first and sync, not the default this lesson started from.

Put it on the server when anyone else sees it, pays for it, or relies on it: scores, prices, permissions, inventory, identity. That covers most of what makes an app worth building.

Take it with you

Explain it without saying “client–server”: “The page only knows what I typed and what the server told me. The server knows who I am because it signed me in, and it decides what my guess means.” Then open the last feature you built with an AI and find one value the browser sends that the server believes.

Paste into your next prompt, and fill in the blanks

The server is the source of truth for <the shared state>.
<Secrets> never reach the browser, in any file or response.
The server knows who is asking from a session it issued. It never takes a
user, player, or account id from a request body.
Treat every request body as a request, not a fact. Never accept
<values the server works out>, such as a score, a price, or a role.
Record each <action> once per <user and key>, so a retried request
does not count twice.
Keep server-only code in <folder>, and fail the build if browser code imports it.
Connections to follow nextRelated lessons

Take the game into your editor. Add a hard mode, where letters you have found must be reused, and decide which side checks it.

Back to architecture →