Minimal split backend/frontend starter: FastAPI + MongoDB behind a
Vite + React 19 + TypeScript frontend, joined by a small typed fetch layer
over /api. This is a bare skeleton — no app features are implemented. Build on
top of it.
farm-ts/
backend/ FastAPI + motor (async MongoDB) + Pydantic v2 — python, /root/.venv
frontend/ Vite + React 19 + Tailwind v4 + shadcn/ui (TypeScript strict)
tests/ Playwright e2e workspace (pre-scaffolded)
Two separate processes, managed by supervisor in the pod (see "Pod conventions" below); to run them by hand from two terminals instead:
cd backend && uvicorn server:app --host 0.0.0.0 --port 8001 --reload # http://localhost:8001
cd frontend && yarn dev # http://localhost:3000Every backend route lives under /api (the backend mounts one
APIRouter(prefix="/api")), and the frontend dev server
(frontend/vite.config.ts) proxies /api/* to http://localhost:8001. So
frontend code always calls a relative path — apiGet("/status") →
/api/status — and never an absolute backend URL. The same code works in dev
(via the Vite proxy) and in production (once both are served behind a single
origin).
FastAPI, async throughout. python is the app venv interpreter
(/root/.venv/bin/python); backend deps are pip-installed from
backend/requirements.txt.
- Entry point:
backend/server.py— createsapp = FastAPI(), createsapi_router = APIRouter(prefix="/api"), registers routes on the router, and callsapp.include_router(api_router)at the bottom. CORS middleware is added fromCORS_ORIGINS. Never hang a route directly offapp— it would land outside/apiand the Vite proxy would not reach it. - The route pattern (copy
statusinserver.py):- a Pydantic model per request body and per response
(
StatusCheckCreate/StatusCheck); - an
async defhandler decorated with@api_router.post("/status", response_model=StatusCheck); awaitthe motor call inside it. FastAPI validates the request against the Pydantic model before your handler runs — a malformed body never reaches your code, it gets an automatic422with a{"detail": [...]}body.
- a Pydantic model per request body and per response
(
- Growing the backend: as
server.pygets crowded, move models tobackend/models/and routers tobackend/routers/(one module per resource, each exporting its ownAPIRouter, mounted fromserver.pyviaapi_router.include_router(...)orapp.include_router(...)with the/apiprefix preserved). - MongoDB: import the shared handle —
from lib.db import client, db(backend/lib/db.pyself-loads.envbefore reading env). Use it fromserver.py, every router, and standalone scripts likeseed.py; never construct anotherAsyncIOMotorClient. Collections are attributes:await db.status_checks.insert_one(...),await db.status_checks.find().to_list(1000). Motor connects lazily, so importingservernever blocks on Mongo.pymongois installed too if you need a sync client in a script. - Ids: documents use a string
id(uuid4) field, not Mongo'sObjectId—ObjectIdis not JSON-serializable and leaks into response bodies. Keep theuuid4default-factory pattern fromStatusCheck. - Config:
backend/.env—MONGO_URL(connection string),DB_NAME(database name),CORS_ORIGINS.server.pyloads it withpython-dotenvabove its local imports, andlib/db.pyself-loads it so standalone scripts inherit it too. The pod runsmongodlocally, soMONGO_URLpoints atlocalhost. Add new secrets/config here; read them withos.environ. - Dates:
backend/lib/dates.py—today_iso(tz=None). The pod clock is UTC; anchor "today" server-side with this, never with client-side date math. - Interactive check:
cd /app/backend && python -c 'import server'catches syntax/import errors without waiting for the supervisor log.
- Vite + React 19 + TypeScript strict, dev server on port
3000. - Tailwind CSS v4 (via the
@tailwindcss/viteplugin — no separatetailwind.config.jsneeded) + shadcn/ui, initialized with thebase-novastyle andneutralbase color,@path alias (@/*→src/*) wired in bothtsconfig.app.json/tsconfig.jsonandvite.config.ts. react-router-domandmotionare preinstalled — don't re-add them.src/App.tsxis the<Routes>table and nothing else; screens live insrc/pages/*.tsxand are imported as@/pages/<Name>.src/pages/Home.tsxships as the worked example. Add a<Route>for every page you write, in the same edit that creates the page — a page with no route is unreachable, and any URL without a matching<Route>renders a blank page —<Routes>matches nothing and mounts nothing.- Components installed under
src/components/ui/: button, card, input, label, select, dialog, sheet, tabs, badge, calendar, sonner, textarea, table, popover, dropdown-menu, checkbox. Add more withnpx shadcn@latest add <component>. src/lib/api.ts— the typed fetch layer:apiGet<T>,apiPost<T>,apiPut<T>,apiPatch<T>,apiDelete<T>, all relative to base/api, throwingApiError(withstatusand the parsed body) on any non-2xx. Nothing infers across the Python boundary — you declare the response type yourself as a TS interface mirroring the endpoint's Pydantic model, and keeping the two in sync is a manual discipline. When you change a Pydantic model, change its TS interface in the same edit.src/pages/Home.tsxis a minimal example of the wiring: TanStack Query'suseQuerywithapiGet<StatusCheck[]>("/status")as thequeryFn. It is a non-blocking connectivity probe, not a proof of the round trip — the result is deliberately discarded so the splash renders identically with no backend.apiGet<T>does no runtime validation either;Tis your assertion, not a check. See the static-preview rule inTEMPLATE.md§4 for why no page may be gated on a fetch.
frontend/tsconfig.app.json / tsconfig.node.json have strict: true. In the
pod:
cd frontend && yarn typecheck— plain tsc --noEmit run from frontend/ checks ZERO files (root tsconfig uses
project references with "files": []) and exits 0 even with type errors. Always
use -b for the frontend. Lint with cd frontend && yarn lint (oxlint).
TanStack Query is wired: QueryClientProvider in src/main.tsx, useQuery demo
in src/pages/Home.tsx (see above). Use useQuery/useMutation, not
fetch-in-useEffect.
When the build is complete, run tier 1 once, all in the same turn: a curl smoke
over the key /api endpoints (assert status AND a response field, plus one
negative case), cd frontend && yarn typecheck, and ONE happy-path browser pass
through the core user journey. Clean on all three → finish; any failure is a real
bug — fix it, re-run the failed check, and escalate to the testing subagent.
No routine typecheck/lint/smoke passes during the build — tier 1 runs exactly once.
Two lanes.
Backend (pytest) — specs in backend/tests/ as test_*.py, run with:
cd /app/backend && pytestbackend/pytest.ini is canonical: addopts = -n 2 --dist loadscope (pytest-xdist,
already parallel — do not pass your own -n) and asyncio_mode = auto (so
async def test_... needs no marker). Serial is -n 0, never
-p no:xdist (that errors, because addopts still passes -n/--dist).
backend/tests/conftest.py is pre-scaffolded — a sync client fixture
(httpx.Client rooted at /api), an async aclient, and an api_url() helper,
all pointed at BACKEND_URL (default http://localhost:8001). Tests hit the
live uvicorn process, so the app under test is the one the browser sees. Add
app-specific fixtures below the marker; do not re-create the file.
Frontend (Playwright) — /app/tests/ is pre-scaffolded:
playwright.config.ts (canonical — edit the marked lines only),
fixtures/helpers.ts, and a package.json that resolves
@playwright/test@1.62.0 (node_modules baked into the image). Write specs into
tests/e2e/. Do NOT re-create the config/helpers or install/upgrade playwright —
matching Chromium browsers live at /pw-browsers.
The backend lane is pytest: this template's backend is Python, so vitest does
not apply to it.
This template runs under supervisord in the Emergent agent pod — supersedes any local-run instructions above.
-
Backend, frontend, and
mongodare each a supervisor program. After code or config changes, restart and wait for readiness:sudo supervisorctl restart frontend backend until curl -sf -o /dev/null http://localhost:3000; do sleep 2; done
-
Status, only after a restart you triggered:
sudo supervisorctl status frontend backend. Logs:/var/log/supervisor/backend.err.log,backend.out.log,frontend.err.log. -
App in a browser: the pod's preview URL (frontend, port
3000). Backend API directly at port8001. -
mongodruns locally in the pod (--bind_ip_all);MONGO_URLinbackend/.envpoints atlocalhost, no separate Mongo container. -
Both dev servers hot-reload on file edits (uvicorn
--reloadfor the backend, Vite HMR for the frontend); no rebuild step needed for normal iteration. A restart is still needed after changing.env,requirements.txt, orvite.config.ts.