There is one build of this package and no `development` mode. Which Connect
instance you talk to and which origin you come back to are **runtime** facts,
read from the environment and from the request — so the same deployed artifact
runs locally, in a preview and in production.

## The four variables

```bash title=".env.local"
CONNECT_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CONNECT_CLIENT_ID=cid_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

| Variable | Required | Purpose |
| --- | --- | --- |
| `CONNECT_API_KEY` | yes | Server-side credential — also your OAuth `client_secret` and `id_token` signing key |
| `CONNECT_CLIENT_ID` | yes | OAuth client id |
| `CONNECT_APP_URL` | no | A non-production Connect instance. Unset means `https://connect.cosmoops.com` |
| `CONNECT_SESSION_COOKIE` | no | Session cookie name. Unset means `connect_session` |

**Production is the absence of configuration.** Only set `CONNECT_APP_URL`
when you are pointing at something other than the public instance, and leave
it out of your production environment entirely rather than setting it to the
default.

`CONNECT_WEBHOOK_SECRET` is a convention, not a variable this package reads —
you pass it to `createConnectWebhookHandler` yourself. See
[Webhooks](/docs/webhooks).

Wiring the client by hand instead of through `/next`? The same switch is an
option:

```ts
const connect = new ConnectClient({
  apiKey: process.env.CONNECT_API_KEY!,
  clientId: process.env.CONNECT_CLIENT_ID,
  baseUrl: process.env.CONNECT_APP_URL, // undefined → production
});
```

<Callout title="Nothing is read at import time">
`/next` builds its client on first use, not at module load, so `next build`
succeeds in a CI environment that holds no credentials — route modules are
imported there but never invoked. A missing variable fails the first request
that needs it, naming itself.
</Callout>

## The browser half points somewhere too

This is the one part of a non-production setup that does not configure itself,
and the mistake is silent, so it is worth reading even if the rest of this page
is familiar.

`CONNECT_APP_URL` is server-side. It does not reach the browser — it is not
`NEXT_PUBLIC_` — and `ConnectProvider` defaults to the public instance
independently:

```tsx
appUrl = "https://connect.cosmoops.com"
```

So the fix is one spread in your root layout, which is a Server Component and
can therefore read the variable without a browser-visible copy of it:

```tsx title="app/layout.tsx"
import { connectProviderProps } from "@cosmoops/connect-sdk/next";
import { ConnectProvider } from "@cosmoops/connect-sdk/client";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConnectProvider {...connectProviderProps()}>{children}</ConnectProvider>
      </body>
    </html>
  );
}
```

`connectProviderProps()` returns `{ appUrl }`, resolved from
`CONNECT_APP_URL` and falling back to the production origin — so the line is
correct in every environment and you write it once. Props you pass yourself
still win, as long as the spread comes first:

```tsx
// Mounted the bridge somewhere else, and building the authorize URL yourself.
<ConnectProvider
  {...connectProviderProps()}
  basePath="/api/auth/connect"
  clientId={process.env.CONNECT_CLIENT_ID}
  redirectUri="https://your-app.example.com/api/auth/connect/callback"
>
```

### Already have an origin helper? Use that instead

An app that builds its own links into Connect — a "Manage team" button, a
pricing page's subscribe target — needs that origin on the server too. One
function owning the read beats this helper sitting beside it, because two
readers of one variable is the shape that causes the bug in the first place:

```ts title="lib/connect-origin.ts"
import "server-only";
import { DEFAULT_BASE_URL } from "@cosmoops/connect-sdk/server";

/** Where Connect is, for this deployment. The server and the browser share it. */
export function connectOrigin(): string {
  return (process.env.CONNECT_APP_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
}
```

```tsx title="app/layout.tsx"
<ConnectProvider appUrl={connectOrigin()}>{children}</ConnectProvider>
```

`DEFAULT_BASE_URL` rather than typing the production origin again, so there is
one string in your codebase and it is the one the SDK would have used anyway.

### What goes wrong without it

Nothing throws. `appUrl` is where the browser **navigates**, so a wrong value
produces working links to the wrong instance:

