# React & Next.js

A React app has two halves and they need opposite answers. On the server, a
secret is ordinary configuration — read it, use it, done. On the client, there is
no such thing as a secret: anything in the bundle is published, and a
[service token](/docs/guides/service-tokens) in the bundle is a credential you
have handed to every visitor.

So this integration is server-side, and the only thing the browser ever holds is
a `{{seekrit:NAME}}` placeholder.

```bash
npm install @seekrit/sdk
```

> **Note:** This guide assumes you've done the [Quickstart](/docs/quickstart) and have a token in `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/service-tokens), so there is no per-secret setup.

## 1. Read a secret in a server component

```tsx
// app/billing/page.tsx
import { secret } from "@seekrit/sdk/react";

export default async function BillingPage() {
  const key = await secret("STRIPE_KEY");
  const charges = await listCharges(key);

  return <ChargesTable rows={charges} />;
}
```

`secret()` fails closed: a name your token cannot see throws rather than
resolving to `undefined`, because an `undefined` credential authenticates as
nothing several frames from the cause. Use `optionalSecret()` when absence is a
real case, and `secrets()` for the whole set.

Both are also available per slice of a [composed group](/docs/guides/environments):

```tsx
const key = await secret("STRIPE_KEY", { with: { tenants: "acme" } });
```

### One resolve per render

The resolve is wrapped in React's `cache`, so a page with a dozen components each
asking for a secret makes **one** `/v1/resolve` call, keyed by the group
overrides asked for:

```tsx
// three components, one round trip
await secret("STRIPE_KEY");
await secret("DATABASE_URL");
await secrets();
```

This holds inside a Server Component render, which is what installs React's cache
dispatcher. In an SSR pass, a route handler, or plain Node there is no request to
key on and `cache` falls through uncached — still correct, just not deduplicated.
Caching *across* requests is a different feature: that is `ttlSeconds` on
[in-process injection](/docs/guides/agent-proxy/in-process).

## 2. Make a leak a build error

Reading a secret on the server is only half of it. The failure mode in an RSC app
is passing one to a client component, where it is serialized into the payload and
shipped:

```tsx
const key = await secret("STRIPE_KEY");
return <PaymentForm apiKey={key} />;   // ← the leak
```

React has an API for exactly this, and this SDK calls it for you: every resolved
value, and the map holding them, is passed to `experimental_taintUniqueValue` and
`experimental_taintObjectReference`. With taint on, that line **throws during
render** instead of shipping the key.

Turn it on in `next.config`:

```ts
// next.config.ts
export default {
  experimental: { taint: true },
};
```

> **Warning:** The taint API is absent from stable React builds, and when it is missing this SDK warns once and keeps working. If taint is why you are here, say so and get a hard failure instead: ```ts import { createSecretReader } from "@seekrit/sdk/react"; export const { secret, secrets } = createSecretReader({ taint: "require" }); ```

`createSecretReader` is also how you bind a different token, API URL, or taint
setting once and export your own `secret` for the rest of the app.

## 3. Let the browser hold a placeholder

Some calls have to leave the browser — a streaming chat completion, an upload
that should not transit your server twice. The answer is not to ship the key: it
is to ship the *placeholder* and substitute it in a route handler on the way out.

```ts
// app/api/openai/[...path]/route.ts
import { seekritRoute } from "@seekrit/sdk/route";
import { auth } from "@/auth";

export const { GET, POST } = seekritRoute({
  upstream: "https://api.openai.com",
  allow: { "api.openai.com": ["OPENAI_API_KEY"] },
  authorize: async (request) => (await auth(request)) !== null,
});
```

```ts
// components/chat.tsx — "use client"
const openai = createOpenAI({
  baseURL: "/api/openai/v1",
  apiKey: "{{seekrit:OPENAI_API_KEY}}",
});
```

The bundle contains the string `{{seekrit:OPENAI_API_KEY}}` and nothing else. The
handler rewrites `/api/openai/v1/chat/completions` onto
`https://api.openai.com/v1/chat/completions`, substitutes the placeholder, and
streams the response back.

This is the [egress proxy](/docs/guides/agent-proxy) shaped as a web
`Request` → `Response` handler — same syntax, same default-deny allowlist, same
403 — so it also runs on Remix, React Router, Hono, and Nitro, anywhere you are
handed a `Request`.

### `authorize` is required

There is no default, and that is the point. This handler is reachable from the
internet, on your own origin, under your own cookies; without an authorization
check it is an open credential proxy that anyone can drive. Pass the same check
the rest of your app makes.

### What the handler does for you

- **Gates every request**, not only the ones carrying a placeholder. The
  in-process shim consults the allowlist only when it sees a placeholder, because
  it is a credential shim rather than an egress firewall. A handler on the public
  internet cannot afford that reading — otherwise an authorized user reaches any
  path or method on the upstream simply by leaving the placeholder out. So
  `methods` and `paths` are checked on the way in, every time.
- **Drops `cookie`** and any `authorization` that is not a placeholder. The
  browser attaches your session to a same-origin request automatically, and
  forwarding it hands your session to a third party.
- **Strips `set-cookie`** from the response, because this route is same-origin
  and an upstream's cookie would be set on your domain.
- **Refuses oversized bodies** (1 MiB by default, `maxBodyBytes` to change it).

### Narrowing per request

`scope` runs per request and can pick a tenant's slice, narrow the allowlist, or
both. Narrowing only intersects — a scope can never widen what the static rules
permit:

```ts
export const { POST } = seekritRoute({
  upstream: "https://api.openai.com",
  allow: { "api.openai.com": ["OPENAI_API_KEY", "STRIPE_SECRET_KEY"] },
  authorize: async (request) => (await auth(request)) !== null,
  scope: async (request) => {
    const session = await auth(request);
    return { with: { tenants: session.orgSlug }, allow: ["OPENAI_API_KEY"] };
  },
});
```

## Where this sits

Three rungs, weakest boundary first:

| Approach | The key lives | Weakness |
| --- | --- | --- |
| `@seekrit/sdk/react` | in the render, on the server | app code on the server can read it |
| `@seekrit/sdk/route` | in your app's process | reachable from the internet; `authorize` is the only gate |
| [`seekrit-proxy`](/docs/guides/agent-proxy) | in a separate process | none of the above — but you run a process |

Nothing here weakens the [zero-knowledge](/docs/concepts/encryption) model: the
API still only ever sees ciphertext, and decryption still happens in your code
with a key derived from your token. What changes is how narrow the blast radius
is on your own side.

## The client hook that does not exist

There is no `useSecret()`, and there will not be one. A hook that resolves in the
browser needs a service token in the browser, which is a credential in a
`view-source`. Both entrypoints here are server-only and refuse to load in a
client bundle — first through the `browser` export condition, so a `use client`
file importing one fails at build, and then at runtime for bundlers that ignore
it.

If you want a client component to show *whether* a secret is configured without
seeing it, read that on the server and pass the boolean.
