The module contract
Conformance with this contract is what makes something a ConvexCompose module. The contract is opinionated about what a module is and silent about how you consume it. The seminal asymmetry: be opinionated about the module, unopinionated about the host. Every MUST below tightens the module's shape; every MUST NOT protects the host's freedom.
Agnostic core, optional reference UI
Two things are held apart inside every module and must never be collapsed.
- The agnostic core. What a module is: a Convex Component plus headless React logic plus a manifest. It carries the invariant business meaning of a domain and assumes no framework, router, styling engine, or platform. It is the thing that ports, and it is what you always get. This is the open ecosystem, ConvexCompose, for any Convex developer on any stack.
- The optional reference UI. Each module ships its own OPTIONAL reference UI: one fully worked, batteries-included way to consume the core, offered as a convenience, never as a requirement. If you want the paved road, drop it in. If not, build your own surface on the same core. There is no single reference app; any consumer is yours to build.
The core maximizes reach; the optional reference UI maximizes convenience for the subset who want the paved road. Neither contaminates the other.
The three-layer model
Every module is structured in three layers, ordered most-portable to most-consumer-specific. You only write the top one.
MUST
- Provide a Convex Component (Layer 1). Ship convex.config.ts, schema, and functions as an isolated, app.use()-installable Component. This is the Convex-native unit of value, with isolation and transactional safety by construction.
- Provide headless React hooks (Layer 2). Expose useX() hooks over Convex hooks. They MUST NOT import a router; navigation MUST be expressed via callbacks the consumer supplies; they MUST NOT assume any styling system.
- Provide a manifest (portal.config.ts). Declare the module and its entity-link points so a host registry can discover and wire it.
- Link entities across modules by explicit ID passed in. A cross-module relationship MUST be expressed by passing a canonical ID (for example the Better Auth userId) into a Component as an explicit argument, never by a cross-Component table join (Convex Components forbid those by design).
Carve-out that keeps M4 idiomatic: deriving identity from ctx for authorization (via requireAuth / requireRole) inside a handler is allowed and expected. M4 governs cross-module linking only. A conforming handler both authenticates its caller from ctx and accepts the canonical foreign-key ID as an explicit argument. The rule of thumb: authorization comes from ctx; cross-module links come from a passed-in ID.
MUST NOT
- Import a router. No router dependency anywhere in Layer 1 or Layer 2.
- Assume a styling system. No hard dependency on Tailwind, NativeWind, or shadcn in the core.
- Assume a platform. No web-only or mobile-only assumption; Layer 2 must run under any React renderer.
OPTIONAL (clearly labeled)
- A reference UI package. For example a React Native plus NativeWind kit shipped alongside a module. It is a drop-in Layer 3 surface for consumers who want one, and is fully ignorable. Omitting it costs the consumer nothing but the convenience.
Contract-to-layer map
| Contract element | Layer | Status |
|---|---|---|
| Convex Component (convex.config.ts + schema + functions) | Layer 1 | MUST |
| Headless useX() hooks, router-free, style-free, callback navigation | Layer 2 | MUST |
| portal.config.ts manifest with entity-link points | cross-cutting | MUST |
| Cross-module linking by explicit passed-in ID (no cross-Component joins) | Layer 1 boundary | MUST |
| Identity from ctx for authorization (requireAuth / requireRole) | Layer 1 handler | ALLOWED |
| Router import / styling assumption / platform assumption | any | MUST NOT |
| Reference UI package (RN + NativeWind kit) | Layer 3 | OPTIONAL |
Why this is the appeal
Because the module forces no stack on anyone, two very different developers are fully served by the same module. A web-only Next plus shadcn developer installs the Component and the headless hooks and writes their own UI in roughly five-line route wrappers. A cross-platform developer pulls the full reference kit and gets a working surface out of the box. Forcing the full React Native stack on everyone would collapse the addressable audience to the minority building cross-platform, when most are web-only. The agnostic core keeps reach broad; the opinionated reference keeps the road paved. They are the two ends of one contract.
An extension, not a competitor
ConvexCompose builds with and on top of Convex.dev Components (the get-convex project, not the unrelated convex.com CRM). It is the "second wave" Convex's own Stack writing predicted: business-domain components above the infra primitives (rate limiter, aggregate, agent). It is not a rival to Convex's Chef app-builder or to Templates.
Ready to build to the contract? See a module installed and used, then run the checklist.