Problem
Building a full-stack todo app on the current scaffold (dogfood) was mostly first-pass correct (webjs check passed on the first run; SSR, the no-JS write path, hydration, optimistic UI, and DB persistence all worked first try), but three avoidable friction points forced correction loops or offered no reference for the agent to copy. All three hit the full-stack default template specifically, the one most agents use for a product with UI. Fixing them moves the default template closer to the "correct on the first pass, no iteration" north-star.
Design / approach
Three independent, small enhancements (implement together or split):
1. Seed the full-stack default with a real example module
The full-stack default creates an EMPTY modules/ dir, so an agent has the modules-architecture CONVENTION (AGENTS.md / CONVENTIONS.md) but no concrete in-scaffold example to mimic. Ironically both other templates DO ship a demo module (verified by scaffolding each): api ships modules/users/ (types.ts + queries/list-users.server.ts + actions/create-user.server.ts) and saas ships modules/auth/ (types.ts + queries/current-user.server.ts + actions/signup.server.ts), i.e. the templates that arguably need the crutch less. Only the full-stack default has an empty modules/. Seed the full-stack default with one tiny, real feature module (a queries/*.server.ts read, an actions/*.server.ts mutation, a types.ts), wired into the example page the same way api wires modules/users into its route, and carry a webjs-scaffold-placeholder marker on it so it follows the existing acknowledge-and-remove pattern (the agent replaces it with the real feature, deletes the marker, and webjs check goes green).
2. Model the typed Metadata import in the example page
The example page.ts uses an UNTYPED export const metadata = { title: ... }. An agent that (correctly) wants to type it has no in-file example and must know Metadata is exported from @webjsdev/core (not @webjsdev/server, where ActionResult lives). I guessed @webjsdev/server and ate a TS2305 from webjs typecheck. Make the scaffold page.ts model import type { Metadata } from '@webjsdev/core' + export const metadata: Metadata = { ... }, so the correct import is copy-pasteable.
3. Document the working Drizzle read pattern (projection typing)
Idiomatic db.select({ col: table.col }).from(t) failed webjs typecheck with TS2554: Expected 0 arguments, but got 1 under the scaffold's drizzle({ client, relations }) (drizzle-orm rc.3 RQBv2 typing). The full-row db.select().from(t) (or db.query.<t>.findFirst({ columns })) types fine. Either document the working read patterns in CONVENTIONS.md / a db recipe so an agent does not hit the projection-overload trip, or investigate whether the projection overload should type-check under RQBv2 and pin/note it. Documentation is the low-risk first step.
4. Encode the corrected patterns in the MCP guided prompts
The @webjsdev/mcp server is how MCP-driven agents learn webjs, and it ships hand-authored guided-workflow PROMPTS (PROMPT_BODIES in packages/mcp/src/mcp-docs.js). The MCP knowledge RESOURCES are the agent-docs/*.md corpus bundled at prepack, so items 2 and 3's doc edits flow to the resources automatically. But the PROMPT bodies are hand-authored and do NOT auto-update, so they must be edited to carry the same correct-first-pass patterns:
add_page: currently says "name metadata via a metadata / generateMetadata named export" but never shows the TYPED import. Model import type { Metadata } from '@webjsdev/core' + export const metadata: Metadata = {...} (the exact thing an agent gets wrong, see item 2).
add_server_action (and/or a read/query prompt): show the working Drizzle read shape (full-row db.select().from(t) or db.query.<t>.findFirst) so the projection-overload trip (item 3) is never modelled.
add_module: confirm its body matches the newly-seeded full-stack example module (item 1) so the MCP and the scaffold agree on the modules shape.
5. Demonstrate optimistic UI (webjs philosophy is optimistic-by-default)
webjs's stated philosophy is to use optimistic UI wherever feasible (root AGENTS.md "Mutations: default to optimistic UI"), but NO scaffold example demonstrates it and the SCAFFOLD's own shipped prose never mentions it. Two gaps to close:
- Example: the seeded full-stack example module's mutation (item 1) must be called from an interactive component using the DECLARATIVE
optimistic(host, { source, update }) API with .add(payload, promise), so the agent has a copy-able optimistic-UI pattern (an instant create/toggle that rolls back on failure), NOT a manual try-catch / cache-and-restore. Model the SAME pattern the taskflow dogfood used (a Todo[] prop + an optimistic store + a mutation action). Keep it PE-safe (a <form> + page action fallback), so it teaches optimistic UI AND progressive enhancement together.
- Prose: the scaffold's shipped agent docs do NOT prescribe optimistic UI (verified:
optimistic appears in root AGENTS.md + agent-docs, but in none of packages/cli/templates/AGENTS.md / CONVENTIONS.md / .cursorrules / .github/copilot-instructions.md / .agents/rules/workflow.md). Add a short "default to optimistic UI for feasible mutations, via optimistic(); skip it for unpredictable / side-effectful / OAuth mutations" guideline to the scaffold prose in lockstep, mirroring the root guidance.
6. Model accessible control labeling (associate clickable text with its control)
A basic a11y/UX practice that an agent (me, dogfooding) MISSED: interactive controls and their clickable text must be ASSOCIATED, so clicking the text/label activates the control (and screen readers announce it). In the taskflow dogfood the toggle was a <button> and the task title a bare <span>, so clicking the text did nothing. The fix is a standard <label for="<control-id>"> (a <button> is a labelable element, so a label click forwards a click to it, which works on BOTH the JS optimistic path AND the no-JS form-submit path). The scaffold's interactive example must model this (<label for> on clickable text, aria-label/aria-pressed on icon-only controls), and the scaffold prose should carry a one-line a11y guideline: "give every interactive control an accessible label, and make clickable text a <label for> (or the control itself) so text clicks activate the control." This is exactly the kind of default an agent should not have to rediscover.
Implementation notes (for the implementing agent)
- The example
page.ts / layout.ts are generated INLINE in packages/cli/lib/create.js (page ~L1134, layout ~L931, export const metadata = { ~L1149), NOT from a templates/app/ file (there is none). Edit the inline template strings there for item 2.
modules/ is created empty via the base dir list in create.js (~L288). The api demo module is generated in the if (isApi) { ... } block (~L787-858: modules/users/{queries/list-users.server.ts, actions/create-user.server.ts, types.ts} + a route wrapper). Mirror that shape for full-stack inside the if (!isApi) block for item 1.
- Landmine (item 1): extending the
webjs-scaffold-placeholder marker to a module file means the no-scaffold-placeholder check must scan modules/** too (today it targets app/page.ts + app/layout.ts); confirm the check covers the new file, or wire the demo module into the example page so it rides the existing page/layout placeholder umbrella. A demo module not imported anywhere is servable-gated (fine) but gives the agent nothing to see rendered, so prefer wiring it into the example page like api does with its route.
- Landmine (item 2):
Metadata is a TYPE-ONLY export; keep it import type so the stripper erases it (invariant 10). Do NOT import it from @webjsdev/server.
- Invariants: the added example must still leave a fresh scaffold failing
webjs check ONLY on the intended no-scaffold-placeholder marker(s), nothing else. Prose edits respect invariant 11.
- Docs surfaces to sync:
packages/cli/templates/CONVENTIONS.md (modules section + the db read pattern), agent-docs/metadata.md (the typed-import line) and agent-docs/recipes.md (a page-with-metadata recipe), the docs site if it restates these. Run test/scaffolds/* + packages/cli/test/* after changing the generator.
- MCP (item 4): edit
PROMPT_BODIES in packages/mcp/src/mcp-docs.js (the add_page / add_server_action / add_module entries); the RESOURCES half (agent-docs corpus) auto-syncs via the prepack copy, so only the prompt bodies need hand-editing. Add/adjust a test in packages/mcp/test/*.test.mjs asserting the add_page prompt models the typed Metadata import. No init-primer heading rename, so the primer needs no change.
Acceptance criteria
Problem
Building a full-stack todo app on the current scaffold (dogfood) was mostly first-pass correct (
webjs checkpassed on the first run; SSR, the no-JS write path, hydration, optimistic UI, and DB persistence all worked first try), but three avoidable friction points forced correction loops or offered no reference for the agent to copy. All three hit the full-stack default template specifically, the one most agents use for a product with UI. Fixing them moves the default template closer to the "correct on the first pass, no iteration" north-star.Design / approach
Three independent, small enhancements (implement together or split):
1. Seed the full-stack default with a real example module
The full-stack default creates an EMPTY
modules/dir, so an agent has the modules-architecture CONVENTION (AGENTS.md / CONVENTIONS.md) but no concrete in-scaffold example to mimic. Ironically both other templates DO ship a demo module (verified by scaffolding each):apishipsmodules/users/(types.ts+queries/list-users.server.ts+actions/create-user.server.ts) andsaasshipsmodules/auth/(types.ts+queries/current-user.server.ts+actions/signup.server.ts), i.e. the templates that arguably need the crutch less. Only the full-stack default has an emptymodules/. Seed the full-stack default with one tiny, real feature module (aqueries/*.server.tsread, anactions/*.server.tsmutation, atypes.ts), wired into the example page the same wayapiwiresmodules/usersinto its route, and carry awebjs-scaffold-placeholdermarker on it so it follows the existing acknowledge-and-remove pattern (the agent replaces it with the real feature, deletes the marker, andwebjs checkgoes green).2. Model the typed
Metadataimport in the example pageThe example
page.tsuses an UNTYPEDexport const metadata = { title: ... }. An agent that (correctly) wants to type it has no in-file example and must knowMetadatais exported from@webjsdev/core(not@webjsdev/server, whereActionResultlives). I guessed@webjsdev/serverand ate aTS2305fromwebjs typecheck. Make the scaffoldpage.tsmodelimport type { Metadata } from '@webjsdev/core'+export const metadata: Metadata = { ... }, so the correct import is copy-pasteable.3. Document the working Drizzle read pattern (projection typing)
Idiomatic
db.select({ col: table.col }).from(t)failedwebjs typecheckwithTS2554: Expected 0 arguments, but got 1under the scaffold'sdrizzle({ client, relations })(drizzle-orm rc.3 RQBv2 typing). The full-rowdb.select().from(t)(ordb.query.<t>.findFirst({ columns })) types fine. Either document the working read patterns in CONVENTIONS.md / a db recipe so an agent does not hit the projection-overload trip, or investigate whether the projection overload should type-check under RQBv2 and pin/note it. Documentation is the low-risk first step.4. Encode the corrected patterns in the MCP guided prompts
The
@webjsdev/mcpserver is how MCP-driven agents learn webjs, and it ships hand-authored guided-workflow PROMPTS (PROMPT_BODIESinpackages/mcp/src/mcp-docs.js). The MCP knowledge RESOURCES are theagent-docs/*.mdcorpus bundled at prepack, so items 2 and 3's doc edits flow to the resources automatically. But the PROMPT bodies are hand-authored and do NOT auto-update, so they must be edited to carry the same correct-first-pass patterns:add_page: currently says "name metadata via ametadata/generateMetadatanamed export" but never shows the TYPED import. Modelimport type { Metadata } from '@webjsdev/core'+export const metadata: Metadata = {...}(the exact thing an agent gets wrong, see item 2).add_server_action(and/or a read/query prompt): show the working Drizzle read shape (full-rowdb.select().from(t)ordb.query.<t>.findFirst) so the projection-overload trip (item 3) is never modelled.add_module: confirm its body matches the newly-seeded full-stack example module (item 1) so the MCP and the scaffold agree on the modules shape.5. Demonstrate optimistic UI (webjs philosophy is optimistic-by-default)
webjs's stated philosophy is to use optimistic UI wherever feasible (root AGENTS.md "Mutations: default to optimistic UI"), but NO scaffold example demonstrates it and the SCAFFOLD's own shipped prose never mentions it. Two gaps to close:
optimistic(host, { source, update })API with.add(payload, promise), so the agent has a copy-able optimistic-UI pattern (an instant create/toggle that rolls back on failure), NOT a manual try-catch / cache-and-restore. Model the SAME pattern the taskflow dogfood used (aTodo[]prop + an optimistic store + a mutation action). Keep it PE-safe (a<form>+ page action fallback), so it teaches optimistic UI AND progressive enhancement together.optimisticappears in root AGENTS.md + agent-docs, but in none ofpackages/cli/templates/AGENTS.md/CONVENTIONS.md/.cursorrules/.github/copilot-instructions.md/.agents/rules/workflow.md). Add a short "default to optimistic UI for feasible mutations, viaoptimistic(); skip it for unpredictable / side-effectful / OAuth mutations" guideline to the scaffold prose in lockstep, mirroring the root guidance.6. Model accessible control labeling (associate clickable text with its control)
A basic a11y/UX practice that an agent (me, dogfooding) MISSED: interactive controls and their clickable text must be ASSOCIATED, so clicking the text/label activates the control (and screen readers announce it). In the taskflow dogfood the toggle was a
<button>and the task title a bare<span>, so clicking the text did nothing. The fix is a standard<label for="<control-id>">(a<button>is a labelable element, so a label click forwards a click to it, which works on BOTH the JS optimistic path AND the no-JS form-submit path). The scaffold's interactive example must model this (<label for>on clickable text,aria-label/aria-pressedon icon-only controls), and the scaffold prose should carry a one-line a11y guideline: "give every interactive control an accessible label, and make clickable text a<label for>(or the control itself) so text clicks activate the control." This is exactly the kind of default an agent should not have to rediscover.Implementation notes (for the implementing agent)
page.ts/layout.tsare generated INLINE inpackages/cli/lib/create.js(page ~L1134, layout ~L931,export const metadata = {~L1149), NOT from atemplates/app/file (there is none). Edit the inline template strings there for item 2.modules/is created empty via the base dir list increate.js(~L288). Theapidemo module is generated in theif (isApi) { ... }block (~L787-858:modules/users/{queries/list-users.server.ts, actions/create-user.server.ts, types.ts}+ a route wrapper). Mirror that shape for full-stack inside theif (!isApi)block for item 1.webjs-scaffold-placeholdermarker to a module file means theno-scaffold-placeholdercheck must scanmodules/**too (today it targetsapp/page.ts+app/layout.ts); confirm the check covers the new file, or wire the demo module into the example page so it rides the existing page/layout placeholder umbrella. A demo module not imported anywhere is servable-gated (fine) but gives the agent nothing to see rendered, so prefer wiring it into the example page likeapidoes with its route.Metadatais a TYPE-ONLY export; keep itimport typeso the stripper erases it (invariant 10). Do NOT import it from@webjsdev/server.webjs checkONLY on the intendedno-scaffold-placeholdermarker(s), nothing else. Prose edits respect invariant 11.packages/cli/templates/CONVENTIONS.md(modules section + the db read pattern),agent-docs/metadata.md(the typed-import line) andagent-docs/recipes.md(a page-with-metadata recipe), the docs site if it restates these. Runtest/scaffolds/*+packages/cli/test/*after changing the generator.PROMPT_BODIESinpackages/mcp/src/mcp-docs.js(theadd_page/add_server_action/add_moduleentries); the RESOURCES half (agent-docs corpus) auto-syncs via the prepack copy, so only the prompt bodies need hand-editing. Add/adjust a test inpackages/mcp/test/*.test.mjsasserting theadd_pageprompt models the typedMetadataimport. Noinit-primer heading rename, so the primer needs no change.Acceptance criteria
optimistic()API (instant update + auto-rollback), PE-safe, so the scaffold demonstrates optimistic UI by example; and the scaffold's shipped prose (AGENTS.md / CONVENTIONS.md / the three per-agent rule files) prescribes optimistic-UI-by-default in lockstep.<label for>on clickable text so text clicks activate the control,aria-label/aria-pressedon icon-only controls), and the scaffold prose carries a one-line a11y label-association guideline.page.tsmodelsimport type { Metadata } from '@webjsdev/core'+export const metadata: Metadata = {...}..select({projection})does not silently trip an agent (or the typing is verified to accept the projection).webjs checkONLY on the intended placeholder marker(s) and passeswebjs typecheck.add_pageguided prompt models the typedimport type { Metadata } from '@webjsdev/core', andadd_server_actionshows the working Drizzle read shape (with apackages/mcp/testassertion).no-scaffold-placeholdercoverage fires.