| name | tech-react-express |
| description | React SPA + Express/Node API stack (stack id `react-express`): idiomatic structure, the per-feature fragment files each feature creates, how those fragments are auto-discovered at boot/build, the multi-stage Docker build, and the Tailwind + shadcn styling tool. Invoke when the lab's stack is `react-express`.
|
tech-react-express skill
The lab is a React single-page app served by an Express API, one container.
Express serves the API at /api/* and the compiled React bundle at /.
The one rule: features only CREATE their own files
A feature author creates ONLY its own files under per-feature paths and
NEVER edits server.js, core.js, or App.jsx. Those three entrypoints
auto-discover features โ they need no edits, ever. There are no AGENT: markers
to fill in anymore; everything wires in by dropping correctly-named files.
This is what lets many feature authors work in parallel without colliding: each
owns a disjoint set of files named after its feature.
File map
Backend (already in $ARENA_WORKDIR/app/):
server.js โ Express app entrypoint. Mounts auth (/api/auth/login|logout|me,
session cookies), /api/health, then calls registerFeatureRoutes(app) and the
guarded static-SPA block. Do not edit it.
core.js โ shared core: the db (better-sqlite3) singleton, currentUser,
requireAuth, initDb(), and the fragment loaders. Do not edit it.
routes/<feature>.js โ you create one of these per feature (your /api/* routes).
schema/<feature>.sql โ you create one per feature (your CREATE TABLEs).
seed/<feature>.sql โ you create one per feature (your seed INSERTs).
Dockerfile โ the multi-stage SPA build is already activated for you.
Frontend (already in $ARENA_WORKDIR/app/frontend/):
src/App.jsx โ the shared, auth-aware shell (login view + app chrome). It is a
real client router: the browser URL (window.location.pathname) drives which
feature shows. It auto-discovers features via import.meta.glob('./features/*.jsx')
and builds the nav + active page from them. Do not edit it.
src/features/<feature>.jsx โ you create one per feature (a nav export +
a default page component).
src/api.js โ api.get/post/put/delete + api.auth.* (relative /api, credentials:"include").
src/components/ui/ โ vendored shadcn components (button, card, input, badge, table).
src/index.css โ Tailwind + the per-run theme tokens.
Scaffold (skeleton stage)
The template trees are already copied into app/ and app/frontend/ and the
multi-stage Dockerfile is already selected โ do NOT copy templates, do NOT
mv the Dockerfile, do NOT run npm. The skeleton stage only:
- Seeds users in
core.js seedUsers() (it needs bcrypt.hashSync; use
INSERT OR IGNORE). This is the only place users are seeded โ feature
authors never seed users.
- Sets the per-run brand + theme tokens in the frontend (see
design-tailwind-shadcn).
How a feature wires in (feature stage)
Auto-discovery means: name the file right, export the right shape, and it appears.
Backend โ drop exactly three files (all named after the feature, e.g. orders):
routes/orders.js โ module.exports IS the register(app) function:
const { db, requireAuth } = require("../core");
module.exports = function register(app) {
app.get("/api/orders", requireAuth, (_req, res) => {
res.json(db.prepare("SELECT * FROM orders ORDER BY id").all());
});
};
db is the shared better-sqlite3 singleton; requireAuth /
currentUser come from ../core. registerFeatureRoutes(app) requires every
non-_ routes/*.js in sorted order and calls its export.
schema/orders.sql โ CREATE TABLE IF NOT EXISTS orders (...);
seed/orders.sql โ INSERT OR IGNORE INTO orders (...) VALUES (...);
schema/*.sql then seed/*.sql are each applied in sorted order at boot;
_-prefixed files are skipped and the skeleton's _base.sql sorts first. Seed
files re-run on every boot, so use INSERT OR IGNORE (or otherwise guarded
inserts) โ never bare INSERT.
Frontend โ drop exactly one file:
src/features/orders.jsx:
export const nav = { id: "orders", label: "Orders", slot: 20, path: "/orders" };
export default function OrdersPage({ user, api, route }) {
}
App.jsx globs ./features/*.jsx, builds the nav sorted by slot then label,
and renders the active page's default export with { user, api, route } props. A
module missing nav.id or a default export is silently skipped, so nav.id
must be present and unique across all features.
Nav grouping โ the optional nav.group field
To keep the top bar small on a large lab (15+ features would otherwise be a wall
of links), the nav supports an optional nav.group (string) field that
collapses related features into ONE dropdown menu:
- A feature with no
group renders as a top-level item (as before), sorted
by slot then label.
- Features sharing the same
group string collapse into one dropdown whose
button label is that string; the group's items (sorted by slot then label)
appear in the menu. The group's position among the top-level items is the
MIN slot of its members โ top-level items and group buttons are ordered
together by that effective slot.
export const nav = { id: "itineraries", label: "Itineraries", slot: 30, group: "Trips", path: "/itineraries" };
export const nav = { id: "bookings", label: "Bookings", slot: 40, group: "Trips", path: "/bookings" };
The feature planner assigns group to keep the top bar small โ it clusters
related features (e.g. all "Trips" features) under one group so the bar stays
short. You normally just use the group string the planner gave your feature.
A feature reached only from another feature's page (a sub-view that has no
business owning a top-bar slot) sets its nav to null (export const nav = null) โ it then has no top-level entry and is navigated to via route.navigate
from the owning feature. group does not apply to such features.
URL-driven navigation โ the nav.path + route contract
The shell is a real client router (History API), so the browser URL changes as you
navigate and deep links work:
nav.path (string, optional, must start with /) โ the feature's base URL,
e.g. "/orders". Defaults to "/" + nav.id. A feature is active when the
pathname matches its base prefix segment-aware: "/orders" matches "/orders"
and "/orders/123" but NOT "/orders-extra"; longest match wins.
- The active component receives a
route prop:
route.path โ the full current pathname (e.g. "/orders/123").
route.subpath โ pathname with the feature base stripped, no leading slash:
"" at the feature root, "123" for "/orders/123".
route.navigate(to) โ pushes an absolute path via history.pushState and
re-renders.
Intra-feature navigation (list โ detail, tabs, sub-views) MUST use
route.navigate('/orders/' + id) and read route.subpath โ NOT in-memory
state โ so sub-views change the URL too (deep-linkable, route-enumerable):
export const nav = { id: "orders", label: "Orders", slot: 20, path: "/orders" };
export default function OrdersPage({ api, route }) {
if (route.subpath) {
return <OrderDetail api={api} id={route.subpath} route={route} />;
}
}
Common pitfalls
- The SPA history fallback uses
/^(?!\/api\/).*/ โ all data routes MUST be under
/api/... or the SPA HTML will shadow them.
- Use
credentials: "include" on fetches (the template's api.js already does).
- Do not run
npm yourself and do not edit the Dockerfile build stage โ
the verifier compiles the SPA inside docker build.
- Do not edit
server.js, core.js, or App.jsx โ they auto-discover your
fragments. One shared nav is built for you from the nav exports; never
hand-roll a second nav.
- Give each feature a unique
nav.id and unique file names โ a duplicate id or
a missing nav.id/default export means your page silently won't appear.
Writing idiomatic react-express code
For copy-pasteable patterns tied to the template's helpers, see
references/writing-react-express.md.
Highlights:
- Backend:
better-sqlite3 is synchronous โ db.prepare(...).get/.all/.run,
no await. Gate routes with requireAuth; read :id from req.params.
- Always use
? placeholders + .get(v)/.all(v) args โ never string-interpolate
values into SQL (for clean/supporting features).
- Return a real
404 (res.status(404).json(...)) for a missing row, not {}/null.
- Frontend: one feature file per feature; build it from the shadcn primitives
(
Card, Button, Table, Badge) โ don't hand-roll styled markup.
- Fetch in
useEffect(โฆ, []); initialize list state to null so loading, empty
([]), and error are three distinct, rendered states.
map with key={row.id} (never the index); for sub-views (list โ detail, tabs)
use route.navigate('/orders/' + id) and read route.subpath so the URL changes
โ do NOT use in-memory selection state or reach into the shell's state.
- Seed real, domain-matched data (real names/statuses/dates) โ no lorem,
no
Item 1 / Item 2. Keep accessible forms (<Label htmlFor> + <Input id>).
Styling
Use Tailwind + shadcn/ui โ invoke the design-tailwind-shadcn skill. Theme
per-run by editing only the theme-tokens block in src/index.css.