This is a SaaS template for Cloudflare Workers. It uses Vinext on Vite to run a Next.js App Router application directly on Cloudflare Workers.
Vinext is Cloudflare's experimental implementation of the public Next.js API surface on top of Vite. The goal is to let a Next.js app keep familiar App Router patterns, React Server Components, route handlers, and next/* imports while using Vite as the build and dev toolchain instead of the standard Next.js compiler pipeline.
For this template, Vinext is the runtime and deployment path for Cloudflare Workers. pnpm dev starts the Vinext development server, pnpm build produces the Vinext/Vite production output, and pnpm deploy uses vinext-cloudflare deploy to build and deploy the Worker. Vinext has first-class Cloudflare Workers support, including access to bindings such as D1, KV, R2, Images, Durable Objects, and AI through cloudflare:workers.
Vinext is not a fork of Next.js and is not affiliated with Vercel. It is still experimental, so framework-sensitive changes should be verified with pnpm run check:vinext, pnpm run typecheck, pnpm run lint, and pnpm run build.
Tip
This template is brought to you by 👉 Aski.Chat 👈 - AI customer support agents that answer visitors, capture leads, and surface customer intelligence from every conversation.
- Website-trained AI agents for support and sales
- Lead capture and qualification from real conversations
- Learn what customers keep asking, then improve your docs, FAQs, and product
Turn every visitor question into a faster answer and a sharper product signal.
- 🔐 Authentication with Lucia Auth
- 📧 Email/Password Sign In
- 📝 Email/Password Sign Up
- 🔑 WebAuthn/Passkey Authentication
- 🌐 Google OAuth/SSO Integration
- 🔄 Forgot Password Flow
- 🔒 Change Password
- ✉️ Email Verification
- 🗝️ Session Management with Cloudflare KV
- 🤖 Turnstile Captcha Integration
- ⚡ Rate Limiting for Auth Endpoints
- 🛡️ Protected Routes and Layouts
- 📋 Session Listing and Management
- 💾 Database with Drizzle and Cloudflare D1
- 🏗️ Type-safe Database Operations
- 🔄 Automatic Migration Generation
- 💻 SQLite for Local Development
- ⚡ Efficient Data Fetching
- 🔍 Type-safe Queries
- 📨 Email Service with Cloudflare Email Service
- 🎨 Beautiful Email Templates
- 👀 Email Preview Mode
- 🔧 Local Email Development Server
- 📬 Transactional Emails
- ✉️ Email Verification Flow
- 📱 Responsive Email Templates
- 🚀 Deployment with Github Actions
- ⚙️ Automatic Deployments
- 🔐 Environment Variables Management
- 📦 Database Migrations
- 🔄 Comprehensive CI/CD Pipeline
- 🧹 Cache Purging
- ✅ Type Checking
- 🧪 Integration Tests
- 🧪 End-to-end Tests
- 📏 Deploy Size Tracking
- 🎨 Modern UI
- 🎨 Tailwind CSS
- 🧩 Shadcn UI Components
- 🌓 Dark/Light Mode
- 📱 Responsive Design
- ⚡ Loading States and Animations
- 🔔 Toast Notifications
- ⚙️ Settings Dashboard
- 🏠 Landing Page
- ✨ Beautiful Email Templates
- 👤 Profile Settings Page
- 🎯 Form Validation States
- 💳 Team Subscription Billing
- 🧩 Per-team plans (Free / Pro / Enterprise) defined in code
- 💳 Embedded Stripe Elements (Payment Element) for checkout — no redirect to hosted pages
- 🔔 Webhook-driven subscription lifecycle (Stripe is the source of truth)
- 🔁 In-app plan changes and cancellation
- 🧾 Stripe Customer Portal for payment methods, invoices, and billing details
- 🔒 Plan-based entitlements and feature gating (e.g. seat limits)
- 🛠️ One-command Stripe setup (
pnpm stripe:setup)
- 🤖 AI-agent platform (public API, OAuth, MCP)
- 🌐 Versioned public REST API built with Hono, reusing the app's own service layer
- 📘 OpenAPI 3.1 document generated from the same Valibot schemas the server validates with
- 📖 Server-rendered API reference with client-side search — no spec-viewer bundle — plus authentication and error-code guides
- 🔑 API keys for users and teams, with scopes, optional expiry, and one-time secret display
- 🪪 OAuth 2.1 authorization server (PKCE, dynamic client registration, consent screen)
- 🔌 Remote MCP server whose tools are derived from the OpenAPI document — no hand-written tool list
- 🧭 Connect guides for Claude Code, claude.ai, ChatGPT, Cursor, VS Code, Codex, Antigravity, and Grok CLI
- 🧹 Self-service revocation from Settings → API & MCP, plus an admin view of registered OAuth apps
- 👑 Admin Dashboard
- 👥 User Management
- 🪪 OAuth app registry with verification and revocation
- 📝 Content Management System
- 🗂️ Config-driven collections for blog and docs content
- ✍️ Rich TipTap editor with markdown paste, markdown copy, tables, code highlighting, and alert blocks
- 🧭 Docs navigation builder with managed public URLs
- 🖼️ Media library with R2-backed image uploads, alt text editing, and featured images
- 🏷️ Tags and categories with entry usage tracking
- 🕒 Draft, published, archived, and scheduled entry workflows
- 🧾 Version history for CMS entries
- ⚡ KV-backed CMS entry caching and cache maintenance actions
- 🔍 Full-text docs search
- 🤖 AI-assisted SEO description generation
- 🧱 Blog, docs, sitemap.xml, and JSON-LD schema rendering
- 📄 Markdown views for public pages through the
.mdURL suffix, orAccept: text/markdownon the page URL - 🤖
llms.txtindex for the API specification, MCP endpoint, and docs tree
- ✨ Validations with Valibot and React Hook Form
- 🛡️ Type-safe Form Validations
- 🔒 Server-side Validations
- 🔍 Client-side Validations
- 🧹 Input Sanitization
- ⚡ Real-time Validation
- 🔄 Form State Management
- 👨💻 Developer Experience
- 🧪 Local Development Setup
- 📘 TypeScript Support
- 🔍 Oxlint Configuration
- 🧪 Co-located Vitest Unit Tests
- 🧪 Cloudflare Workers Vitest Integration Tests
- 🧪 Vitest and Playwright E2E Tests
- ⚡ Vinext and Vite Build Pipeline
- 🔐 Type-safe Environment Variables
- 🏗️ Cloudflare Types Generation
- 🤖 AI-powered Development with AI Agents
- 📚 Comprehensive Documentation
- 📐 Project Structure Best Practices
- ⚡ Edge Computing
- 🌍 Global Deployment with Cloudflare Workers
- 🚀 Zero Cold Starts
- 💨 Edge Caching
- ⚛️ React Server Components
- 🖥️ Server-side Rendering
- 💾 Edge Database with D1
- 🗄️ Session Storage with KV
- 🖼️ Cloudflare Images-powered Image Optimization
- ⚡ API Rate Limiting
- 🏢 Multi-tenancy Support
- 👥 Organization Management
- 👤 User Roles and Permissions
- 🔍 Tenant Isolation
- 🔄 Resource Sharing Controls
- 📊 Per-tenant Analytics
- 🔐 Tenant-specific Configurations
- 💼 Team Collaboration Features
- 🌐 Internationalization (i18n) on
use-intland an in-repo routing layer- 🔗 Localized URLs: the default locale is served bare, every other locale is prefixed
- 🍪 Locale from the URL prefix, then the cookie, then
Accept-Language - 🔀 Locale switcher in the footer
- 🗂️ JSON message catalogs (English and Spanish included)
- 🔒 Type-safe message keys
The template owns its i18n layer. use-intl
supplies the ICU translator and the React hooks; everything around it — the locale
routing, the middleware, and the server API — lives in src/i18n/.
Every page lives under src/app/[locale]/, so the URL carries the locale. Prefixing
is "as needed": the default locale is served at the bare path (/blog) and every
other locale is prefixed (/es/blog). src/proxy.ts resolves the locale once per
request — URL prefix, then the selected_locale cookie, then Accept-Language, then
the default — and rewrites or redirects from that one answer. It only reads the cookie:
only an explicit choice writes it, and src/i18n/locale-cookie.ts names the writers.
Module map:
| Module | What it owns |
|---|---|
config.ts |
Locale list, default locale, served set, cookie name, switcher labels |
resolve-locale.ts |
The one answer to "what locale is this request" |
middleware.ts |
The pure locale route (decideLocaleRoute); src/proxy.ts is its adapter |
localized-pathname.ts |
The one answer to "which URL serves this path in this locale" |
navigation.ts |
Link, usePathname, useRouter, getPathname, redirect, permanentRedirect |
server.ts |
getLocale and getTranslations for the current App Router request |
client.ts / provider.tsx |
useTranslations/useLocale/useFormatter, and the provider above them |
translator.ts |
getTranslator, the locale-explicit translator that needs no request scope |
locale.ts / locale-actions.ts |
The user's own locale, and the action that stores it |
locale-cookie.client.ts |
enterLocale: writes the locale cookie, then loads the page under the new locale |
post-auth-target.ts |
Where a sign-in lands, and which locale cookie it writes |
new-account-locale.ts |
The locale a new account stores: the locale of the sign-up page |
messages/<locale>.json |
One message catalog per locale |
Which import to reach for:
- Client components:
useTranslationsfrom@/i18n/client. - Server components, metadata, and server actions:
getTranslationsfrom@/i18n/server. - Shared
src/lib/**andsrc/utils/**, the API, and MCP:getTranslatorfrom@/i18n/translator. Those run outside the App Router, whereheaders()throws. - Links and redirects:
@/i18n/navigation, nevernext/navigation.
To add a locale (e.g. French):
- Add
"fr"toLOCALESand afrentry toLOCALE_LABELSandLOCALE_OG_MAPinsrc/i18n/config.ts. - Add an
frloader toCATALOG_LOADERSinsrc/i18n/message-catalogs.ts. - Create
src/i18n/messages/fr.jsonwith every key inen.json; nothing merges the default catalog in at runtime.
messages/en.json is the source of truth for type-safe keys, through the augmentation
in src/i18n/use-intl.d.ts.
- Update Meta SEO tags 🔍
- Dynamic OpenGraph images 📸
- Notifications 🔔
- Webhooks 🔗
pnpm installpnpx wrangler login- Login to your Cloudflare account to use Cloudflare bindings while testing locally.- Copy
.env.exampleto.envand fill in the values. pnpm db:migrate:dev- Creates a local SQLite database and applies migrationspnpm db:seed- Seeds the database with test datapnpm dev- Starts the Vinext development server- Go to http://localhost:3000/sign-in and login with the test user credentials: test@test.com / password
- Go to http://localhost:3000/admin to manage users and the CMS.
Billing is per-team: each team owns a single Stripe subscription to a plan (Free / Pro / Enterprise). Plans are defined in code in src/constants/plans.json and consumed by both the app and the setup script, so Stripe and code never drift. Payment collection uses embedded Stripe Elements (the Payment Element), and plan changes/cancellation are handled by in-app server actions. The hosted Stripe Customer Portal complements this for self-service payment method updates, invoice history, and billing details — the billing page shows a "Manage billing" button once the team has a Stripe customer.
The subscription lifecycle is webhook-driven: Stripe is the source of truth and the DB is a cache updated from webhook events. The app never mutates subscription state from the client's confirmPayment result alone.
Billing is enabled only when STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY are all set (see isBillingEnabled() in src/flags.ts); otherwise the billing UI is hidden and the webhook route no-ops.
Happy path:
- Create a Stripe account and copy
sk_test_.../pk_test_...into.env(STRIPE_SECRET_KEY,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY). - Run
pnpm stripe:setup— this idempotently creates the products + recurring prices for the paid plans and, by default, upserts the resolvedSTRIPE_PRICE_PRO/STRIPE_PRICE_ENTERPRISEvalues into.env(replacing existing keys in place, never duplicating). When yearly billing is enabled (see below) it also creates the yearly prices and writesSTRIPE_PRICE_PRO_YEAR/STRIPE_PRICE_ENTERPRISE_YEAR. It refuses to run against a live key unless you pass--live. Use--dry-runto preview without changing anything, or--no-writeto create the Stripe resources but only print the values instead of touching.env. - Local webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook, then copy the printedwhsec_...into.envasSTRIPE_WEBHOOK_SECRET. - Production:
pnpm stripe:setup --with-webhook https://<domain>/api/stripe/webhook, then set the secrets viawrangler secret put STRIPE_SECRET_KEYandwrangler secret put STRIPE_WEBHOOK_SECRET. - Manual Dashboard alternative: create the Products + recurring Prices yourself, add a webhook endpoint for the subscription/invoice events to
/api/stripe/webhook, and copy the price IDs + signing secret into your env. - Customer Portal:
pnpm stripe:setupalso provisions a portal configuration (written to.envasSTRIPE_PORTAL_CONFIG_ID) that enables payment method updates, invoice history, and billing-details editing — including business name, address, and VAT/tax IDs, which Stripe prints on invoices automatically. Plan changes and cancellation are deliberately disabled in the portal because they're handled by in-app server actions; keeping a single code path avoids conflicting subscription state. IfSTRIPE_PORTAL_CONFIG_IDis unset, sessions fall back to the account's default portal configuration (Settings → Billing → Customer portal).
The billing page lives at /dashboard/teams/[teamSlug]/billing (the generic /dashboard/billing nav item redirects there for the selected team).
Yearly billing: set yearlyDiscountPercent in src/constants/plans.json (e.g. 20) to offer every monthly paid plan as a yearly subscription at that percentage off — the yearly amount is derived as monthly × 12 × (100 − discount) / 100, the billing page gains a Monthly/Yearly toggle with a "Save X%" badge, and existing subscribers can switch interval in place (prorated by Stripe). Re-run pnpm stripe:setup after changing it so the yearly prices exist. Remove the key to keep billing monthly-only.
Entitlements & downgrades: feature access is derived from the team's plan via getTeamEntitlements (src/utils/entitlements.ts). Plan limits (e.g. seats) are enforced only at grow points such as inviting members — a team that drops to a lower plan keeps its existing members and is never auto-evicted.
The template ships a public machine surface next to the web app: a versioned REST API, an OpenAPI document, an OAuth 2.1 authorization server, and a remote MCP server. They are one pipeline rather than four features — an endpoint is described once and shows up in the reference, in openapi.json, and as an agent tool.
Public REST API. A Hono app mounted at /api/v1 (src/api/). Handlers call the same src/lib/** service functions the app's server actions use, so business rules exist in exactly one place. Requests are authenticated by a bearer credential, rate limited per credential by the app's shared KV limiter (RATE_LIMITS.API_AUTHED / RATE_LIMITS.API_ANON in src/utils/with-rate-limit.ts), and failures come back as RFC 9457 problem documents whose code member is a stable, untranslated identifier.
OpenAPI + docs. GET /api/v1/openapi.json is generated from the Valibot request/response schemas in src/schemas/api/ and the describeRoute metadata on each route. The reference at /docs/api is our own: every operation, field, example, and curl snippet is rendered on the server from that document, and the only client JavaScript is an instant filter over the already-rendered endpoints. /docs/authentication, /docs/api/errors, and /docs/mcp cover credentials, error codes, and agent setup. Add .md to a public page URL to get Markdown, or send Accept: text/markdown to the page URL and the Worker redirects there. /llms.txt points agents at the spec, MCP endpoint, and docs tree. How a fork extends the API and MCP surface is repo documentation, not a public page: docs/extending-api-and-mcp.md.
API keys. Users create keys under Settings → API & MCP, teams under team settings (gated by the MANAGE_API_KEYS team permission). A key carries scopes and an optional expiry, its secret is displayed once, and only a hash plus the last characters are stored. Keys are cached in KV for a few minutes with the same version/TTL discipline as sessions, so revocation is immediate locally and propagates in about a minute. The prefixes live at the top of src/constants.ts — rebrand them when forking, because secret scanners attribute a leaked key to whoever owns the prefix.
OAuth 2.1 provider. @cloudflare/workers-oauth-provider wraps the Worker in worker-entrypoint.ts. It serves the discovery documents, the token endpoint, Client ID Metadata Document identity, and (when OAUTH_OPEN_DCR_ENABLED is on) dynamic client registration; the consent screen at /oauth/authorize is our own page. Stable CIMD or operator-issued client identities are preferred for trusted integrations. DCR remains the compatibility fallback: every generated client ID is kept distinct, shown as unverified, and clamped to DCR_ALLOWED_SCOPES until an admin verifies that exact registration at /admin/oauth-apps. Users see and revoke every connected app under Settings → API & MCP, alongside the agent-connection guide and their API keys.
MCP server. /mcp speaks Streamable HTTP and accepts both credential types. Tools are derived from the OpenAPI document at request time (src/mcp/derive-tools.ts) and filtered by the caller's scopes, so tools/list never advertises something the credential could not call. Tool calls dispatch in-process into the Hono app — no self-fetch.
Connecting an agent. Hosted assistants (claude.ai, ChatGPT) connect over OAuth: paste the MCP URL and approve the consent screen. CLI and editor clients (Claude Code, Codex, Cursor, VS Code, Antigravity, Grok CLI) can do that too, or send an API key as an Authorization: Bearer header. Ready-made snippets for each client are rendered from src/constants/agent-clients.ts on the API-keys settings page and at /docs/mcp.
Extending it in your fork:
| File | What to change there |
|---|---|
src/api/index.ts |
registerCustomRoutes — mount your routers; they inherit auth, rate limiting, and problem+json errors |
src/schemas/api/ |
Valibot request/response schemas (these also type the derived agent tools) |
src/lib/api/scopes.ts |
The scope catalog and the ceiling for self-registered OAuth clients |
src/mcp/tool-overrides.ts |
Rename, re-describe, or hide a derived tool by operationId |
src/mcp/index.ts |
registerCustomTools — agent tools with no REST equivalent |
src/constants/agent-clients.ts |
The agent-client registry behind every setup snippet |
No extra secrets are needed: the provider generates and stores its own key material. The Cloudflare-side additions are covered under Changes to wrangler.jsonc.
E2E tests use Vitest with Playwright-driven Chromium pages and run against the production-style local Worker preview.
After cloning the project, install the Playwright Chromium browser once on your machine:
pnpm exec playwright install chromiumThis browser binary is not downloaded automatically by pnpm install. It is kept outside the repo in Playwright's local browser cache, so you normally only need to run the install command after a fresh machine setup, a new Playwright version, or a cleared Playwright cache.
Run the E2E suite with:
pnpm run test:e2eThe E2E runner stores its temporary files under tmp/e2e, creates a fresh local Wrangler/D1 state under tmp/e2e/wrangler-state, applies all D1 migrations, runs src/db/seed.sql, builds the app, starts Wrangler preview on that isolated state, then runs the browser tests against the preview Worker. If the existing dist output matches the current build input fingerprint, the runner reuses that fresh Vinext build instead of rebuilding. The build and D1 setup run in parallel, and Vitest runs test files in parallel with isolated Playwright browser contexts. This keeps E2E data separate from your normal local .wrangler development state.
VS Code Vitest Explorer is configured through .vscode/settings.json to use vitest.e2e.config.ts. Running an individual E2E test from the editor uses Vitest global setup to create the same isolated Wrangler/D1 state and preview Worker before the selected test starts.
In CI, install Chromium before running the suite:
pnpm exec playwright install --with-deps chromium
pnpm run test:e2e| Command | Purpose |
|---|---|
pnpm dev |
Start the Vinext development server |
pnpm build |
Build the app with Vinext and Vite |
pnpm start |
Start the local Vinext production server |
pnpm preview |
Build, then preview the Worker locally with Wrangler |
pnpm run test:unit |
Run co-located Vitest unit tests |
pnpm run test:integration |
Run Cloudflare Workers Vitest integration tests with local D1/KV/Queue bindings |
pnpm run test:e2e |
Run Playwright-driven E2E tests against a clean local Wrangler/D1 preview |
pnpm deploy |
Build and deploy with vinext-cloudflare deploy |
pnpm deploy:dryrun |
Build and run a Wrangler deploy dry run into worker-dist |
pnpm check:vinext |
Run the Vinext compatibility checker |
pnpm run lint |
Run Oxlint |
pnpm run typecheck |
Run TypeScript without emitting files |
pnpm run cf-typegen |
Regenerate Cloudflare Worker types |
After making a change to wrangler.jsonc, run pnpm run cf-typegen to regenerate worker-configuration.d.ts.
Cloudflare bindings are defined in wrangler.jsonc and exposed to server code through cloudflare:workers or the local helper in src/utils/cloudflare-context.ts. The custom Worker entry lives in worker-entrypoint.ts and is configured as the main entry in wrangler.jsonc.
Bindings worth knowing about when you fork:
D1_DB,KV_STORE, andR2_BUCKET— the app database, the shared KV namespace, and the media bucket. They were namedNEXT_TAG_CACHE_D1,NEXT_INC_CACHE_KV, andNEXT_INC_CACHE_R2_BUCKETbefore; rename them in your ownwrangler.jsoncwhen you merge this change. The resource ids do not change, so no data moves.OAUTH_KV— a second binding onto the same KV namespace asKV_STORE, not a new namespace.@cloudflare/workers-oauth-providerhardcodes the binding name, and its keys are prefix-scoped (seeOAUTH_RESERVED_KV_PREFIXESinsrc/constants.ts), so nothing collides with the app's own keys. Point it at your own namespace id together withKV_STORE.- The
global_fetch_strictly_publiccompatibility flag is required by the OAuth provider's client-metadata fetches; leave it in place.
- Go to
src/constants.tsand update it with your project details — includingAPI_KEY_PREFIX_LIVE/API_KEY_PREFIX_TEST, so leaked keys are attributed to you and not to the template - Update the
namefield inpackage.jsonto your project name so generated metrics and package metadata identify the reused template correctly - Update
AGENTS.mdwith your project specification so that AI coding agents can give you better suggestions - Update the footer in
src/components/footer.tsxwith your project details and links - Replace the logo — the template ships a placeholder mark (three cascading planes in the marketing amber). It appears in the header, the footer lockup, every generated OpenGraph card, and the favicon.
src/constants/logo.tsis the only source: swap the two gradient stops to rebrand it in place, or editLOGO_PLANE_PATHSto replace the artwork. Then runpnpm logo:generateto rewrite the three static copies,src/app/icon.svg,src/app/favicon.ico, andpublic/logo.svg.pnpm run test:unitfails while a copy is stale - Update the OpenGraph card colors in the
COLORSmap at the top ofsrc/lib/og/og-image.tsx. These are literal hex mirrors of the dark-theme tokens inglobals.css, duplicated because satori resolves neither CSS variables noroklch()— so restyling the app does not restyle the social cards, and they will keep shipping the template's amber until you change them here too - Optional: Update the color palette in
src/app/globals.css - Update the metadata in
src/utils/root-metadata.ts(authors,creator, the Twitter handle) with your project details — the root layout,src/app/[locale]/layout.tsx, reads it - Update
cms.config.tsif necessary
The source of truth for preparing and deploying this template to production is the repo-local AI agent skill at .agents/skills/prepare-cloudflare-production-deployment/SKILL.md.
That skill covers Cloudflare resource provisioning with Cloudflare MCP, GitHub Actions secrets and variables with the GitHub CLI, wrangler.jsonc binding updates, Worker secrets, Turnstile, Email Sending, and deployment verification.
This migration promotes previously non-unique indexes to unique ones. On a database with existing team data, run these read-only checks first — any returned rows must be deduplicated manually (keep the active/oldest row, repoint references) before applying the migration, or CREATE UNIQUE INDEX will fail mid-deploy:
-- Duplicate team memberships (same team + user more than once)
SELECT teamId, userId, COUNT(*) AS n FROM team_membership GROUP BY teamId, userId HAVING n > 1;
-- Duplicate role names within a team
SELECT teamId, name, COUNT(*) AS n FROM team_role GROUP BY teamId, name HAVING n > 1;Run them with wrangler d1 execute <DB_NAME> --remote --command "<query>".
The migration also deletes all pending (unaccepted) team invitations: invitation tokens are now stored hashed, so pre-existing plaintext-token invite links can no longer be redeemed. After deploying, ask team owners to resend any outstanding invitations.