Configuration
The repo is easiest to maintain when each file owns one lane or one class of configuration.
File ownership matrix
| File | Owns | Does not own |
|---|---|---|
.env.local | app/local secrets and app runtime values for the local-app lane | local Wrangler-only vars, preview/prod tenant identity |
.dev.vars.dev | local Wrangler vars for the local-worker lane | app-lane defaults, preview/prod secrets |
.env.cloudflare / .env.cloudflare.preview | remote helper, seeding, and Cloudflare API credentials | checked-in deployment identity |
wrangler.toml | tracked template-safe baseline and local-development config | private preview/prod tenant IDs and routes |
wrangler.remote.toml | private preview/prod Cloudflare tenant config | local-only development defaults |
Bootstrap wrangler.remote.toml from the committed wrangler.remote.example.toml.
Application compositions
src/config/product.ts is the client-safe owner of deployment selections. Choose one of its typed productCompositions when editing publicProductConfig.capabilities, then rebuild and restart:
capabilities: {
docs: true, blog: true,
...productCompositions.core, // or ["personal-paid"] / ["organization-collaboration"]
feedbackAttachments: false,
analyticsClient: false, analyticsServer: false, devReview: false,
}| Composition | New personal purchases | New organizations / invitations | Required capabilities |
|---|---|---|---|
core | Off | Existing members only | Identity, initialized database, personal Bookmark resources; existing operational access |
personal-paid | Live | Existing members only | Core plus supported Stripe configuration, exact published catalog offers and product entitlement consumers |
organization-collaboration | Off | Enabled | Core plus current organization schema, recipient mailbox verification and fresh membership authorization |
The shipped declaration keeps new personal purchases selected (live) and organization enrollment enabled to preserve the existing application surfaces. Missing selling configuration remains unavailable. live permits the installed Stripe purchase flow with either Stripe test or live credentials; it is not proof of payment or production readiness. demo is an unavailable purchase mode in the current implementation and never creates charges or entitlements. Personal purchases and organization enrollment are independent: override checkout: "live" on the collaboration example when both are needed. Docs/blog and analytics remain separate choices.
New purchases require APP_URL, a supported STRIPE_SECRET_KEY, PAYMENT_PROVIDER=stripe (the default), valid same-origin return paths, and an exact published product/offer/revision. Configure STRIPE_WEBHOOK_SECRET for signed receipts and verify the provider independently before selling. /pricing returns 404 and checkout/renew commands refuse when checkout is off. Missing configuration exposes safe input names through npm run adoption:check; it does not initialize a provider.
Stopping new sales preserves account purchase history, original-attempt read/resume, billing management, verified webhooks, subscription maintenance and already confirmed access. Keep the original payment configuration while those obligations exist. organizations: "existing-only" stops creation, invitation issue and acceptance; existing members still reach Existing organizations, shared resources, role management, revocation and exit. On a fresh database there are no existing organizations. This is not an uninstall switch or data deletion policy.
Selection never replaces authorization. Personal metering and API access are separate opt-ins described below. Team billing, seat billing, usage-based invoicing, enterprise SSO, MFA, production delivery guarantees and removing unused dependencies are outside these compositions. Use the maintained installation checks and the accepted business replacement example for adoption.
Terms, privacy and support links
Set the optional destinations in src/config/product.ts, inside brand.links:
links: {
terms: null,
privacy: null,
support: null,
repository: null,
docsRepositoryBase: null,
}Replace each null only when you have a destination to publish. Use an absolute local path such as /policies/terms, or a credential-free HTTPS URL such as https://help.your-domain.com/support. Local query strings and fragments are allowed. Relative paths, protocol-relative URLs, HTTP, executable schemes, embedded credentials, malformed escapes, whitespace and control characters are rejected during configuration loading/build. These are public source values: never put access tokens or private URLs here. Rebuild after changing them; there are no additional environment variables.
Configured entries appear consistently in the main-site footer, documentation footer and the login/registration information links. Unset entries remain hidden, including the empty footer/navigation. Links open in the same tab; external links do not send the referring page. The template does not create the destination pages or probe their availability. Publish and maintain your own documents and support destination.
These links are informational. They do not add an acceptance checkbox, record consent, change authentication, or establish that a policy is legally sufficient or accepted by a user.
Optional content modules
Set capabilities.docs and capabilities.blog in src/config/product.ts, then rebuild and restart the application. Both default to true.
| Selection | Application behavior |
|---|---|
| Docs off | Navigation is hidden and /docs plus nested documentation routes return not found |
| Blog off | Navigation and editorial routes are absent; public feed/detail routes return not found, editorial Actions return unavailable, and the Blog refresh job is not registered |
| Blog on, no CMS inputs | Bundled MDX and configured database publications remain available; external CMS sources stay unloaded |
| Blog on, partial CMS inputs | System diagnostics identify missing input names; that source reports unavailable while other content remains usable |
Notion requires NOTION_TOKEN and NOTION_DATABASE_ID; Sanity requires SANITY_PROJECT_ID and SANITY_DATASET, with optional SANITY_READ_TOKEN. Disabled Blog ignores residual CMS credentials. Configuration permits initialization; it does not prove connectivity. Editorial operations always check current administrator authority. Disabling a module retains its stored content and build dependencies.
Typed env layer
Personal API access is a separate, default-off runtime capability. Set FEATURE_PERSONAL_API=true to permit credential issuance and scoped Bookmark reads; existing credentials remain listable/revocable while disabled. See Personal API for the management page, generated Node client and lifecycle behavior.
Application runtime values are validated through @t3-oss/env-nextjs. The canonical typed entrypoint is src/env.ts, which composes the domain schemas under src/env/*.
That means the normal workflow for new variables is:
- Add the variable to the right schema file under
src/env/. - Re-export it through
src/env.ts. - Decide which ownership file should carry the value by default.
- Update the matching example file.
Use direct process.env access only where raw process access is genuinely required, such as CLI scripts or runtime bridges.
local-app example
APP_URL="http://localhost:3101"
AUTH_SECRET="replace-me-with-a-local-secret"
DATABASE_URL="file:./dev.db"
XADMIN_SECRET="replace-me-for-local-seeding"This lane is the canonical Next.js + Playwright development lane.
local-worker example
Copy .dev.vars.example to .dev.vars.dev and keep the local Wrangler URL on port 8787:
APP_URL="http://localhost:8787"
AUTH_SECRET="replace-me-with-a-local-worker-secret"
XADMIN_SECRET="replace-me-for-local-worker-seeding"
CRON_SECRET="replace-me-for-local-worker-cron"
TRIGGER_SECRET="replace-me-for-local-worker-triggers"Keep .env.local on the app lane even if you also use Wrangler locally.
Remote helper example
Use .env.cloudflare for production helper flows and .env.cloudflare.preview for preview helper flows:
CLOUDFLARE_API_TOKEN="replace-me-with-api-token"
CLOUDFLARE_D1_TOKEN="replace-me-with-d1-or-api-token"
APP_URL="https://your-app.example.com"
XADMIN_SECRET="replace-me-with-remote-admin-secret"These files are for helper credentials and remote script inputs. They are not where preview or production account IDs, routes, or binding IDs should live.
Cloudflare config files
wrangler.toml- tracked
- template-safe baseline
- local-development defaults
wrangler.remote.toml- untracked
- private preview and production tenant identity
- bootstrapped from
wrangler.remote.example.toml
In other words: do not directly edit tracked wrangler.toml with live tenant IDs for preview or production.
Ports and URLs
local-app:http://localhost:3101local-worker:http://localhost:8787remote-cloudflare: whatever domain or route you place in private remote config
Keep those lane URLs explicit. Do not reuse one APP_URL value for all three lanes.
Common optional env groups
Personal, organization and commercial capabilities
Start with a signed-in personal account and /workspace. Free personal resources do not require organization membership, a commercial offer or payment credentials. The public product declaration in src/config/product.ts selects presentation and checkout mode; it does not grant customer permissions.
Organization collaboration uses /organizations and explicit organization resource routes. Apply the current migrations to your database before using it. An owner can invite a recipient email before that person registers, then privately share the /organizations/invitations address and its separate invitation code. When invitation email is configured, an authorized manager can select Send invitation by email before creating the invitation. Manual sharing remains the default.
The recipient opens that page, signs in or creates an account using the invited email, and verifies their mailbox. Registration returns to the invitation page; the recipient then enters the separately shared invitation code and explicitly accepts. Keep the code outside the address bar. Creating an account or opening the invitation page does not join the organization. Codes expire after seven days; an owner can revoke or replace a pending invitation.
An invitation issued to an existing account remains bound to that account when its email changes. An invitation issued before registration requires current verified control of the intended address, then becomes permanently bound to the accepting account. Personal resources stay personal when an organization is created or selected.
Commercial products and immutable offer revisions come from the Payments catalog. Pricing, purchase recovery, product entitlement and personal billing consume the same product identity. Publishing a plan or configuring a provider key alone does not publish a purchasable offer. Follow the Payments module’s catalog setup in your application composition and select live checkout only when its provider and offers are configured.
Current billing belongs to the signed-in person. Organization membership and payment administration are separate: organization roles do not authorize another person’s billing portal. Organization billing, seat billing and usage-based invoicing are not implemented.
Personal usage and API access
| Optional capability | Selection | Behavior and boundary |
|---|---|---|
| Bookmark-copy metering | BOOKMARK_COPY_METERING=enabled | Records newly committed personal-copy operations in the same transaction as the copy; does not change paid eligibility or organization resource rules |
| Monthly copy quota | BOOKMARK_COPY_MONTHLY_LIMIT=100 | Example limit of 100 copies per UTC month while metering is enabled; omit for no limit, use 0 to allow no new metered copies |
| Personal read API | FEATURE_PERSONAL_API=true | Enables scoped credential issuance and personal Bookmark reads; follow the Personal API guide for scopes, revocation and the generated client |
Metering is disabled when omitted. Apply the current migrations before enabling it. /me/usage shows the person’s recorded monthly history and current allowance; /admin/usage provides bounded administrator aggregates. Disabling metering does not erase facts, and an empty history means no recorded consumption, not proof of zero historical use. These records do not generate invoices or implement organization billing. API access is disabled when omitted; listing and revoking existing credentials remain available independently of new issuance.
Organization invitation email
Invitation email is optional and turned off by default. Apply the current migrations, configure SMTP and a trusted APP_URL, and set the product’s brand name. Then configure:
| Setting | Purpose |
|---|---|
ORGANIZATION_INVITATION_MAIL_ENABLED | Set true to make the manager’s email choice available. |
ORGANIZATION_INVITATION_MAIL_KEY_ID | Identify the current dedicated key with 1–64 letters, digits, underscores or hyphens. |
ORGANIZATION_INVITATION_MAIL_KEY | A dedicated random 32-byte key encoded as unpadded base64url, stored through your deployment’s secret mechanism. |
Generate the dedicated key locally with node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64url"))'. Keep the value private and separate from database backups; do not reuse your authentication secret. Changing or removing this key prevents unsent invitations protected by the old key from being emailed.
On the organization page, enter the intended email and role, select Send invitation by email, then choose Create and email invitation. The page reports invitation issuance separately from email status. Mail transport acceptance does not prove inbox delivery, reading or joining. If the response is lost, reload in the same browser tab and choose Check invitation and email status; this reads the original request without sending another invitation. Unknown or restored effects are never retried automatically. Creating another invitation is a separate decision and replaces the previous pending code for that recipient.
The recipient receives the fixed invitation-page link and a separate private code. They must sign in or register with the intended email, verify the mailbox and explicitly accept. The invitation email itself does not verify the address or join the organization. Turning email off leaves manual sharing and existing acceptance available; unsent mail is stopped when processed. Restored pending mail is held and cannot send automatically.
OAuth providers
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_DISCORD_ID="your-discord-client-id"
AUTH_DISCORD_SECRET="your-discord-client-secret"Payments
PAYMENT_PROVIDER="mock"
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_USER="smtp-user"
SMTP_PASS="smtp-pass"
SMTP_FROM="Product <no-reply@example.com>"Analytics
Analytics is off by default. In src/config/product.ts, set capabilities.analyticsClient to true for browser pageviews and/or capabilities.analyticsServer to true for server authentication events. Configure the corresponding provider key below; keys alone never enable tracking.
NEXT_PUBLIC_POSTHOG_KEY="phc_..."
NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com"
POSTHOG_API_KEY="phc_..."
POSTHOG_HOST="https://app.posthog.com"Browser pageviews include the origin and path only. Query strings, fragments, referrers and automatic campaign attribution are excluded; session replay and autocapture are disabled. Missing keys or invalid hosts leave the selected capability unavailable. Restart the local app after changing configuration; rebuild deployments when public configuration changes.
What not to do
- Do not replace
.env.localwith a generic.envworkflow for normal local development. - Do not put local Wrangler vars in
.env.local. - Do not store remote tenant identity in tracked
wrangler.toml. - Do not treat
.env.cloudflare*as a deployment identity file.
Database note
After changing the schema, generate and review the SQL, then apply the complete chain to the guarded local-app database:
npm run db:generate
npm run db:pushFor remote D1, use the explicit migration release workflow. db:push here is a local-app command and does not release remote schema.
Next Steps
After configuration, learn about:
- Deployment - Deploy to production
- Authentication Guide - Set up authentication
- Database initialization - Initialize the guarded local database
Public discovery and canonical addresses
Set APP_URL to your site’s HTTPS origin, for example https://www.your-company.com, with no credentials, path, query or fragment. Production discovery refuses loopback and reserved example/test/invalid hosts. Local development accepts an explicitly configured loopback HTTP origin such as http://127.0.0.1:3101; request Host and forwarded headers never choose canonical addresses. Keep APP_URL consistent between build and runtime, and rebuild after changing it: Docs metadata is prerendered with the configured build origin.
/sitemap.xml lists the home page, selected Docs pages, the selected Blog index and currently public internal articles. /robots.txt advertises that sitemap and discourages crawling account, operator and disabled content routes. Robots is advisory; authentication and authorization still protect private pages. When Blog or Docs is disabled in src/config/product.ts, discovery does not initialize that module’s readers or providers.
Missing or unsafe APP_URL keeps page text metadata but omits canonical/share URLs and requests no indexing. Robots disallows crawling and omits the sitemap address; sitemap requests return an error. Malformed URLs may fail environment validation before the application can serve requests. Fix the configuration before exposing the site to crawlers. A configured content-source failure also fails the sitemap instead of publishing an incomplete list; an intentionally empty source remains valid.
The current single sitemap supports at most 10,000 distinct URLs, each at most 2,048 characters, and 1,000 Docs map items. It also rejects output whose escaped UTF-8 size plus a conservative XML markup allowance exceeds 50 MiB. It fails explicitly above those limits. It reuses the existing complete Blog feed and source limits, including Notion’s single-page limit; it does not add a search service or pagination framework. No synthetic modification timestamps are emitted.
For database articles, Save Draft preserves the published title, address, description and sharing image. Publish saved draft selects the new values; Withdraw, archive, deletion or publishing a nonpublic audience removes the article from public discovery. Meta Description falls back to the article summary/title, and Cover Image URL supplies an optional sharing image. External CMS cards retain their external destinations and are not advertised as local article pages.