Skip to content

Commit e228169

Browse files
committed
Refactor Next.js adapter to include defineNextPublicEnv for client-only environment validation, update README for installation instructions, and ensure consistent dependency management across packages. Adjust contributing guidelines for clarity.
1 parent 4eda205 commit e228169

9 files changed

Lines changed: 159 additions & 45 deletions

File tree

CONTRIBUTING.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ We **do not** use `NPM_TOKEN` in GitHub Actions. Releases use [npm Trusted Publi
4747

4848
Trusted Publisher is configured **per package** on npm, but the **link is to one GitHub repo + one workflow file** (`release.yml`). For this repo you repeat the same publisher setup for each published package:
4949

50-
| Package | On npm → Package → Settings → Trusted publishing |
51-
| --------------------- | -------------------------------------------------- |
52-
| `@envra/core` | GitHub: `hasansmadix/envra`, workflow `release.yml` |
53-
| `@envra/cli` | same |
54-
| `@envra/next` | same |
55-
| `@envra/eslint-plugin`| same |
50+
| Package | On npm → Package → Settings → Trusted publishing |
51+
| ---------------------- | --------------------------------------------------- |
52+
| `@envra/core` | GitHub: `hasansmadix/envra`, workflow `release.yml` |
53+
| `@envra/cli` | same |
54+
| `@envra/next` | same |
55+
| `@envra/eslint-plugin` | same |
5656

5757
All must live under an npm org/user that allows those publishes. The workflow filename must match **exactly** (e.g. `release.yml`, case-sensitive).
5858

@@ -66,7 +66,7 @@ The Release job needs:
6666

6767
#### Optional hardening (npm UI)
6868

69-
After Trusted Publishing works, npm recommends restricting classic publish tokens for those packages. See npm docs: *Publishing access* → require 2FA / disallow tokens where appropriate.
69+
After Trusted Publishing works, npm recommends restricting classic publish tokens for those packages. See npm docs: _Publishing access_ → require 2FA / disallow tokens where appropriate.
7070

7171
### Release flow
7272

README.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ Not just parsing `process.env`: envra gives you a **contract** (metadata + types
3232

3333
Config breaks in production for boring, repeatable reasons:
3434

35-
| Problem | How envra helps |
36-
| ------------------------------- | ---------------------------------------------------- |
37-
| Missing or mistyped keys | Validated schema, clear errors |
38-
| Wrong formats | Built-in parsers (URL, int, bool, JSON, …) |
39-
| Stale `.env.example` | `envra sync` from the same schema |
40-
| Leaked secrets to the client | `secret()`, `serverOnly()`, Next client rules |
41-
| Drift between environments | `envra doctor` (undeclared vars, typos, deprecations) |
35+
| Problem | How envra helps |
36+
| ---------------------------- | ----------------------------------------------------- |
37+
| Missing or mistyped keys | Validated schema, clear errors |
38+
| Wrong formats | Built-in parsers (URL, int, bool, JSON, …) |
39+
| Stale `.env.example` | `envra sync` from the same schema |
40+
| Leaked secrets to the client | `secret()`, `serverOnly()`, Next client rules |
41+
| Drift between environments | `envra doctor` (undeclared vars, typos, deprecations) |
4242

4343
Other libraries validate well; envra adds **generated docs**, **`.env.example` sync**, **`doctor` hygiene**, and a **rich result object** (`values`, `get`, `has`, `meta`) instead of a plain map.
4444

@@ -101,12 +101,12 @@ Export `defineEnv` as `default` / `env`, or export field builders as `schema`, `
101101

102102
**`--json`** on `check` / `doctor` prints machine-readable output for CI.
103103

104-
| Command | Use case |
105-
| --------- | --------------------------------- |
106-
| `check` | CI / preflight — validate merged env |
107-
| `sync` | Regenerate `.env.example` |
108-
| `docs` | Regenerate `ENVIRONMENT.md` |
109-
| `doctor` | Undeclared vars, typos, deprecations, profile rules |
104+
| Command | Use case |
105+
| -------- | --------------------------------------------------- |
106+
| `check` | CI / preflight — validate merged env |
107+
| `sync` | Regenerate `.env.example` |
108+
| `docs` | Regenerate `ENVIRONMENT.md` |
109+
| `doctor` | Undeclared vars, typos, deprecations, profile rules |
110110

111111
---
112112

@@ -116,9 +116,11 @@ Export `defineEnv` as `default` / `env`, or export field builders as `schema`, `
116116
| ---------------------- | ----------------------------------------------------------------- |
117117
| `@envra/core` | Schema DSL, `defineEnv`, validation, generators, `doctor` helpers |
118118
| `@envra/cli` | `envra` binary |
119-
| `@envra/next` | `defineNextEnv({ server, client, runtimeEnv })` |
119+
| `@envra/next` | `defineNextEnv` / `defineNextPublicEnv`; **peer** `@envra/core` — see [packages/next/README](packages/next/README.md) |
120120
| `@envra/eslint-plugin` | `envra/no-process-env` |
121121

122+
Install Next adapter with **`pnpm add @envra/next @envra/core`** so one `@envra/core` version is used (avoids TypeScript `FieldBuilder` clashes). On pnpm, use `overrides` if duplicates appear — [packages/next/README.md](packages/next/README.md#pnpm--duplicate-envracore).
123+
122124
---
123125

124126
## Comparison (high level)

examples/next-app-router/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"type": "module",
55
"dependencies": {
66
"@envra/cli": "workspace:*",
7+
"@envra/core": "workspace:*",
78
"@envra/next": "workspace:*"
89
}
910
}

packages/next/README.md

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ Next.js adapter for [envra](https://github.com/hasansmadix/envra): split **serve
55
## Install
66

77
```bash
8-
pnpm add @envra/next
8+
pnpm add @envra/next @envra/core
99
# or
10-
npm install @envra/next
10+
npm install @envra/next @envra/core
1111
```
1212

13-
Peer: `next` >= 14 (optional peer for typing; install `next` in your app).
13+
**Peers:** `next` >= 14 (optional for typing in non-Next contexts), **`@envra/core` ^0.1.2** (install explicitly so a single copy is used — avoids duplicate `FieldBuilder` types in TypeScript).
1414

15-
## Example
15+
## Example (full server + client)
1616

1717
```ts
1818
import { defineNextEnv, str, url, secret } from "@envra/next";
@@ -28,6 +28,51 @@ export const env = defineNextEnv({
2828
});
2929
```
3030

31+
## Client-only helper (`defineNextPublicEnv`)
32+
33+
Use when you only validate `NEXT_PUBLIC_*` keys and want a module that is safe to import from **Client Components** (no server secrets in that file):
34+
35+
```ts
36+
import { defineNextPublicEnv, url } from "@envra/next";
37+
38+
export const publicEnv = defineNextPublicEnv({
39+
client: {
40+
NEXT_PUBLIC_APP_URL: url(),
41+
},
42+
runtimeEnv: process.env,
43+
});
44+
```
45+
46+
For secrets and server-only variables, keep a separate module with `import "server-only"` and `defineNextEnv` (or `defineEnv` from `@envra/core`).
47+
48+
## Next.js App Router: server vs client modules
49+
50+
1. **Server env** — e.g. `lib/env.ts` with `import "server-only"` at the top, then `defineNextEnv` with both `server` and `client` (or server-only schema via `@envra/core`).
51+
2. **Public env** — e.g. `lib/env-public.ts` **without** `server-only`, using `defineNextPublicEnv` or only the `client` block patterns above.
52+
3. **Do not** import the server env module from code that is bundled for the client (Client Components, or shared `services/` / `lib/` pulled in by them). Use `publicEnv` for anything that needs env inside client bundles.
53+
54+
## pnpm / duplicate `@envra/core`
55+
56+
If TypeScript reports that `FieldBuilder` types are incompatible (*separate declarations of a private property*), you likely have **two versions** of `@envra/core` installed. Fix with a single version, for example:
57+
58+
```json
59+
{
60+
"pnpm": {
61+
"overrides": {
62+
"@envra/core": "0.1.2"
63+
}
64+
}
65+
}
66+
```
67+
68+
## Re-exports
69+
70+
`@envra/next` re-exports common builders (`str`, `int`, `secret`, …), **`FieldBuilder`**, **`InferSchema`**, and `defineEnv` from `@envra/core` so you can use **one import path** in Next apps:
71+
72+
```ts
73+
import { defineNextEnv, str, type InferSchema } from "@envra/next";
74+
```
75+
3176
## Documentation
3277

3378
[github.com/hasansmadix/envra](https://github.com/hasansmadix/envra)

packages/next/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"node": ">=18"
3939
},
4040
"peerDependencies": {
41-
"next": ">=14"
41+
"next": ">=14",
42+
"@envra/core": "^0.1.2"
4243
},
4344
"peerDependenciesMeta": {
4445
"next": {
@@ -49,10 +50,8 @@
4950
"build": "tsup",
5051
"test": "vitest run"
5152
},
52-
"dependencies": {
53-
"@envra/core": "workspace:*"
54-
},
5553
"devDependencies": {
54+
"@envra/core": "workspace:*",
5655
"@types/node": "^22.10.2",
5756
"tsup": "^8.3.5",
5857
"typescript": "~5.7.2",

packages/next/src/define-next-env.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { defineNextEnv } from './define-next-env'
2+
import { defineNextEnv, defineNextPublicEnv } from './define-next-env'
33
import { str, url, secret } from '@envra/core'
44

55
describe('defineNextEnv', () => {
@@ -22,7 +22,7 @@ describe('defineNextEnv', () => {
2222
expect(() =>
2323
defineNextEnv({
2424
server: {},
25-
client: { NEXT_PUBLIC_X: secret(str()) } as never,
25+
client: { NEXT_PUBLIC_X: secret(str()) },
2626
runtimeEnv: { NODE_ENV: 'test', NEXT_PUBLIC_X: 'a' },
2727
}),
2828
).toThrow(/secret/)
@@ -38,3 +38,26 @@ describe('defineNextEnv', () => {
3838
).toThrow(/NEXT_PUBLIC_/)
3939
})
4040
})
41+
42+
describe('defineNextPublicEnv', () => {
43+
it('validates client-only keys', () => {
44+
const env = defineNextPublicEnv({
45+
client: { NEXT_PUBLIC_APP_URL: url() },
46+
runtimeEnv: {
47+
NODE_ENV: 'test',
48+
NEXT_PUBLIC_APP_URL: 'https://app.example',
49+
},
50+
profile: 'test',
51+
})
52+
expect(env.values.NEXT_PUBLIC_APP_URL).toBe('https://app.example')
53+
})
54+
55+
it('rejects secret on client', () => {
56+
expect(() =>
57+
defineNextPublicEnv({
58+
client: { NEXT_PUBLIC_X: secret(str()) },
59+
runtimeEnv: { NODE_ENV: 'test', NEXT_PUBLIC_X: 'a' },
60+
}),
61+
).toThrow(/secret/)
62+
})
63+
})

packages/next/src/define-next-env.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
1-
import { defineEnv, type DefineEnvOptions } from '@envra/core'
2-
import type { FieldBuilder } from '@envra/core'
3-
import type { InferSchema } from '@envra/core'
4-
import type { EnvraResult } from '@envra/core'
1+
import {
2+
defineEnv,
3+
type DefineEnvOptions,
4+
type EnvraResult,
5+
type FieldBuilder,
6+
type InferSchema,
7+
} from '@envra/core'
8+
9+
/**
10+
* Schema object accepted by `defineNextEnv` / `defineNextPublicEnv`.
11+
* Uses `FieldBuilder<any>` so concrete builders like `FieldBuilder<string | undefined>`
12+
* stay assignable (avoids class variance issues with `FieldBuilder<unknown>`).
13+
*/
14+
export type NextEnvSchema = Record<string, FieldBuilder<any>>
515

616
export interface DefineNextEnvOptions<
7-
S extends Record<string, FieldBuilder<unknown>>,
8-
C extends Record<string, FieldBuilder<unknown>>,
17+
S extends NextEnvSchema,
18+
C extends NextEnvSchema,
919
> extends Pick<DefineEnvOptions, 'onValidationError'> {
1020
server: S
1121
client: C
1222
runtimeEnv: Record<string, string | undefined>
1323
profile?: string
1424
}
1525

16-
function assertClientField(key: string, builder: FieldBuilder<unknown>): void {
26+
export interface DefineNextPublicEnvOptions<C extends NextEnvSchema>
27+
extends Pick<DefineEnvOptions, 'onValidationError'> {
28+
client: C
29+
runtimeEnv: Record<string, string | undefined>
30+
profile?: string
31+
}
32+
33+
function assertClientField(key: string, builder: FieldBuilder<any>): void {
1734
const def = builder.build()
1835
if (def.secret || def.visibility === 'server') {
1936
throw new Error(
@@ -28,13 +45,13 @@ function assertClientField(key: string, builder: FieldBuilder<unknown>): void {
2845
}
2946

3047
export function defineNextEnv<
31-
S extends Record<string, FieldBuilder<unknown>>,
32-
C extends Record<string, FieldBuilder<unknown>>,
48+
S extends NextEnvSchema,
49+
C extends NextEnvSchema,
3350
>(
3451
opts: DefineNextEnvOptions<S, C>,
3552
): EnvraResult<InferSchema<S> & InferSchema<C>> {
3653
for (const [key, builder] of Object.entries(opts.client)) {
37-
assertClientField(key, builder as FieldBuilder<unknown>)
54+
assertClientField(key, builder)
3855
}
3956

4057
const merged = {
@@ -48,3 +65,19 @@ export function defineNextEnv<
4865
onValidationError: opts.onValidationError,
4966
}) as EnvraResult<InferSchema<S> & InferSchema<C>>
5067
}
68+
69+
/**
70+
* Client-only Next.js env: same rules as the `client` block in `defineNextEnv`, without a server schema.
71+
* Safe to import from modules used by Client Components (no server secrets in this path).
72+
*/
73+
export function defineNextPublicEnv<C extends NextEnvSchema>(
74+
opts: DefineNextPublicEnvOptions<C>,
75+
): EnvraResult<InferSchema<C>> {
76+
return defineNextEnv({
77+
server: {},
78+
client: opts.client,
79+
runtimeEnv: opts.runtimeEnv,
80+
profile: opts.profile,
81+
onValidationError: opts.onValidationError,
82+
}) as EnvraResult<InferSchema<C>>
83+
}

packages/next/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
export { defineNextEnv, type DefineNextEnvOptions } from './define-next-env'
1+
export {
2+
defineNextEnv,
3+
defineNextPublicEnv,
4+
type DefineNextEnvOptions,
5+
type DefineNextPublicEnvOptions,
6+
type NextEnvSchema,
7+
} from './define-next-env'
28
export {
39
defineEnv,
410
str,
@@ -13,4 +19,6 @@ export {
1319
secret,
1420
type EnvraResult,
1521
type DefineEnvOptions,
22+
type FieldBuilder,
23+
type InferSchema,
1624
} from '@envra/core'

pnpm-lock.yaml

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)