This document covers how the frontend and backend communicate, why the development environment needs a proxy, how validation works at every layer, and traces the complete lifecycle of each major user action through the app.
Browser (React SPA)
│
│ httpOnly cookies (set by backend, sent automatically)
│ Hono RPC calls (fetch under the hood, proxied via Vite in dev)
▼
Hono Backend (Bun, port 8080)
│
├── Kinde SDK ──────────────────── Kinde Auth Server (OAuth)
│
├── Drizzle ORM ────────────────── Neon (serverless Postgres)
│
└── AWS SDK ────────────────────── S3 (image storage)
When developing locally, you run two completely separate servers at the same time:
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Vite Dev Server │ │ Hono Backend │
│ localhost:5173 │ │ localhost:8080 │
│ │ │ │
│ Serves your React app │ │ Handles /api/* routes │
│ Hot reloads when you save │ │ Talks to DB, Kinde, S3 │
└──────────────────────────────┘ └──────────────────────────────┘
Your browser opens localhost:5173 and gets the React app. But when the React app
needs data (like your wardrobe items), it needs to reach the backend at localhost:8080.
Browsers have a built-in security rule called CORS (Cross-Origin Resource Sharing):
"A webpage can only freely talk to the same server it was loaded from."
localhost:5173 and localhost:8080 are considered different origins because they
use different ports. If the React app at :5173 tried to call http://localhost:8080/api/wardrobe
directly, the browser would block it with a CORS error — before the request even leaves
the browser.
Instead of calling the backend directly, the frontend calls itself. Vite intercepts
any request to /api/* and secretly forwards it to the backend:
❌ Without proxy:
Browser (5173) ──fetch──→ localhost:8080/api/wardrobe
Browser blocks this! CORS error.
✅ With proxy:
Browser (5173) ──fetch──→ localhost:5173/api/wardrobe
Vite sees /api/* → forwards to :8080
Backend responds
Vite hands response back to browser
Browser never knew it left port 5173
Configured in frontend/vite.config.ts:
server: {
proxy: {
'/api': {
target: 'http://localhost:3000', // backend port
changeOrigin: true,
}
}
}The proxy is purely a development convenience. It doesn't exist in production.
In production (CloudFront + S3 + Lambda), both the frontend files and the /api/*
routes are served from the same domain (yourdomain.com). Same origin — no CORS,
no proxy. CloudFront handles the routing internally based on the URL path.
Development Production
────────────────────────────── ──────────────────────────────────────
localhost:5173 (Vite, React) yourdomain.com/* → CloudFront → S3
localhost:8080 (Hono, API) yourdomain.com/api/* → CloudFront → Lambda
Vite proxy bridges the two No proxy — same domain throughout
Components don't call fetch directly. They use TanStack Query, which acts like
a smart assistant: you tell it what you want, and it handles the how — making the
request, caching the result, tracking loading/error state, and reusing data across
components.
// In the home page component
const { data, isPending, error } = useQuery(getAllItemsQueryOptions)
// ↑ TanStack Query manages everything from hereWithout TanStack Query, you'd write this manually for every piece of data:
// The naive approach — don't do this
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch('/api/wardrobe')
.then(r => r.json())
.then(data => { setItems(data.items); setLoading(false) })
.catch(e => { setError(e); setLoading(false) })
}, [])
// And this has no caching — every component refetches independentlyTanStack Query gives you caching, deduplication, background refetching, and loading/ error states for free.
The actual HTTP call is made through the Hono RPC client (api), defined in
frontend/src/lib/api.ts. Under the hood it's still just fetch, but TypeScript
knows exactly what each endpoint accepts and returns:
// Hono RPC call (fully typed)
const res = await api.wardrobe.$get()
const data = await res.json()
// TypeScript knows data is: { items: { id: number, name: string, ... }[] }
// Equivalent raw fetch (no type safety)
const res = await fetch('/api/wardrobe')
const data = await res.json() // typed as `any` — no safetyIf you pass the wrong body shape or call a route that doesn't exist, TypeScript catches it at compile time — not at runtime when a user hits the bug.
Before any route handler runs, Hono passes the request through a series of middleware functions. Think of it like airport security checkpoints — every request must pass through every checkpoint in order. If it fails one, it's turned away before reaching its destination.
Request arrives at /api/wardrobe
↓
logger middleware prints "GET /api/wardrobe" in your terminal
↓
getUser middleware reads cookies → calls Kinde to verify → attaches user to request
if no valid session → returns 401 immediately, handler never runs
↓
zValidator middleware validates the request body against the Zod schema (POST/PUT only)
if body is invalid → returns 400 immediately, handler never runs
↓
route handler safe to run — user is verified, data is validated
The handler at the end only ever runs if every checkpoint above it passed. This is why auth and validation middleware are so powerful — you write them once and they protect every route automatically.
Data is validated three times as it travels from the user's browser to the database. Each layer has a different job and catches different problems.
Think of it like a restaurant kitchen:
Customer (browser form)
↓ Waiter checks the order makes sense before writing it down [Layer 1]
Order ticket (HTTP request)
↓ Kitchen manager checks the ticket before passing to the chef [Layer 2]
Chef + Ingredients (database)
↓ Ingredients physically can't be combined wrong [Layer 3]
User types in the Name field
↓
TanStack Form onChange fires
↓
Zod runs: createItemSchema.shape.name (z.string().min(1, '...'))
↓
name = "" → "Name must be at least 1 character long" shown instantly
name = "T-Shirt" → ✓ error cleared
Purpose: Instant feedback before any network request is made. Purely for user experience — it would be frustrating to fill out a whole form, wait for a server round-trip, and then find out a field was empty.
Can it be bypassed? Yes — anyone can open DevTools and send a raw request without using your form. That's exactly why Layer 2 exists.
wardrobeRoute.post(
'/',
getUser,
zValidator('json', createItemSchema), // ← middleware runs before handler
async (c) => {
const body = c.req.valid('json') // guaranteed valid if we got here
// ...
}
)zValidator runs before the handler. If the body doesn't match createItemSchema,
Hono returns a 400 Bad Request and the handler never runs. The database is never touched.
POST /api/wardrobe { name: "", type: "Top", size: "M", color: "White", imageUrl: "..." }
↓
zValidator: name fails z.string().min(1)
↓
400 Bad Request returned — handler and DB query never execute
Purpose: Defends against bad data from any source — not just your own frontend, but bots, API clients, or someone testing your endpoints with curl. The server never trusts input just because it arrived.
The Drizzle schema defines hard rules at the Postgres level:
imageUrl: text('image_url').notNull(),
userId: text('user_id').notNull(),If a bug in your backend code somehow tried to insert a row with a null imageUrl,
Postgres itself refuses it and throws an error. No code above it can override this.
Purpose: The database is the final guarantee. Even if your own backend code has a bug that accidentally produces bad data, the DB schema enforces the rules at the storage level. No row in the database can ever violate these constraints, regardless of what code ran above.
| Layer | Catches | Protects |
|---|---|---|
| Form (Zod + TanStack Form) | Empty fields, typos | The user — caught before any request fires |
| API (Hono zValidator) | Bad requests from any source | The server — no invalid data reaches handlers |
| Database (Postgres constraints) | Bugs in your own backend code | The data — a hard guarantee at storage level |
Removing any one of them means something can slip through. Together, they make it nearly impossible for corrupt data to reach storage.
All three layers use the same Zod schema, defined once in server/sharedTypes.ts
and imported by both the backend and the frontend:
server/db/schema/items.ts Drizzle table + createInsertSchema()
↓
server/sharedTypes.ts .omit({ userId, createdAt, id }) → createItemSchema
↓
├── server/routes/wardrobe.ts zValidator('json', createItemSchema) [Layer 2]
│
└── frontend/src/routes/ createItemSchema.shape.name [Layer 1]
create-item.tsx (imported via @server alias)
The DB schema and validation schema are always in sync — you can't add a field to one without it affecting the other.
When a user first opens the app:
1. Browser loads index.html → React app boots
↓
2. TanStack Router evaluates route: "/"
→ matched by /_authenticated layout
↓
3. _authenticated.tsx: beforeLoad hook fires
→ queryClient.ensureQueryData(userQueryOptions)
→ calls api.me.$get()
[dev: Vite proxy forwards GET /api/me from :5173 → :8080]
↓
4. GET /api/me hits Hono backend
→ getUser middleware runs
→ reads cookies from request (id_token, access_token, etc.)
→ kindeClient.isAuthenticated(sessionManager) checks token validity
↓
5a. If NOT authenticated:
→ getUser returns 401
→ ensureQueryData throws
→ beforeLoad catches error → returns { user: null }
→ _authenticated component renders <Login /> (link to /api/login)
5b. If authenticated:
→ kindeClient.getUserProfile() returns { id, email, given_name, family_name }
→ /api/me handler returns user JSON
→ beforeLoad returns { user }
→ _authenticated component renders <Outlet />
→ home page (index.tsx) loads
Once the auth guard passes and the home page renders:
1. index.tsx mounts
→ useQuery(getAllItemsQueryOptions) called
→ useQuery(getTotalClothesQueryOptions) called
↓
2. TanStack Query cache check:
→ If ['get-all-items'] exists and is fresh (< 5 min): return cached data immediately
→ If stale or absent: fire GET /api/wardrobe
[dev: Vite proxy forwards :5173 → :8080]
↓
3. GET /api/wardrobe → Hono backend
→ logger middleware: prints request to terminal
→ getUser middleware: validate cookies → get user.id
↓
4. Drizzle query:
db.select().from(items)
.where(eq(items.userId, user.id)) ← only this user's items
.orderBy(desc(items.createdAt)) ← newest first
.limit(100)
↓
5. Neon Postgres executes:
SELECT * FROM items WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100
↓
6. Response: { items: [...] }
→ TanStack Query stores in cache under ['get-all-items']
→ Component re-renders with data
→ Item cards render with Framer Motion entrance animation
This is the most complex flow — it touches every system: form validation, S3, the API middleware chain, the database, and cache updates.
User types in the "Name" field
↓
TanStack Form field onChange fires
↓
zodValidator runs createItemSchema.shape.name (z.string().min(1, '...'))
↓
If empty: field.state.meta.errors = ['Name must be at least 1 character long']
→ error displayed below input — no network request made
If valid: errors cleared, field value updated in form state
User clicks file input → selects a .jpg from their device
↓
onChange handler: URL.createObjectURL(file) → browser-local preview URL
→ img src set to preview URL → image previewed instantly (no upload yet)
→ file stored in component state for later upload
User clicks "Add Item"
↓
form.handleSubmit() fires
↓
Full form validation runs against createItemSchema:
{
name: z.string().min(1) ✓
type: z.string().min(1) ✓
size: z.string().min(1) ✓
color: z.string().min(1) ✓
imageUrl: z.string().url() — will be set after S3 upload
}
↓
If any field fails: validation errors shown, submission stops here
If all pass: onSubmit({ value }) fires
Before any async work starts:
↓
queryClient.setQueryData(['loading-create-item'], { item: formData })
↓
Home page's loadingCreateItemQueryOptions subscription triggers re-render
→ A skeleton card appears at the top of the grid
→ User sees immediate visual feedback that something is happening
GET /api/signed-url (Hono RPC)
[dev: Vite proxy forwards :5173 → :8080]
↓
Hono backend:
→ getUser middleware: verify auth
→ AWS SDK: PutObjectCommand presigned URL
Bucket: stylify-local-minh
Key: `${Date.now()}.jpg`
Expires: 60 seconds
ContentLengthRange: 0–10MB
→ returns { signedURL: 'https://s3.amazonaws.com/...' }
fetch(signedURL, {
method: 'PUT',
body: imageFile,
headers: { 'Content-Type': imageFile.type }
})
↓
Image uploaded directly from browser to S3
→ Backend is NOT in this request path (no proxy, goes straight to S3)
→ S3 URL: https://stylify-local-minh.s3.us-east-2.amazonaws.com/1234567890.jpg
api.wardrobe.$post({
json: {
name: 'White T-Shirt',
type: 'Top',
size: 'M',
color: 'White',
imageUrl: 'https://stylify-local-minh.s3...jpg'
}
})
[dev: Vite proxy forwards :5173 → :8080]
↓
POST /api/wardrobe → Hono backend — middleware chain runs:
↓
[1] logger → prints request to terminal
↓
[2] getUser → reads cookies → verifies with Kinde → attaches user.id
if no valid session → 401 returned here, stops
↓
[3] zValidator → validates request body against createItemSchema
name: ✓ type: ✓ size: ✓ color: ✓ imageUrl: valid URL ✓
if any field fails → 400 returned here, stops
↓
[4] handler runs → all checks passed, safe to proceed
↓
Drizzle insert (Layer 3 — DB constraints enforced by Postgres):
db.insert(items)
.values({
name: 'White T-Shirt',
type: 'Top',
size: 'M',
color: 'White',
imageUrl: 'https://...',
userId: user.id ← set server-side from the verified session, never from client
})
.returning()
↓
Neon Postgres executes:
INSERT INTO items (name, type, size, color, image_url, user_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
↓
Returns: { id: 42, userId: 'kp_abc', name: 'White T-Shirt', ..., createdAt: '2024-...' }
↓
Backend responds: 200 { id: 42, ... }
onSubmit receives the new item from the API response
↓
Manual cache updates — no refetch needed, UI updates instantly:
queryClient.setQueryData(['get-all-items'], (old) => ({
items: [newItem, ...old.items], ← prepend new item to the list
}))
queryClient.setQueryData(['get-total-clothes'], (old) => ({
total: old.total + 1, ← increment count
}))
queryClient.setQueryData(['loading-create-item'], {}) ← clear skeleton
↓
All components subscribed to these query keys re-render:
→ Skeleton card replaced by the real item card (Framer Motion animation)
→ Total count increments in the header
→ Toast notification: "Item created successfully"
↓
Router navigates to "/" (home page)
User clicks Delete on an item card
↓
useMutation({ mutationFn: deleteItem }) fires
↓
DELETE /api/wardrobe/:id
[dev: Vite proxy forwards :5173 → :8080]
↓
Middleware chain:
[1] logger → logs request
[2] getUser → verify auth
↓
Handler:
Drizzle delete:
db.delete(items)
.where(and(
eq(items.id, id),
eq(items.userId, user.id) ← ownership check — can only delete your own items
))
.returning()
AWS SDK: DeleteObjectCommand → removes the image from S3
Returns: deleted item
↓
onSuccess:
queryClient.setQueryData(['get-all-items'], (old) => ({
items: old.items.filter(i => i.id !== deletedId),
}))
queryClient.setQueryData(['get-total-clothes'], (old) => ({
total: old.total - 1,
}))
↓
Item card animates out (Framer Motion exit animation)
Total count decrements
User clicks "Logout" on profile page
↓
Browser navigates to /api/logout
[dev: Vite proxy forwards :5173 → :8080]
↓
Hono backend: kindeClient.logout(c, sessionManager)
→ sessionManager.destroySession() deletes all auth cookies:
id_token, access_token, user, refresh_token
→ redirects to Kinde logout URL
↓
Kinde invalidates the session server-side
→ redirects back to the app (KINDE_LOGOUT_REDIRECT_URI)
↓
App reloads → _authenticated.beforeLoad fires
→ GET /api/me → 401 (no cookies present)
→ user: null
→ <Login /> shown
| Concern | Handled By |
|---|---|
| Dev proxy (CORS workaround) | Vite dev server (vite.config.ts) |
| Route protection | TanStack Router beforeLoad + _authenticated layout |
| Token storage & verification | Kinde SDK + httpOnly cookies (server-side only) |
| Form state & per-field validation | TanStack Form (Layer 1) |
| Shared validation schema | Zod createItemSchema in server/sharedTypes.ts |
| Request body validation | Hono zValidator middleware (Layer 2) |
| Data integrity guarantee | Postgres NOT NULL constraints via Drizzle schema (Layer 3) |
| Type-safe API calls | Hono RPC client (hc<AppType>) |
| Request caching & deduplication | TanStack Query (queryClient) |
| Optimistic UI updates | Manual queryClient.setQueryData() after mutations |
| Image storage | S3 via presigned URL (browser uploads directly, bypasses backend) |
| Data persistence | Drizzle ORM → Neon Postgres |