Skip to content

Instantly share code, notes, and snippets.

@louis-young
Last active March 9, 2026 11:08
Show Gist options
  • Select an option

  • Save louis-young/bfb12f82aa115c1a18a9a6057856a82a to your computer and use it in GitHub Desktop.

Select an option

Save louis-young/bfb12f82aa115c1a18a9a6057856a82a to your computer and use it in GitHub Desktop.
src/configuration/runtime/index.ts
typescriptimport { z } from 'zod/v4'
/*
* Internal primitive, not exported.
*
* Reads a variable from the correct runtime source. On the server this is
* process.env. On the client this is window.__PUBLIC_APPLICATION_CONFIGURATION__,
* which is populated at request time by the ApplicationConfigurationScript
* server component.
*
* Validation is Zod's responsibility. This function returns undefined for absent
* values rather than throwing, so that Zod can produce a single coherent error
* describing all missing variables at once rather than failing on the first one.
*/
const isServer = typeof window === 'undefined'
function readVariable(key: string): string | undefined {
if (isServer) return process.env[key]
const injected = window.__PUBLIC_APPLICATION_CONFIGURATION__
if (injected === undefined) {
throw new Error(
'window.__PUBLIC_APPLICATION_CONFIGURATION__ is not defined. ' +
'Ensure ApplicationConfigurationScript is rendered in the document head before ' +
'any client JavaScript executes. In the root layout this happens automatically. ' +
'If this error appears in global-error.tsx, ensure ApplicationConfigurationScript ' +
'is rendered there directly, as global-error replaces the root layout entirely.',
)
}
const value = (injected as Record<string, unknown>)[key]
return typeof value === 'string' ? value : undefined
}
/*
* Runtime configuration
*
* These variables differ between environments running the same build. Staging
* and production both run the same built artefact but point at different API
* endpoints, different buckets, and different external services. Infrastructure
* injects them at runtime only. They are not present at `next build`.
*
* Configuration is split into public and private slices:
*
* public — injected into the client at request time via
* window.__PUBLIC_APPLICATION_CONFIGURATION__ by
* ApplicationConfigurationScript. Only values that are intentionally
* visible in the browser belong here. Client components, including
* error boundaries, can safely read public configuration via
* runtimeConfiguration.public.
*
* private — server-side only. Not secret, but not intended for the client.
* Internal service URLs, bucket names, background API endpoints,
* and anything else that has no business being sent to a browser.
*
* Access pattern: import { runtimeConfiguration } and access properties
* directly, e.g. runtimeConfiguration.public.orderingApiBaseUrl. The exported
* value is a Proxy that defers all process.env reads and Zod validation until
* the first property access, which only ever happens at request time. This
* means the module is safe to import at build time — no reads happen at module
* load time, only when a property is first accessed.
*
* Do not access runtimeConfiguration from next.config.ts or from module-level
* code in any file that next.config.ts imports. Those are evaluated at build
* time and runtime variables are not yet present. For next.config.ts and
* anything it imports, use buildConfiguration from configuration/build.
*
* Accessing runtimeConfiguration from middleware.ts, server components, and
* route handlers is safe. None of those execute at build time.
*
* Note: APP_TEST_PROXY_ENABLED is intentionally absent from this module.
* It is consumed directly via process.env in next.config.ts at build time only.
* To use it in tests, set it in your Playwright config env block when starting
* the dev server.
*/
const PublicRuntimeConfigurationSchema = z.object({
orderingApiBaseUrl: z.url(),
raygunApiKey: z.string().min(1),
})
const PrivateRuntimeConfigurationSchema = z.object({
internalOrderingApiBaseUrl: z.url(),
assetsBucketName: z.string().min(1),
cmsApiBaseUrl: z.url(),
})
const RuntimeConfigurationSchema = z.object({
public: PublicRuntimeConfigurationSchema,
private: PrivateRuntimeConfigurationSchema,
})
export type PublicRuntimeConfiguration = z.infer<typeof PublicRuntimeConfigurationSchema>
export type PrivateRuntimeConfiguration = z.infer<typeof PrivateRuntimeConfigurationSchema>
export type RuntimeConfiguration = z.infer<typeof RuntimeConfigurationSchema>
/*
* Lazy singleton backing the Proxy. Parsed and memoised on first property
* access. Never called at module load time or at build time.
*/
let _resolved: RuntimeConfiguration | undefined
function resolve(): RuntimeConfiguration {
if (_resolved) return _resolved
_resolved = RuntimeConfigurationSchema.parse({
public: {
orderingApiBaseUrl: readVariable('APP_PUBLIC_ORDERING_API_BASE_URL'),
raygunApiKey: readVariable('APP_PUBLIC_RAYGUN_API_KEY'),
},
private: {
internalOrderingApiBaseUrl: readVariable('INTERNAL_ORDERING_API_BASE_URL'),
assetsBucketName: readVariable('ASSETS_BUCKET_NAME'),
cmsApiBaseUrl: readVariable('CMS_API_BASE_URL'),
},
})
return _resolved
}
/*
* runtimeConfiguration is typed as RuntimeConfiguration so that consumers get
* full type inference and autocompletion with no awareness of the Proxy.
*
* The cast via `unknown` is necessary because a Proxy<object> is not directly
* assignable to RuntimeConfiguration in TypeScript's type system, even though
* it is structurally identical at runtime. The Proxy faithfully delegates all
* property access to the resolved RuntimeConfiguration object, so the cast is
* safe. The underlying object is validated by Zod before being cached, which
* provides the runtime correctness guarantee that the type cast relies on.
*/
export const runtimeConfiguration = new Proxy(
{} as object,
{
get(_target, key: string | symbol) {
return resolve()[key as keyof RuntimeConfiguration]
},
has(_target, key: string | symbol) {
return key in resolve()
},
},
) as unknown as RuntimeConfiguration
src/configuration/runtime/types.d.ts
typescriptdeclare global {
interface Window {
/*
* Populated at request time by ApplicationConfigurationScript.
* Typed as unknown because the shape is validated by Zod on first property
* access of runtimeConfiguration. Do not read from this property directly
* anywhere in the application. Use runtimeConfiguration instead.
*/
__PUBLIC_APPLICATION_CONFIGURATION__?: unknown
}
}
export {}
src/configuration/build/index.ts
typescriptimport 'server-only'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { z } from 'zod/v4'
/*
* Build configuration
*
* These variables are properties of a given build, not of any particular
* environment running that build. They are set once when the build is produced
* by CI and do not vary across deployments of that build. Every environment
* running build v1.4.2 will have APP_VERSION=1.4.2, whether that is staging,
* production, or anything else.
*
* Because of this, these variables must be present in two contexts:
*
* 1. At `next build`, injected by CI.
* 2. At runtime, injected by infrastructure with the same values. The
* convention is to bake them into the Docker image at build time so they
* are carried into every runtime environment automatically.
*
* Since they are present in both contexts, this module can be evaluated eagerly
* at module load time. Zod will throw immediately, at build time or on server
* startup, if any value is missing or malformed. This gives fast and clear
* failure rather than a runtime error mid-request.
*
* buildConfiguration is a plain object, not a Proxy. Because it is eagerly
* evaluated at module load time there is no deferred read to wrap — the values
* are already resolved by the time any consumer imports this module. A Proxy
* would add complexity for no benefit.
*
* This module reads directly from process.env rather than through readVariable(),
* because it is server-only and never runs in the browser. There is no reason
* for build configuration to reach a client component, and process.env reads on
* the client would silently return undefined for everything.
*
* The parsed object and each of its values are tainted so that React will throw
* loudly if any of them are accidentally passed to a client component. This is
* intentionally aggressive. The error is loud and confusing by design. We prefer
* that friction over silently exposing server-side configuration to the browser.
* If a value genuinely needs to reach the client in future, that decision should
* be made deliberately, and the value should be added to the public slice of
* runtime configuration instead.
*
* Note: React taint APIs remain experimental as of early 2026.
* Enable them in next.config.ts: experimental: { taint: true }
*
* Safe to import from anywhere on the server: next.config.ts, middleware.ts,
* server components, and route handlers. Do not import from client components;
* the server-only package enforces this at the module boundary, and taint
* enforces it at the value boundary.
*
* Do not add deployment-specific variables here. Variables that differ between
* staging and production belong in runtime configuration.
*/
const BuildConfigurationSchema = z.object({
applicationVersion: z.string().min(1),
nodeEnvironment: z.enum(['development', 'test', 'production']),
})
export type BuildConfiguration = z.infer<typeof BuildConfigurationSchema>
const parsed = BuildConfigurationSchema.parse({
applicationVersion: process.env['APP_VERSION'],
nodeEnvironment: process.env['NODE_ENV'],
})
experimental_taintObjectReference(
'Do not pass buildConfiguration to a client component. ' +
'It is server-only by design. If a value genuinely needs to reach the client, ' +
'add it to the public slice of runtime configuration instead.',
parsed,
)
for (const [key, value] of Object.entries(parsed)) {
experimental_taintUniqueValue(
`Do not pass buildConfiguration.${key} to a client component. ` +
'See the buildConfiguration taint comment for guidance.',
parsed,
value,
)
}
export const buildConfiguration = parsed
src/configuration/secrets/index.ts
typescriptimport 'server-only'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { z } from 'zod/v4'
/*
* Application secrets
*
* Sensitive credentials whose exposure would constitute a security incident
* rather than merely an operational inconvenience. Database connection strings,
* encryption keys, internal service tokens.
*
* Secrets are injected at runtime by infrastructure from a secrets manager such
* as AWS SSM or Secrets Manager. The application has no knowledge of the secrets
* management system; infrastructure is solely responsible for retrieval and
* injection. From the application's perspective these are simply environment
* variables that happen to be present at runtime.
*
* Access pattern: import { secrets } and access properties directly, e.g.
* secrets.databaseUrl. The exported value is a Proxy that defers all
* process.env reads and Zod validation until the first property access, which
* only ever happens at request time. This is the same pattern as
* runtimeConfiguration and ensures consistent ergonomics across all injected
* configuration.
*
* Taint is applied to both the underlying parsed object and the Proxy itself.
* This is necessary because React's taint registry works on object references:
* tainting the parsed object does not automatically taint the Proxy, which is
* a different reference. By tainting both, React will throw if either the Proxy
* or the underlying object is passed to a client component as a prop. Individual
* secret values are also tainted via experimental_taintUniqueValue, so
* destructured values are caught as well.
*
* This module is marked server-only so the Proxy never reaches a client bundle.
* Taint is a defence-in-depth mechanism for the server render path — preventing
* a secret value from being passed as a prop from a server component to a client
* component even though both run on the server during rendering.
*
* Note: React taint APIs remain experimental as of early 2026.
* Enable them in next.config.ts: experimental: { taint: true }
*
* Lazy singleton pattern: this module is imported at build time when Next.js
* traverses the module graph to compile the application bundle. Secrets are not
* injected by infrastructure until runtime, so any process.env read at module
* load time would cause Zod to throw and break the build. The Proxy defers all
* reads and validation until the first actual property access, which only ever
* happens at request time. The result is then memoised for the lifetime of the
* process.
*/
const SecretsSchema = z.object({
databaseUrl: z.url(),
encryptionKey: z.string().min(32),
internalServiceToken: z.string().min(1),
})
export type Secrets = z.infer<typeof SecretsSchema>
/*
* Lazy singleton backing the Proxy. Parsed, tainted, and memoised on first
* property access. Never called at module load time or at build time.
*/
let _resolved: Secrets | undefined
function resolve(): Secrets {
if (_resolved) return _resolved
const parsed = SecretsSchema.parse({
databaseUrl: process.env['DATABASE_URL'],
encryptionKey: process.env['ENCRYPTION_KEY'],
internalServiceToken: process.env['INTERNAL_SERVICE_TOKEN'],
})
experimental_taintObjectReference(
'Do not pass the secrets object to a client component. ' +
'Secrets are server-only. Consume them in server components or route handlers only.',
parsed,
)
for (const [key, value] of Object.entries(parsed)) {
experimental_taintUniqueValue(
`Do not pass secrets.${key} to a client component.`,
parsed,
value,
)
}
_resolved = parsed
return _resolved
}
/*
* secrets is typed as Secrets so that consumers get full type inference and
* autocompletion with no awareness of the Proxy.
*
* The cast via `unknown` is necessary because a Proxy<object> is not directly
* assignable to Secrets in TypeScript's type system, even though it is
* structurally identical at runtime. The Proxy faithfully delegates all
* property access to the resolved Secrets object, so the cast is safe. The
* underlying object is validated by Zod and tainted before being cached, which
* provides the runtime correctness and security guarantees the type cast relies on.
*
* The Proxy itself is also tainted after creation. This is necessary because
* React's taint registry operates on object references — tainting the underlying
* parsed object does not automatically taint this Proxy wrapper. Both must be
* tainted independently to ensure React throws if either reference is passed
* to a client component.
*/
const secretsProxy = new Proxy(
{} as object,
{
get(_target, key: string | symbol) {
return resolve()[key as keyof Secrets]
},
has(_target, key: string | symbol) {
return key in resolve()
},
},
) as unknown as Secrets
experimental_taintObjectReference(
'Do not pass the secrets proxy to a client component. ' +
'Secrets are server-only. Consume them in server components or route handlers only.',
secretsProxy,
)
export { secretsProxy as secrets }
src/data-access/checkout-database.ts
typescriptimport 'server-only'
import { z } from 'zod/v4'
import { ChannelConfigurationSchema } from '@/features/multitenancy/channel-configuration/schemas'
/*
* Data access for the checkout database.
*
* This module is a thin, framework-agnostic layer over the database. It has no
* knowledge of Next.js caching, React, or taint. Those concerns belong in the
* feature layer above this one. Methods are named after what they return rather
* than how they fetch.
*
* In production, replace mock implementations with Prisma queries.
*/
export const checkoutDatabase = {
channelConfigurations: {
async findMany(): Promise<z.infer<typeof ChannelConfigurationSchema>[]> {
// Replace with: return prisma.channelConfiguration.findMany()
return z.array(ChannelConfigurationSchema).parse([
{
id: '11111111-1111-1111-1111-111111111111',
slug: 'acme',
checkoutOrigin: 'https://checkout.acme.example.com',
defaultLocale: 'en-GB',
supportedLocales: ['en-GB', 'fr-FR'],
},
{
id: '22222222-2222-2222-2222-222222222222',
slug: 'globex',
checkoutOrigin: 'https://pay.globex.example.com',
defaultLocale: 'en-US',
supportedLocales: ['en-US'],
},
])
},
},
}
src/data-access/ordering-api-client.ts
typescriptimport 'server-only'
import { z } from 'zod/v4'
import { ChannelSecretsSchema } from '@/features/multitenancy/channel-secrets/schemas'
/*
* Data access for the ordering API and its associated sync service.
*
* This module is a thin, framework-agnostic layer over the ordering API. It has
* no knowledge of Next.js caching, React, or taint. Those concerns belong in the
* feature layer above this one. In production, initialise the HTTP client with
* runtimeConfiguration.private.internalOrderingApiBaseUrl and
* secrets.internalServiceToken.
*
* Modelled after the ordering API resource hierarchy. Add new resource namespaces
* here as the API surface grows.
*/
export const orderingApiClient = {
channels: {
/*
* Fetches ordering API credentials and the redirect origin allowlist for a
* given channel. Source: sync service that mirrors per-channel credentials
* from the monolith, avoiding direct coupling to the monolith database.
*/
async getSecrets(channelId: string): Promise<z.infer<typeof ChannelSecretsSchema>> {
// Replace with: fetch(`${runtimeConfiguration.private.internalOrderingApiBaseUrl}/channels/${channelId}/secrets`, ...)
const fixtures: Record<string, unknown> = {
'11111111-1111-1111-1111-111111111111': {
channelId: '11111111-1111-1111-1111-111111111111',
orderingApiClientId: 'acme-client-id',
orderingApiClientSecret: 'acme-client-secret',
redirectOriginAllowlist: ['https://www.acme.example.com'],
},
'22222222-2222-2222-2222-222222222222': {
channelId: '22222222-2222-2222-2222-222222222222',
orderingApiClientId: 'globex-client-id',
orderingApiClientSecret: 'globex-client-secret',
redirectOriginAllowlist: ['https://www.globex.example.com'],
},
}
const raw = fixtures[channelId]
if (!raw) {
throw new Error(
`No credentials found in the sync service for channel: ${channelId}`,
)
}
return ChannelSecretsSchema.parse(raw)
},
},
}
src/features/multitenancy/channel-configuration/schemas.ts
typescriptimport { z } from 'zod/v4'
/*
* Channel configuration schema.
*
* Represents checkout-owned, non-secret configuration for a channel, stored in
* the checkout database. This is distinct from channel secrets, which are
* credentials fetched from the ordering API sync service.
*/
export const ChannelConfigurationSchema = z.object({
id: z.string().uuid(),
slug: z.string().min(1),
checkoutOrigin: z.url(),
defaultLocale: z.string().min(2),
supportedLocales: z.array(z.string().min(2)).min(1),
})
export type ChannelConfiguration = z.infer<typeof ChannelConfigurationSchema>
src/features/multitenancy/channel-configuration/index.ts
typescriptimport 'server-only'
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { checkoutDatabase } from '@/data-access/checkout-database'
import type { ChannelConfiguration } from './schemas'
/*
* Channel configuration
*
* This module is the Next.js and React aware layer over the channel configuration
* data access functions in the data access layer. It is responsible for caching,
* taint, and server-only enforcement. Raw database access lives in
* src/data-access/checkout-database.ts, which has no framework-specific concerns.
*
* Marked server-only. Channel configuration is not currently secret, but it has
* no business being in a client bundle. If a downstream client component
* genuinely needs a specific value, such as the channel slug for analytics, it
* should be passed explicitly as a prop from a server component, making the data
* flow visible and intentional.
*
* Individual values are tainted as well as the container object. This is a
* deliberate and aggressive trade-off. Channel configuration contains nothing
* secret today, but tainting it now means that if sensitive fields are added
* later, which is likely, the protection is already in place and does not need
* to be retrofitted. The cost of this decision is that hitting the taint
* boundary mid-feature is loud and confusing. That is intentional. We prefer
* that friction over a future where a sensitive field is quietly passed to a
* client component because the pattern was not established. If a value genuinely
* needs to cross the server/client boundary, remove the taint for that specific
* field with a comment explaining why.
*
* Note: React taint APIs remain experimental as of early 2026.
* Enable them in next.config.ts: experimental: { taint: true }
*
* Caching uses two layers:
*
* unstable_cache wraps the database fetch and persists the result across
* requests with a five minute fallback TTL. This will be migrated to the
* `use cache` directive and cacheTag/cacheLife APIs once they stabilise,
* as those are the preferred caching primitives in Next.js 15 and beyond.
* Channel configuration changes rarely and should not be re-fetched on every
* request. For on-demand invalidation without a redeploy, call
* revalidateTag('channel-configuration') from a webhook route handler when
* configuration changes in the database.
*
* React cache() wraps the outer function and deduplicates calls within a
* single request render tree. Multiple server components calling this in the
* same render pass will share a single result without redundant fetches.
*
* Note: a Proxy is not used here unlike runtimeConfiguration and secrets.
* Channel configuration is fetched asynchronously from the database and cannot
* be wrapped in a Proxy that returns values synchronously from property access.
* Async data access must remain as explicit async function calls.
*/
function taintChannelConfiguration(configuration: ChannelConfiguration): void {
experimental_taintObjectReference(
'Do not pass a channel configuration object to a client component. ' +
'Pass only the specific scalar values a component needs as explicit typed props. ' +
'See the channel configuration taint comment for the reasoning behind this decision.',
configuration,
)
for (const [key, value] of Object.entries(configuration)) {
if (typeof value === 'string') {
experimental_taintUniqueValue(
`Do not pass channelConfiguration.${key} to a client component directly. ` +
'Pass it as an explicit typed prop instead.',
configuration,
value,
)
}
}
}
const fetchAllChannelConfigurations = unstable_cache(
async (): Promise<ChannelConfiguration[]> => {
const configurations = await checkoutDatabase.channelConfigurations.findMany()
configurations.forEach(taintChannelConfiguration)
return configurations
},
['channel-configuration'],
{
tags: ['channel-configuration'],
revalidate: 300,
},
)
export const getAllChannelConfigurations = cache(
async (): Promise<ChannelConfiguration[]> => fetchAllChannelConfigurations(),
)
export async function getChannelConfigurationBySlug(
slug: string,
): Promise<ChannelConfiguration | null> {
const configurations = await getAllChannelConfigurations()
return configurations.find((configuration) => configuration.slug === slug) ?? null
}
export async function getChannelConfigurationByOrigin(
origin: string,
): Promise<ChannelConfiguration | null> {
const configurations = await getAllChannelConfigurations()
return configurations.find((configuration) => configuration.checkoutOrigin === origin) ?? null
}
src/features/multitenancy/channel-secrets/schemas.ts
typescriptimport { z } from 'zod/v4'
/*
* Channel secrets schema.
*
* Represents per-channel ordering API credentials fetched from the ordering API
* sync service. The redirect origin allowlist is stored at the API credential
* level in the monolith alongside the client credentials, so it lives here
* rather than in channel configuration.
*/
export const ChannelSecretsSchema = z.object({
channelId: z.string().uuid(),
orderingApiClientId: z.string().min(1),
orderingApiClientSecret: z.string().min(1),
redirectOriginAllowlist: z.array(z.url()).min(1),
})
export type ChannelSecrets = z.infer<typeof ChannelSecretsSchema>
src/features/multitenancy/channel-secrets/index.ts
typescriptimport 'server-only'
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react'
import { orderingApiClient } from '@/data-access/ordering-api-client'
import type { ChannelSecrets } from './schemas'
/*
* Channel secrets
*
* This module is the Next.js and React aware layer over the channel secrets
* data access functions in the data access layer. It is responsible for taint
* and server-only enforcement. Raw API access lives in
* src/data-access/ordering-api-client.ts, which has no framework-specific concerns.
*
* Not cached across requests. Credentials must always be fresh.
*
* The container object and the client secret are both tainted on every call.
* The client secret receives individual taint in addition to the container
* because it is the most sensitive value and the one most likely to be
* accidentally passed somewhere it should not be.
*
* Note: a Proxy is not used here unlike runtimeConfiguration and secrets.
* Channel secrets are fetched asynchronously per channel and cannot be wrapped
* in a synchronous Proxy. Async data access must remain as explicit async
* function calls.
*
* Note: React taint APIs remain experimental as of early 2026.
* Enable them in next.config.ts: experimental: { taint: true }
*/
export async function getChannelSecrets(channelId: string): Promise<ChannelSecrets> {
const fetched = await orderingApiClient.channels.getSecrets(channelId)
experimental_taintObjectReference(
`Do not pass channel secrets for channel ${channelId} to a client component.`,
fetched,
)
experimental_taintUniqueValue(
'Do not pass orderingApiClientSecret to a client component.',
fetched,
fetched.orderingApiClientSecret,
)
return fetched
}
src/components/ApplicationConfigurationScript.tsx
typescriptimport 'server-only'
import { runtimeConfiguration } from '@/configuration/runtime'
/*
* Renders an inline script that populates window.__PUBLIC_APPLICATION_CONFIGURATION__
* with the public slice of runtime configuration at request time.
*
* Must be rendered in the document head before any client JavaScript executes.
* Inline scripts in the head are executed synchronously by the browser during
* HTML parsing, before any deferred or async scripts. Since Next.js injects its
* client bundles with defer or at the end of body, the window property is always
* populated before any client component code runs.
*
* Only the public runtime configuration slice is written to the window. Private
* runtime configuration and build configuration are never serialised into the page.
*
* This component must be included in both the root layout and global-error.tsx.
* The root layout covers all normal routes. global-error.tsx replaces the root
* layout entirely when the top-level error boundary fires and must therefore
* include this component directly in its own head. Without it,
* window.__PUBLIC_APPLICATION_CONFIGURATION__ will be undefined and any
* client-side configuration reads will throw.
*
* Rendering this component forces dynamic rendering for any route that includes
* it, because it reads from process.env at request time via runtimeConfiguration.
* This is intentional. It also means this component is incompatible with partial
* pre-rendering's static shell, which is generated at build time without runtime
* variables present. This is not a practical concern for a checkout application
* where all meaningful content is dynamic by nature. See
* docs/configuration-architecture.md for a full explanation.
*/
export function ApplicationConfigurationScript() {
return (
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: intentional server-to-client configuration hydration
dangerouslySetInnerHTML={{
__html: `window.__PUBLIC_APPLICATION_CONFIGURATION__=${JSON.stringify(runtimeConfiguration.public)};`,
}}
/>
)
}
src/middleware.ts
typescriptimport { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { buildConfiguration } from '@/configuration/build'
/*
* Resolves the current channel from the incoming host header and rewrites the
* request path to include the channel slug as a dynamic route segment.
*
* This is the single point of channel resolution for the entire application.
* No page, layout, or server component needs to re-derive channel identity from
* headers. Requests from unknown hosts receive a 404 before any application
* code runs.
*
* The application version is attached as a response header on every request so
* that the deployed build can be identified from any network response without
* needing to inspect the page source. buildConfiguration is safe to use here
* because its values are properties of the build itself and are present in the
* runtime environment as well as at build time.
*
* The static HOST_TO_CHANNEL_SLUG map below is a placeholder. Since this
* application is self-hosted in Docker rather than running at the edge,
* middleware executes in the same Node.js process as the rest of the application.
* Replace the static map with a call to getChannelConfigurationByOrigin() wrapped
* in a module-level cache with a TTL, so the lookup is performed once on first
* request and reused across all subsequent requests to that process instance.
* Invalidate via revalidateTag('channel-configuration') from a webhook handler
* when channel configuration changes.
*/
const HOST_TO_CHANNEL_SLUG: Record<string, string> = {
'checkout.acme.example.com': 'acme',
'pay.globex.example.com': 'globex',
'localhost:3000': 'acme',
}
export function middleware(request: NextRequest) {
const host = request.headers.get('host') ?? ''
const channelSlug = HOST_TO_CHANNEL_SLUG[host]
if (!channelSlug) {
return new NextResponse(null, { status: 404 })
}
const rewrittenUrl = request.nextUrl.clone()
rewrittenUrl.pathname = `/${channelSlug}${request.nextUrl.pathname}`
const response = NextResponse.rewrite(rewrittenUrl)
response.headers.set('x-application-version', buildConfiguration.applicationVersion)
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
src/app/layout.tsx
typescriptimport type { Metadata } from 'next'
import { ApplicationConfigurationScript } from '@/components/ApplicationConfigurationScript'
/*
* Dynamic rendering is forced for the entire application.
*
* This application uses a build-once-deploy-everywhere strategy. The same built
* artefact is deployed to every environment, with deployment-specific
* configuration injected by infrastructure at runtime. Every page must be
* rendered at request time by the origin server so that runtime configuration
* can be read from process.env and the public slice injected into the page via
* ApplicationConfigurationScript.
*
* Placing dynamic = 'force-dynamic' here propagates to all server component
* pages and layouts in the application. Note that error.tsx and global-error.tsx
* must be client components and are not covered by this export, but they are
* also incapable of static rendering so no special handling is needed for them.
*
* Partial pre-rendering is incompatible with this layout as written, because
* ApplicationConfigurationScript reads from process.env at request time via
* runtimeConfiguration and cannot be rendered as part of a build-time static
* shell. This is not a meaningful limitation for a checkout application. Cached
* components via the `use cache` directive are fully compatible and can be used
* freely within any route to cache expensive subtrees at runtime. ISR via
* revalidate is similarly compatible. See docs/configuration-architecture.md
* for a full explanation.
*/
export const dynamic = 'force-dynamic'
export const metadata: Metadata = {
title: 'Checkout',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<ApplicationConfigurationScript />
</head>
<body>{children}</body>
</html>
)
}
src/app/global-error.tsx
typescript'use client'
import { useEffect } from 'react'
import { ApplicationConfigurationScript } from '@/components/ApplicationConfigurationScript'
import { runtimeConfiguration } from '@/configuration/runtime'
/*
* Global error boundary.
*
* Handles uncaught errors in the root layout and its children. Because it
* replaces the root layout entirely when rendered, it must include its own html
* and body tags.
*
* ApplicationConfigurationScript must be rendered directly in this component's
* head. Because global-error replaces the root layout, it does not inherit the
* layout's head content. Without this, window.__PUBLIC_APPLICATION_CONFIGURATION__
* would be undefined and any access to runtimeConfiguration in client components
* within this boundary would throw.
*
* Public runtime configuration is available here via runtimeConfiguration.public,
* which reads from window.__PUBLIC_APPLICATION_CONFIGURATION__ on the client.
* Use it for initialising error reporting with the correct API key or similar.
* Private runtime configuration and secrets are not available in client components.
*/
interface GlobalErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
useEffect(() => {
const { raygunApiKey } = runtimeConfiguration.public
// Initialise error reporting and forward the error using raygunApiKey.
console.error(error)
void raygunApiKey
}, [error])
return (
<html lang="en">
<head>
<ApplicationConfigurationScript />
</head>
<body>
<h1>Something went wrong</h1>
<button onClick={reset}>Try again</button>
</body>
</html>
)
}
src/app/[channelSlug]/checkout/page.tsx
typescriptimport { runtimeConfiguration } from '@/configuration/runtime'
import { secrets } from '@/configuration/secrets'
import { getChannelConfigurationBySlug } from '@/features/multitenancy/channel-configuration'
import { getChannelSecrets } from '@/features/multitenancy/channel-secrets'
interface CheckoutPageProps {
params: Promise<{ channelSlug: string }>
}
export default async function CheckoutPage({ params }: CheckoutPageProps) {
const { channelSlug } = await params
const channelConfiguration = await getChannelConfigurationBySlug(channelSlug)
if (!channelConfiguration) {
// Middleware is the primary guard against unknown channel slugs.
// This check is defence in depth.
throw new Error(`No channel configuration found for slug: ${channelSlug}`)
}
// All of the following are consumed server-side only. None of them cross
// the server/client boundary. Taint enforces this at runtime.
const channelSecrets = await getChannelSecrets(channelConfiguration.id)
// secrets and runtimeConfiguration.private are accessed here to initialise
// server-side services, call the ordering API on behalf of the channel, etc.
void channelSecrets
void secrets.databaseUrl
void runtimeConfiguration.private.internalOrderingApiBaseUrl
return (
<main>
{/*
* Only the channel slug crosses to the client as an explicit prop.
* Public runtime configuration is available in client components via
* runtimeConfiguration.public, which reads from
* window.__PUBLIC_APPLICATION_CONFIGURATION__ on the client.
* No sensitive values are passed as props.
*/}
<CheckoutClient channelSlug={channelConfiguration.slug} />
</main>
)
}
// In practice this lives in its own file with 'use client' at the top.
function CheckoutClient({ channelSlug }: { channelSlug: string }) {
return <div>Checkout for {channelSlug}</div>
}
docs/configuration-architecture.md
markdown# Configuration Architecture
This document explains the configuration system for the checkout application:
the constraints it operates under, the decisions made, and the trade-offs
accepted. It is intended as the primary reference for anyone working with
environment variables, secrets, or channel-specific configuration.
---
## The Core Constraint: Build Once, Deploy Everywhere
The checkout application is built once by CI and the resulting Docker image is
deployed to every environment. Staging and production run identical artefacts.
The only thing that differs between them is the environment variables that
infrastructure injects at runtime.
This is a deliberate operational strategy. It means that what you deploy to
production is exactly what was tested in staging, with no possibility of a
build-time difference introducing a regression. It also means the entire
configuration system must be designed around a single constraint: no
deployment-specific value can be resolved at build time.
---
## Compatibility with Next.js Rendering Strategies
### Vanilla SSG is Incompatible
Traditional static site generation runs page components at `next build` to
pre-render HTML. At that point, deployment-specific environment variables have
not been injected by infrastructure. Any access to `runtimeConfiguration` or
`secrets` during static generation will trigger the lazy singleton, encounter
missing variables, cause Zod to throw, and break the build.
Even if that were somehow avoided, the pre-rendered HTML would contain
configuration values from the build environment rather than from any real
deployment environment. The build environment is not staging or production. The
output would be incorrect for every environment it was deployed to.
Vanilla SSG is therefore incompatible with this architecture. It is also largely
unnecessary, because cached components and ISR solve the same problem more
flexibly and without the build-time constraint.
### Partial Pre-Rendering is Incompatible
Partial pre-rendering (PPR) generates a static shell at `next build` and streams
dynamic holes at request time. The root layout contains
`ApplicationConfigurationScript`, which accesses `runtimeConfiguration.public`
at render time. When Next.js attempts to generate the static shell at build time
it will render the root layout, access the Proxy, trigger the lazy singleton,
and encounter missing runtime variables. Zod throws and the build fails.
PPR is therefore incompatible with this architecture as currently structured.
This is not a practical limitation for a checkout application. The static shell
can only contain content with no data dependencies whatsoever — for checkout
that is essentially nothing useful. The meaningful performance optimisations for
checkout are better served by cached components, which provide far more granular
control at the component and function level.
### ISR is Fully Compatible
Incremental static regeneration does not pre-render at `next build`. It renders
on the first actual request to a live server instance, with all runtime
environment variables present. The resulting HTML is then cached on that server
instance and served to subsequent visitors until the TTL expires or the cache is
invalidated on demand.
Each deployment environment runs its own isolated server instances with their
own independent caches. Staging servers cache HTML rendered with staging
configuration. Production servers cache HTML rendered with production
configuration. These caches are completely separate and never interact.
For pages with rarely changing content such as legal terms or open source
licence pages, ISR with a long TTL or on-demand invalidation via `revalidateTag`
from a webhook handler is the appropriate approach. It gives the same practical
result as vanilla SSG without the build-time incompatibility, and content can be
updated without triggering a full rebuild.
### Cached Components are Fully Compatible and Preferred
Next.js 15 introduced cached components via the `use cache` directive, alongside
`cacheLife` for TTL configuration and `cacheTag` for invalidation. This is the
formal replacement for both `unstable_cache` and the older ISR `revalidate`
export on pages. Cached components operate entirely at runtime — nothing is
generated at build time — and are fully compatible with this architecture.
Cached components are strictly more powerful than ISR for a checkout application
because caching can be applied at the component or function level rather than
just at the page level. Different subtrees of the same page can have independent
cache lifetimes and invalidation strategies. An expensive channel branding
component can be cached for an hour. A CMS content component can be cached until
explicitly invalidated via webhook. The basket state component is not cached at
all. This granularity is impossible with page-level ISR.
The `unstable_cache` and `React.cache()` calls in the current codebase will be
migrated to `use cache` and `cacheTag` as those APIs stabilise. The semantics
are identical; the API is cleaner and more expressive.
### Dynamic Rendering is Forced Application-Wide
All current pages in this application are inherently dynamic. The checkout page
fetches basket state, session data, and channel configuration on every request
and would gain nothing from page-level caching. The application also depends on
`ApplicationConfigurationScript` being present in every render, which requires
the origin server to be in the request path.
To prevent Next.js from attempting to statically generate routes it considers
eligible, `dynamic = 'force-dynamic'` is exported from the root layout. This
propagates to all server component pages and layouts in the application.
Two notes on special routes:
`error.tsx` and `global-error.tsx` must be client components. Next.js enforces
this because error boundaries are a React concept requiring `'use client'`. They
do not inherit `dynamic = 'force-dynamic'` from the root layout, but they are
also incapable of static rendering so no special handling is needed. Client
components in error boundaries can access `runtimeConfiguration.public`, which
reads from `window.__PUBLIC_APPLICATION_CONFIGURATION__` on the client. This is
appropriate for use cases like initialising error reporting with the correct API
key. Private runtime configuration and secrets are not available in client
components.
One important detail specific to `global-error.tsx`: because it replaces the
root layout entirely when rendered, it does not inherit the layout's head
content. `ApplicationConfigurationScript` must therefore be rendered directly
inside `global-error.tsx`. Without it, `window.__PUBLIC_APPLICATION_CONFIGURATION__`
will be undefined and any access to `runtimeConfiguration.public` within the
error boundary will throw. See `src/app/global-error.tsx` for the implementation.
`not-found.tsx` is a server component by default and inherits `dynamic =
'force-dynamic'` from the root layout correctly. No special treatment is needed.
This is not a permanent constraint. Cached components and ISR are available for
any future page where caching is appropriate, with no architectural changes
required.
---
## Access Patterns
### Injected configuration: Proxy-based direct property access
`runtimeConfiguration` and `secrets` are both JavaScript `Proxy` objects that
defer process.env reads and Zod validation until the first property access, which
only ever happens at request time. They are typed as their Zod-inferred types so
consumers get full autocompletion and type safety with no awareness of the Proxy.
```typescript
// Runtime configuration — isomorphic, reads from process.env on server
// and from window.__PUBLIC_APPLICATION_CONFIGURATION__ on client
runtimeConfiguration.public.orderingApiBaseUrl
runtimeConfiguration.private.internalOrderingApiBaseUrl
// Secrets — server-only
secrets.databaseUrl
secrets.encryptionKey
```
The Proxy pattern is used because `runtimeConfiguration` and `secrets` are
conceptually plain objects of injected values. Accessing them as properties
rather than calling a function to retrieve them reflects this accurately and
provides a consistent, ergonomic API. `buildConfiguration` is a plain eagerly
evaluated object for the same reason — it has no deferred read to wrap.
Channel configuration and channel secrets use explicit async function calls
rather than Proxies because they involve asynchronous database queries and API
calls. A Proxy cannot make property access return a Promise in a usable way.
### Taint on Proxy-wrapped objects
React's `experimental_taintObjectReference` operates on object references. A
Proxy is a different reference from the underlying object it wraps. For `secrets`,
both the underlying parsed object and the Proxy are independently registered in
the taint registry. This ensures React throws whether the Proxy or the underlying
object is passed to a client component. Individual secret values are also tainted
via `experimental_taintUniqueValue`, so destructured values are caught as well.
`runtimeConfiguration` is not tainted because it is designed to be partially
accessible on the client — the public slice is intentionally injected into
`window.__PUBLIC_APPLICATION_CONFIGURATION__`. The private slice is protected
by not being written to the window, and by the fact that `server-only` is not
applied to this module (it must be importable by client components). Developers
are expected not to pass `runtimeConfiguration.private` values as props, and
code review is the enforcement mechanism for that boundary.
---
## Structure
Configuration and secrets are divided into two groups reflecting their
fundamentally different natures.
### Injected values: `src/configuration/`
Build configuration, runtime configuration, and application secrets are passive.
The application does not fetch them — they are pushed into the process from
outside, either by CI or by infrastructure. This shared nature is why they live
together under `src/configuration/`.
### Fetched values: `src/features/multitenancy/` and `src/data-access/`
Channel configuration and channel secrets are active. The application fetches
them from a database and an external API respectively. They involve data access
objects, caching strategies, and framework-specific concerns. This is a
fundamentally different nature from injected configuration.
The data access layer in `src/data-access/` contains thin, framework-agnostic
functions for raw external communication. The feature layer in
`src/features/multitenancy/` wraps those functions with Next.js and React
specific concerns: caching, `server-only`, and taint. This separation keeps
the data access layer free of framework concerns and independently testable.
---
## Configuration Categories
### Build Configuration (`src/configuration/build/`)
Values that are properties of a given build, not of any particular environment
running that build. Set once by CI. Do not vary across deployments of that build.
Must be present at both `next build` and at runtime. Baked into the Docker image
so they are carried into every runtime environment automatically. Evaluated
eagerly at module load time as a plain object — no Proxy needed because there is
no deferred read. Marked `server-only` and tainted.
### Runtime Configuration (`src/configuration/runtime/`)
Values that differ between environments running the same build. Infrastructure
injects these at runtime only. Not present at `next build`.
Exported as a Proxy named `runtimeConfiguration`. Property access triggers the
lazy singleton on first use. Zod validates the full shape at that point.
Split into two slices:
**Public** — intentionally visible in the browser. Injected into the client via
`window.__PUBLIC_APPLICATION_CONFIGURATION__` by `ApplicationConfigurationScript`.
Accessible in client components via `runtimeConfiguration.public`.
**Private** — server-side only. Not secret, but not intended for the client.
Internal service URLs, bucket names, background API endpoints.
Do not access `runtimeConfiguration` from `next.config.ts` or from module-level
code in any file `next.config.ts` imports. Use `buildConfiguration` from
`src/configuration/build/` in those contexts instead.
### Secrets (`src/configuration/secrets/`)
Sensitive credentials whose exposure would constitute a security incident.
Injected by infrastructure at runtime from a secrets manager.
Exported as a Proxy named `secrets`. Same lazy singleton pattern as runtime
configuration. Marked `server-only`. Both the Proxy and the underlying parsed
object are tainted. All individual values are tainted.
### Channel Configuration (`src/features/multitenancy/channel-configuration/`)
Per-channel, non-secret configuration owned by the checkout application. Stored
in the checkout database. Fetched asynchronously — explicit async function calls,
not a Proxy.
Marked `server-only` and tainted aggressively despite containing no current
secrets, because sensitive fields are likely to be added.
Cached using `unstable_cache` for cross-request persistence with a five minute
fallback TTL, and `React.cache()` for within-request deduplication. Will be
migrated to `use cache` and `cacheTag` as those APIs stabilise.
### Channel Secrets (`src/features/multitenancy/channel-secrets/`)
Per-channel ordering API credentials. Fetched asynchronously from the ordering
API sync service. Explicit async function calls, not a Proxy. Not cached across
requests. Tainted per call.
---
## How Public Configuration Reaches the Client
`ApplicationConfigurationScript` is a server component rendered in the document
head. At render time it accesses `runtimeConfiguration.public`, JSON-serialises
it, and writes it to `window.__PUBLIC_APPLICATION_CONFIGURATION__` via an inline
script. Inline scripts in the head are executed synchronously by the browser
during HTML parsing, before any deferred or async scripts. Since Next.js injects
its client bundles with `defer` or at the end of `body`, the window property is
always populated before any client component code runs.
When a client component accesses `runtimeConfiguration.public.orderingApiBaseUrl`,
the Proxy triggers the lazy singleton, which calls `readVariable()`, which checks
`typeof window` and reads from `window.__PUBLIC_APPLICATION_CONFIGURATION__`
rather than `process.env`. The routing is transparent to the consumer.
Only the public slice is ever written to the window. Private runtime
configuration and build configuration are never serialised into the page.
`ApplicationConfigurationScript` must be included in both the root layout and
`global-error.tsx`. The root layout covers all normal routes. `global-error.tsx`
replaces the root layout entirely when the top-level error boundary fires and
must include the script directly in its own head.
---
## What Can Be Imported Where
| Module | next.config.ts | middleware.ts | Server components | Client components |
|---|---|---|---|---|
| `configuration/build` | Yes | Yes | Yes | No (server-only + taint) |
| `configuration/runtime` | No | Yes | Yes | Yes (public slice only, via window) |
| `configuration/secrets` | No | No | Yes | No (server-only + taint) |
| `features/multitenancy/channel-configuration` | No | Yes (cached) | Yes | No (server-only + taint) |
| `features/multitenancy/channel-secrets` | No | No | Yes | No (server-only + taint) |
The prohibition on accessing `runtimeConfiguration` or `secrets` from
`next.config.ts` is because `next.config.ts` is evaluated at build time, when
runtime variables are not yet present. Triggering the lazy singleton at build
time causes Zod to throw. Any file that `next.config.ts` imports directly has
the same restriction.
---
## Test Runner Configuration
`APP_TEST_PROXY_ENABLED` is intentionally absent from all configuration modules.
It is not an application concern — it is a test runner concern. Playwright
injects it directly via the `env` block in `playwright.config.ts` when it starts
the dev server for integration tests. `next.config.ts` reads it directly from
`process.env` at build time to configure the test proxy. This is the only
legitimate consumer.
This pattern applies to any future variable that exists solely to configure the
application's behaviour during a specific test run. Such variables belong in the
Playwright configuration, not in the application configuration system.
---
## Local Development and Secrets Management
For guidance on setting up `.env.local`, targeting remote environments, and
managing secrets safely on developer machines, see `docs/local-development.md`.
---
## Note on React Taint APIs
`experimental_taintObjectReference` and `experimental_taintUniqueValue` are used
throughout this system to prevent secrets and server-only configuration from
being passed to client components. These APIs remain experimental as of early
2026 and must be enabled explicitly in `next.config.ts`:
```typescript
experimental: {
taint: true,
}
```
Taint causes React to throw at runtime if a tainted value is passed to a client
component as a prop. It is a defence-in-depth mechanism that surfaces accidental
leakage loudly during development and in tests, before anything reaches
production. It complements rather than replaces the `server-only` package:
`server-only` prevents a module from being imported into a client bundle at all,
while taint prevents individual values from crossing the boundary even when
passed as props from a server component to a client component.
For Proxy-wrapped objects, both the Proxy reference and the underlying object
reference must be independently registered with `experimental_taintObjectReference`.
React's taint registry operates on object identity — tainting one reference does
not automatically taint another reference to the same underlying data.
docs/local-development.md — unchanged from the previous version, reproduced here for completeness.
markdown# Local Development
This document explains how to configure the application for local development,
how to target remote environments when needed, and how secrets are managed
safely on developer machines.
---
## Environment Variable Files
The application uses `.env.local` for local configuration. This file is
gitignored and must never be committed to version control under any
circumstances, regardless of whether it contains real credentials or placeholder
values.
`.env.local.example` is committed to the repository and serves as the canonical
reference for every variable the application needs. It contains safe placeholder
values for local development and commented-out 1Password references for
targeting remote environments. When setting up the application for the first
time, copy it to `.env.local`:
```bash
cp .env.local.example .env.local
```
---
## Fully Local Development
The recommended approach for day-to-day development is to run all infrastructure
locally using the local-dev tool, which provisions local instances of the
database and dependent services. In this mode, `.env.local` contains only
non-sensitive placeholder values pointing at local services. No real credentials
are needed and no secrets manager is involved.
Refer to the local-dev tool documentation for setup instructions. Once running,
start the application with:
```bash
next dev
```
The placeholder values in `.env.local.example` are configured to work with
local-dev's default service ports and database credentials out of the box.
---
## Targeting a Remote Environment
Sometimes it is more practical to point your local application at a remote
environment such as dev or an on-demand deployment rather than running the full
stack locally. This requires real credentials for that environment.
**Real credentials must never be stored on disk in plaintext.** Use the
1Password CLI to keep them off disk entirely.
### How 1Password Secret References Work
Instead of putting real credential values in `.env.local`, you put references
that point at items in the team's 1Password vault:
```bash
DATABASE_URL=op://vault-name/checkout-dev/database-url
ENCRYPTION_KEY=op://vault-name/checkout-dev/encryption-key
```
You then start the application using `op run` rather than `next dev` directly:
```bash
op run --env-file=".env.local" -- next dev
```
`op run` resolves all `op://` references by fetching the real values from
1Password, injects them as environment variables into the child process, and
then starts the application. The actual secret values never touch the filesystem.
Your `.env.local` file contains only references and remains safe, though it
should stay gitignored regardless.
### Setup
Install the 1Password CLI:
```bash
brew install 1password-cli
```
Sign in to the team's 1Password account:
```bash
op signin
```
You must be a member of the team's 1Password account with access to the
appropriate vault. Contact your team lead or infrastructure owner to request
access.
### Choosing an Environment
Replace the plaintext values in `.env.local` with the commented-out 1Password
references from `.env.local.example`, substituting the correct vault name and
item names for the environment you want to target. The vault structure and item
names are documented in the team's 1Password account.
Use dev or an on-demand deployment for feature development and debugging.
Only target staging when there is a specific reason to do so, and only if you
have been granted access. Never target production under any circumstances.
---
## Secrets on Developer Machines
The following rules apply without exception:
Real credentials must never be stored on disk in plaintext. Use 1Password
references as described above.
`.env.local` must never be committed to version control, even if it contains
only placeholder values. The gitignore entry must not be removed.
Production credentials must never be present on a developer machine under any
circumstances. Production vault access is restricted to infrastructure and a
small number of named individuals for break-glass scenarios only.
If you suspect a credential has been compromised, rotate it immediately and
notify the infrastructure team.
---
## Test Runner Configuration
A small number of environment variables are used exclusively by the test runner
and are not part of the application configuration system. These are injected
directly by Playwright when it starts the dev server for integration tests,
via the `env` block in `playwright.config.ts`:
```typescript
// playwright.config.ts
export default defineConfig({
webServer: {
command: 'next dev',
env: {
APP_TEST_PROXY_ENABLED: 'true',
},
},
})
```
`next.config.ts` reads `APP_TEST_PROXY_ENABLED` directly from `process.env` at
build time to configure the test proxy. This is an intentional exception to the
application configuration system: the variable is a test runner concern, not an
application concern, and it has no business being in any configuration module.
Do not set `APP_TEST_PROXY_ENABLED` manually in `.env.local`. Setting it outside
of the Playwright context will enable the test proxy in your local dev server
unintentionally.
---
## Variable Reference
For a full description of every variable, which configuration module owns it,
and where it can be imported, see `docs/configuration-architecture.md`.
| Variable | Category | Local default |
|---|---|---|
| `APP_VERSION` | Build | `0.0.0-local` |
| `NODE_ENV` | Build | `development` |
| `APP_PUBLIC_ORDERING_API_BASE_URL` | Runtime — public | `http://localhost:4000` |
| `APP_PUBLIC_RAYGUN_API_KEY` | Runtime — public | `local-raygun-key` |
| `INTERNAL_ORDERING_API_BASE_URL` | Runtime — private | `http://localhost:4000` |
| `ASSETS_BUCKET_NAME` | Runtime — private | `checkout-local` |
| `CMS_API_BASE_URL` | Runtime — private | `http://localhost:4001` |
| `DATABASE_URL` | Secret | Local Postgres instance |
| `ENCRYPTION_KEY` | Secret | Local placeholder (min 32 chars) |
| `INTERNAL_SERVICE_TOKEN` | Secret | Local placeholder |
| `APP_TEST_PROXY_ENABLED` | Test runner only | Set by Playwright — do not set manually |
.env.local.example — unchanged from the previous version, reproduced for completeness.
bash# =============================================================================
# Local development configuration
#
# Copy this file to .env.local and fill in the values for your environment.
# .env.local is gitignored and must never be committed.
#
# For fully local development using the local-dev tool, the defaults below
# point at locally running services and no real credentials are needed.
#
# For targeting a remote environment such as dev or an OD, use 1Password CLI
# to avoid storing real credentials on disk in plaintext. See the commented-out
# references at the bottom of this file and docs/local-development.md.
# =============================================================================
# -----------------------------------------------------------------------------
# Build configuration
#
# Properties of the build itself. APP_VERSION is set to a placeholder locally.
# NODE_ENV should remain 'development' for local development.
# -----------------------------------------------------------------------------
APP_VERSION=0.0.0-local
NODE_ENV=development
# -----------------------------------------------------------------------------
# Runtime configuration — public
#
# Injected into the client at request time. Defaults point at local services.
# -----------------------------------------------------------------------------
APP_PUBLIC_ORDERING_API_BASE_URL=http://localhost:4000
APP_PUBLIC_RAYGUN_API_KEY=local-raygun-key
# -----------------------------------------------------------------------------
# Runtime configuration — private
#
# Server-side only. Not exposed to the client. Defaults point at local services.
# -----------------------------------------------------------------------------
INTERNAL_ORDERING_API_BASE_URL=http://localhost:4000
ASSETS_BUCKET_NAME=checkout-local
CMS_API_BASE_URL=http://localhost:4001
# -----------------------------------------------------------------------------
# Secrets
#
# For local development against local infrastructure, the placeholder values
# below work with the local-dev tool out of the box and do not represent real
# credentials.
#
# For targeting a remote environment, replace these with 1Password references
# and run via `op run`. See docs/local-development.md.
# -----------------------------------------------------------------------------
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/checkout
ENCRYPTION_KEY=local-encryption-key-min-32-characters-long
INTERNAL_SERVICE_TOKEN=local-internal-service-token
# -----------------------------------------------------------------------------
# 1Password references for remote environments
#
# To target a remote environment, comment out the plaintext values above and
# uncomment the references below. Replace vault-name and item-name with the
# correct paths from the team's 1Password account.
#
# Run the dev server with:
# op run --env-file=".env.local" -- next dev
# -----------------------------------------------------------------------------
# APP_PUBLIC_ORDERING_API_BASE_URL=op://vault-name/checkout-dev/ordering-api-base-url
# APP_PUBLIC_RAYGUN_API_KEY=op://vault-name/checkout-dev/raygun-api-key
# INTERNAL_ORDERING_API_BASE_URL=op://vault-name/checkout-dev/internal-ordering-api-base-url
# ASSETS_BUCKET_NAME=op://vault-name/checkout-dev/assets-bucket-name
# CMS_API_BASE_URL=op://vault-name/checkout-dev/cms-api-base-url
# DATABASE_URL=op://vault-name/checkout-dev/database-url
# ENCRYPTION_KEY=op://vault-name/checkout-dev/encryption-key
# INTERNAL_SERVICE_TOKEN=op://vault-name/checkout-dev/internal-service-token
# -----------------------------------------------------------------------------
# Test runner configuration
#
# Not part of the application configuration system. Injected by Playwright
# via playwright.config.ts when starting the dev server for integration tests.
# Do not set this manually — it will enable the test proxy unintentionally.
# -----------------------------------------------------------------------------
# APP_TEST_PROXY_ENABLED=true
Context: Next.js checkout application configuration architecture
This is a standalone multi-tenant checkout application built with Next.js App Router and React Server Components. The entire configuration architecture is shaped by one core constraint: the application is built once by CI and the resulting Docker image is deployed to every environment unchanged. Deployment-specific values are injected by infrastructure at runtime only. Nothing environment-specific can be resolved at next build.
Configuration categories
There are five distinct categories of configuration, separated by nature rather than just by sensitivity.
src/configuration/build/ — build configuration. Properties of the build itself, not of any deployment. Present at both next build (injected by CI) and at runtime (baked into the Docker image). Eagerly evaluated at module load time as a plain exported object called buildConfiguration. Marked server-only. All values tainted with experimental_taintObjectReference and experimental_taintUniqueValue.
src/configuration/runtime/ — runtime configuration. Deployment-specific values that differ between staging and production. Present at runtime only, not at next build. Exported as a Proxy named runtimeConfiguration that defers all process.env reads and Zod validation until first property access at request time. Split into a public slice (injected into the client via window.__PUBLIC_APPLICATION_CONFIGURATION__ by ApplicationConfigurationScript) and a private slice (server-side only). Isomorphic: on the server reads from process.env, on the client reads from window.__PUBLIC_APPLICATION_CONFIGURATION__. Not marked server-only because client components need runtimeConfiguration.public.
src/configuration/secrets/ — application secrets. Sensitive credentials injected by infrastructure at runtime from a secrets manager. Exported as a Proxy named secrets using the same lazy singleton pattern as runtimeConfiguration. Marked server-only. Both the Proxy reference and the underlying parsed object are independently tainted, because React's taint registry operates on object identity and a Proxy is a different reference from the object it wraps. Individual values also tainted.
src/features/multitenancy/channel-configuration/ — channel configuration. Per-channel non-secret configuration owned by checkout, stored in the checkout database. Fetched asynchronously via explicit async functions, not a Proxy. Marked server-only. Aggressively tainted despite containing no current secrets, as a pre-emptive measure against future sensitive fields. Two-layer cache: unstable_cache (5 minute TTL, revalidateTag('channel-configuration') for on-demand invalidation) and React.cache() for within-request deduplication. Will migrate to use cache and cacheTag when those APIs stabilise.
src/features/multitenancy/channel-secrets/ — channel secrets. Per-channel ordering API credentials fetched from an ordering API sync service. Async functions only. Not cached. Tainted per call.
Why injected config uses Proxies but fetched config uses async functions
runtimeConfiguration and secrets are injected values — the application is passive, they are pushed in from outside. They are conceptually plain objects. A Proxy wrapping a lazy singleton gives direct property access (secrets.databaseUrl, runtimeConfiguration.public.orderingApiBaseUrl) without triggering reads at module load time. This is safe to import at build time because the Proxy target is an empty object and the handler only reads process.env when a property is actually accessed, which only happens at request time.
Channel configuration and channel secrets cannot use Proxies because they are fetched asynchronously. A Proxy cannot make synchronous property access return a useful Promise. Async data access must remain as explicit async function calls.
Why buildConfiguration is not a Proxy
Build configuration variables are present at both next build and at runtime. Eager evaluation is desirable — if APP_VERSION is missing the build should fail immediately with a clear Zod error rather than failing silently mid-request. There is no deferred read to wrap.
The public/private split and ApplicationConfigurationScript
ApplicationConfigurationScript is a server-only component rendered in the document <head> of both the root layout and global-error.tsx. At render time it accesses runtimeConfiguration.public, JSON-serialises it, and writes it to window.__PUBLIC_APPLICATION_CONFIGURATION__ via an inline script. Inline scripts in <head> execute synchronously during HTML parsing before any deferred Next.js bundles, so the window property is always populated before client component code runs.
global-error.tsx must include ApplicationConfigurationScript directly in its own <head> because it replaces the root layout entirely when the top-level error boundary fires, so it does not inherit the layout's head content.
Lazy singleton pattern
Both runtimeConfiguration and secrets back their Proxy with a let _resolved singleton that is populated on first access and memoised for the process lifetime. Next.js traverses the module graph at build time, which means these modules are imported at build time, but the Proxy handler only calls the resolver when a property is actually accessed, which only happens at request time when all environment variables are present.
Rendering strategy compatibility
Vanilla SSG is incompatible. Pre-rendering at next build would trigger the lazy singletons, encounter missing runtime variables, and fail. Even if bypassed, the rendered HTML would contain build-environment values rather than real deployment values.
Partial pre-rendering (PPR) is incompatible as currently structured. The root layout contains ApplicationConfigurationScript, which accesses runtimeConfiguration.public at render time. Next.js cannot generate the static shell at build time without runtime variables present. This is not a practical limitation for checkout — a static shell would contain nothing meaningful since all content is dynamic by nature.
ISR is fully compatible. Rendering happens at request time in a live process with all variables present. Each deployment environment has isolated server instances and isolated caches.
Cached components (use cache directive, Next.js 15) are fully compatible and preferred. They are a runtime caching mechanism with no build-time implications. More granular than ISR — caching applies at the component or function level, not the page level. Different subtrees can have different TTLs and invalidation strategies. The existing unstable_cache usage in channel configuration will migrate to use cache once it stabilises. The key distinction: use cache and PPR are independent features despite being introduced together. use cache does not require PPR.
Data layer structure
src/data-access/ contains thin framework-agnostic modules: checkout-database.ts (Prisma queries, currently mocked) and ordering-api-client.ts (fetch calls to ordering API sync service, currently mocked). These have no knowledge of Next.js caching, React, or taint.
src/features/multitenancy/ wraps the data access layer with Next.js and React specific concerns. This separation keeps the data access layer independently testable.
Import constraints
buildConfiguration — safe everywhere on the server including next.config.ts and middleware. Not for client components.
runtimeConfiguration — not from next.config.ts or any file it imports (evaluated at build time). Safe from middleware, server components, route handlers. Safe from client components for the public slice only.
secrets — only from server components and route handlers. Not from next.config.ts, middleware, or client components.
Channel configuration — not from next.config.ts. Safe from middleware (with caching) and server components.
Channel secrets — only from server components and route handlers.
Taint approach
experimental_taintObjectReference and experimental_taintUniqueValue are used throughout. Requires experimental: { taint: true } in next.config.ts. Still experimental as of early 2026. Taint complements server-only: server-only prevents a module being imported into a client bundle; taint prevents values crossing the server/client boundary as props even when both components are rendered server-side. For Proxy-wrapped objects, both the Proxy reference and the underlying object must be independently registered in the taint registry.
Local development and secrets
.env.local is gitignored and never committed. .env.local.example is committed with safe local defaults and commented-out 1Password references. For targeting remote environments (dev, on-demand deployments), the 1Password CLI op run command resolves op://vault-name/item/field references and injects real values into the child process without writing them to disk. Developers should never target staging without explicit need or production under any circumstances.
APP_TEST_PROXY_ENABLED is intentionally absent from all configuration modules. It is injected by Playwright via playwright.config.ts env block when starting the dev server for integration tests, and read directly from process.env in next.config.ts at build time. It is a test runner concern, not an application concern.
Multi-tenancy
Middleware is the single point of channel resolution. It reads the host header, maps it to a channel slug, and rewrites the request path to include [channelSlug] as a dynamic route segment. Unknown hosts receive a 404. All routes are prefixed with the channel slug. No page or component re-derives channel identity from headers.
Tech stack specifics
Next.js App Router, React Server Components, TypeScript, Zod v4 (zod/v4 import path), Biome for linting. React taint APIs still experimental. use cache directive and cacheLife/cacheTag APIs are Next.js 15 features targeted for migration from unstable_cache. dynamic = 'force-dynamic' exported from root layout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment