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 owns the client-safe brand and explicit capability selections. The SaaS foundation has no default example business or organization module.
capabilities: {
docs: true, blog: true,
...productCompositions.core, // or productCompositions["personal-paid"]
feedbackAttachments: false,
analyticsClient: false, analyticsServer: false, devReview: false,
}| Composition | New purchases | Required capabilities |
|---|---|---|
core | Off | Authentication and an initialized database; basic profile and administration |
personal-paid | Live | Core plus supported Stripe configuration and published product/offer revisions |
The shipped configuration selects live checkout. Missing provider or catalog configuration remains unavailable. live permits the installed Stripe flow with test or live credentials; it does not prove payment or production readiness. demo is unavailable for real purchases and never grants access.
New purchases require a trusted APP_URL, supported STRIPE_SECRET_KEY, PAYMENT_PROVIDER=stripe, same-origin return paths and an exact published offer revision. Configure STRIPE_WEBHOOK_SECRET for signed receipts. /pricing returns 404 and new checkout commands refuse when checkout is off; /me/billing retains existing purchases, original-attempt recovery and supported billing management. Keep provider configuration while those obligations exist.
Customers enter /me for basic profile information and existing-password change. Registration/login default to that account page. The template no longer exposes Bookmark, Decision Log, organization collaboration, a tool dashboard, customer Inbox, API credentials, export, usage or preference centers. Historical database records are not erased by source retirement.
Selection never grants authorization. Add a real domain using the business integration guide; its commands and queries must enforce current identity and resource ownership. Docs/blog and analytics remain independent choices.
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
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
Accounts and commercial capabilities
Start with /me and the selected administrator routes. Basic profile and password flows require no commercial offer. Auth owns account identity; Payments owns published products, immutable offer revisions, confirmed payment facts and personal billing. A catalog label or return URL never grants business access.
Introduce any paid business operation through an explicit domain-owned entitlement policy and a real consumer. The foundation has no default Bookmark or Decision rule. Existing purchase identity and billing recovery remain meaningful independently of new sales.
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
Browser analytics is off by default. Set NEXT_PUBLIC_ANALYTICS_ENABLED="true" and a provider key at build time; no source edit is required. Explicit false disables both GA4 and PostHog. An unset or empty switch retains capabilities.analyticsClient from src/config/product.ts (the template default is false).
For Google Analytics 4:
NEXT_PUBLIC_ANALYTICS_ENABLED="true"
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID="G-XXXXXXXXXX"Create a GA4 Web data stream and use its measurement ID, not a GTM or Universal Analytics ID. Disable Enhanced Measurement in that stream: the application sends one pageview on first load and each pathname change. Its page URL and referrer omit query/hash, and its title is the pathname. Rebuild and redeploy after changing either public value; restart local development. A runtime-only Worker secret cannot change an already-built browser bundle. Verify reception in GA Realtime after deployment.
PostHog is optional and independent; configure it instead of or alongside GA4:
NEXT_PUBLIC_POSTHOG_KEY="phc_..."
NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com"Server authentication events use the existing PostHog selection, capabilities.analyticsServer=true, and these server-only inputs:
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.
Google login and Search Console
Once the database and deployment are initialized, configure these alongside the analytics values above:
APP_URL="https://www.your-company.com"
AUTH_SECRET="your-stable-random-secret"
AUTH_TRUST_HOST="1"
AUTH_GOOGLE_ID="your-client-id.apps.googleusercontent.com"
AUTH_GOOGLE_SECRET="your-client-secret"
GOOGLE_SITE_VERIFICATION="your-meta-content-token"Use .env.local for the local app. In production supply public values to the Next build and server credentials to the runtime; supply AUTH_SECRET for production build validation too. Keep APP_URL and GOOGLE_SITE_VERIFICATION consistent at build and runtime and rebuild after changing them so compiled configuration and runtime remain aligned. Docs resolves language per request. Preserve an existing auth secret; for a new deployment generate one with openssl rand -base64 32. Trust Host only for your controlled deployment entrance. The installation and deployment steps initialize SQLite or D1 DB and Worker KV; Google settings do not create those resources.
- Google login: create an OAuth Web application in Google Auth Platform. Register the exact redirect URI
https://your-domain/api/auth/callback/google; locally use your exact host and port, such ashttp://localhost:3101/api/auth/callback/google. Configure audience/test users as required by that Google project. Both credentials enable the existing login and registration buttons; no UI or auth source change is needed. Provider account-linking rules still apply. - Search Console: create a URL-prefix property matching
APP_URL, choose HTML tag verification, and set only the tag’scontentvalue. Deploy, complete Verify, then submithttps://your-domain/sitemap.xml. A Domain property requires DNS TXT verification instead of this meta tag. Keep the verification method in place.
GA4, Google OAuth and Search Console use separate identifiers. Check the login flow with your Google account, the verification meta tag in served HTML, and /sitemap.xml after deployment; local tests do not establish Google-side acceptance or indexing.
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: discovery uses trusted configured values, while Docs resolves language per request.
/sitemap.xml automatically discovers public App Router page files, including pricing and selected Docs/Blog pages, at build time. Add an ordinary page and deploy; no URL list needs updating. Dynamic /blog/[slug] addresses expand from current public articles at request time. Other dynamic domains need one public-data reader per domain, not one entry per URL; unresolved placeholders are never emitted. New private areas must join the central exclusions in src/config/sitemap-policy.ts. /robots.txt advertises the 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.
Production snapshots use the existing SQLite cache or Worker KV for at most one hour. Every request checks the current Blog publication revision, so publishing or withdrawing an article changes the cache key immediately. Expired entries regenerate on the next request; no extra cron or sitemap environment setting is needed. Source MDX changes still need a build. Google decides when it crawls and indexes the resulting URLs.
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. 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. After manually restoring or importing old publication generations, clear the public-sitemap cache or rebuild with a new build ID.
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.
Language and privacy readiness
The application uses one set of routes and components with English and Simplified Chinese interface messages. Blog and Docs have explicit /en/... and /zh-CN/... content addresses and independently maintained complete versions. All eight bundled Docs pages and all three bundled Blog posts have complete English and Chinese versions. Content without a published translation provides an English reading link.
NEXT_PUBLIC_ANALYTICS_ENABLED selects optional integrations; it does not authorize collection. Complete the real bilingual publisher, contact, processing, retention and translation review fields in src/privacy/config.ts, then the device must explicitly save a consent choice before an eligible analytics service loads. No banner appears when no optional service is eligible. Read /privacy and /cookies, and use Privacy settings in the footer to review or withdraw the choice. Server analytics suppress events without the required current subject, purpose and permission. Do not substitute invented operator details to make the template appear ready.