| name | tech-vue-fastapi |
| description | Vue SPA + FastAPI API stack (stack id `vue-fastapi`): idiomatic structure, the self-contained fragment files a feature drops (auto-discovered at boot/build), where features wire in, the multi-stage Docker build, and the Tailwind styling tool. Invoke when the lab's stack is `vue-fastapi`.
|
tech-vue-fastapi skill
The lab is a Vue single-page app served by a FastAPI backend, one container.
FastAPI serves the API at /api/* and the compiled Vue bundle at /.
Umbrella rule — a feature only CREATES its own files
A feature author creates ONLY its own fragment files and NEVER edits
main.py, core.py, or App.vue. Those entrypoints auto-discover features
at boot/build time, so parallel feature agents never touch shared files and
never conflict. There are no markers to edit — the old
# --- AGENT: add routes below --- / <!-- AGENT FEATURE NAV/SECTIONS -->
steps are gone, and those markers no longer exist.
A feature drops exactly these files:
| Stack | File a feature creates | Discovered by |
|---|
| FastAPI | routers/<feature>.py (exposes register(app)) | core.register_feature_routers() |
| FastAPI | schema/<feature>.sql (CREATE TABLE IF NOT EXISTS …) | core.init_db() |
| FastAPI | seed/<feature>.sql (INSERT OR IGNORE …) | core.init_db() |
| Vue | src/features/<feature>.vue (SFC + nav export) | App.vue import.meta.glob |
File map
Shared entrypoints (already in $ARENA_WORKDIR/app/ — never edit these):
main.py — FastAPI app: auth routes (/api/auth/login|logout|me), session
middleware, then register_feature_routers(app) and the guarded StaticFiles
mount (serves static_assets/).
core.py — shared helpers + the fragment loaders. Features import from here:
get_db, get_current_user, require_auth, pwd_hash. Also owns
init_db() (creates the users table, applies schema/*.sql, runs
seed_users(), applies seed/*.sql) and register_feature_routers().
frontend/src/App.vue — the shared, auth-aware shell and the URL-driven
client router (History API, no vue-router). Auto-discovers
src/features/*.vue, builds the nav (sorted by slot then label), matches
the active feature by nav.path against the URL, and renders it with a route
prop. Deep links / back-forward work; the backend serves index.html for any
non-/api path.
Dockerfile — the multi-stage SPA build is already activated for you.
Shared frontend helpers (use, don't edit):
src/api.js — api.get/post/... + api.auth.* (relative /api,
credentials:"include").
src/style.css — Tailwind + the theme tokens block.
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.py seed_users(db) — this is the one skeleton-only
step that touches a shared file, because it needs pwd_hash to hash passwords.
Use INSERT OR IGNORE. (Feature authors never touch this.)
- Sets the per-run brand (
BRAND in App.vue) + theme tokens in
src/style.css (see design-tailwind-shadcn).
Feature tables are NOT seeded here — each feature owns its own seed/<feature>.sql.
What a feature drops (feature stage)
FastAPI backend — three files
routers/<feature>.py — owns the routes, exposes register(app):
from core import get_db, require_auth
from fastapi import APIRouter, Depends, Request
router = APIRouter()
@router.get("/api/orders")
def list_orders(user: dict = Depends(require_auth)):
db = get_db()
rows = db.execute("SELECT id, item FROM orders ORDER BY id").fetchall()
db.close()
return [dict(r) for r in rows]
def register(app):
app.include_router(router)
schema/<feature>.sql — owns the tables:
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item TEXT NOT NULL
);
seed/<feature>.sql — owns the demo data (re-runs every boot, so use
INSERT OR IGNORE):
INSERT OR IGNORE INTO orders (id, item) VALUES (1, 'Widget'), (2, 'Gadget');
Loader rules to keep in mind:
- All three dirs are applied in sorted order, deterministically.
routers/_*.py (leading underscore) is skipped by the loader.
_base.sql (the skeleton's base schema/seed) sorts first, so base tables exist
before feature tables/seed.
- Routers register before the
StaticFiles mount, so every /api/* route
wins over the SPA.
Vue frontend — one SFC
src/features/<feature>.vue is a self-contained SFC with a plain <script>
block exporting the nav descriptor plus a <script setup> for the component:
<script>
// Nav metadata.
// id — page key (must be unique).
// label — nav text.
// slot — nav order left->right (lower first; default 100, ties break by label).
// path — the feature's base URL, must start with "/" (optional; defaults to
// "/" + id). This is the route the URL shows when the feature is active.
// group — OPTIONAL string; collapse related features into ONE top-nav dropdown
// (see "Nav grouping" below). Omit for a top-level link.
export const nav = { id: "orders", label: "Orders", slot: 20, path: "/orders" };
</script>
<script setup>
import { ref, onMounted } from "vue";
import { api } from "../api.js";
// The shell hands every feature a `route` prop (plus `user` and `api`).
const props = defineProps({ user: Object, api: Object, route: Object });
const orders = ref([]);
const loading = ref(true);
const error = ref("");
onMounted(async () => {
try {
orders.value = await api.get("/orders");
} catch (e) {
error.value = e.data?.error || "Could not load orders";
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<!-- render loading / empty / error / data -->
</div>
</template>
App.vue is a real URL-driven client router (History API, no vue-router). It
auto-discovers via import.meta.glob('./features/*.vue'), builds the nav sorted
by slot then label, picks the active feature by matching nav.path against
window.location.pathname (segment-aware longest-prefix), and renders it with
<component :is="…" :user :api :route />. No edits to App.vue are ever
needed to add a feature.
Nav grouping (optional nav.group) — keep the bar small
On a large lab (15+ features) one top-nav link per feature becomes an unusable
wall. The OPTIONAL nav.group field (a string) collapses related features into
ONE dropdown so the top bar stays short. The planner assigns groups; you usually
just copy the value it gives you.
- Features with no
group render as top-level links (as before), sorted by
slot then label.
- Features sharing the same
group string collapse into one dropdown button
labelled with that string; its items (sorted by slot then label) live in the
menu. The group's position among the top-level entries is the MIN slot of
its members.
- Everything is still URL-driven: dropdown items are real
<a :href> anchors on
the existing client router (plain click navigates + closes the menu; modified /
middle click opens a new tab); the group button shows the active state when one
of its features is the active route.
export const nav = { id: "flights", label: "Flights", slot: 40, group: "Trips" };
export const nav = { id: "hotels", label: "Hotels", slot: 30, group: "Trips" };
A feature that is reached only from another page (e.g. a section linked from a
list, never from the top nav) sets export const nav = null; — the SFC is
still auto-discovered, but it contributes no top-nav entry at all. (Most
"reached-from-another-page" views are sub-routes of their parent feature, driven
by route.subpath; reach for nav = null only for a standalone SFC that should
never show in the bar.)
The route prop and intra-feature navigation (REQUIRED)
The active feature receives a route prop. Sub-views (list→detail, tabs) MUST be
URL-driven through it — never in-memory selectedId/page state, so the
browser URL is real and routes enumerate:
route.path — full pathname, e.g. "/orders/123".
route.subpath — pathname minus the feature base, no leading slash
("" at the feature root, "123" for "/orders/123").
route.navigate(to) — push an absolute path, e.g. route.navigate("/orders/" + id).
Minimal list→detail (the list pushes a URL; the detail reads route.subpath):
<script setup>
import { computed } from "vue";
const props = defineProps({ user: Object, api: Object, route: Object });
const orderId = computed(() => props.route.subpath ? Number(props.route.subpath) : null);
</script>
<template>
<!-- list (feature root, subpath === "") -->
<ul v-if="!route.subpath">
<li v-for="o in orders" :key="o.id">
<button @click="route.navigate('/orders/' + o.id)">{{ o.item }}</button>
</li>
</ul>
<!-- detail (deep-linkable: /orders/123) -->
<div v-else>Order #{{ orderId }} <button @click="route.navigate('/orders')">Back</button></div>
</template>
CAVEATS:
- Never put a literal
</script> inside a comment in the SFC — it closes the
script block early and breaks the build. (Write it as </scr + ipt> or
reword the comment.)
nav.id must be unique across features — duplicate ids collide on the same
page key.
- The shell owns
/ (home) — don't set a feature's nav.path to "/"; the
root is the landing view.
- Intra-feature navigation MUST use
route.navigate + route.subpath, not
in-memory state (selectedId/page). Reaching into local-only state breaks
deep links, back/forward, and route enumeration.
Common pitfalls
- Every data route MUST be under
/api/...; the / StaticFiles mount catches
everything else.
- Use
credentials: "include" (the template's api.js already does).
- Do not run
npm yourself and do not edit the Dockerfile build stage.
- Do not edit
main.py, core.py, or App.vue — drop your own fragment
files and let auto-discovery wire them in. One shared nav, built from the nav
exports; never hand-roll a second nav.
Writing idiomatic vue-fastapi code
For copy-pasteable patterns tied to the template's helpers, see
references/writing-vue-fastapi.md.
Highlights:
- Backend: gate routes with
user: dict = Depends(require_auth). Open with
get_db(), fetchall()/fetchone(), and always db.close() (no pool).
- Type path params (
item_id: int); raise HTTPException(status_code=404, …)
for a missing row, not bare None/{}.
- For clean write routes, take a Pydantic
BaseModel param — FastAPI parses
and validates the body (free 422); don't hand-roll await request.json().
- Always use
? placeholders + a params tuple — never f-string values into SQL
(for clean/supporting features).
- Frontend: one
src/features/<feature>.vue SFC per feature, <script setup>
- Composition API. Hold data in
ref([]) with parallel loading/error refs;
fetch in onMounted; clear loading in a finally.
v-for with :key="row.id" (never the index); for in-feature navigation
(list→detail/tabs) use the route prop — route.navigate('/orders/'+id) and
read route.subpath — never local-only selectedId/page state.
- Render three distinct states — loading, empty, error — never a blank card.
- Seed real, domain-matched data (no lorem) in
seed/<feature>.sql; keep
accessible forms (<label> + bound <input>).
Styling
Use Tailwind (token-driven) — invoke the design-tailwind-shadcn skill (the
Tailwind half applies to Vue). Theme per-run by editing only the theme tokens
block in src/style.css.