mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Merge branch 'dev' into promptless/document-developer-tools
This commit is contained in:
commit
d91af98d58
@ -1,14 +1,340 @@
|
||||
---
|
||||
title: Build a SaaS with Stack Auth
|
||||
description: End-to-end tutorial stub for building a SaaS app with Stack Auth.
|
||||
description: Bootstrap auth, resolve the signed-in user, protect your app, and ship-with optional pointers to teams, RBAC, and billing.
|
||||
---
|
||||
|
||||
This tutorial is coming soon.
|
||||
This tutorial is a **generic SaaS path** on Stack Auth: install and configure the SDK, wire sign-in, read the current user on the client and server, and gate your product. It does **not** assume teams, workspaces, or a particular permission model-those live in [Build a team-based app](/guides/other/tutorials/build-a-team-based-app) and the linked guides below.
|
||||
|
||||
## Planned coverage
|
||||
## What you will have at the end
|
||||
|
||||
- Project setup and auth bootstrapping
|
||||
- App onboarding, teams, and permissions
|
||||
- Production deployment checklist
|
||||
- A working sign-in and account flow using Stack Auth's handler routes (Next.js) or your framework’s equivalent.
|
||||
- A clear pattern for **who** is signed in across server components, client components, server actions, and route handlers.
|
||||
- **Protected** areas of your app (for example middleware on `/app/*` or `getUser({ or: "redirect" })` on key routes).
|
||||
- A short map for **where** multi-tenant and authorization work fits when you need it, plus a mindset for **production** domains, OAuth, and email.
|
||||
|
||||
If you want this prioritized, open a request in our community channels and include your stack details.
|
||||
## Prerequisites
|
||||
|
||||
- A **Next.js** project using the **App Router** (Stack’s first-class path for hosted UI and handlers), or another stack supported in [Setup](/guides/getting-started/setup) (React, Express, or REST from any backend).
|
||||
- A Stack Auth account and a **project** in the [dashboard](https://app.stack-auth.com/projects).
|
||||
|
||||
<Note>
|
||||
Stack does not officially support the Next.js Pages Router. If you are on Pages Router, consider the React or JavaScript SDKs per the [FAQ](/guides/faq).
|
||||
</Note>
|
||||
|
||||
The examples below focus on **Next.js (App Router)**. The same ideas apply on other stacks-swap in `StackClientApp` / REST calls as in [Setup](/guides/getting-started/setup).
|
||||
|
||||
## 1. Install Stack and wire environment variables
|
||||
|
||||
The fastest path for JavaScript and TypeScript is the **setup wizard**:
|
||||
|
||||
```bash title="Terminal"
|
||||
npx @stackframe/stack-cli@latest init
|
||||
```
|
||||
|
||||
Then create or open a project in the dashboard and copy **project ID**, **publishable client key** (if your project uses one), and **secret server key** into your app configuration. For Next.js, that usually means `.env.local`:
|
||||
|
||||
```bash title=".env.local"
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=<your-project-id>
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=<your-publishable-client-key>
|
||||
STACK_SECRET_SERVER_KEY=<your-secret-server-key>
|
||||
```
|
||||
|
||||
### What the wizard sets up (Next.js)
|
||||
|
||||
After `init`, you should see files similar to:
|
||||
|
||||
- `app/handler/[...stack]/page.tsx` - hosted sign-in, sign-up, account settings, and more
|
||||
- `app/layout.tsx` - wraps the app with `StackProvider` and `StackTheme`
|
||||
- `app/loading.tsx` - Suspense boundary for Stack’s async hooks
|
||||
- `stack/server.ts` - `stackServerApp` for server components, actions, and route handlers
|
||||
- `stack/client.ts` - `stackClientApp` when you need the client app object explicitly
|
||||
|
||||
If you ever need to align manually with the wizard output, the core pieces look like this:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="stack/server.ts">
|
||||
```typescript title="stack/server.ts"
|
||||
import "server-only";
|
||||
import { StackServerApp } from "@stackframe/stack";
|
||||
|
||||
export const stackServerApp = new StackServerApp({
|
||||
tokenStore: "nextjs-cookie",
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="stack/client.ts">
|
||||
```typescript title="stack/client.ts"
|
||||
import { StackClientApp } from "@stackframe/stack";
|
||||
|
||||
export const stackClientApp = new StackClientApp({
|
||||
// Reads NEXT_PUBLIC_STACK_* from the environment by default
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="app/handler/[...stack]/page.tsx">
|
||||
```tsx title="app/handler/[...stack]/page.tsx"
|
||||
import { StackHandler } from "@stackframe/stack";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default function Handler(props: unknown) {
|
||||
return <StackHandler fullPage app={stackServerApp} routeProps={props} />;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="app/layout.tsx">
|
||||
```tsx title="app/layout.tsx"
|
||||
import { StackProvider, StackTheme } from "@stackframe/stack";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<StackProvider app={stackServerApp}>
|
||||
<StackTheme>{children}</StackTheme>
|
||||
</StackProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="app/loading.tsx">
|
||||
```tsx title="app/loading.tsx"
|
||||
export default function Loading() {
|
||||
return <>Loading...</>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Full variants (React, Express, Python, manual install) are in [Setup](/guides/getting-started/setup).
|
||||
|
||||
After setup, open the hosted auth UI (for example `/handler/sign-up`), create a test user, and confirm you land back in your app.
|
||||
|
||||
### Marketing header: sign in / sign out
|
||||
|
||||
Use `useStackApp()` so you do not hard-code handler URLs (they can be customized in the project):
|
||||
|
||||
```tsx title="components/auth-header.tsx"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useStackApp, useUser } from "@stackframe/stack";
|
||||
|
||||
export function AuthHeader() {
|
||||
const app = useStackApp();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<header style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
{user ? (
|
||||
<>
|
||||
<span>{user.displayName ?? user.primaryEmail ?? user.id}</span>
|
||||
<Link href={app.urls.accountSettings}>Account</Link>
|
||||
<Link href={app.urls.signOut}>Sign out</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href={app.urls.signIn}>Sign in</Link>
|
||||
<Link href={app.urls.signUp}>Sign up</Link>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Resolve the signed-in user everywhere
|
||||
|
||||
Almost every SaaS screen starts from the **current user**: profile, preferences, billing state in your database, or admin vs end-user behavior you define yourself.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/dashboard/page.tsx"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await stackServerApp.getUser();
|
||||
|
||||
if (!user) {
|
||||
return <p>You are not signed in.</p>;
|
||||
}
|
||||
|
||||
return <p>Hello, {user.displayName ?? user.primaryEmail ?? user.id}</p>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Server Component (require auth)">
|
||||
```tsx title="app/app/page.tsx"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default async function AppHomePage() {
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
return <p>Hello, {user.displayName ?? user.primaryEmail ?? user.id}</p>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="components/greeting.tsx"
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export function Greeting() {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) {
|
||||
return <p>Please sign in.</p>;
|
||||
}
|
||||
|
||||
return <p>Hello, {user.displayName ?? user.primaryEmail ?? user.id}</p>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client Component (require auth)">
|
||||
```tsx title="components/greeting.tsx"
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export function Greeting() {
|
||||
const user = useUser({ or: "redirect" });
|
||||
return <p>Hello, {user.displayName ?? user.primaryEmail ?? user.id}</p>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Server action that requires a user
|
||||
|
||||
`{ or: "throw" }` is useful when a redirect would be wrong (for example, from a form POST). The example below only needs Stack for identity; your own persistence layer stores product data keyed by `user.id`.
|
||||
|
||||
```tsx title="app/actions/onboarding.ts"
|
||||
"use server";
|
||||
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export async function completeOnboardingStepAction(formData: FormData) {
|
||||
const user = await stackServerApp.getUser({ or: "throw" });
|
||||
const step = String(formData.get("step") ?? "").trim();
|
||||
if (!step) {
|
||||
throw new Error("Step is required");
|
||||
}
|
||||
|
||||
// TODO: write to your database using user.id as the tenant key (or your own model).
|
||||
return { userId: user.id, step };
|
||||
}
|
||||
```
|
||||
|
||||
### Route Handler (App Router API)
|
||||
|
||||
```tsx title="app/api/me/route.ts"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const user = await stackServerApp.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
primaryEmail: user.primaryEmail,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware for a `/app` (or `/private`) section
|
||||
|
||||
Match only the routes that should be gated, and **exclude** `/handler` so Stack’s auth pages keep working:
|
||||
|
||||
```tsx title="middleware.ts"
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const user = await stackServerApp.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL("/handler/sign-in", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/app/:path*",
|
||||
};
|
||||
```
|
||||
|
||||
More detail on protection patterns and sensitive HTML is in [User fundamentals](/guides/getting-started/user-fundamentals).
|
||||
|
||||
<Info>
|
||||
Treat **client-side** checks as **UX only**. Anything that mutates data or exposes another customer’s data must be enforced again on the server (server components, server actions, route handlers, or your backend using the secret key or verified tokens).
|
||||
</Info>
|
||||
|
||||
## 3. Tenancy, teams, and permissions (when you need them)
|
||||
|
||||
Stack gives you **users** and authentication primitives; **your** SaaS decides how rows and features map to customers.
|
||||
|
||||
- **Single-user or simple B2C** - Often enough to key application data to `user.id` and enforce access in your API with the same user you resolved via `stackServerApp.getUser()`.
|
||||
- **Shared accounts, workspaces, or B2B orgs** - Use Stack **teams** as the customer boundary, **team selection** for the active workspace, and **RBAC** for roles and fine-grained actions. Walk through that shape in [Build a team-based app](/guides/other/tutorials/build-a-team-based-app), with reference material in [Teams](/guides/apps/teams/overview), [Team selection](/guides/apps/teams/team-selection), and [RBAC](/guides/apps/rbac/overview).
|
||||
|
||||
Non-JavaScript or custom frontends can use the [REST API](/api/overview) with the same project keys; the identity model you choose (user-only vs teams) stays consistent across clients.
|
||||
|
||||
## 4. Product polish: onboarding, email, and optional billing
|
||||
|
||||
Hook flows to the guides-no extra Stack APIs are required at this layer:
|
||||
|
||||
- **Onboarding and sign-up rules** - [User onboarding](/guides/apps/authentication/user-onboarding), [Sign-up rules](/guides/apps/authentication/sign-up-rules)
|
||||
- **Email** - [Emails](/guides/apps/emails/overview)
|
||||
- **Stripe / plans** - [Payments](/guides/apps/payments/overview)
|
||||
|
||||
Example: after sign-up, send users to an onboarding route from your own `app/page.tsx` or a server layout once `getUser()` is non-null.
|
||||
|
||||
## 5. Production checklist
|
||||
|
||||
Before going live, tighten **callback domains**, replace shared **OAuth** keys with your own provider apps where needed, and review email and security defaults. Follow [Launch checklist](/guides/apps/launch-checklist/overview).
|
||||
|
||||
## Related guides
|
||||
|
||||
| Topic | Guide |
|
||||
|--------|--------|
|
||||
| Install and configure | [Setup](/guides/getting-started/setup) |
|
||||
| `StackApp` object | [Stack App](/guides/going-further/stack-app) |
|
||||
| Current user and page protection | [User fundamentals](/guides/getting-started/user-fundamentals) |
|
||||
| Teams, membership, and RBAC (deeper path) | [Build a team-based app](/guides/other/tutorials/build-a-team-based-app) |
|
||||
| Teams reference | [Teams](/guides/apps/teams/overview) |
|
||||
| Permissions | [RBAC](/guides/apps/rbac/overview) |
|
||||
| Pre-launch hardening | [Launch checklist](/guides/apps/launch-checklist/overview) |
|
||||
| Billing (optional) | [Payments](/guides/apps/payments/overview) |
|
||||
| General questions | [FAQ](/guides/faq) |
|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Do I have to use teams for a SaaS?">
|
||||
No. This guide stays user-centric until you opt into teams. When multiple people share one customer account, follow [Build a team-based app](/guides/other/tutorials/build-a-team-based-app) and the [Teams](/guides/apps/teams/overview) docs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Where should I enforce permissions?">
|
||||
Use dashboard-defined permissions for **authorization** when you use RBAC; always enforce **business rules** on the server: Server Components, server actions, route handlers, or your backend with the **secret server key** or validated access tokens. Client checks alone are not enough for sensitive operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I use Stack only as a backend API?">
|
||||
Yes. Non-JS or custom frontends can use the [REST API](/api/overview) with the same project keys; the mental model (users, and optionally teams and permissions) stays the same.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How do I test locally with OAuth and redirects?">
|
||||
Localhost callback behavior and production domain restrictions are covered under **Domains** in [Launch checklist](/guides/apps/launch-checklist/overview). Keep localhost allowances enabled only for development.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How does this relate to the team-focused tutorial?">
|
||||
[Build a team-based app](/guides/other/tutorials/build-a-team-based-app) is the place for **teams**, **team selection**, and **RBAC** walkthroughs. This SaaS tutorial covers the **generic** product path: auth bootstrap, resolving the user, protecting routes, then linking out for tenancy and launch details.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Where do I get help or report doc gaps?">
|
||||
See [FAQ](/guides/faq) for contribution and community pointers.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@ -1,12 +1,380 @@
|
||||
---
|
||||
title: Build a Team-Based App
|
||||
description: Tutorial stub for implementing teams and role-based access with Stack Auth.
|
||||
description: Model B2B tenants as teams, wire team selection and deep links, enforce RBAC on the server, and grow membership with invitations.
|
||||
---
|
||||
|
||||
This tutorial is coming soon.
|
||||
This tutorial walks through a **multi-tenant** product where a **team** is the customer boundary (workspace, organization, account). You get membership-scoped team access, permission checks, team selection UX, and invitations.
|
||||
|
||||
## Planned coverage
|
||||
If you have not installed Stack yet (handler routes, `StackProvider`, environment variables), start with [Build a SaaS with Stack Auth](/guides/other/tutorials/build-a-saas-with-stack-auth), then continue here.
|
||||
|
||||
- Team creation and membership flows
|
||||
- Team selection patterns
|
||||
- RBAC permission modeling for multi-tenant apps
|
||||
## What you will have at the end
|
||||
|
||||
- Teams listed and resolved **for the signed-in user** (safe deep links like `/team/[teamId]`).
|
||||
- **Team creation** from the client (with dashboard settings) and an optional **server** provisioning pattern.
|
||||
- A **team switcher** pattern using `SelectedTeamSwitcher` with deep links and optional `selectedTeam` updates.
|
||||
- **RBAC** checks aligned with dashboard-defined team permissions, with server-side enforcement before mutations.
|
||||
- A path to **invite** collaborators and accept invitations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Stack Auth installed as in [Build a SaaS with Stack Auth](/guides/other/tutorials/build-a-saas-with-stack-auth) (Next.js App Router examples below assume `stackServerApp` in `stack/server.ts` and `@stackframe/stack` in the app).
|
||||
- A project in the [dashboard](https://app.stack-auth.com/projects) where you can edit **Teams** and **Team permissions**.
|
||||
|
||||
<Note>
|
||||
On the server, prefer **`user.getTeam(id)`** and **`user.listTeams()`** from `stackServerApp.getUser()` so you only ever load teams the current user belongs to. `stackServerApp.getTeam` / `stackServerApp.listTeams` operate at **project** scope (useful for admin or provisioning, not for normal tenant pages).
|
||||
</Note>
|
||||
|
||||
## 1. Turn on teams in the dashboard
|
||||
|
||||
In your Stack project:
|
||||
|
||||
1. **Teams** - Enable team features and review defaults (for example whether a personal team is created on sign-up), per [Teams](/guides/apps/teams/overview).
|
||||
2. **Client-side team creation** - If the browser will call `user.createTeam`, enable client team creation in team settings (same guide).
|
||||
3. **Team permissions** - In **Team permissions**, define the actions your product needs (for example `export_reports`, nested under a role like `admin`). Add Stack **system** permissions where needed; their IDs start with `$` (for example `$invite_members`, `$read_members`, `$update_team`). See [RBAC](/guides/apps/rbac/overview).
|
||||
|
||||
Keep client-side permission checks as **UX only**; always re-check on the server before changing data.
|
||||
|
||||
## 2. List teams and resolve a team from a URL
|
||||
|
||||
Use the **current user** as the scope: `listTeams` / `useTeams`, and `getTeam` / `useTeam` for one id.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/team/[teamId]/page.tsx"
|
||||
import Link from "next/link";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
type PageProps = { params: { teamId: string } };
|
||||
|
||||
export default async function TeamHomePage({ params }: PageProps) {
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const team = await user.getTeam(params.teamId);
|
||||
|
||||
if (!team) {
|
||||
return <p>You are not a member of this team.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>{team.displayName}</h1>
|
||||
<p>Team ID: {team.id}</p>
|
||||
<Link href="/team">All teams</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="app/team/[teamId]/team-home.tsx"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
type Props = { teamId: string };
|
||||
|
||||
export function TeamHome({ teamId }: Props) {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const team = user.useTeam(teamId);
|
||||
|
||||
if (!team) {
|
||||
return <p>You are not a member of this team.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>{team.displayName}</h1>
|
||||
<p>Team ID: {team.id}</p>
|
||||
<Link href="/team">All teams</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If your framework types route `params` as a `Promise` (newer Next.js), `await` the params object before reading `teamId`.
|
||||
|
||||
### Index page: all workspaces
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Server">
|
||||
```tsx title="app/team/page.tsx"
|
||||
import Link from "next/link";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default async function TeamsIndexPage() {
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const teams = await user.listTeams();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<Link href={`/team/${team.id}`}>{team.displayName}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client">
|
||||
```tsx title="app/team/page.tsx"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export default function TeamsIndexPage() {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const teams = user.useTeams();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<Link href={`/team/${team.id}`}>{team.displayName}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## 3. Create teams
|
||||
|
||||
### Signed-in user creates a team (client)
|
||||
|
||||
After enabling **client-side team creation**, the creator is added as a member with your project’s default creator permissions:
|
||||
|
||||
```tsx title="components/create-workspace-button.tsx"
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export function CreateWorkspaceButton() {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const team = await user.createTeam({ displayName: "My workspace" });
|
||||
router.push(`/team/${team.id}`);
|
||||
}}
|
||||
>
|
||||
New workspace
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Provision a team without a browser session (server)
|
||||
|
||||
For imports, support tools, or other **server** jobs, `stackServerApp.createTeam` creates a team at project scope (see [Teams](/guides/apps/teams/overview)); wire membership separately if your flow requires it.
|
||||
|
||||
```tsx title="scripts/provision-team.example.ts"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export async function provisionEmptyTeam(displayName: string) {
|
||||
return await stackServerApp.createTeam({ displayName });
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Team selection and deep links
|
||||
|
||||
Stack tracks a **`selectedTeam`** on the user for “current workspace” UX. For B2B apps, **deep links** (`/team/[teamId]`) are usually recommended so shared URLs always refer to the same tenant; see [Team selection](/guides/apps/teams/team-selection).
|
||||
|
||||
`SelectedTeamSwitcher` updates `selectedTeam` when `selectedTeam` is passed, optionally navigates via `urlMap`, and can skip updating stored selection with `noUpdateSelectedTeam`:
|
||||
|
||||
```tsx title="components/team-switcher.tsx"
|
||||
"use client";
|
||||
|
||||
import { SelectedTeamSwitcher, useUser } from "@stackframe/stack";
|
||||
|
||||
type Props = { currentTeamId: string };
|
||||
|
||||
export function TeamSwitcher({ currentTeamId }: Props) {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const team = user.useTeam(currentTeamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedTeamSwitcher
|
||||
urlMap={(t) => `/team/${t.id}`}
|
||||
selectedTeam={team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use `noUpdateSelectedTeam` when you only want navigation (for example “open workspace” without changing the persisted default). Details and examples are in [Team selection](/guides/apps/teams/team-selection).
|
||||
|
||||
## 5. Enforce RBAC (server + client)
|
||||
|
||||
Define permissions in the dashboard, then branch on **`getPermission`** / **`usePermission`** on the **user**, scoped to a **team**.
|
||||
|
||||
Replace `export_reports` with a permission you created.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/team/[teamId]/reports/page.tsx"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
type PageProps = { params: { teamId: string } };
|
||||
|
||||
export default async function ReportsPage({ params }: PageProps) {
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const team = await user.getTeam(params.teamId);
|
||||
if (!team) {
|
||||
return <p>Workspace not found.</p>;
|
||||
}
|
||||
|
||||
const canExport = await user.getPermission(team, "export_reports");
|
||||
if (!canExport) {
|
||||
return <p>You do not have access to exports in this workspace.</p>;
|
||||
}
|
||||
|
||||
return <button type="button">Download report</button>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client Component (UX only)">
|
||||
```tsx title="components/export-reports-button.tsx"
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@stackframe/stack";
|
||||
import type { CurrentUser, Team } from "@stackframe/stack";
|
||||
|
||||
type Props = { teamId: string };
|
||||
|
||||
/** Split so `usePermission` only runs when `useTeam` returned a real team (Rules of Hooks). */
|
||||
export function ExportReportsButton({ teamId }: Props) {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const team = user.useTeam(teamId);
|
||||
if (!team) {
|
||||
return <p>Workspace not found.</p>;
|
||||
}
|
||||
return <ExportReportsButtonInner user={user} team={team} />;
|
||||
}
|
||||
|
||||
function ExportReportsButtonInner({ user, team }: { user: CurrentUser; team: Team }) {
|
||||
const permission = user.usePermission(team, "export_reports");
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
return <button type="button">Download report</button>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
System permissions use the same API, for example:
|
||||
|
||||
```tsx
|
||||
const canInvite = await user.getPermission(team, "$invite_members");
|
||||
```
|
||||
|
||||
For listing effective permissions, use `listPermissions` / `usePermissions` ([RBAC](/guides/apps/rbac/overview)).
|
||||
|
||||
### Server action before a mutation
|
||||
|
||||
```tsx title="app/actions/reports.ts"
|
||||
"use server";
|
||||
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export async function requestReportExportAction(teamId: string) {
|
||||
const user = await stackServerApp.getUser({ or: "throw" });
|
||||
const team = await user.getTeam(teamId);
|
||||
if (!team) {
|
||||
throw new Error("Not a member of this team");
|
||||
}
|
||||
const canExport = await user.getPermission(team, "export_reports");
|
||||
if (!canExport) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
// TODO: enqueue export job scoped to team.id
|
||||
return { ok: true as const };
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Invitations and membership
|
||||
|
||||
Users with **`$invite_members`** can invite by email from the `Team` object (options object in the SDK):
|
||||
|
||||
```tsx
|
||||
await team.inviteUser({ email: "colleague@company.com" });
|
||||
```
|
||||
|
||||
Recipients with a **verified** email matching the invitation can list pending invites and accept:
|
||||
|
||||
```tsx title="components/pending-invites.tsx"
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export function PendingInvites() {
|
||||
const user = useUser({ or: "redirect" });
|
||||
const invitations = user.useTeamInvitations();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{invitations.map((inv) => (
|
||||
<li key={inv.id}>
|
||||
{inv.teamDisplayName} - {inv.recipientEmail}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await inv.accept();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Sender-side listing and revokes use `team.listInvitations` / `team.useInvitations` ([Teams](/guides/apps/teams/overview)).
|
||||
|
||||
## 7. Metadata, profiles, and members
|
||||
|
||||
Teams support `clientMetadata`, `serverMetadata`, and `clientReadOnlyMetadata` on `team.update` ([Teams](/guides/apps/teams/overview)). Per-team member display uses **`getTeamProfile` / `useTeamProfile`** on the user; listing members uses **`team.listUsers` / `team.useUsers`** and requires **`$read_members`** on the client.
|
||||
|
||||
## Related guides
|
||||
|
||||
| Topic | Guide |
|
||||
|--------|--------|
|
||||
| Auth bootstrap and route protection | [Build a SaaS with Stack Auth](/guides/other/tutorials/build-a-saas-with-stack-auth) |
|
||||
| Teams API reference | [Teams](/guides/apps/teams/overview) |
|
||||
| `SelectedTeamSwitcher` and URL strategies | [Team selection](/guides/apps/teams/team-selection) |
|
||||
| Permission modeling and nesting | [RBAC](/guides/apps/rbac/overview) |
|
||||
| REST without the SDK | [API overview](/api/overview) |
|
||||
| Pre-launch | [Launch checklist](/guides/apps/launch-checklist/overview) |
|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="When should I use user.getTeam vs stackServerApp.getTeam?">
|
||||
For product pages and APIs acting as the signed-in user, use **`(await stackServerApp.getUser()).getTeam(id)`** so membership is enforced by Stack. Use **`stackServerApp.getTeam`** only when you intentionally need **project-wide** team lookup (for example internal admin tools), then apply your own authorization.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Why split the client export button into two components?">
|
||||
React hooks must run unconditionally. `useTeam` may leave you without a `Team` instance; `usePermission` needs that team. A small wrapper that returns early, and an inner component that only calls `usePermission` when `team` is defined, keeps hooks valid.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Do permissions replace checks in my database?">
|
||||
Stack permissions answer **whether this Stack user may perform an action in this Stack team**. You should still scope **your** rows by `team.id` (or equivalent) and validate inputs-Stack does not replace your data model.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@ -1,12 +1,177 @@
|
||||
---
|
||||
title: Ship Production-Ready Auth
|
||||
description: Tutorial stub for hardening a Stack Auth deployment before launch.
|
||||
description: Lock down page and API access, handle secrets and environments, then align domains, OAuth, email, webhooks, and dashboard production mode with Stack Auth.
|
||||
---
|
||||
|
||||
This tutorial is coming soon.
|
||||
Going live is not only “turning on production mode.” This guide focuses on **what must be true** so only signed-in users reach protected surfaces, **secrets stay server-only**, and Stack’s dev-friendly defaults are replaced with **your** domains, OAuth apps, and email.
|
||||
|
||||
## Planned coverage
|
||||
If you are still wiring Stack into your app, complete [Build a SaaS with Stack Auth](/guides/other/tutorials/build-a-saas-with-stack-auth) first. For team RBAC before launch, see [Build a team-based app](/guides/other/tutorials/build-a-team-based-app).
|
||||
|
||||
- Environment and key management strategy
|
||||
- Provider, email, and webhook production checks
|
||||
- Launch readiness and rollback planning
|
||||
## What you will have at the end
|
||||
|
||||
- A clear model for **protecting pages**, layouts, route handlers, and server actions—and when to use middleware vs in-render checks.
|
||||
- Awareness of **Next.js + sensitive HTML** so you do not leak data through composition.
|
||||
- A **secrets and environment** split that keeps the server key out of the browser.
|
||||
- A **launch-aligned** checklist (domains, OAuth, email, production mode) with pointers to deeper docs.
|
||||
- A **webhook** verification mindset (signed payloads only).
|
||||
|
||||
## 1. Protect a page (and know what that actually guarantees)
|
||||
|
||||
You typically combine **one or more** of:
|
||||
|
||||
1. **Middleware** — cheap gate when the URL alone tells you the area is private (for example everything under `/app`).
|
||||
2. **Server Components / server loaders** — `await stackServerApp.getUser({ or: "redirect" })` (or handle `null`) on the route that renders sensitive UI.
|
||||
3. **Client Components** — `useUser({ or: "redirect" })` for UX; **never** the only layer for authorization.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Middleware (route prefix)">
|
||||
```tsx title="middleware.ts"
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const user = await stackServerApp.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL("/handler/sign-in", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/app/:path*",
|
||||
};
|
||||
```
|
||||
|
||||
Match **only** prefixes that should be gated. Do **not** blanket-match `/` or you can block static assets and Stack’s **`/handler`** routes (sign-in, sign-up, callbacks).
|
||||
|
||||
If your project uses **custom handler base paths**, use the sign-in URL from `stackServerApp.urls.signIn` as the redirect target instead of hard-coding `/handler/sign-in` (it may be an absolute URL depending on configuration—`NextResponse.redirect` accepts that).
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/app/dashboard/page.tsx"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
await stackServerApp.getUser({ or: "redirect" });
|
||||
return <h1>Dashboard</h1>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Client Component (UX gate)">
|
||||
```tsx title="app/app/settings/client-gate.tsx"
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@stackframe/stack";
|
||||
|
||||
export function SettingsGate({ children }: { children: React.ReactNode }) {
|
||||
useUser({ or: "redirect" });
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### `redirect` vs `throw`
|
||||
|
||||
- **`{ or: "redirect" }`** — use when a browser navigation is appropriate (pages, most Server Components).
|
||||
- **`{ or: "throw" }`** — use in **server actions**, **route handlers**, and other places where redirecting would be wrong; map errors to `401`/`403` responses yourself.
|
||||
|
||||
```tsx title="app/api/me/route.ts"
|
||||
import { stackServerApp } from "@/stack/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await stackServerApp.getUser({ or: "throw" });
|
||||
return NextResponse.json({ id: user.id });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive content and Next.js composition
|
||||
|
||||
Authentication prevents **impersonation**, but **HTML composition** still matters:
|
||||
|
||||
- **Client Components** ship to the browser; gating with hooks does not hide their bundled code.
|
||||
- **Server Components**: a protected parent does **not** guarantee an unprotected child (or an unprotected segment under a layout) never reaches the client. Anything that embeds **secrets or PII** should **itself** call `getUser({ or: "redirect" })` / `getUser({ or: "throw" })` (or otherwise avoid rendering that data when unauthenticated).
|
||||
- **Middleware**: on **Next.js 15.2.3+**, middleware-only protection does not leak unprotected server-rendered fragments the way older versions could; still treat **authorization** (who may perform an action) as a **server** concern.
|
||||
|
||||
Read the full discussion in [User fundamentals — Protecting a page](/guides/getting-started/user-fundamentals#protecting-a-page).
|
||||
|
||||
<Info>
|
||||
Treat **client-side** checks as **UX only**. Anything that mutates data or exposes another customer’s data must be enforced on the server (Server Components, server actions, route handlers, or your backend with the **secret server key** or validated tokens). For team-scoped apps, re-check [RBAC](/guides/apps/rbac/overview) on the server, not only with `usePermission`.
|
||||
</Info>
|
||||
|
||||
## 2. Secrets, keys, and environments
|
||||
|
||||
<Warning>
|
||||
**`STACK_SECRET_SERVER_KEY`** (or `ssk_...`) must **only** exist in server-side environments (SSR, route handlers, server actions, your backend). Never prefix it with `NEXT_PUBLIC_`, never import it from code that runs in the browser, and never log it. See [Stack App](/guides/going-further/stack-app) and the [REST API overview](/api/overview).
|
||||
</Warning>
|
||||
|
||||
Practical split:
|
||||
|
||||
| Variable | Typical exposure | Use |
|
||||
|----------|------------------|-----|
|
||||
| `NEXT_PUBLIC_STACK_PROJECT_ID` | Browser + server | Identifies the project to the hosted UI and client SDK. |
|
||||
| `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` (if used) | Browser + server | Client-safe key where your project uses publishable keys. |
|
||||
| `STACK_SECRET_SERVER_KEY` | **Server only** | Elevated server SDK and REST **server** API. |
|
||||
|
||||
Use **separate** Stack projects or at least **separate** env values for production vs staging when possible. Rotate keys from the dashboard if a secret is exposed.
|
||||
|
||||
## 3. Domains, OAuth, email, and production mode
|
||||
|
||||
Stack’s dev defaults (localhost callbacks, shared OAuth keys, shared mail) are convenient but **not** what you want for real users.
|
||||
|
||||
<Steps>
|
||||
<Step title="Domains and callbacks">
|
||||
Add your real **`https://…` origin** under **Domain & Handlers** and disable **Allow all localhost callbacks** when you no longer need local redirects against production configuration. Details: [Launch checklist — Domains](/guides/apps/launch-checklist/overview#domains).
|
||||
</Step>
|
||||
<Step title="OAuth providers">
|
||||
Create **your own** OAuth clients per provider, set the provider callback URLs Stack documents, then paste **your** client ID and secret in the dashboard (leave shared keys for local dev only). Details: [Launch checklist — OAuth providers](/guides/apps/launch-checklist/overview#oauth-providers) and [Auth providers](/guides/apps/authentication/auth-providers).
|
||||
</Step>
|
||||
<Step title="Email">
|
||||
Point outbound mail at **your** SMTP/domain so magic links and invitations come from a domain users trust. Details: [Launch checklist — Email server](/guides/apps/launch-checklist/overview#email-server) and [Emails](/guides/apps/emails/overview).
|
||||
</Step>
|
||||
<Step title="Enable production mode">
|
||||
After the above, turn on **production mode** in **Project Settings** so dashboard guardrails match how you run in prod ([Launch checklist — Enabling production mode](/guides/apps/launch-checklist/overview#enabling-production-mode)).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 4. Webhooks
|
||||
|
||||
If you consume Stack webhooks, **verify every payload** (for example with Svix and `STACK_WEBHOOK_SECRET`) before acting on events—treat unsigned or failed verification as `400`. Implementation patterns: [Webhooks](/guides/apps/webhooks/overview).
|
||||
|
||||
## 5. Before you flip traffic
|
||||
|
||||
- **Smoke-test** sign-in, sign-up, password reset, and OAuth **on the production domain** after DNS and env vars are final.
|
||||
- **Confirm** redirect URLs in third-party consoles (OAuth, IdP) match the Stack callback URLs you use in prod.
|
||||
- **Align** session / cookie behavior with your hosting (same-site, HTTPS, reverse proxies) per your platform docs.
|
||||
- **Plan rollback**: keep prior env values or a maintenance window note so you can revert dashboard or env changes quickly.
|
||||
|
||||
## Related guides
|
||||
|
||||
| Topic | Guide |
|
||||
|--------|--------|
|
||||
| First integration | [Build a SaaS with Stack Auth](/guides/other/tutorials/build-a-saas-with-stack-auth) |
|
||||
| Page protection details | [User fundamentals](/guides/getting-started/user-fundamentals) |
|
||||
| Domains, OAuth, email, prod mode | [Launch checklist](/guides/apps/launch-checklist/overview) |
|
||||
| `StackServerApp` and keys | [Stack App](/guides/going-further/stack-app) |
|
||||
| Webhook verification | [Webhooks](/guides/apps/webhooks/overview) |
|
||||
| REST from your backend | [API overview](/api/overview) |
|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Is middleware enough to protect my API?">
|
||||
Middleware runs on **matching routes** and is great for coarse “must be logged in” gates. **Route handlers** and **server actions** should still call `getUser({ or: "throw" })` (or equivalent) and return proper HTTP errors—middleware will not run for every internal call path, and **authorization** (what that user may do) belongs next to your business logic.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Should production and development share one Stack project?">
|
||||
Prefer **separate projects** (or strictly separated keys and OAuth clients) so you never point production OAuth callbacks at localhost, and so a leaked dev key cannot touch production data.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Where do teams and RBAC fit for launch?">
|
||||
Model permissions in the dashboard, then enforce them on the server for every sensitive operation. Walkthrough: [Build a team-based app](/guides/other/tutorials/build-a-team-based-app).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user