# 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.

Full-Stack Developer roadmap · Connect them · 16 min read · https://levelcode.org/roadmaps/full-stack/full-stack-architecture

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:

```sequence
title: Loading a page and its data
participants: Browser, Static host, API, Database
Browser -> Static host: GET /orders
Static host --> Browser: index.html, JS and CSS bundles
> The React app starts in the browser and renders the page shell.
Browser -> API: GET /api/orders (session cookie)
API -> Database: SELECT ... WHERE customer_id = $1
Database --> API: Rows
API --> Browser: 200 JSON
> React renders the orders.
```

- **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.

```text
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
```

```json
{
  "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.

```json
{
  "name": "@shop/shared",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "zod": "^4.1.0" }
}
```

```ts
// 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>
```

```ts
// 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:

```ts
// 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`:

```ts
// 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:

```ts
const sentryDsn = import.meta.env.VITE_SENTRY_DSN
```

> [!WARNING]
> Everything in the frontend bundle is public. Anyone can open DevTools and read it. Never put an API secret, database URL or private key in a `VITE_` variable. If the frontend needs to use a secret, it calls your API, and the API uses the secret.

Because 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:

1. Create the workspace root, `apps/web` (with `npm create vite@latest web -- --template react-ts` inside `apps`), `apps/api` (Hono, with routes under `/api`) and `packages/shared`.
2. Put the `CreateOrder` schema in `shared`. The API validates `POST /api/orders` with it and returns `{ id, items }`; for now, store orders in memory.
3. Configure the Vite proxy, and from the React app, submit a hard-coded order with `fetch('/api/orders', ...)` and display the result.
4. Validate the order in the browser with the same schema before sending, and show the first error message if it fails.
5. Confirm in DevTools that the request goes to `localhost:5173/api/orders` (same origin) and that there are no CORS errors.

**Solution**

```ts
// 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)
  })
```

```ts
// 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}`))
```

```tsx
// 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>
  )
}
```

```json
{
  "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/api` and `packages/shared` in 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.

## Quiz

1. Why put the CreateOrder Zod schema in a shared package?

   A. Zod only works in shared packages
   B. The frontend and API validate with the same definition, so they can never disagree about what's valid
   C. It makes the API faster
   D. It removes the need for server-side validation

   **Answer: B.** One schema gives the form and the API identical rules and types. The API must still validate, because the browser can't be trusted.

2. What does Vite's server.proxy setting for /api achieve in development?

   A. It caches API responses
   B. It bundles the API into the frontend
   C. The browser talks only to the Vite origin, which forwards /api requests to the API, so no CORS setup is needed
   D. It encrypts API traffic

   **Answer: C.** Requests stay same-origin from the browser's point of view, so cookies and fetch work without CORS, just like the same-origin production setup.

3. A developer adds VITE_STRIPE_SECRET_KEY to the frontend's environment. What's the problem?

   A. Vite variables must be lowercase
   B. Vite ignores variables containing SECRET
   C. It only works in development
   D. VITE_ variables are inlined into the public JavaScript bundle, so anyone can read the secret

   **Answer: D.** The bundle runs in users' browsers and can be inspected. Secrets must stay on the server, and the frontend calls the API to use them.

4. Why is serving the frontend and API from one origin usually preferred?

   A. It avoids CORS preflights and cross-site cookie restrictions entirely
   B. Browsers block cross-origin requests completely
   C. It's required for React
   D. It makes the bundle smaller

   **Answer: A.** Same-origin requests need no CORS configuration, and session cookies behave simply. Separate origins work but need careful CORS and cookie settings.
