{"schemaVersion":"1.0","type":"Article","slug":"refactoring-amplitude-integration-in-next-js-from-build-time-to-runtime-configuration-y7ml9","url":"https://api.zyvop.com/refactoring-amplitude-integration-in-next-js-from-build-time-to-runtime-configuration-y7ml9","title":"Refactoring Amplitude Integration in Next.js: From Build-Time to Runtime Configuration","subtitle":null,"tldr":"How we solved environment variable issues in a production Next.js application using Amplitude's...","keywords":["react","nextjs","TypeScript","Programming"],"entities":["Giovambattista Fazioli","Senior Full-stack Engineer, Lead Developer","react","nextjs","TypeScript","Programming","ZyVOP"],"keyTakeaways":["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."],"headings":["Executive Summary","The Problem: Build-Time Environment Variables in a Runtime World","Original Implementation Issues","The Solution: Three-Tier Architecture","1. Runtime Environment Provider (EnvironmentProvider)","2. Client-Side Initialization (AmplitudeInitializer)","3. Secure Feature Flags Proxy (API Route)","Comparison: Old vs New Architecture","Old Architecture (Build-Time)","New Architecture (Runtime)","Implementation Highlights","1. Centralized Configuration","2. Separation of Concerns","3. Type Safety","4. Error Handling","Performance Considerations","Bundle Size Impact","Runtime Performance","Migration Guide","Step 1: Add Runtime ENV Support","Step 2: Replace Module-Level Initialization","Step 3: Create Feature Flags API Proxy","Step 4: Update Client-Side Hook","Step 5: Update Layout Component Tree"],"outboundLinks":["https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide","https://nextjs.org/docs/app/building-your-application/configuring/environment-variables","https://nextjs.org/docs/app/api-reference/functions/connection","https://www.docs.developers.amplitude.com/data/sdks/typescript-browser/","https://swr.vercel.app/"],"contentText":"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_KEY is 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 ( &lt;html lang=\"en\"&gt; &lt;body&gt; &lt;MantineProvider theme={theme}&gt; &lt;EnvironmentProvider value={value}&gt; {/* Children receive runtime configuration */} &lt;/EnvironmentProvider&gt; &lt;/MantineProvider&gt; &lt;/body&gt; &lt;/html&gt;);} 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(() =&gt; { 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 amplitudeApiKey exists in the EnvironmentContext 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(() =&gt; { return user?.email ? { user_id: user.email } : null;}, [user?.email]); const fetcher = async (url: string) =&gt; { 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 &lt;AmplitudeContextProvider value={value}&gt;{children}&lt;/AmplitudeContextProvider&gt;;} Enter fullscreen mode Exit fullscreen mode While AmplitudeInitializer handles SDK setup: &lt;EnvironmentProvider value={envVars}&gt;&lt;AmplitudeInitializer /&gt; {/* ← SDK initialization */}&lt;AmplitudeProvider&gt; {/* ← Business logic */} {children}&lt;/AmplitudeProvider&gt;&lt;/EnvironmentProvider&gt; Enter fullscreen mode Exit fullscreen mode This separation makes testing easier: Mock EnvironmentProvider for unit tests Mock 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 error Enter 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-server from 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 ( &lt;html&gt; &lt;body&gt; &lt;EnvironmentProvider value={envVars}&gt; {children} &lt;/EnvironmentProvider&gt; &lt;/body&gt; &lt;/html&gt;);} 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(() =&gt; { if (amplitudeApiKey &amp;&amp; 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) =&gt; {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 &lt;EnvironmentProvider value={envVars}&gt;&lt;AmplitudeInitializer /&gt; {/* ← Add */}&lt;AmplitudeProvider&gt; {/* ← Keep */} &lt;SWRProvider&gt; {children} &lt;/SWRProvider&gt;&lt;/AmplitudeProvider&gt;&lt;/EnvironmentProvider&gt; Enter fullscreen mode Exit fullscreen mode Testing Strategy Unit Tests describe('AmplitudeInitializer', () =&gt; {it('initializes with valid API key', () =&gt; { const mockEnv = { amplitudeApiKey: 'test-key' }; render( &lt;EnvironmentProvider value={mockEnv}&gt; &lt;AmplitudeInitializer /&gt; &lt;/EnvironmentProvider&gt; ); expect(amplitude.initAll).toHaveBeenCalledWith('test-key', expect.any(Object));}); it('warns when API key is missing', () =&gt; { const consoleWarnSpy = jest.spyOn(console, 'warn'); const mockEnv = { amplitudeApiKey: undefined }; render( &lt;EnvironmentProvider value={mockEnv}&gt; &lt;AmplitudeInitializer /&gt; &lt;/EnvironmentProvider&gt; ); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Missing API key') );});}); Enter fullscreen mode Exit fullscreen mode Integration Tests describe('Feature Flags API', () =&gt; {it('returns flags for valid user', async () =&gt; { const response = await fetch('/api/amplitude/flags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 'test@example.com' }), }); const flags = await response.json(); expect(flags).toHaveProperty('feature-name');}); it('returns 400 for missing user_id', async () =&gt; { 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 .env from Docker image Pass environment variables at runtime via -e flags 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 access Separate 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) Resources Next.js Environment Variables Next.js Dynamic Rendering with connection() Amplitude Unified SDK Documentation SWR Data Fetching","contentHash":"sha256:cace93b3676d4a6b4d52a9173c5f9b105a2d6ecf31252b208cbf6c9e406eaad3","authorName":"Giovambattista Fazioli","authorUrl":"https://api.zyvop.com/author/giovambattista","authorSameAs":["https://gfazioli.github.io","https://github.com/gfazioli","https://x.com/gfazioli","https://www.linkedin.com/in/giovambattistafazioli/"],"category":null,"tags":["react","nextjs","TypeScript","Programming"],"audience":"Readers researching react","tone":"Professional, senior full-stack engineer, lead developer perspective","readingTimeMinutes":9,"wordCount":2080,"faqs":null,"primaryTopic":"react","publishedAt":"2026-09-04T08:20:00.574Z","updatedAt":"2026-09-04T08:20:00.574Z","canonicalUrl":"https://dev.to/undolog/refactoring-amplitude-integration-in-nextjs-from-build-time-to-runtime-configuration-1iam"}