Step 03 of 8
How a full-stack app fits together
Structure a React frontend and a Node API as one product: a workspace monorepo, shared code, a dev proxy, same-origin deployment and configuration.
You've built a React frontend and a Node API separately. A full-stack developer owns the line between them: how the two are laid out in one repository, how they share code, how requests get from the browser to the API in development and in production, and where configuration lives. Get these decisions right and the rest of the work is building features; get them wrong and you spend weeks on CORS errors, duplicated types and deploys that break the other half. This lesson sets up that structure.
The shape of the system
At runtime, a typical full-stack app has three parts:
- Static assets: the built React app (HTML, JavaScript, CSS, images). They're the same for every user and cache well.
- The API: your Hono server, which authenticates requests, applies business rules and talks to the database.
- The database and other services (object storage, email, queues), which only the API talks to.
The browser is untrusted: everything that matters (authorisation, validation, prices) is enforced in the API, even when the frontend also checks it for a better experience.
One repository: a workspace monorepo
Keeping frontend and backend in one repository lets a single pull request change an API endpoint and the screen that uses it, reviewed and deployed together. npm, pnpm and Yarn all support workspaces: several packages in one repository, each with its own package.json, sharing one install and one lock file.
shop/
package.json # workspace root
package-lock.json
tsconfig.base.json
apps/
web/ # React + Vite
package.json
vite.config.ts
src/
api/ # Hono + PostgreSQL
package.json
src/
packages/
shared/ # code used by both: schemas, types, constants
package.json
src/index.ts{
"name": "shop",
"private": true,
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "npm run dev --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"build": "npm run build --workspaces --if-present"
}
}npm install at the root installs everything and links workspace packages into node_modules, so apps/api can import ... from '@shop/shared' as if it were a published package. npm run test --workspaces runs each package's test script. For running dev servers for several apps at once with readable output, tools such as concurrently or Turborepo help, but plain scripts are enough to start.
A shared package
The most valuable thing to share is the contract between the two sides: validation schemas and the types derived from them.
{
"name": "@shop/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": { "zod": "^4.1.0" }
}// packages/shared/src/orders.ts
import { z } from 'zod'
export const OrderStatus = z.enum(['pending', 'paid', 'shipped', 'cancelled'])
export const CreateOrder = z.object({
items: z
.array(z.object({ sku: z.string().min(1), quantity: z.number().int().min(1).max(100) }))
.min(1, 'Add at least one item'),
note: z.string().max(500).optional(),
})
export type CreateOrder = z.infer<typeof CreateOrder>
export type OrderStatus = z.infer<typeof OrderStatus>// packages/shared/src/index.ts
export * from './orders.ts'The API validates request bodies with CreateOrder; the React form validates with the same CreateOrder before submitting. One definition, so the two can never disagree about what a valid order is. Exporting the TypeScript source directly ("exports": "./src/index.ts") works because both Vite and Node's type stripping handle .ts files, so the package needs no build step of its own.
What belongs in shared: schemas, types, constants (status names, limits), and small pure functions both sides need (formatting money). What doesn't: anything using Node APIs, database code, React components, or secrets. Bundlers include whatever the frontend imports, so a careless import from shared can pull server-only code into the browser bundle.
Development: one origin through a proxy
In development, Vite serves the frontend on localhost:5173 and the API runs on localhost:3000. Those are different origins, so calls from the app to the API would need CORS configuration, and cookies get complicated.
The simple fix is to make the browser talk only to Vite, and have Vite proxy API requests:
// apps/web/vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})The React code calls relative URLs, fetch('/api/orders'), and Vite forwards them to the API. From the browser's point of view, everything is one origin: no CORS, and cookies just work. The API mounts its routes under /api:
// apps/api/src/app.ts
export const app = new Hono().basePath('/api')
app.route('/orders', ordersRoutes)Production: keep it same-origin
Mirror the development setup in production by serving the frontend and the API from one origin. Three common topologies:
| Topology | How | Trade-offs |
|---|---|---|
| API serves the frontend | The Node server serves the built static files and the /api routes | One deployable, simplest; static files compete with API traffic for the same processes |
| CDN in front of both | A CDN (or reverse proxy) routes /api/* to the API and everything else to static hosting | Fast static delivery worldwide, still one origin; a bit more infrastructure |
| Separate origins | app.example.com (static) calls api.example.com | Needs CORS and careful cookie settings; sometimes required by organisational boundaries |
Prefer the first two. Same-origin removes a whole class of problems: CORS preflights, third-party-cookie restrictions and cross-site cookie settings. The final lesson of this roadmap ships the first topology.
Configuration on both sides
The API reads configuration from environment variables at runtime, validated at startup, as in the Node roadmap.
The frontend is different: it's static files in the user's browser, so there's no runtime environment to read. Vite inlines variables prefixed with VITE_ into the bundle at build time:
const sentryDsn = import.meta.env.VITE_SENTRY_DSNBecause frontend values are baked in at build time, building once and deploying the same bundle to staging and production requires that the bundle not depend on environment-specific values. Same-origin relative URLs (/api/...) help here: the bundle never needs to know the API's address.
Practice
Set up the shop monorepo:
- Create the workspace root,
apps/web(withnpm create vite@latest web -- --template react-tsinsideapps),apps/api(Hono, with routes under/api) andpackages/shared. - Put the
CreateOrderschema inshared. The API validatesPOST /api/orderswith it and returns{ id, items }; for now, store orders in memory. - Configure the Vite proxy, and from the React app, submit a hard-coded order with
fetch('/api/orders', ...)and display the result. - Validate the order in the browser with the same schema before sending, and show the first error message if it fails.
- Confirm in DevTools that the request goes to
localhost:5173/api/orders(same origin) and that there are no CORS errors.
Show solution
// apps/api/src/app.ts
import { zValidator } from '@hono/zod-validator'
import { CreateOrder } from '@shop/shared'
import { Hono } from 'hono'
let nextId = 1
export const app = new Hono()
.basePath('/api')
.post('/orders', zValidator('json', CreateOrder), (c) => {
const order = { id: nextId++, ...c.req.valid('json') }
return c.json(order, 201)
})// apps/api/src/server.ts
import { serve } from '@hono/node-server'
import { app } from './app.ts'
serve({ fetch: app.fetch, port: 3000 }, (info) => console.log(`api on :${info.port}`))// apps/web/src/App.tsx
import { CreateOrder } from '@shop/shared'
import { useState } from 'react'
export default function App() {
const [message, setMessage] = useState('')
async function submit(order: unknown) {
const parsed = CreateOrder.safeParse(order)
if (!parsed.success) {
setMessage(`Not sent: ${parsed.error.issues[0]?.message}`)
return
}
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
})
setMessage(`${res.status}: ${JSON.stringify(await res.json())}`)
}
return (
<main>
<button onClick={() => submit({ items: [{ sku: 'PEN-01', quantity: 2 }] })}>Place valid order</button>
<button onClick={() => submit({ items: [] })}>Place empty order</button>
<p>{message}</p>
</main>
)
}{
"name": "@shop/web",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "tsc -b && vite build" },
"dependencies": { "@shop/shared": "*", "react": "^19.0.0", "react-dom": "^19.0.0" }
}Run node --watch src/server.ts in apps/api and npm run dev in apps/web. "Place valid order" shows 201: {"id":1,"items":[...]}; "Place empty order" shows Not sent: Add at least one item without any request being made. The Network tab shows POST http://localhost:5173/api/orders: the browser only ever talks to Vite, which forwards the call to port 3000. If you sent the empty order anyway, the API would reject it with the same message, because the server runs the same schema; client validation is for the user, server validation is for safety.
Key takeaways
- A full-stack app is static assets, an API, and the database and services only the API touches; the API enforces everything that matters.
- A workspace monorepo keeps
apps/web,apps/apiandpackages/sharedin one repository with one lock file, so a feature ships in one pull request. - Share the contract (Zod schemas and their types) through a package both sides import, and keep server-only code out of it.
- Use Vite's dev proxy and serve frontend and API from one origin in production to avoid CORS and cookie problems.
- Frontend configuration is baked into a public bundle at build time; secrets stay in the API.
Check yourself
Quiz
0/4 correct · 4 left
1.Why put the CreateOrder Zod schema in a shared package?
2.What does Vite's server.proxy setting for /api achieve in development?
3.A developer adds VITE_STRIPE_SECRET_KEY to the frontend's environment. What's the problem?
4.Why is serving the frontend and API from one origin usually preferred?
Finished reading?
Mark it complete to fill in your roadmap.