Add your own optional feature to the boilerplate

Start state: the stock boilerplate. End state: a new optional notes feature that every future app can include with --features notes — proven by the self-test matrix and by scaffolding it both ways. A feature is defined in exactly two places: marker blocks in the template, and ONE entry in tools/lib/features.mjs.

How the marker system works

The templates in _template/ always compile with ALL features on; scaffolding only removes code. Feature code is wrapped in @feature:<id> -- start/end marker blocks that work in every comment syntax the templates use (//, {/* */}, <!-- -->, and -- SQL comments). When a feature is excluded, the scaffolder strips its blocks from every text file and deletes the whole files it owns; a final scan guarantees no marker survives.

The template must ALWAYS compile with every feature enabled. If your feature only works when another is off, it is designed wrong.

1. Schema block

In _template/app/convex/schema.ts, add a marker-wrapped table beside the others:

// @feature:notes -- start
notes: defineTable({
  userId: v.string(),
  text: v.string(),
}).index("by_user", ["userId"]),
// @feature:notes -- end

2. Backend module

Create _template/app/convex/notes.ts, shaped like convex/items.ts: requireUserId(ctx) first in every handler, thin list/create/delete functions over the by_user index. No markers needed inside — the whole file is owned by the feature and deleted when it is excluded.

3. Feature folder

Create _template/app/src/features/notes/ with an index.tsx exporting the module and a small page (Skeleton while useQuery is undefined, EmptyState when empty):

export const notesFeature: FeatureModule = {
  id: "notes",
  title: "Notes",
  pages: [{ path: "/notes", component: NotesPage, nav: { label: "Notes", icon: "✎" } }],
};

Then register it in _template/app/src/features/index.tsx with marker-wrapped import and array entries, exactly like the existing features:

// @feature:notes -- start
import { notesFeature } from "./notes";
// @feature:notes -- end

// ...inside the features array:
// @feature:notes -- start
notesFeature,
// @feature:notes -- end

4. ONE manifest entry

Add the feature to FEATURES in tools/lib/features.mjs. The presence of a convex key = pool membership for that db; the array lists the whole files the feature owns:

// tools/lib/features.mjs
notes: {
  convex: ["src/features/notes", "convex/notes.ts"],
},

That single entry drives everything else: the feature pool, the wizard, pnpm new --list, the --list --json manifest, and file deletion when excluded. If notes ever needed another feature on, the dependency would go in FEATURE_IMPLIES (that is how mcp forces api-keys).

5. Prove the matrix still passes

pnpm test              # full 17-shape matrix
pnpm test -- --fast    # quicker: tsc only, skip vite builds

Expected: all 17 shapes scaffold, strip cleanly (no leaked tokens or markers), and typecheck. webapp-all now includes notes automatically because the default feature set is the whole pool. Run pnpm smoke <slug> too before committing template changes.

6. See the stripping work

pnpm new with-notes --type webapp --features notes
pnpm new without-notes --type webapp --features example
grep -r "notes" apps/without-notes/src/features apps/without-notes/convex || echo "clean"

Expected: with-notes has src/features/notes/ + convex/notes.ts and Notes in the nav; without-notes prints "clean" — no folder, no file, no marker residue, and pnpm new --list now shows notes in the feature pool.

What you built

  • A first-class optional feature: marker blocks in the template + one FEATURES entry, nothing else
  • Proof at both levels: the 17-shape matrix stays green, and a with/without scaffold pair shows clean inclusion and clean stripping
  • The exact recipe for any future feature — including supabase support (add a supabase key with that template's owned files)