An opiniated SaaS starter with auth, workspace/orgs, billing, emails, etc. Perfect for your next project without rebuilding the boring SaaS infrastructure!
- WorkOS authentication - SSO, SAML, magic links
- Convex backend - Queries, mutations, actions, HTTP routes, cron jobs
- Workspace-based tenancy - Role-based access control (owner, admin, member)
- Polar billing - Subscription webhooks, plan management
- Resend emails - Transactional invites, bounce handling, suppression
- Entitlement model - Plan/features/limits with backend + UI gating
- Cloudflare R2 storage - Workspace file upload/download with presigned URLs and deletion reconciliation
- User avatar uploads - Profile picture management backed by R2 with WorkOS fallback
- Onboarding flow - Welcome dialog for first-time users
This template is intentionally opinionated. The goal is to give you a reliable starting point with clear backend rules, consistent deletion behavior, and predictable feature-gating patterns.
# 1. Install dependencies
bun install
# 2. Configure environment variables (see below)
# 3. Start development
bun run devFrontend
-
React 19, Vite, TanStack Router, TanStack Form
-
Tailwind CSS v4, shadcn/ui components
-
next-themesfor dark/light theme management -
@base-ui/reactfor headless UI primitives Backend -
Convex (DB + server functions + HTTP routes + cron jobs)
Integrations
- Auth: WorkOS +
@convex-dev/workos-authkit - Billing: Polar (
@polar-sh/sdk) - Email: Resend (transactional emails + invite flows)
- File Storage: Cloudflare R2
.
├── package.json # workspace-level scripts
├── apps/
│ ├── web/ # frontend workspace
│ └── backend/ # Convex backend workspace
│ └── convex/
└── packages/
├── shared/ # cross-runtime shared types/utilities (errors)
└── convex-api/ # re-exported Convex generated API/types for frontendbun installFrontend (apps/web/.env.local):
VITE_CONVEX_URLVITE_WORKOS_CLIENT_IDVITE_WORKOS_REDIRECT_URI
Backend (apps/backend/.env.local or Convex runtime environment):
CONVEX_DEPLOYMENTWORKOS_CLIENT_IDWORKOS_API_KEYPOLAR_ORGANIZATION_TOKENPOLAR_WEBHOOK_SECRETPOLAR_PRO_MONTHLY_PRODUCT_IDPOLAR_PRO_YEARLY_PRODUCT_IDPOLAR_SERVER(sandboxorproduction, defaults tosandbox)APP_ENV(devorprod, defaults todev)APP_ORIGIN(required, used for billing return URLs)CONVEX_LOG_LEVEL(debug|info|warn|error, defaults toinfo)
Resend (for invite emails and webhooks):
RESEND_API_KEY(required for sending invites)RESEND_WEBHOOK_SECRET(required for webhook verification)RESEND_FROM_EMAIL(required, e.g.Acme <invites@acme.com>)
Cloudflare R2 (for file storage and avatar uploads):
R2_BUCKETR2_ENDPOINTR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEY
bun run devbun run check # lint + typecheck + format
bun run generate # regenerate Convex schema/api typesDev data tooling lives in apps/backend/convex/dev/index.ts and is hard-blocked unless APP_ENV=dev.
bun run dev:seed-data # Create/update deterministic demo workspaces/users/billing state
bun run dev:reset-data # Clear workspace + billing + invite data (preserves users)
bun run dev:reseed-data # Reset then seed in one commanddev:reset-datarequires an explicit confirmation token in the script (RESET_DEV_DATA).dev:reset-datapreserves users by default because auth is provider-backed.- For a full wipe, include users explicitly:
bunx convex run dev/index.js:resetDevData '{"confirm":"RESET_DEV_DATA","includeUsers":true}'- Never run these with
--prod. Even if attempted, functions are blocked unlessAPP_ENV=dev.
- Tenancy unit is a
workspace. - Membership is explicit in
workspaceMemberswith roles:owner,admin,member. - Access checks are centralized in backend helpers:
getWorkspaceMembership(...)for membership requirementrequireWorkspaceAdminOrOwner(...)for elevated role requirement
Why this choice: it prevents UI-only authorization mistakes and keeps sensitive checks server-side.
User deletion uses tombstones (not immediate hard delete):
Deletion flow:
deleteAccountvalidates ownership/billing constraints.- Memberships and pending invites are cleaned up.
- WorkOS delete is enqueued via Workpool.
- User transitions to
deletingwith retry metadata. - Completion handler marks user
deletedand removes PII. - Daily cron purges deleted user tombstones after retention.
Why this choice: deletion stays reliable, retryable, and auditable without blocking request/response paths.
Workspace deletion uses tombstones (not immediate hard delete):
status = 'deleted'deletedAt,purgeAt,deletedByUserId- memberships, invites, and contacts are removed immediately at tombstone time
- daily cron purges workspace tombstones after retention
Deletion is blocked if workspace billing is still billable (trialing/active/past_due).
Why this choice: it matches the user lifecycle approach and gives safer operational behavior.
workspaceBillingStateis the source of truth for a workspace's billing state.- Polar webhook endpoint:
POST /billing/polar/events. - All user billing changes are made through Polar's portal. It is synced with the app via webhooks.
- Webhook handling is idempotent using
billingEvents.providerEventId. - Out-of-order webhook updates are ignored using
providerSubscriptionUpdatedAt. - Plan mapping is internalized through product IDs:
free(no Polar product)pro_monthlypro_yearly
Why this choice: provider events are normalized first, so feature checks always run against internal state.
Entitlements are derived from billing state + usage:
- plan key (
free/pro_monthly/pro_yearly) - features (
team_members) - limits (
members,invites) - lifecycle (
status,isLocked, grace period)
Important behavior:
past_duehas a grace period.- during grace, effective lifecycle stays usable.
- after grace, workspace is locked for gated flows.
Why this choice: feature logic should not depend directly on raw billing provider status. Everything goes through entitlements.
Invites are designed to be safe, idempotent, and easy to reason about:
- Only
ownerandadmincan create/revoke invites. - Admins can invite
memberonly (notadmin). - Inviting yourself is blocked.
- Invite links expire after 7 days.
- If there is already an active pending invite for the same workspace + email, the invite is refreshed (resend behavior) instead of creating a duplicate active invite.
- Historical invite rows are preserved for audit/history (accepted/revoked/expired invites are not hard-deleted as part of normal flow).
- Acceptance is validated server-side for token state, expiry, email/account match, membership status, and active workspace state.
- Invite creation/acceptance is also gated by entitlements (
team_members, member limits, and workspace lock state). - Invite creation is blocked for suppressed email addresses (bounce or spam complaint).
Why this choice: invite logic needs to be strict on the backend so links cannot bypass role, billing, or identity rules.
Transactional emails are handled via Resend with proper webhook validation:
- Resend webhook endpoint:
POST /emails/resend/events. - Bounce (
email.bounced) and spam complaint (email.complained) events create/update suppression rows. - Suppressed emails are automatically prevented from receiving future invite emails.
- Invite email sending is wrapped in entitlement checks and workspace lock validation.
- Resend component data is cleaned daily via cron (
cleanupOldEmails,cleanupAbandonedEmails).
Why this choice: Resend provides reliable transactional email delivery with built-in bounce/complaint handling, ensuring invite flows remain safe and spam-free.
Errors are standardized with shared codes and categories in shared/errors.ts.
- Backend throws structured
ConvexErrorpayloads viathrowAppErrorForConvex(...). - Frontend parses with
parseAppError(...). - Mutation/action hooks return
Result<T, AppErrorData>(neverthrow) to keep UI handling explicit.
Why this choice: you get consistent backend/frontend behavior and safer user-facing messaging.
- Public auth routes: sign-in/callback.
- App routes: wrapped in
UserProviderand protected. - Invite routes: authenticated and validated against invite token + signed-in user.
Why this choice: access stays protected even when users know the URL.
- Backend logs are centralized through
convex/logging.tsvialogger.debug/info/warn/error. - All backend logs are emitted as JSON strings to
console.*, so they appear in Convex deployment logs. - Log level is controlled by
CONVEX_LOG_LEVEL.
Where to look:
- Open Convex Dashboard -> Deployment -> Logs.
- Filter by
eventname (example:billing.webhook.handled,auth.user.delete_requested). - If debugging a user-facing exception, copy the Convex request ID (
[Request ID: ...]) and search by request ID in Logs.
Notes:
- Convex dashboard logs are a realtime/short-history view.
- For long-term retention and bulk export, configure Convex log streams.
workspaceFilesis the source of truth for stored files.- Files are stored in R2 with presigned upload and download URLs.
- Upload records are tracked with expiration; incomplete uploads are cleaned up via cron.
- Failed R2 deletions are queued in
r2DeleteQueuefor reconciliation. - See section 13 for the related cron schedule.
Why this choice: R2 provides cost-effective object storage without egress fees. Presigned URLs keep credentials server-side while giving the client direct upload/download access.
Distributed rate limiting is applied via @convex-dev/rate-limiter to protect write paths:
createWorkspaceByUser— workspace creationcreateInviteByUser— invite creationacceptInviteByUser— invite acceptancemutateContactsByActor— contact mutations
Why this choice: Convex mutations run on a shared runtime, so server-side rate limits prevent abuse without requiring a separate infrastructure layer.
The following scheduled jobs run automatically. Operators should be aware of what runs and when:
| Job | Schedule (UTC) |
|---|---|
| Reconcile stuck user deletions | Daily 2:30 AM |
| Purge deleted user tombstones | Daily 3:00 AM |
| Purge deleted workspace tombstones | Daily 3:30 AM |
| Cleanup Resend email component data | Daily 4:00 AM |
| Cleanup expired workspace file uploads | Daily 4:00 AM |
| Cleanup expired avatar uploads | Daily 4:30 AM |
| Reconcile failed R2 deletes | Daily 5:00 AM |
This template includes a minimal Contacts CRUD example you can keep or delete per project.
- Route:
/w/$workspaceKey/contacts - Backend:
convex/contacts/index.ts - Table:
contactsinconvex/schema.ts - UI page:
src/routes/_app/w/$workspaceKey/contacts.tsx
What it demonstrates:
- TanStack Form validation (
namerequired, optional valid email) - Convex CRUD flow (
listContacts,createContact,updateContact,deleteContact) - Workspace membership checks in backend handlers
- Data cleanup when a workspace is tombstoned or purged
If you do not need this starter pack in a new project, remove the route file, backend module, schema table, and navigation links.
This template includes a workspace file manager example you can keep or delete per project.
- Route:
/w/$workspaceKey/files - Backend:
convex/workspaceFiles/index.ts - Table:
workspaceFilesinconvex/schema.ts - UI page:
src/routes/_app/w/$workspaceKey/files.tsx
What it demonstrates:
- Drag-and-drop file upload (max 50MB) with presigned R2 URLs
- Signed download URLs with File System Access API save picker
- Per-workspace file listing and deletion
- R2 cleanup on workspace tombstone/purge
If you do not need this starter pack in a new project, remove the route file, backend module, schema table, and navigation links.