Providers
RisingTideProviders - the full provider stack for a Rising Tide app in one config-driven component, including the entry curtain every app gets.
RisingTideProviders (from @rising-tide/ui/providers) is the one component that wires an app's entire client stack: idle sleep, auth method tracking, the invite-code stash, app-mode gating, the Privy wallet provider, the SDK token provider, the React Query client, the protocol user context, the sign-in dialog, and the entry curtain - in the order they have to be in.
That order is load-bearing. The real Privy provider must mount above the SDK's token provider, or the first signup request 401s. RisingTideProviders encodes the correct order once so you never reassemble it by hand.
Usage
Because a Next.js root layout is a Server Component, configure the providers in a thin client wrapper and render that from the layout. This is the complete minimum: every field shown is required by the config type.
"use client";
import { RisingTideProviders } from "@rising-tide/ui/providers";
import { rtClient } from "@/lib/sdk/client";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<RisingTideProviders
config={{
sdk: rtClient,
storagePrefix: "myapp",
network: process.env.NEXT_PUBLIC_STACKS_NETWORK,
privy: {
appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
appearance: { theme: "light" },
},
auth: {
appName: "My App",
signupRedirect: "/welcome",
},
}}
>
{children}
</RisingTideProviders>
);
}import { AppProviders } from "@/providers/AppProviders";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}Config
| Field | Required | Purpose |
|---|---|---|
sdk | yes | The app's configured SDK client (rtClient) |
storagePrefix | yes | Namespace for auth-method / app-mode / invite-code / user state in local storage |
network | yes | The chain this build targets, read literally from NEXT_PUBLIC_STACKS_NETWORK. Everything network-gated in the stack derives from it, so pass it even if the value is undefined |
appId | no | This app's numeric protocol id. Falls back to NEXT_PUBLIC_APP_ID, which is how every app in the estate sets it. It is what the signup posture (requiresInvite), the server-set app mode and the entry curtain are read against, so a gated app must have one or the other |
idleSleep.afterMs | no | Idle deadline before live updates pause. Defaults to 10 minutes, testnet only |
idleSleep.disabled | no | Opt out of idle sleep even on testnet |
privy.appId | yes | Privy app ID. Without it, Privy is skipped and a console error is logged |
privy.appearance | yes | Privy appearance config |
privy.loginMethods | no | Defaults to Google and X, and nothing else. An invite is the only way in and the claim works with those two, so a method that signs somebody in but cannot claim leaves them with an empty account. No app in the estate passes this, and narrowing it can only ask for less than the Privy dashboard allows |
auth.appName | yes | The sign-in dialog's heading, and the default room name for the entry curtain and the app-mode gate |
auth.appIconSrc | no | The sign-in dialog's icon, served from this app's own public/. Defaults to /rt-logo-square-t.png, which is a path convention rather than a brand - a client app must serve its own mark here or the dialog shows the protocol's |
auth.audience | no | "consumer" (default) or "operator". Operator drops the education block from the sign-in dialog |
auth.signupRedirect | no | Route pushed after first-time signup |
auth.signOutRedirect | no | Route pushed after sign-out (defaults to /) |
auth.onSignupComplete | no | Custom handler ({ user, router, queryClient }) - overrides signupRedirect (e.g. invalidate caches before routing) |
auth.onSignOut | no | Custom handler ({ router, queryClient }) - overrides signOutRedirect |
auth.linkInviteCodes | no | Link a pending invite code at signup. Defaults to true |
modeGate | no | { appName } to gate on maintenance / coming-soon mode. Omit for always-live apps |
accessGate.appName | no | Room name for the curtain's locked screen. Defaults to auth.appName |
accessGate.teaser | no | Your own locked screen, replacing the shared one. Receives { appName, authenticated, onSignIn } |
accessGate.selfGated | no | Opt this app out of the curtain because it already gates its own entry with something stricter. See below before setting it |
query.onMutationError | no | Mutation error sink (wire your error reporter here) |
welcomeDialog | no | A component shown to first-time users, receiving { stxAddress, onComplete, onSkip } |
There is no auth.appDescription. The sign-in dialog shows a heading and no description paragraph, to buy vertical space on a phone.
App-specific outer providers (theme, toasts, a campaign context) wrap RisingTideProviders as its parent or sit inside as children - everything Privy / Query / auth-shaped lives within.
The entry curtain
Every app rendered through RisingTideProviders gets an entry curtain, whether or not the accessGate block is present. It sits below the protocol user context (it needs the resolved viewer) and wraps the app's whole tree, chrome included. What it draws is decided by the server, not by the app:
- It asks
GET /api/apps/{id}/accessfor the app named byappId/NEXT_PUBLIC_APP_ID. That one route answers for a signed-in viewer (does this account hold a grant?) and for a stranger (does this room have a door at all?). - An app whose
accessPolicyisPUBLICresolvesOPENand the curtain never appears. That is why the hub and the events calendar have no locked screen - they have no door, not because a list exempts them. - An app whose
accessPolicyisGRANTEDresolvesUNLOCKEDfor a viewer with an active grant andLOCKEDfor everyone else, signed in or not. Signing in never reduces what a viewer can see. - It never reads the catalog or the app's
catalogTeaseflag. The catalog omits gated rooms an operator chose not to advertise, so a gate that resolved from it would turn "do not advertise this room" into "let everyone into it". - A signed-in viewer is held until the bearer token has reached the SDK client. Asking earlier asks as a stranger, and a stranger's answer at a gated room is
LOCKED- which would then be cached for somebody who holds a grant. - While the verdict is in flight the room renders, and the locked screen replaces it only on a resolved
LOCKED. The cost is that an ungranted viewer sees the room for the round trip it takes their verdict to arrive; every action is still refused server-side byrequireAppAccess. The reason it is this way round: a server render never has a restored session, so a curtain that held while resolving sent every page of every app as an empty body. PassloadingonAccessGateyourself only if you mount it directly and prefer a blank. - On fetch errors or a missing app id the curtain fails open. The binding enforcement is the server's; the curtain exists so a locked room shows a door instead of a wall of 403s.
The curtain replaces the app's own chrome on the locked screen, so the shared teaser carries its own sign-in control (onSignIn opens the shared dialog). A custom accessGate.teaser must do the same: it receives authenticated, and copy that addresses "your account" has to branch on it, because a stranger reaches the same screen.
accessGate.selfGated: true opts an app out. The claim has to be true - "this app already gates its own entry with something stricter than an app grant". rt-admin is the one app in the estate that sets it: its AdminGate asks for an entitlement behind its own sign-in screen, and a curtain above it would add a way to be locked out of the tool that fixes a lockout. An app whose reads are simply open is not self-gated, and setting this on one reopens the hole the curtain closes. Say why at the call site.
The consequence for an app you are building: App.accessPolicy is a product decision. GRANTED no longer means only "an ungranted user's writes 403"; it means "an ungranted viewer is shown the door".