Skip to content

Commit b0ed473

Browse files
committed
✨ Add serverOnly() for safe isomorphic access to server secrets
Introduce a new function that reads server-only environment variables without throwing in browser contexts. Unlike env(), serverOnly(): - Returns the fallback value when called from client code - Never throws errors, enabling safe use in shared modules - Supports lazy evaluation patterns with Zod schemas This enables cleaner isomorphic code where server secrets can be referenced in shared configuration without conditional imports. Includes comprehensive tests covering both browser and server contexts using module re-importing to properly test the IS_BROWSER detection.
1 parent f36e54c commit b0ed473

5 files changed

Lines changed: 191 additions & 6 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,42 @@ const apiUrl = requireEnv('NEXT_PUBLIC_API_URL')
194194
// Error: "Required environment variable 'NEXT_PUBLIC_API_URL' is not defined."
195195
```
196196

197+
### Server-Only Variables
198+
199+
Use `serverOnly()` for non-public environment variables in code that runs on both client and server. Unlike `env()`,
200+
this function **never throws** in the browser—it gracefully returns the fallback value:
201+
202+
```tsx
203+
import { serverOnly } from 'next-dynenv'
204+
205+
// Returns the actual value on server, fallback on client
206+
const dbUrl = serverOnly('DATABASE_URL', 'postgresql://localhost:5432/dev')
207+
const apiSecret = serverOnly('API_SECRET_KEY') // undefined on client
208+
```
209+
210+
This is particularly useful for shared configuration modules with lazy evaluation:
211+
212+
```tsx
213+
import { env, serverOnly } from 'next-dynenv'
214+
import { z } from 'zod'
215+
216+
const configSchema = z.object({
217+
supabaseUrl: z.string().url(),
218+
supabaseAnonKey: z.string(),
219+
// Server-only: returns undefined on client, real value on server
220+
supabaseServiceKey: z.string().optional(),
221+
})
222+
223+
// Safe to import anywhere—evaluates lazily
224+
export const config = lazy(() =>
225+
configSchema.parse({
226+
supabaseUrl: env('NEXT_PUBLIC_SUPABASE_URL'),
227+
supabaseAnonKey: env('NEXT_PUBLIC_SUPABASE_ANON_KEY'),
228+
supabaseServiceKey: serverOnly('SUPABASE_SERVICE_KEY'),
229+
}),
230+
)
231+
```
232+
197233
### Type-Safe Parsers
198234

199235
Use `envParsers` to convert environment strings to typed values:

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,6 @@
7474
"peerDependencies": {
7575
"next": "^15 || ^16",
7676
"react": "^19"
77-
}
77+
},
78+
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
7879
}

src/script/env.spec.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { afterEach, describe, expect, it } from 'vitest'
2-
import { env, requireEnv } from './env'
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { env, requireEnv, serverOnly } from './env'
33

44
declare global {
55
var mockWindow: (envVars: Record<string, string>) => void
@@ -89,3 +89,79 @@ describe('requireEnv()', () => {
8989
)
9090
})
9191
})
92+
93+
describe('serverOnly()', () => {
94+
afterEach(() => {
95+
delete process.env.SECRET_KEY
96+
delete process.env.DATABASE_URL
97+
})
98+
99+
// In jsdom, window always exists, so IS_BROWSER is true
100+
// These tests verify browser behavior (returns fallback)
101+
102+
it('should return fallback in browser context', () => {
103+
// window exists in jsdom, so this simulates browser
104+
expect(serverOnly('SECRET_KEY', 'fallback-value')).toEqual('fallback-value')
105+
})
106+
107+
it('should return undefined when no fallback provided in browser', () => {
108+
expect(serverOnly('SECRET_KEY')).toBeUndefined()
109+
})
110+
111+
it('should not read process.env in browser context', () => {
112+
process.env.SECRET_KEY = 'actual-secret'
113+
// Even though the env var exists, browser should return fallback
114+
expect(serverOnly('SECRET_KEY', 'fallback')).toEqual('fallback')
115+
})
116+
117+
// Test server behavior by re-importing module without window
118+
describe('server context', () => {
119+
it('should return process.env value on server', async () => {
120+
// Save original window
121+
const originalWindow = globalThis.window
122+
123+
// Remove window to simulate server
124+
// @ts-expect-error - intentionally removing window
125+
delete globalThis.window
126+
127+
// Reset module cache and re-import
128+
vi.resetModules()
129+
const { serverOnly: serverOnlyServer } = await import('./env')
130+
131+
process.env.SECRET_KEY = 'server-secret'
132+
expect(serverOnlyServer('SECRET_KEY')).toEqual('server-secret')
133+
134+
// Restore window
135+
globalThis.window = originalWindow
136+
vi.resetModules()
137+
})
138+
139+
it('should return fallback when env var undefined on server', async () => {
140+
const originalWindow = globalThis.window
141+
// @ts-expect-error - intentionally removing window
142+
delete globalThis.window
143+
144+
vi.resetModules()
145+
const { serverOnly: serverOnlyServer } = await import('./env')
146+
147+
expect(serverOnlyServer('NONEXISTENT_VAR', 'default')).toEqual('default')
148+
149+
globalThis.window = originalWindow
150+
vi.resetModules()
151+
})
152+
153+
it('should return undefined when no fallback and env var undefined on server', async () => {
154+
const originalWindow = globalThis.window
155+
// @ts-expect-error - intentionally removing window
156+
delete globalThis.window
157+
158+
vi.resetModules()
159+
const { serverOnly: serverOnlyServer } = await import('./env')
160+
161+
expect(serverOnlyServer('NONEXISTENT_VAR')).toBeUndefined()
162+
163+
globalThis.window = originalWindow
164+
vi.resetModules()
165+
})
166+
})
167+
})

src/script/env.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { isBrowser } from '../helpers/is-browser'
1+
import { isBrowser as isBrowserWithEnv } from '../helpers/is-browser'
22
import { PUBLIC_ENV_KEY } from './constants'
33

4+
const IS_BROWSER = typeof window !== 'undefined'
5+
46
/**
57
* Reads environment variables safely from both browser and server contexts.
68
*
@@ -64,7 +66,7 @@ import { PUBLIC_ENV_KEY } from './constants'
6466
export function env(key: string): string | undefined
6567
export function env<T extends string>(key: string, defaultValue: T): string
6668
export function env(key: string, defaultValue?: string): string | undefined {
67-
if (isBrowser()) {
69+
if (isBrowserWithEnv()) {
6870
if (!key.startsWith('NEXT_PUBLIC_')) {
6971
throw new Error(
7072
`Environment variable '${key}' is not public and cannot be accessed in the browser.\n` +
@@ -125,3 +127,73 @@ export function requireEnv(key: string): string {
125127
}
126128
return value
127129
}
130+
131+
/**
132+
* Safely reads a server-only environment variable.
133+
*
134+
* Returns the fallback value when running in the browser, allowing this function
135+
* to be safely called from code that runs on both client and server (e.g., shared
136+
* lazy getters, utility modules).
137+
*
138+
* Unlike {@link env}, this function:
139+
* - **Never throws** in the browser—it gracefully returns the fallback
140+
* - Is designed for **non-`NEXT_PUBLIC_*`** variables that shouldn't be exposed to clients
141+
* - Provides a clean pattern for isomorphic code that needs server secrets
142+
*
143+
* @param key - The environment variable name to retrieve
144+
* @param fallback - Optional fallback value returned in the browser or if undefined
145+
* @returns The environment variable value on the server, or the fallback on the client/if undefined
146+
*
147+
* @example
148+
* Basic usage with server-only secrets:
149+
* ```ts
150+
* import { serverOnly } from 'next-dynenv'
151+
*
152+
* // Returns the actual value on server, undefined on client
153+
* const dbUrl = serverOnly('DATABASE_URL')
154+
*
155+
* // With a fallback for development
156+
* const apiKey = serverOnly('API_SECRET_KEY', 'dev-key')
157+
* ```
158+
*
159+
* @example
160+
* In shared code with lazy evaluation (e.g., with Zod schemas):
161+
* ```ts
162+
* import { env, serverOnly } from 'next-dynenv'
163+
* import { z } from 'zod'
164+
*
165+
* const configSchema = z.object({
166+
* supabaseUrl: z.string().url(),
167+
* supabaseAnonKey: z.string(),
168+
* // Server-only: returns fallback on client, real value on server
169+
* supabaseServiceKey: z.string().optional(),
170+
* })
171+
*
172+
* // This lazy getter can be safely imported anywhere
173+
* const config = lazy(() => configSchema.parse({
174+
* supabaseUrl: env('NEXT_PUBLIC_SUPABASE_URL'),
175+
* supabaseAnonKey: env('NEXT_PUBLIC_SUPABASE_ANON_KEY'),
176+
* supabaseServiceKey: serverOnly('SUPABASE_SERVICE_KEY'),
177+
* }))
178+
* ```
179+
*
180+
* @example
181+
* Conditional server-side logic:
182+
* ```ts
183+
* import { serverOnly } from 'next-dynenv'
184+
*
185+
* // Safe to call anywhere—returns undefined on client
186+
* const internalUrl = serverOnly('INTERNAL_SERVICE_URL') || publicUrl
187+
* ```
188+
*
189+
* @see {@link env} for public variables accessible on both client and server
190+
* @see {@link requireEnv} for required variables that throw if undefined
191+
*/
192+
export function serverOnly(key: string): string | undefined
193+
export function serverOnly<T extends string>(key: string, fallback: T): string
194+
export function serverOnly(key: string, fallback?: string): string | undefined {
195+
if (IS_BROWSER) {
196+
return fallback
197+
}
198+
return process.env[key] ?? fallback
199+
}

src/script/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* istanbul ignore file */
22

33
export { PUBLIC_ENV_KEY } from './constants'
4-
export { env, requireEnv } from './env'
4+
export { env, requireEnv, serverOnly } from './env'
55
export { EnvScript } from './env-script'
66
export { envParsers } from './parsers'
77
export { PublicEnvScript } from './public-env-script'

0 commit comments

Comments
 (0)