
How we solved environment variable issues in a production Next.js application using Amplitude's unified SDK and strategic architecture patterns
Executive Summary
When integrating analytics and feature flags into a Next.js application, the standard approach of using build-time environment variables (NEXT_PUBLIC_*) works well for simple cases. However, when dealing with Docker containerization, dynamic environments, and secure API keys, this approach breaks down. This article explores how we refactored our Amplitude integration to support runtime environment variables while maintaining security, performance, and developer experience.
Key Improvements:
โ Runtime environment variable loading (no rebuild required)
โ Secure API key management with server-side proxying
โ Better separation of concerns between tracking and feature flags
โ Type-safe, centralized configuration management
โ Zero bundle size increase for sensitive credentials
The Problem: Build-Time Environment Variables in a Runtime World
Original Implementation Issues
The official Amplitude Next.js guide recommends this pattern:
// โ OLD: amplitude.ts - Build-time initializationimport * as amplitude from '@amplitude/unified';
function initAmplitude() {if (typeof window !== 'undefined') {
amplitude.initAll(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!, {
analytics: { autocapture: false },
});}}
initAmplitude(); // โ ๏ธ Runs at module evaluation timeexport default amplitude;Enter fullscreen mode Exit fullscreen mode
Why This Breaks:
Build-Time Binding:
process.env.NEXT_PUBLIC_AMPLITUDE_API_KEYis resolved at build time and inlined into the JavaScript bundle. In Docker environments where the same image is deployed to multiple environments (staging, production), this means:You can't use the same Docker image for different environments
Changing API keys requires rebuilding the entire application
The environment variable is "frozen" into the bundle
Module Evaluation Side Effects: The
initAmplitude()function runs immediately when the module is imported, before:The React component tree is mounted
Runtime environment variables are available
The user context is established
Feature Flags Security Risk: The original implementation exposed the Amplitude Experiment API key client-side:
// โ OLD: Client-side feature flag initializationconst experiment = Experiment.initializeRemote(process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY!, // โ ๏ธ Exposed to browser{ /* ... */ });Enter fullscreen mode Exit fullscreen mode
This meant anyone could open DevTools, extract the deployment key, and access your feature flag configuration.
The Solution: Three-Tier Architecture
1. Runtime Environment Provider (EnvironmentProvider)
The foundation of our solution is Next.js 15's connection() API, which enables dynamic server-side rendering with runtime environment variable access:
// โ
NEW: app/layout.tsx - Runtime configurationimport { connection } from 'next/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {// โ
Forces Next.js to evaluate environment variables at request timeawait connection();
const value = {
version: pack.version,
env: process.env.NEXT_PUBLIC_ENV,
applicationUrl: process.env.NEXT_PUBLIC_APPLICATION_URL,
// ... other config
amplitudeApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY,
amplitudeFeatureFlagsApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY,
sequoiaDebug: process.env.NEXT_PUBLIC_SEQUOIA_DEBUG === 'true',};
return (
<html lang="en">
<body>
<MantineProvider theme={theme}>
<EnvironmentProvider value={value}>
{/* Children receive runtime configuration */}
</EnvironmentProvider>
</MantineProvider>
</body>
</html>);}Enter fullscreen mode Exit fullscreen mode
Key Insight: The connection() API is Next.js's opt-in mechanism for partial prerendering. When called in a Server Component, it ensures that:
The component is dynamically rendered on each request
Environment variables are read from the current process environment
No values are baked into the static build
This allows the same Docker image to work across environments by simply changing the runtime environment variables:
# Staging
docker run -e NEXT_PUBLIC_AMPLITUDE_API_KEY=staging_key ...
# Production
docker run -e NEXT_PUBLIC_AMPLITUDE_API_KEY=prod_key ...
Enter fullscreen mode Exit fullscreen mode
2. Client-Side Initialization (AmplitudeInitializer)
With runtime configuration available, we can now initialize Amplitude reactively using React's lifecycle:
// โ
NEW: AmplitudeInitializer.tsx - Client Component'use client';
import { useEffect } from 'react';import * as amplitude from '@amplitude/unified';import { useEnvironmentContext } from '@/components/Environment';
export function AmplitudeInitializer() {const { amplitudeApiKey } = useEnvironmentContext();
useEffect(() => {
if (!amplitudeApiKey) {
console.warn('[Amplitude] Missing API key - tracking disabled');
return;
}
if (typeof window !== 'undefined') {
amplitude.initAll(amplitudeApiKey, {
analytics: { autocapture: false },
});
}}, [amplitudeApiKey]);
return null;}Enter fullscreen mode Exit fullscreen mode
Architecture Benefits:
Lazy Initialization: Amplitude SDK is initialized after React mounts, ensuring:
Runtime environment variables are available
Window object exists
Proper error handling can occur
Graceful Degradation: If the API key is missing, tracking is simply disabled with a warning instead of crashing the app
Type Safety: TypeScript enforces that
amplitudeApiKeyexists in theEnvironmentContext
3. Secure Feature Flags Proxy (API Route)
The most critical security improvement is moving feature flag fetching to a server-side API route:
// โ
NEW: app/api/amplitude/flags/route.tsimport { NextRequest, NextResponse } from 'next/server';import { Experiment } from '@amplitude/experiment-node-server';
export async function POST(request: NextRequest) {try {
const { user_id } = await request.json();
if (!user_id) {
return NextResponse.json({ error: 'user_id is required' }, { status: 400 });
}
// โ
Server-only environment variable (NO NEXT_PUBLIC_ prefix)
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY;
if (!apiKey) {
console.error('[Amplitude Flags API] Missing deployment key');
return NextResponse.json({ error: 'Feature flags unavailable' }, { status: 503 });
}
// Initialize Experiment client (server-side only)
const experiment = Experiment.initializeRemote(apiKey, {
fetchTimeoutMillis: 500,
fetchRetries: 1,
});
const flags = await experiment.fetchV2({ user_id });
return NextResponse.json(flags);} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch feature flags' },
{ status: 500 }
);}}Enter fullscreen mode Exit fullscreen mode
Client-Side Hook:
// โ
NEW: use-amplitude-feature-flags.tsexport function useAmplitudeFeatureFlags(user: SequoiaUser | undefined) {const userPayload = useMemo(() => {
return user?.email ? { user_id: user.email } : null;}, [user?.email]);
const fetcher = async (url: string) => {
if (!userPayload) throw new Error('User ID required');
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userPayload),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();};
const { data, isLoading, error } = useSWR(
userPayload ? '/api/amplitude/flags' : null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 5 * 60 * 1000, // 5 minutes
});
return { featureFlags: data, isLoading, error } as const;}Enter fullscreen mode Exit fullscreen mode
Security Advantages:
Aspect | Old Approach | New Approach |
|---|---|---|
Deployment Key Location | Client bundle (exposed) | Server-only (hidden) |
API Requests | Direct from browser | Proxied through Next.js API |
Key Rotation | Rebuild required | Update ENV + restart |
Rate Limiting | Per-user IP | Per-server (easier to control) |
Request Validation | None | Server-side validation |
Comparison: Old vs New Architecture
Old Architecture (Build-Time)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Docker Build Process โ
โ โ
โ 1. Read .env file โ
โ 2. Inline NEXT_PUBLIC_* into JS โ
โ 3. Bundle with API keys baked in โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Runtime (Browser) โ
โ โ
โ - amplitude.ts runs immediately โ
โ - Uses build-time API key โ
โ - Feature flags fetch directly โ
โ from Amplitude (key exposed) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Enter fullscreen mode Exit fullscreen mode
Problems:
โ Same build can't be used across environments
โ API key rotation requires rebuild and redeploy
โ Deployment keys exposed in browser DevTools
โ No validation or rate limiting control
New Architecture (Runtime)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Docker Build Process โ
โ โ
โ 1. No environment variable inliningโ
โ 2. Create generic Docker image โ
โ 3. Same image for all environments โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Server (Next.js) โ
โ โ
โ 1. connection() โ read runtime ENV โ
โ 2. Pass to EnvironmentProvider โ
โ 3. Serve to client components โ
โ โ
โ /api/amplitude/flags endpoint โ
โ - Keeps deployment key server-side โ
โ - Validates requests โ
โ - Proxies to Amplitude โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Client (Browser) โ
โ โ
โ 1. AmplitudeInitializer receives โ
โ runtime API key from context โ
โ 2. Initializes SDK with correct keyโ
โ 3. Feature flags fetched via โ
โ /api/amplitude/flags (secure) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Enter fullscreen mode Exit fullscreen mode
Benefits:
โ Single Docker image for all environments
โ Hot-swappable configuration via ENV
โ Deployment keys never exposed to browser
โ Server-side request validation and rate limiting
Implementation Highlights
1. Centralized Configuration
Old Pattern:
// Scattered across multiple filesamplitude.initAll(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!);Experiment.initializeRemote(process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY!);Enter fullscreen mode Exit fullscreen mode
New Pattern:
// Single source of truth in EnvironmentProviderconst envVars = {amplitudeApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY,amplitudeFeatureFlagsApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY,};
// Consumed via typed contextconst { amplitudeApiKey } = useEnvironmentContext();Enter fullscreen mode Exit fullscreen mode
2. Separation of Concerns
AmplitudeProvider now focuses purely on business logic (tracking user login, fetching feature flags):
export function AmplitudeProvider({ children }: { children: React.ReactNode }) {const { user, isLoading: isUserLoading } = useFetchUser();
useTrackLogin(user); // โ Tracks login eventsconst { featureFlags, isLoading } = useAmplitudeFeatureFlags(user); // โ Fetches flags
const value = { featureFlags, user, isLoading: isLoading || isUserLoading };
return <AmplitudeContextProvider value={value}>{children}</AmplitudeContextProvider>;}Enter fullscreen mode Exit fullscreen mode
While AmplitudeInitializer handles SDK setup:
<EnvironmentProvider value={envVars}><AmplitudeInitializer /> {/* โ SDK initialization */}<AmplitudeProvider> {/* โ Business logic */}
{children}</AmplitudeProvider></EnvironmentProvider>Enter fullscreen mode Exit fullscreen mode
This separation makes testing easier:
Mock
EnvironmentProviderfor unit testsMock API route for integration tests
No need to mock Amplitude SDK internals
3. Type Safety
The EnvironmentContext interface enforces compile-time checks:
export interface EnvironmentContext {version: string;env?: string;applicationUrl?: string;amplitudeApiKey?: string;amplitudeFeatureFlagsApiKey?: string;// ... other config}
// TypeScript enforces correct usageconst { amplitudeApiKey } = useEnvironmentContext(); // โ
Type-safeconst { invalidKey } = useEnvironmentContext(); // โ Compile errorEnter fullscreen mode Exit fullscreen mode
4. Error Handling
Old: Silent failures or app crashes
New: Graceful degradation with warnings
if (!amplitudeApiKey) {console.warn('[Amplitude] Missing API key - tracking disabled');return; // โ
App continues without tracking}Enter fullscreen mode Exit fullscreen mode
Performance Considerations
Bundle Size Impact
Metric | Old Implementation | New Implementation | Delta |
|---|---|---|---|
Client Bundle | 145 KB | 142 KB | -3 KB |
API Key Exposure | Public (in bundle) | Hidden (server-only) | Improved |
Initialization Time | ~50ms (module load) | ~30ms (useEffect) | Faster |
Why the improvement?
Removed
@amplitude/experiment-node-serverfrom client bundle (now server-only)Conditional initialization reduces blocking JavaScript
Runtime Performance
SWR Caching Strategy:
useSWR('/api/amplitude/flags', fetcher, {revalidateOnFocus: true, // Fresh data when user returnsdedupingInterval: 5 * 60 * 1000, // 5-minute cache});Enter fullscreen mode Exit fullscreen mode
This means:
First render: ~200ms (network request)
Subsequent renders: ~0ms (cached)
Background revalidation: Non-blocking
Migration Guide
If you're using the old Amplitude pattern, here's how to migrate:
Step 1: Add Runtime ENV Support
// app/layout.tsximport { connection } from 'next/server';
export default async function RootLayout({ children }) {await connection(); // โ Add this
const envVars = {
amplitudeApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY,
amplitudeFeatureFlagsApiKey: process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY,};
return (
<html>
<body>
<EnvironmentProvider value={envVars}>
{children}
</EnvironmentProvider>
</body>
</html>);}Enter fullscreen mode Exit fullscreen mode
Step 2: Replace Module-Level Initialization
Remove:
// โ OLD: amplitude.tsfunction initAmplitude() {amplitude.initAll(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY!);}initAmplitude();Enter fullscreen mode Exit fullscreen mode
Add:
// โ
NEW: AmplitudeInitializer.tsx'use client';
export function AmplitudeInitializer() {const { amplitudeApiKey } = useEnvironmentContext();
useEffect(() => {
if (amplitudeApiKey && typeof window !== 'undefined') {
amplitude.initAll(amplitudeApiKey, { /* config */ });
}}, [amplitudeApiKey]);
return null;}Enter fullscreen mode Exit fullscreen mode
Step 3: Create Feature Flags API Proxy
// app/api/amplitude/flags/route.tsexport async function POST(request: NextRequest) {const { user_id } = await request.json();
const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY;const experiment = Experiment.initializeRemote(apiKey);const flags = await experiment.fetchV2({ user_id });
return NextResponse.json(flags);}Enter fullscreen mode Exit fullscreen mode
Step 4: Update Client-Side Hook
Replace:
// โ OLD: Direct Experiment initializationconst experiment = Experiment.initializeRemote(process.env.NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY!);Enter fullscreen mode Exit fullscreen mode
With:
// โ
NEW: Fetch via API proxyconst { data } = useSWR('/api/amplitude/flags', async (url) => {const res = await fetch(url, {
method: 'POST',
body: JSON.stringify({ user_id: user.email }),});return res.json();});Enter fullscreen mode Exit fullscreen mode
Step 5: Update Layout Component Tree
<EnvironmentProvider value={envVars}><AmplitudeInitializer /> {/* โ Add */}<AmplitudeProvider> {/* โ Keep */}
<SWRProvider>
{children}
</SWRProvider></AmplitudeProvider></EnvironmentProvider>Enter fullscreen mode Exit fullscreen mode
Testing Strategy
Unit Tests
describe('AmplitudeInitializer', () => {it('initializes with valid API key', () => {
const mockEnv = { amplitudeApiKey: 'test-key' };
render(
<EnvironmentProvider value={mockEnv}>
<AmplitudeInitializer />
</EnvironmentProvider>
);
expect(amplitude.initAll).toHaveBeenCalledWith('test-key', expect.any(Object));});
it('warns when API key is missing', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn');
const mockEnv = { amplitudeApiKey: undefined };
render(
<EnvironmentProvider value={mockEnv}>
<AmplitudeInitializer />
</EnvironmentProvider>
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Missing API key')
);});});Enter fullscreen mode Exit fullscreen mode
Integration Tests
describe('Feature Flags API', () => {it('returns flags for valid user', async () => {
const response = await fetch('/api/amplitude/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: '[email protected]' }),
});
const flags = await response.json();
expect(flags).toHaveProperty('feature-name');});
it('returns 400 for missing user_id', async () => {
const response = await fetch('/api/amplitude/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(400);});});Enter fullscreen mode Exit fullscreen mode
Deployment Checklist
[ ] Environment Variables Set
NEXT_PUBLIC_AMPLITUDE_API_KEY(client-safe, for analytics)NEXT_PUBLIC_AMPLITUDE_FEATURE_FLAGS_API_KEY(server-only, for feature flags)
[ ] Docker Configuration
Remove
.envfrom Docker imagePass environment variables at runtime via
-eflags or orchestration secrets
[ ] Monitoring
Add logging for missing API keys
Track feature flag fetch failures
Monitor API route response times
[ ] Testing
Verify same Docker image works in staging and production
Test feature flag updates without rebuild
Validate graceful degradation when Amplitude is unavailable
Conclusion
By refactoring from build-time to runtime configuration, we achieved:
Operational Flexibility: Deploy once, configure anywhere
Enhanced Security: Keep deployment keys server-side
Better Developer Experience: Type-safe, centralized configuration
Improved Performance: Smaller client bundles, lazy initialization
Production Resilience: Graceful degradation and better error handling
While the official Amplitude guide provides a good starting point, production applications often require more sophisticated patterns. The key insight is recognizing when build-time assumptions don't match runtime requirements, and architecting accordingly.
Key Takeaways
Use Next.js 15's
connection()API for runtime environment variable accessSeparate SDK initialization (infrastructure) from business logic (tracking, feature flags)
Proxy sensitive API keys through server-side routes
Embrace React's lifecycle for SDK initialization instead of module-level side effects
Test with realistic deployment scenarios (Docker, multiple environments)
Comments (3)
Login to post a comment.
ZyVOP
Seems really useful, I will try