Developer Docs
Build on top of Chavica.
Architecture, the complete API, and how templates work under the hood — everything in one place.
How it works
Architecture
Subdomain routing
middleware.ts inspects the incoming Host header. Registered subdomains and connected custom domains get rewritten to the published-site renderer; everything else falls through to the marketing site and dashboard.
Pages as JSON
Every page — and every template — stores its content as { html, css }. GrapeJS exports and imports directly from this format, so rendering a published page server-side is just interpolating two strings — no editor runtime needed.
Multi-page sites
A site can have many pages. Templates ship with pre-built sub-pages (not blank ones) stored on the template record, copied onto the new site the moment it's created.
Data model
User
├─ Site[] one user, many sites
├─ Domain[] custom domains owned by the user
├─ TeamMember[] teams the user owns or belongs to
├─ ApiKey[] API keys for the v1 API
└─ Subscription? current billing subscription (via Bachs)
Site
├─ user owner
├─ template? which Template it was created from (nullable)
├─ Page[] every page on the site (home + sub-pages)
├─ Domain[] custom domains connected to this site
├─ pageViews analytics events
└─ published whether it's live on its subdomain
Page
├─ site
├─ slug, title
├─ content Json { html, css } — GrapeJS output
└─ isHome Boolean — exactly one page per site is home
Template
├─ content Json { html, css } — the home page
├─ pages Json? [{ slug, title, content: { html, css } }, ...]
├─ isPremium gates FREE-plan users
└─ hidden soft-delete; hidden templates skip the pickerEditor → Save → Publish lifecycle
- 1
The editor loads all Pages for a site and opens the home page in GrapeJS.
- 2
Template CSS is injected directly into the canvas iframe's <head> on load — not via a data: URI <link>, since Chrome silently blocks those.
- 3
On save, the editor returns { html, css }, PATCHed to /api/sites/[siteId]/pages/[pageId].
- 4
Switching page tabs auto-saves the current page first, then loads the next page's content into the same GrapeJS instance.
- 5
Publishing sets Site.published = true. The editor shows a two-phase modal: a loading state while the request is in flight, then a live-URL confirmation once it resolves.
- 6
Visiting subdomain.chavica.com renders the home Page; subdomain.chavica.com/any-slug renders the matching non-home Page — both require published === true or they 404.
Billing & payments (Bachs)
All money flows through Bachs — chosen for cross-border collection (card, bank transfer, mobile money, crypto in NGN/USD + multi-currency). The flow is redirect-based, not a client-side popup, which changes where the business logic lives:
- 1
A route (billing/subscribe, icons/unlock, domains/purchase) creates a Bachs checkout with an amount and a metadata object tagging what's being purchased. Nothing is provisioned yet — it just returns a checkoutUrl.
- 2
The client redirects the whole browser to that hosted checkout page. There's no inline JS popup.
- 3
The webhook is the only reliable signal that payment succeeded — success_url/cancel_url redirects are UX only and can land back on Chavica before the webhook arrives.
- 4
/api/webhooks/bachs verifies the signature, checks a WebhookEvent table for idempotency (Bachs guarantees at-least-once delivery), then dispatches on metadata.type to actually upgrade the plan, unlock the icon pack, or register the domain.
- 5
The page that triggered checkout polls router.refresh() a few times after redirect, since the webhook may land a moment later.
Reference
Full API reference
Every route under app/api. Unless noted, routes require the chavica_token session cookie — getCurrentUser() returns 401 if it's missing.
Auth
/api/auth — sets/clears the chavica_token session cookie
/auth/registerCreate a user, sets session cookie/auth/loginVerify password, sets session cookie/auth/logoutClears session cookie/auth/meCurrent user info/auth/change-passwordChange password (requires current password)/auth/forgot-passwordSends a reset link/token/auth/reset-passwordValidate reset token / set new passwordSites & Pages
/api/sites — requires an authenticated session
/sitesList the current user's sites/sitesCreate a site. Body: { name, templateId?, subdomain? }/sites/[siteId]Fetch one site (must be owner)/sites/[siteId]Update name, favicon, metaTitle, metaDesc/sites/[siteId]Delete a site, cascades pages/domains/sites/[siteId]/analyticsPage view counts for the site/sites/[siteId]/publishSet published = true/sites/[siteId]/publishSet published = false/sites/[siteId]/pagesList pages on a site/sites/[siteId]/pagesCreate a blank page. Body: { slug, title }/sites/[siteId]/pages/[pageId]Fetch one page/sites/[siteId]/pages/[pageId]Save editor content. Body: { content: { html, css } }/sites/[siteId]/pages/[pageId]Delete a page (not the home page)Templates
/api/templates
/templatesList visible templates (hidden: false) for the pickerDomains
/api/domains
/domains/search?q=Check domain availability/domains/purchaseCreate a Bachs checkout for a domain, returns { checkoutUrl }/domains/[domainId]/connectConnect a purchased domain to a site/resolve-domain?host=Maps an incoming Host header to a Site (used by middleware)Billing
/api/billing — Bachs-backed, cross-border
/billing/subscribeCreate a Bachs checkout for a plan, returns { checkoutUrl }/billing/cancelCancel the active subscription (local-only)/billing/white-labelUpdate brandName, brandLogo, brandColorTeam & API Keys
/api/team and /api/api-keys
/teamList team members the user owns or belongs to/teamInvite a team member by email/team/[memberId]Remove a team member/api-keysList the user's API keys/api-keysIssue a new API key/api-keys/[keyId]Revoke a keyPublic v1 API
/api/v1 — authenticate with an API key, not a session cookie
/v1/sitesList / create sites programmatically/v1/sites/[siteId]Fetch one site/v1/sites/[siteId]Update one site/v1/sites/[siteId]Delete one site/v1/analyticsAnalytics for the key owner's sitesIcon Packs
/api/icons
/icons/statusWhich icon packs the user has unlocked/icons/unlockCreate a Bachs checkout for a premium icon pack, returns { checkoutUrl }Misc
/uploadCloudinary image upload, used inside the editor/contact/[siteId]Public contact form submission for a published site/webhooks/bachsVerifies X-Bachs-Signature, handles collection.succeeded/failed/abandoned/underpaid — source of truth for billingAdmin
/api/admin — requires role ADMIN or SUPER_ADMIN
/admin/sites/[siteId]Force-edit or delete any user's site/admin/users/[userId]Edit role/plan or delete any user/admin/templatesList all templates (incl. hidden) / create a new one/admin/templates/[templateId]Empty body toggles hidden; full body updates any field incl. pages/admin/templates/[templateId]Permanently delete a templateTemplates
How templates are built
Every template is a { html, css } home page plus a pages array of pre-built sub-pages — never blank scaffolding. The 9 shipped templates:
/about, /contact/menu, /reservations/contact/work, /about, /contact/shop, /lookbook, /contact/articles, /about, /newsletter/about, /contact/about, /contactThree legacy templates (Portfolio, SaaS Landing Page, Restaurant) exist in the database using an old GrapeJS content format the current renderer can't read — they're permanently hidden and replaced by Mono, Nebula, and Bella Vista above.
Seeding scripts
# Creates/updates each template's home page
npx tsx prisma/seed.ts
# Builds and attaches every pre-built sub-page
npx tsx prisma/seed-pages.ts
Adding a new template
- 1
Add it to prisma/seed.ts with a unique id, name, category, content, and isPremium flag, then run npx tsx prisma/seed.ts.
- 2
If it needs sub-pages, add a block to prisma/seed-pages.ts following the existing pattern, then run npx tsx prisma/seed-pages.ts.
- 3
Or skip code entirely: open the admin panel's Templates tab and use "Manage Pages" on any template to add/edit/remove sub-pages through a UI.
Good to know
Template previews
Template card thumbnails and the full-screen preview modal both render the actual template HTML/CSS in a sandboxed iframe (the card scales it to 0.3x) — what you see is exactly what you get, not a static screenshot. Both inject a script that intercepts every click on a link or submit button, so clicking inside a preview never navigates away from the page you're on.
