Environments
Which Connect instance an integration talks to, what differs between local and production, and the four environment variables involved.
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
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.
Wiring the client by hand instead of through /next? The same switch is an
option:
const connect = new ConnectClient({
apiKey: process.env.CONNECT_API_KEY!,
clientId: process.env.CONNECT_CLIENT_ID,
baseUrl: process.env.CONNECT_APP_URL, // undefined → production
});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.
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:
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:
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:
// 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:
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(/\/+$/, "");
}<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.
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.
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/callbackConnect 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.
3000and3001are 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
npx @cosmoops/connect-sdk doctorOffline, 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.