| Surface | Opens |
| --- | --- |
| `ConnectUserButton` | `{appUrl}/profile`, `/billing`, `/billing/invoices` |
| `ConnectAppsGrid` | `{appUrl}/apps/{id}`, for an app the workspace has not subscribed to |
| `ConnectPlans` | checkout, and a subscription's cancel page |
| `signIn()` | `{appUrl}/sso/authorize` — **only** if you passed `redirectUri` |

`AppShell` draws the first two in its rail, so a shell with the prop missing is
a shell whose whole account menu leaves for production.

<Callout type="warn" title="The concrete cost is a real charge">
Point a local build's server at a local or staging Connect, leave the prop off,
and the rail's **Billing** row and the access gate's **subscribe** button open
**production** Connect — which holds live payment credentials. Someone testing
the subscribe flow can put a real charge on a real workspace, and nothing on
screen says they left the environment they thought they were in.
</Callout>

Your API reads are unaffected either way: every browser request goes to your
own origin at `basePath` and is relayed by the bridge, which uses the server's
`baseUrl`. Sign-in is unaffected too *unless* you pass `redirectUri` — the
default flow bounces through your own `/signin` route, which builds the
authorize URL server-side.

### It is not a security issue

Worth stating plainly, because "the browser points at the wrong host" sounds
like one. `appUrl` is a navigation target you set from your own environment at
render time:

- **It carries no credential.** Your API key never leaves your server, and the
  only thing on those URLs is `client_id`, which is public.
- **Nothing authorizes on it.** No gate, permission check or session validation
  reads it. It cannot grant access to anything.
- **It is not reachable by an attacker.** It comes from your env, never from a
  URL, a cookie or a request body.
- **Redirect URIs are matched exactly, server-side.** Even a wrong authorize
  URL cannot deliver a code to an unregistered callback.

A wrong `appUrl` is a correctness bug — with the charge above as its worst
case — not a way in.

## One redirect URI per origin

The bridge derives its own callback from the request it is handling —
`{the origin that was called}/api/connect/callback` — so there is nothing to
configure per environment. What each origin does need is a **registration**,
in your application's Connect client section:

```
http://localhost:3000/api/connect/callback
https://staging.your-app.example.com/api/connect/callback
https://your-app.example.com/api/connect/callback
```

Connect matches a redirect URI exactly, not by origin. Two consequences worth
planning for:

- A per-deployment preview URL cannot be registered ahead of time. Give your
  preview environment a **stable domain** and register that one.
- A port change in local development is a new origin. `3000` and `3001` are
  two registrations.

This is the one step nothing in this package can do or check for you — Connect
exposes no endpoint listing a client's registered URIs.

## What actually differs at runtime

| | Local (`http://`) | Deployed (`https://`) |
| --- | --- | --- |
| Session cookie `Secure` | off | on |
| A bridge `500`'s body | includes the underlying message when `NODE_ENV=development` | `"Something went wrong"` |

The cookie flag follows the protocol of the request being answered, so neither
is a setting. `HttpOnly`, `Path=/` and `SameSite=Lax` are the same everywhere,
and the cookie's lifetime matches the session token's own — about 15 days.

The withheld message is deliberate: an unexpected throw can carry a DSN, a
stack or a credential. In development it is included, because the alternative
is a bare failure on a callback with the only explanation in a terminal you
had no reason to be watching.

Everything else is identical, including the parts you might expect to be
looser locally: importing `/server`, `/next` or `/mcp` from a Client Component
fails the build in both, and `ConnectClient` refuses to construct in a browser
at runtime in both.

## Checking a setup

```bash
npx @cosmoops/connect-sdk doctor
```

Offline, needs no credentials, and safe to run in CI on a pull request — it
reads `.env.local` directly rather than trusting `process.env`, because the
interesting case is a shell that never loaded it. It reports the bridge route,
the provider, stylesheet order and the two required variables, and exits `1`
on a genuine failure. The redirect URI it can only remind you about.

It also catches the split described above: `CONNECT_APP_URL` set with no
`appUrl` on the provider is reported as a note, with the fix in the output.