# Agent-Friendly Docs URL: https://ship-and-scale.vercel.app/docs/agent-friendly-docs Download the complete public manual as one Markdown file and give your coding agent reliable context about the starter. **Agent context packet** [Download ship-and-scale-docs.md](/ship-and-scale-docs.md): every public guide combined into one portable Markdown file. The download excludes paid content, internal records, credentials and customer data. The download gives an LLM the same public documentation you can read on this site, in one continuous Markdown file. It covers the starter's architecture, setup, provider configuration, authentication, workspaces, billing, email, storage, integrations, public website, deployment and operations. Headings and source URLs separate the guides so an agent can trace an answer back to the page you should review. Use the file when you want an agent to understand the starter before it plans or changes code. It is useful in Codex, Claude Code, Cursor, ChatGPT and other tools that accept file attachments or local project context. The format is plain Markdown rather than a PDF, so code samples, commands, links and section structure stay readable to both you and the model. ## Add it to your agent [#add-it-to-your-agent] ### Download the context file [#download-the-context-file] Select **Download complete documentation** above. Your browser saves `ship-and-scale-docs.md`. The file is generated from the current public manual, so there is no separate hand-maintained copy to drift out of date. ### Make it available to the session [#make-it-available-to-the-session] Attach the file directly to a conversation, or place it inside the project when your coding tool reads local files. If the agent can already inspect your repository, keep the source code available too: the manual explains intended behavior and boundaries, while the checked-out files show the exact version you are editing. ### Give the agent context for future features [#give-the-agent-context-for-future-features] Use the prompt below to orient the agent before your first feature request. The downloaded manual explains the starter; the repository's `AGENTS.md` supplies project instructions. Keep both available and ask the agent to read the relevant code for each change. Paste this prompt at the start of your session:
```text title="Prompt for your coding agent" Read ship-and-scale-docs.md and follow the repository's AGENTS.md, including relevant nested instructions. Inspect the code to understand the architecture, existing features and conventions. Use this context for the features I ask you to build. Read the relevant guides and code, reuse existing patterns, and flag conflicts or missing decisions before editing. Preserve workspace isolation, permissions and secret handling. Implement each requested feature, run relevant checks, and report anything unverified. Start with a brief summary of how the app fits together, then wait for my first feature request. ```
Then describe the feature you want and how it should work for your users. Repeat the context step in a new session; the prompt does not give an agent permanent memory. ## What the file does and doesn't do [#what-the-file-does-and-doesnt-do] The download is a product manual, not a replacement for your repository. It explains how the starter fits together, which files and commands matter, and which security rules a change must keep. Your agent should still read the current code, `AGENTS.md`, package scripts and nearby tests before editing. When the manual and the code disagree, the code in your checkout is the source of truth. The file contains only this public documentation. It has no access to your repository, environment values or provider accounts, and it can't tell your agent what's actually configured in Supabase, Stripe or Vercel. Review any files or logs you attach from your own project before sending them to an external model. ## Keep the context current [#keep-the-context-current] Download a fresh copy after the documentation changes or when you come back to the project after an update. You can also open [`/llms-full.txt`](/llms-full.txt) to read the same text in the browser, or use **View Markdown** on any guide when you only need one topic. For long-running work, keep the complete file as background context and attach the relevant guide again when a task needs extra focus. Ask the agent to separate what it read in the manual, what it checked in the code and what still needs confirming in your provider dashboards. --- # Introduction URL: https://ship-and-scale.vercel.app/docs Your starting point for a production-ready SaaS. Ship & Scale is a Next.js SaaS starter with Auth, workspaces, subscriptions and a customizable interface already connected. Start with the infrastructure your product needs and focus on what makes it worth using. You get the source code and run it on your own provider accounts. Kickstart handles the initial service setup. The example app is called **Forma**; replace its brand and features with your own. [Start with Tech stack](/docs/build/codebase), then follow Setup → Accounts → Configuration → Kickstart. ## Who it's for [#who-its-for] * Founders building their first SaaS with a coding agent or developer. * AI builders who want an established architecture and documentation their agent can use. * Indie hackers launching multiple products without rebuilding the same infrastructure. ## How Kickstart works [#how-kickstart-works] Three credentials connect your project to Supabase, Stripe and Resend. Kickstart checks access, then creates the services behind your app. ### Add your credentials [#add-your-credentials] After cloning and installing the starter, add `SUPABASE_ACCESS_TOKEN`, `STRIPE_TEST_SECRET_KEY` and `RESEND_API_KEY` to your local `.env`. Accounts and Configuration cover the required permissions. Keep secret values out of chats and source control. ### Check access and provision [#check-access-and-provision] `npm run kickstart:check` runs read-only checks. Then `npm run kickstart` creates your hosted Supabase project with its schema, permissions, Auth, storage and functions. It publishes five Resend email templates, configures Stripe test products, prices, webhooks and the billing portal, and saves generated settings to `.env`. ### Run your app [#run-your-app] Run `npm run dev` and test signup, email and billing. Next.js runs locally against your hosted services. The [Kickstart guide](/docs/start/project-kickstart) covers checks and recovery; [deployment](/docs/start/deployment-kickstart) follows with GitHub, Vercel and production credentials. **Before launch** The providers' free plans cover building and launching; upgrade Vercel to Pro before you take payments. Customize the product, review content and permissions, and test customer flows using the [production launch checklist](/docs/operations/launch). ## Build with your agent [#build-with-your-agent] [Download the docs (.md)](/ship-and-scale-docs.md) and give your agent access to your repository. Use this prompt to build context for future features. [Agent-Friendly Docs](/docs/agent-friendly-docs) explains how the manual and `AGENTS.md` work together. **Example agent prompt** ```text title="Prompt for your coding agent" Read ship-and-scale-docs.md and follow the repository's AGENTS.md, including relevant nested instructions. Inspect the code to understand the architecture, existing features and conventions. Use this context for the features I ask you to build. Read the relevant guides and code, reuse existing patterns, and flag conflicts or missing decisions before editing. Preserve workspace isolation, permissions and secret handling. Implement each requested feature, run relevant checks, and report anything unverified. Start with a brief summary of how the app fits together, then wait for my first feature request. ``` ## What's in the starter [#whats-in-the-starter] Each card links to a detailed guide. [Auth](/docs/core/authentication) Email/password, magic links, recovery and configurable verification. Google sign-in is available once you connect your OAuth app. [Workspaces](/docs/core/workspaces) Workspace invitations, switching and Owner, Admin and Member roles. Server checks and database policies keep workspace data isolated. [Subscriptions](/docs/billing/subscriptions) Stripe Checkout, recurring plans and a billing portal. Each workspace has its own subscription, with webhooks syncing billing state. [Customer dashboard](/docs/core/onboarding-settings) A protected app shell with onboarding, profile settings and workspace controls. Reuse its layout and components for your product. [Admin panel](/docs/operations/maintenance#admin-panel) Read-only customer visibility and operator-access management. Access is separate from workspace roles; workspace admins don't automatically get access. [Storage](/docs/storage/overview) Private avatars in Supabase Storage and database-backed workspace logos, with upload validation and access policies. [Emails](/docs/email/templates-delivery) Five Resend templates: welcome, verification, password reset, magic-link sign-in and workspace invitations. Edit the source and publish through Kickstart. [In-app notifications](/docs/core/notifications-feedback) A private inbox with read/unread controls and preferences. Extend the welcome notification example with your own product events. [Customer feedback](/docs/core/notifications-feedback) A shared feature-request board with voting, plus private bug reports. Reports and voter identities stay private. [Marketing site](/docs/website/pages-blog) Landing, pricing, FAQ and contact pages, plus sample legal content. Replace the example brand and copy before launch. [Blog](/docs/website/pages-blog) Public posts backed by published content in Supabase. Add your own articles and authors; a general-purpose CMS editor isn't included. [Product documentation](/docs/website/documentation) Fumadocs pages, search and navigation for your customers. This is separate from the Ship & Scale manual you're reading now. [SEO](/docs/website/seo-metadata) Metadata, canonical URLs, social previews and sitemap controls. Configure your production domain and review launch settings before enabling indexing. [REST API](/docs/integrations/api) Workspace API keys and permission-checked endpoints to read and rename a workspace. Extend these patterns for your own resources. [MCP](/docs/integrations/mcp) OAuth and scoped tools to read and rename a workspace through MCP clients. Add tools for your product's actions. [AI chat · optional](/docs/integrations/ai) OpenRouter streaming chat, saved conversations and workspace credit checks. Supply a funded provider account, or remove AI while keeping the core SaaS features. ## Your member dashboard [#your-member-dashboard] Your Ship & Scale member area contains the Starter kit, Documentation, Founder playbook and Discord community. It's separate from the customer dashboard in your app. Open **Starter kit** to connect GitHub and get access to the starter. [Setup](/docs/start/environment) walks through each step. Ship & Scale member dashboard with separate cards for Starter kit, Documentation, Founder playbook and Discord community. ## Start here [#start-here] Continue through **Tech stack → Setup → Accounts → Configuration → Kickstart** to get your local app running. [Troubleshooting](/docs/operations/troubleshooting) is available along the way. [Next: Tech stack](/docs/build/codebase) Review the stack and architecture before setting up your project. --- # GitHub access token URL: https://ship-and-scale.vercel.app/docs/accounts/github Create a GitHub token and save it for deployment. By the end of this guide, your GitHub token will be saved in the starter's root `.env` file. Deployment Kickstart uses it to create your app's private repository and publish your source code. You can do this after your local app works. For downloading the starter, follow [Setup](/docs/start/environment#get-access-to-the-starter); the deployment token isn't needed for your first Kickstart. ## Create and save the token [#create-and-save-the-token] ### Open Fine-grained tokens [#open-fine-grained-tokens] Sign in to [GitHub](https://github.com/login), or [create an account](https://github.com/signup). Open **Settings → Developer settings → Personal access tokens → Fine-grained tokens**, or go directly to [Fine-grained tokens](https://github.com/settings/personal-access-tokens). Click **Generate new token**. If GitHub asks you to confirm access, complete its verification to continue.
GitHub Developer settings with Fine-grained tokens selected and the green Generate new token button.
GitHub · Fine-grained tokens → Generate new token. Existing accounts may show a token list here.
### Choose the owner and repository access [#choose-the-owner-and-repository-access] Fill in the token form: 1. **Token name:** enter `Ship & Scale deployment`. 2. **Resource owner:** choose the account or organization that will own the app repository. It must match `GITHUB_OWNER`. 3. **Expiration:** choose **90 days**. That covers deployment and retries; Kickstart only uses this token during deployment. 4. **Repository access:** automatic repository creation needs **All repositories**, which also covers every current and future repo under that owner. To limit access to one repository, first create the exact empty private repository in GitHub, then choose **Only select repositories** and select that repository. Leave **Description** blank unless you want to add a note.
GitHub token form with the deployment name, repository owner, 30-day expiration and All repositories selected.
GitHub - choose the account that owns the repo. The example shows All repositories for automatic creation and a 30-day expiry.
**Automatic creation needs broad repository access** **All repositories** includes every current and future repository under the selected owner. A fine-grained token can select one owner only. For least access, precreate the exact empty private repository and use **Only select repositories**; set `GITHUB_OWNER`, `GITHUB_REPO_NAME` and its numeric `GITHUB_REPO_ID` to that repository before checking deployment. ### Add the repository permissions [#add-the-repository-permissions] Scroll to **Permissions**. With **Repositories** selected, click **Add permissions**. Search for and select **Administration**, then **Contents**. Close the menu and change each permission's **Access** to **Read and write**.
GitHub Add permissions menu with Administration entered in the search field and its checkbox ready to select.
GitHub · Add permissions → search for a permission → select it. Repeat for Contents.
Your permissions should match this table: | Permission | Access | | -------------- | ------------------------------------------------------------- | | Administration | Read and write (create/manage the repository) | | Contents | Read and write (publish source) | | Metadata | Read-only, added automatically | | Workflows | Read and write only when publishing `.github/workflows` files | Leave **Account** permissions unchanged. A fine-grained token for an organization may need owner approval before it works.
GitHub repository permissions with Administration and Contents set to Read and write, Metadata set to Read-only, and the green Generate token button below.
GitHub · Check the access levels, then click Generate token.
Click **Generate token**, then copy the token immediately. You won't be able to view the full value again after leaving the page. ### Save it in `.env` [#save-it-in-env] Open the root `.env` file beside `package.json`. If it doesn't exist, copy `.env.example` to `.env`. Find the existing `GITHUB_TOKEN=` line, paste your token after the equals sign and save: ```dotenv title="Your private .env" GITHUB_TOKEN=replace_with_your_github_token ``` Replace the example value with your token. Keep one active `GITHUB_TOKEN` line and preserve the file's other settings. On Windows, make sure the file is named `.env`, not `.env.txt`. Keep this token only in your local `.env`. It's a setup credential, so it never goes into Vercel's environment variables either. ## You are finished when [#you-are-finished-when] * `GITHUB_TOKEN` has one saved value in the root `.env` file. * For a new automatic deployment, `GITHUB_OWNER`, `GITHUB_REPO_NAME` and `GITHUB_REPO_ID` remain blank. Kickstart fills them in. Keep existing values if you've already started deployment. Next, prepare your [Vercel token and GitHub App connection](/docs/accounts/vercel). Vercel needs that connection to build from your repository. If you limit the App to selected repositories, add the new repository when Kickstart pauses for authorization, then rerun with the same saved settings. Once all deployment accounts are ready, run this read-only check: ```sh npm run kickstart:deploy -- --check ``` Resolve any errors before deploying. This command checks your configuration and account access; it doesn't create a repository or prove that a future push will succeed. ## Use a precreated empty repository [#use-a-precreated-empty-repository] **Optional: limit the token to one repository** 1. Create a **private** repository in your account. Leave it empty: don't add a README, license, `.gitignore` or initial commit. 2. In the token form, choose **Only select repositories** and select that repository. Use the same permissions listed above. 3. Get its numeric ID. GitHub doesn't show it on the repository page. If you use GitHub CLI, replace the owner and repository in this command: ```sh gh api repos/YOUR_OWNER/YOUR_REPOSITORY --jq .id ``` Without GitHub CLI, ask your coding agent to read the `id` field from `https://api.github.com/repos/YOUR_OWNER/YOUR_REPOSITORY` using the `GITHUB_TOKEN` in your `.env`, without printing the token. 4. Save your token and these identifiers in `.env`, replacing all example values: ```dotenv GITHUB_OWNER=your-owner GITHUB_REPO_NAME=your-empty-repository GITHUB_REPO_ID=123456789 ``` Use the numeric ID returned by GitHub. Kickstart can initialize a matching empty private repository; a populated repository stops setup. **If the token or repository access doesn't work** For an expired or invalid token, create a replacement with the same owner and permissions and update only `GITHUB_TOKEN`. Keep your generated repository settings and rerun the deployment check. For an organization-owned repository, choose that organization as **Resource owner** and set `GITHUB_OWNER` to its name. Organization policies may require approval before the token can access private repositories. See [GitHub's token guide](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). If a push fails after repository creation, fix the access problem and rerun with the saved settings. Keep the `.kickstart` receipt; don't delete repository IDs, force-push or create a replacement repository to bypass the error. If the remote repository has new commits, reconcile them with normal Git tools first. [Back to Accounts](/docs/accounts) Continue with Vercel to prepare for deployment. --- # Google OAuth client URL: https://ship-and-scale.vercel.app/docs/accounts/google Create a Google OAuth client and connect it to Supabase for optional Google sign-in. Prepare these credentials before deployment if you want customers to sign in with Google. Email sign-in works without them, so you can skip this guide if you're not offering Google sign-in yet. Google uses a **client ID and client secret**, rather than an API token. Save both in your hosted Supabase project's Google provider settings. They don't belong in the root `.env`, Vercel variables or browser code. ## Create and connect your credentials [#create-and-connect-your-credentials] Complete the first [Kickstart](/docs/start/project-kickstart) before these steps so you have a hosted Supabase project and its callback URL. ### Prepare your Google project [#prepare-your-google-project] Sign in to [Google Cloud Console](https://console.cloud.google.com/). Use the project picker at the top to select a project for your app, or choose **New project** and give it your app's name. Google Cloud projects are free; this one only holds your sign-in settings. Open **Google Auth Platform** from the menu or search bar. If it asks you to get started, follow its prompts. Then complete these sections in its sidebar: 1. **Branding:** enter your app's name and a support email. Customers see these on Google's consent screen. 2. **Audience:** choose **External** so any Google account can sign in. A new app starts in **Testing**, where only the accounts listed under **Test users** can sign in. Add your own Google account there for now. 3. **Data Access:** add the `openid`, `.../auth/userinfo.email` and `.../auth/userinfo.profile` scopes. These are all Supabase needs for basic sign-in. Google's screens change from time to time. If yours differ, follow the current [Supabase Google setup instructions](https://supabase.com/docs/guides/auth/social-login/auth-google). ### Copy your Supabase callback URL [#copy-your-supabase-callback-url] In [Supabase](https://supabase.com/dashboard), open the project created for this app. Go to **Authentication → Sign In / Providers → Google** and copy the callback URL shown there. It normally looks like this: ```text title="Example callback shape — use your project's actual URL" https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback ``` Use the URL from your own project. This is where Google returns the sign-in result to Supabase; it isn't your app's dashboard URL. ### Create a web client in Google [#create-a-web-client-in-google] Return to **Google Auth Platform → Clients**, choose **Create client** and select **Web application**. Give the client a recognizable name for your app. * Under **Authorized JavaScript origins**, add each address your app runs at, without a path: `http://localhost:3000` for local testing and your live address, such as `https://your-app.example`. * Under **Authorized redirect URIs**, paste the exact Supabase callback URL from the previous step. Create the client and copy its client ID and client secret. Keep the secret out of screenshots and agent chat. ### Save the credentials in Supabase [#save-the-credentials-in-supabase] Return to the same Supabase project's **Google** provider settings. Paste the client ID and client secret into their matching fields, enable the provider and save. Keep Supabase's site URL and allowed application redirects aligned with the app address you're using. Kickstart manages the app's callback allowlist; deployment updates it for the production origin. ## What Kickstart handles [#what-kickstart-handles] Kickstart reports Google provider status and manages the app's allowed callback URLs. It doesn't create a Google OAuth client or enter, store or deploy the Google client credentials for you. Those are the manual steps above. Your local app and the default deployment share the same hosted Supabase project. Check the active app URL when testing redirects, especially after deployment changes the hosted configuration. ## Check Google sign-in [#check-google-sign-in] Open your app and choose its Google sign-in button. Use an account allowed by your Google audience settings, complete consent and confirm that you return to the app with a working session and workspace. Reload, sign out and sign in again. If Google reports a redirect mismatch, compare its authorized redirect URI with the callback shown in Supabase. If the app says Google is unavailable, check that the provider is enabled and its credentials are saved in the correct project. Audience restrictions and test-user settings can also prevent an account from signing in. Before launch, return to **Audience** and choose **Publish app** so accounts outside your test users can sign in. Google may ask you to verify your app's branding first. A successful test sign-in only proves it works for your test users. The [authentication guide](/docs/core/authentication) covers the app's other sign-in methods and session checks. [Back to Accounts](/docs/accounts) Review the remaining deployment accounts. --- # Accounts overview URL: https://ship-and-scale.vercel.app/docs/accounts See which accounts and keys you need for Kickstart and deployment. Prepare the accounts for your current stage. Each service name links to a guide showing where to get its key and where to save it. You can build and launch on the free plans; the costs column shows where a paid plan or credits come in. ## Before Kickstart [#before-kickstart] The default setup needs **Supabase, Stripe test and Resend credentials**. OpenRouter is optional.
| Service | API key variable | What it's used for | Cost | | -------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------- | | [Supabase](/docs/accounts/supabase) | `SUPABASE_ACCESS_TOKEN` | | Free plan | | [Stripe](/docs/accounts/stripe#test-secret-key) | `STRIPE_TEST_SECRET_KEY` | | Free; test mode moves no real money | | [Resend](/docs/accounts/resend) | `RESEND_API_KEY` | | Free plan | | [OpenRouter](/docs/accounts/openrouter) (optional) | `OPENROUTER_API_KEY` | | Paid credits for AI requests |
Stripe gives every account a **test** environment and a **live** one. Test keys start with `sk_test_` and use test card numbers, so you can try checkout without charging anyone. You only need the live key when you deploy. For AI, add the OpenRouter key and set `AI_ENABLED=true`. Otherwise, leave `AI_ENABLED=false`: your app works, but AI chat is unavailable. ## Before deployment [#before-deployment] Keep your existing `.env`, Supabase project and optional OpenRouter setup. Prepare these when your local app is ready to go online:
| Service | API key variable | What it's used for | Cost | | ---------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------ | | [GitHub](/docs/accounts/github) | `GITHUB_TOKEN` | | Free | | [Vercel](/docs/accounts/vercel) | `VERCEL_TOKEN` | | Free Hobby plan; Pro for commercial use | | [Stripe](/docs/accounts/stripe#live-secret-key) | `STRIPE_LIVE_SECRET_KEY` | | Free; Stripe's fees apply to real payments | | [Resend](/docs/accounts/resend#verify-a-production-sender) | `RESEND_API_KEY` (keep existing) | | Free plan; you need a domain | | [Google sign-in](/docs/accounts/google) (optional) | Client ID and secret in Supabase; no `.env` variable | | Free |
Vercel's Hobby plan is limited to personal, non-commercial projects. Upgrade to Pro before your app takes payments. Every provider has its own usage limits, and live Stripe payments charge real money. ## Where to save your keys [#where-to-save-your-keys] All keys go in one file named `.env`, in the project folder beside `package.json`. Create it from the example file if it doesn't exist yet: ```sh cp .env.example .env ``` ```sh copy .env.example .env ``` Run the command from the project folder, or keep your existing `.env` if you already have one. Open `.env` in your editor, paste each key after the `=` on its existing line and save. Use `.env`, not `.env.local` or `.env.txt`. Files starting with a dot are hidden in Finder by default; your editor's file list shows them. If you use an AI agent, give it this prompt to prepare the file, then paste the keys yourself: **Agent prompt: prepare my .env file** ```text title="Copy this prompt into your agent" Prepare the root .env file for this Ship & Scale project, following AGENTS.md. Find the folder containing package.json and .env.example. If .env is missing, copy .env.example to .env. Preserve an existing .env and all its values. Don't create .env.local or modify the example. Confirm .env is excluded from Git. Open it in my editor if supported, or tell me where to find it. Don't print, upload or commit its contents. Point me to SUPABASE_ACCESS_TOKEN, STRIPE_TEST_SECRET_KEY and RESEND_API_KEY. Explain that optional AI needs OPENROUTER_API_KEY and AI_ENABLED=true; otherwise leave AI_ENABLED=false. I'll paste the keys myself. Don't create credentials or run Kickstart yet. ``` **Keep your keys private** These keys can create resources, send email and manage billing on your accounts. Keep them only in `.env`. Don't paste them into chat, screenshots, Git commits or any `NEXT_PUBLIC_` field, because those values are visible in the browser. If a key leaks, revoke it in the provider's dashboard and create a new one. ## You're ready when [#youre-ready-when] * `.env` exists beside `package.json`. * `SUPABASE_ACCESS_TOKEN`, `STRIPE_TEST_SECRET_KEY` and `RESEND_API_KEY` each have one saved value. * If you want AI chat, `OPENROUTER_API_KEY` is saved and `AI_ENABLED=true`. [Next: Configuration](/docs/build/configuration) Name your app and review the settings Kickstart will use. --- # OpenRouter API key URL: https://ship-and-scale.vercel.app/docs/accounts/openrouter Create an OpenRouter API key and save it to enable AI chat in your app. By the end of this guide, your OpenRouter API key will be saved in the starter's root `.env` file, ready for Kickstart to enable AI chat. OpenRouter is optional. If you don't want AI chat yet, leave `AI_ENABLED=false` and `OPENROUTER_API_KEY` blank, then return to [Accounts](/docs/accounts). ## Create and save the key [#create-and-save-the-key] ### Sign in and open API Keys [#sign-in-and-open-api-keys] Create an account or sign in at [OpenRouter](https://openrouter.ai/), then open [API Keys](https://openrouter.ai/settings/keys). If you have several workspaces, choose the one for your app using the selector at the top of the sidebar. Click the green **New Key** button in the top-right corner. Use **API Keys**, not **Management Keys**.
OpenRouter API Keys page with the workspace selector at the top left, API Keys selected in the sidebar, and the green New Key button at the top right. Existing keys are filtered out.
OpenRouter · API Keys → New Key. Existing keys are filtered out for privacy.
### Create a key for your app [#create-a-key-for-your-app] Fill in the form: 1. **Name:** enter a recognizable name, such as `Ship & Scale`. 2. **Expiration:** choose a lifetime, such as **90 days**. Replace the key in your app before it expires. 3. **Credit limit:** choose **Custom amount** and enter a testing budget, such as `5` for a $5 limit. 4. **Reset limit every…:** leave **N/A** for a total limit that doesn't renew automatically.
OpenRouter key form filled with Name Ship & Scale, Expiration 90 days, a custom credit limit of 5 US dollars, Reset limit every set to N/A, and the green Create button.
OpenRouter · Example settings for a key with a $5 total spending limit.
Click **Create**, then copy the generated key and save it privately. You'll paste it into `.env` in the final step. The credit limit caps this key's usage; it doesn't add money to your account. When the limit is reached, requests stop until you increase it. ### Add credits for model requests [#add-credits-for-model-requests] Open [Credits](https://openrouter.ai/settings/credits) from the sidebar. The starter's default model requires paid credits. If your account already has an available balance, you can skip the purchase. Otherwise, click **Add Credits** under **Buy Credits**.
The Buy Credits panel on OpenRouter’s Credits page, with its green Add Credits button.
OpenRouter · Credits → Buy Credits → Add Credits.
Choose your **Amount**, add a payment method if needed, and review **Service fees**, any tax and **Total due** before clicking **Purchase**. The amounts below are an example; use the total shown in your checkout.
OpenRouter Purchase Credits form showing Amount, billing options, Service fees, Sales Tax / VAT, Total due and the green Purchase button. Payment and account details are outside the crop.
OpenRouter · Review the amount and total before purchasing credits.
After payment, confirm that **Total available** shows a balance. Your app's workspace credits are separate from this OpenRouter balance. ### Save the key in `.env` [#save-the-key-in-env] Open the root `.env` file beside `package.json`. If it doesn't exist, copy `.env.example` to `.env`. Update these existing lines and save: ```dotenv title="Your private .env" AI_ENABLED=true OPENROUTER_API_KEY=replace_with_your_openrouter_key AI_MODEL=openai/gpt-4.1-mini ``` Replace the example key with your real value. Keep the default `AI_MODEL` for now and only one active line for each setting. On Windows, make sure the file is named `.env`, not `.env.txt`. **Keep the key private** This key authorizes spending from your OpenRouter account. Keep it only in `.env`, as described in [Keep your keys private](/docs/accounts#where-to-save-your-keys). ## You are finished when [#you-are-finished-when] * Your key is saved as `OPENROUTER_API_KEY` in the root `.env` file. * `AI_ENABLED=true` and `AI_MODEL` has a value. * Your OpenRouter account has credits and the key has an unused spending allowance. Continue with Accounts, then Configuration and [Kickstart](/docs/start/project-kickstart). Once your other required credentials are ready, `npm run kickstart:check` checks the key and model without generating a paid response. Full Kickstart installs the AI feature. **Adding AI to an app you’ve already set up?** Save the settings above in your existing `.env`, preserving its other values. From the project root, run the check first: ```sh npm run kickstart:ai -- --check ``` When it passes, install the AI feature: ```sh npm run kickstart:ai ``` This uses your existing managed Supabase project and preserves workspace credits and chat history. Restart your local app afterward. For an already deployed app, apply the new settings through the [deployment workflow](/docs/start/deployment-kickstart); changing local `.env` alone won't update production. Open AI chat and send a short message to verify a real response. This uses provider credits. If it fails, check the key's expiration and limit, your OpenRouter balance and model access. See [OpenRouter's key instructions](https://openrouter.ai/docs/api_reference/authentication) or the [AI feature guide](/docs/integrations/ai) for more help. [Back to Accounts](/docs/accounts) Check your Kickstart keys, then continue to Configuration. --- # Resend API key URL: https://ship-and-scale.vercel.app/docs/accounts/resend Create your email API key and prepare a verified sender when you're ready to deploy. Resend sends your app's authentication emails, workspace invitations and other messages. You need a Full access API key before Kickstart. You can test locally with Resend's restricted sender, then verify your own sending domain before deployment. ## Get your API key [#get-your-api-key] Create an account or sign in at [Resend](https://resend.com). Use the account that should own your app's email templates and sending domain. ### Open API Keys [#open-api-keys] Choose **API Keys** in the dashboard, or open [the API Keys page](https://resend.com/api-keys) directly. Select **Create API key** and give it a recognizable name, such as `My app Kickstart`. ### Choose Full access [#choose-full-access] Set the permission to **Full access**. Kickstart needs to inspect domains and manage email templates as well as send messages. A Sending access key doesn't cover those setup operations. Authentication and invitation email uses Resend's API-published templates through Supabase's signed Send Email Hook. This is an API-template flow, not SMTP. Kickstart installs the Resend key only as a server-side function secret; keep it out of browser code and Vercel unless a separate server feature needs it. Create the key and copy its value while it is shown. Save the key itself, not its name or ID. Resend's [key management guide](https://resend.com/docs/dashboard/api-keys/introduction) explains the permission choices. **Screenshot: Full access in the key form** Resend's empty Add API Key dialog with Full access selected and All domains shown. Check the Permission field before selecting Add. The image contains no API key. ### Add the key to .env [#add-the-key-to-env] In your editor, open `.env` in the main project folder beside `package.json`. If you haven't created it, copy `.env.example` to `.env`. Keep an existing `.env` and edit it. Find the existing `RESEND_API_KEY=` line, paste the key after `=`, and save: ```dotenv title="Your private .env" RESEND_API_KEY=replace_with_your_resend_full_access_key ``` Use the actual value you copied and keep one `RESEND_API_KEY` line. Don't add a second assignment at the bottom of the file. ## Start with local test emails [#start-with-local-test-emails] On a new installation, leave `RESEND_FROM_EMAIL`, `RESEND_TEST_MODE` and the `RESEND_TEMPLATE_*_ID` fields blank. Kickstart discovers a verified domain or selects the restricted test sender, then publishes the templates and saves their IDs. With no verified domain, emails from `onboarding@resend.dev` can only go to the address associated with your Resend account. Use that address to test the app's email flows. If you have several verified domains, Kickstart asks which one to use. **Before emailing other people** Verify a sending domain you control before inviting customers or testing with other recipients. Owning a domain and verifying it for outbound email are separate steps. ## Verify a production sender [#verify-a-production-sender] You can return to this section when your local app works and you're preparing deployment. 1. Open [Resend Domains](https://resend.com/domains) and add your domain or a sending subdomain, such as `mail.your-domain.com`. 2. Open the DNS settings where your domain is managed. That's usually where you bought it, such as Namecheap, GoDaddy or Cloudflare. If you changed nameservers, use the provider they point to. 3. Add the exact record types, names and values Resend gives you. Each domain has its own verification records. 4. Return to Resend and start verification. DNS changes can take from a few minutes to several hours to appear. Use Resend's diagnostics to fix pending or failed records. 5. Choose a From address on that verified domain and edit the existing fields in `.env`: ```dotenv title="Production sender in .env" RESEND_FROM_EMAIL=noreply@your-verified-domain.example RESEND_TEST_MODE=false ``` Replace the example address with your own. If your public contact form should email you, also set `CONTACT_TO_EMAIL` to an inbox you monitor. Resend's [domain guide](https://resend.com/docs/dashboard/domains/introduction) explains DNS verification. Setting up an outbound sender doesn't create an inbox. ## Check that you're ready [#check-that-youre-ready] For the first Kickstart, a saved Full access key is enough to continue collecting credentials. Once all three are ready, run `npm run kickstart:check`. For deployment, you also need verified domain status and the production sender settings above. Follow [Deployment Kickstart](/docs/start/deployment-kickstart) to apply them. Editing `.env` alone doesn't update the hosted Auth email hook or an existing production deployment. **Example: the email settings in .env** Illustrative editor view of deployment fields, including the production email sender, using example values. Keep your existing API key. Update the sender fields when your domain is verified. ## Technical reference [#technical-reference] **Technical reference: permissions, generated settings and recovery** ## Check configuration and real delivery separately [#check-configuration-and-real-delivery-separately] Run `npm run kickstart:check` only after the other local credentials are ready. Required account-level failures prevent project provisioning. With no project yet, later project and hook checks remain deferred. Template listing access proves neither future template-write permission nor inbox delivery. After installation, trigger a recovery or sign-in email to an authorized test recipient. Inspect the message in the actual inbox, follow its link once, and verify the result in the application. Check spam placement and sender identity. For production readiness, repeat with an address different from the Resend account email. A Resend accepted or delivered event is useful transport evidence. It does not prove the recipient saw the email or completed the application flow. Auth links contain one-use credentials, so avoid full URLs in diagnostic screenshots and logs. ## Troubleshoot and rotate [#troubleshoot-and-rotate] | Problem | Check | Recovery | | ----------------------------------- | ------------------------------------------ | --------------------------------------------------------------- | | Domain/template access denied | Key permission and account | Use the intended account's Full access key | | Mail only reaches one address | `RESEND_TEST_MODE` and sender domain | Verify a production domain, then reconcile sender configuration | | Several verified domains | Terminal choice or explicit sender | Select the intended domain; do not choose by list order | | Sender rejected despite a valid key | Domain verification and exact From address | Correct the address/status, then rerun | | Mail sent, link fails | Active `APP_URL`, callback and link expiry | Use a fresh link at the configured origin | If Kickstart stops partway through setup, finish it with the same Resend key. Kickstart remembers which key started the installation, so a different key can block recovery. After installation you can replace the key with another one from the same Resend account. Keep the saved template IDs so reruns reuse your existing templates. To change the email content itself, see [email templates](/docs/email/templates-delivery). [Back to Accounts](/docs/accounts) Add OpenRouter if you want AI chat, or continue to Configuration. --- # Stripe secret key URL: https://ship-and-scale.vercel.app/docs/accounts/stripe Find your Stripe test Secret key and save it for Kickstart. By the end of this guide, your Stripe test Secret key will be saved in the starter's root `.env` file. Kickstart uses it to set up your app's test billing, including products, prices and the billing portal. You don't need to create those yourself. ## Test Secret key [#test-secret-key] ### Sign in and choose a sandbox [#sign-in-and-choose-a-sandbox] Open the [Stripe Dashboard](https://dashboard.stripe.com). Create an account if you don't have one, or sign in to the account you'll use for your app. Open the account menu in the top-left corner and choose **Switch to sandbox**. Select your app's sandbox, or choose **Create sandbox** if you haven't made one. Keep using the same sandbox throughout setup. Check for the **Sandbox** banner at the top. An existing **Test mode** environment also works; the screenshot below shows that view. Payments here don't move real money.
Stripe Dashboard showing the Sandbox banner and Test mode account label.
Stripe · Confirm you're in a test environment.
### Copy the test Secret key [#copy-the-test-secret-key] Open [API keys](https://dashboard.stripe.com/apikeys) and check that you're still in your chosen test environment. Select **Secret keys**, find the **Secret key** row and click the copy icon beside its token. If Stripe shows a reveal control first, use it to display the key.
Stripe API keys page with Secret keys selected and the Secret key row. The token value is hidden in this screenshot.
Stripe · Copy the token from the Secret key row. Key values are hidden here.
The key for this walkthrough starts with `sk_test_`. The **Publishable key** at the top starts with `pk_test_` and won't work in this field. If you have no Secret key, choose **Create secret key**, complete Stripe's verification and name it `Ship & Scale Kickstart`. Copy the key when Stripe displays it. If your account can't manage keys, ask its owner for access. See [Stripe's API key guide](https://docs.stripe.com/keys) if your controls differ. ### Save it in `.env` [#save-it-in-env] Open the root `.env` file beside `package.json`. If it doesn't exist, copy `.env.example` to `.env`. Find `STRIPE_TEST_SECRET_KEY=`, paste your key after the equals sign and save: ```dotenv title="Your private .env" STRIPE_TEST_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_KEY ``` Replace the example with your actual key. Keep one line for this setting and leave `STRIPE_LIVE_SECRET_KEY` empty for now. **Keep the Secret key private** Anyone with this key can manage your Stripe account's test data. Store it only in `.env`, as described in [Keep your keys private](/docs/accounts#where-to-save-your-keys). ## You are finished when [#you-are-finished-when] * `STRIPE_TEST_SECRET_KEY` contains your `sk_test_` key in the root `.env` file. * The key belongs to the test environment you chose for your app. After saving the Supabase, Stripe and Resend credentials, run `npm run kickstart:check` to check them before setup. ## Live Secret key [#live-secret-key] You only need this before deploying with billing enabled. First [activate your Stripe account](https://docs.stripe.com/get-started/account/activate) by adding your business and payout details; Stripe needs these before it accepts real payments. Then leave the sandbox, open your live account and return to **API keys**. Copy its live Secret key into the separate field: ```dotenv title="Before deployment" STRIPE_LIVE_SECRET_KEY=sk_live_REPLACE_WITH_YOUR_KEY ``` Keep your test key in its original field. Save a newly created live key immediately; Stripe only shows it once. Complete the [launch checks](/docs/operations/launch) before accepting real payments. [Back to Accounts](/docs/accounts) Continue with the Resend API key. --- # Supabase access token URL: https://ship-and-scale.vercel.app/docs/accounts/supabase Create a Supabase account and save the management token Kickstart needs. By the end of this guide, your Supabase account will be ready and its management token will be saved in the starter's root `.env` file. Kickstart uses that token to create and configure your hosted project, so you do not need to create a Supabase project yourself. ## Create and save the token [#create-and-save-the-token] ### Create or sign in to your account [#create-or-sign-in-to-your-account] Open the [Supabase dashboard](https://supabase.com/dashboard). Choose **Sign up** if you are new to Supabase, or sign in to your existing account. A new account includes a default organization where you are the Owner. With an existing account, use an organization where you are an Owner or Administrator and can create another project. The Free plan allows two active projects per organization, so pause or delete an unused one if you've reached that limit. ### Generate a management token [#generate-a-management-token] Open your avatar menu, choose **Account preferences**, then select **Access Tokens** under Preferences. You can also go directly to [Access Tokens](https://supabase.com/dashboard/account/tokens). Click the green **Generate new token** button in the top-right corner.
Supabase Account settings screen with Access Tokens selected under Preferences and the green Generate new token button at the top right.
Supabase · Account preferences → Access Tokens → Generate new token.
Prefer a **scoped personal access token**. For a new Kickstart, scope it to the organization that will own the project and grant these permissions. Kickstart lists the account and organization, creates the project, then configures its database, keys, Auth and email function: | Scope | Access | Why Kickstart needs it | | ----------------------- | ---------- | -------------------------------------------------------- | | Organizations | Read | List accessible organizations | | Projects (account-wide) | Read | List accessible projects | | Organization Projects | Read-write | Create the new project and inspect available regions | | Project Settings | Read-write | Read project health and update Auth settings | | Database | Read-write | Run setup SQL and versioned migrations | | API Keys | Read-write | Read or create the project's publishable and secret keys | | API Key Secrets | Read | Reveal the generated secret key privately | | Auth Config | Read-write | Read and configure Auth | | Edge Functions | Read-write | Inspect and deploy the signed email hook | | Edge Function Secrets | Read-write | Inspect and install only the hook's selected secrets | These names match Supabase's [scoped-token permission list](https://supabase.com/docs/guides/platform/personal-access-tokens#permission-scopes). They only narrow your existing Supabase role; they cannot grant access you do not have. Kickstart's `--check` proves the reads it performs, not future project creation, key creation, SQL writes or function deployment. For an existing installation, scope project permissions to that exact project. Migration-only maintenance needs **Project Settings: Read** and **Database: Read** for `npm run kickstart:migrate -- --check`; applying pending migrations also needs **Database: Read-write**. Full Kickstart still performs account discovery, so retain the two account-level read permissions when rerunning it. If scoped tokens are unavailable for your account, create a **Legacy** token instead. It inherits your account's access across all organizations and projects, with no per-permission restriction. Choose an expiry, copy the token once and keep it private. A project publishable or secret API key cannot replace the Management API token.
Supabase Legacy access token dialog with an expiry selected.
Supabase · Open Expires in and choose an expiry. Legacy-token example; prefer the scoped permissions above.
**Optional: connect Supabase directly** If your coding agent supports the hosted [Supabase MCP server](https://supabase.com/docs/guides/ai-tools/mcp), you can connect it for ongoing work. Its browser sign-in means you do not need to keep a permanent token in `.env`. For local CLI work, you can run `supabase login` and link the repository after Kickstart creates the project. You still need the token in this guide for the initial Kickstart setup. **Keep the management token private** Prefer a scoped token with only the organization and project permissions listed above. A Legacy token instead inherits your full Supabase account access until it expires or you revoke it. Both types belong only in the private local `.env`; never replace them with a project publishable or secret API key. ### Save it in `.env` [#save-it-in-env] Open the root `.env` file beside `package.json`. If it does not exist, copy `.env.example` to `.env`. Find the existing `SUPABASE_ACCESS_TOKEN=` line, paste the token after the equals sign and save the file: ```dotenv title="Your private .env" SUPABASE_ACCESS_TOKEN=replace_with_your_supabase_management_token ``` Replace the example value with the real token and keep one active `SUPABASE_ACCESS_TOKEN` line. ## You are finished when [#you-are-finished-when] * `SUPABASE_ACCESS_TOKEN` has one saved value in the root `.env` file. * `SUPABASE_PROJECT_REF`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` remain blank for a new installation. Kickstart fills them in. * The token is only in `.env`, following [Keep your keys private](/docs/accounts#where-to-save-your-keys). After you add the Stripe test key and Resend key, `npm run kickstart:check` checks that this token can read your account and organizations. The command does not create the project. Project creation and configuration happen when you run Kickstart. **If the token screen or account access is different** Scoped permissions only reduce the access your Supabase role already has. If an organization or project is missing, confirm the token's resource scope and your Owner or Administrator role. Supabase's [access control guide](https://supabase.com/docs/guides/platform/access-control) explains organization roles. When a token expires, create a replacement for the same target and update only `SUPABASE_ACCESS_TOKEN`; keep the project, receipts and generated settings. [Back to Accounts](/docs/accounts) Continue with the Stripe test key. --- # Vercel access token URL: https://ship-and-scale.vercel.app/docs/accounts/vercel Save your Vercel token and connect GitHub for Deployment Kickstart. By the end of this guide, your Vercel token will be saved in the starter's root `.env` file and GitHub will be connected for deployment. Kickstart creates the Vercel project and configures hosting for you. You only need Vercel when you're ready to deploy. Finish the first Kickstart and prepare your [GitHub access token](/docs/accounts/github) before continuing. ## Create and save the token [#create-and-save-the-token] ### Create or sign in to your account [#create-or-sign-in-to-your-account] Open [Vercel](https://vercel.com) and sign up or sign in. Use the team that will own your app, with permission to create projects and deployments. The free [Hobby plan](https://vercel.com/docs/plans/hobby) works for building and testing your deployment. It's limited to personal, non-commercial projects, so upgrade to [Pro](https://vercel.com/pricing) before your app takes payments. ### Generate an access token [#generate-an-access-token] Open [Account Tokens](https://vercel.com/account/tokens). This is under your personal account's **Settings → Tokens**, even when the app belongs to a team. The **Create Token** form appears on this page.
Vercel account settings with Tokens selected in the sidebar and the Create Token form visible.
Vercel · Account settings → Tokens.
1. Under **Token Name**, enter `Ship & Scale Kickstart`. 2. Under **Scope**, select the team that will own your app. You don't need **Full Account** access. 3. Under **Expiration**, choose **90 days**. That covers deployment and any retries. Choose **No Expiration** only if a coding agent will keep using this token for Vercel changes, and revoke it when you no longer need it. 4. Click **Create** and copy the token immediately. Vercel only shows its full value once. The token must be scoped to the team that owns this project. Keep `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID` matched to the team and project saved during deployment. For a new project, leave the project ID blank until Kickstart records it; for later checks and secret updates, use the recorded project rather than a similarly named project.
Vercel Create Token form with Ship & Scale Kickstart as the name and No Expiration selected. Scope still needs your team selection.
Enter a name, select your own team in Scope, choose an expiration, then click Create. The example shows No Expiration.
### Save it in `.env` [#save-it-in-env] Open the root `.env` file beside `package.json`. Keep the settings from your local installation. Find the existing `VERCEL_TOKEN=` line, paste the token after the equals sign and save: ```dotenv title="Your private .env" VERCEL_TOKEN=replace_with_your_vercel_token ``` Replace the example with your real token and keep one active `VERCEL_TOKEN` line. For a new deployment, leave `VERCEL_TEAM_ID`, `VERCEL_PROJECT_NAME`, `VERCEL_PROJECT_ID` and `APP_URL_LIVE` blank. Kickstart fills them in. **Keep the token private** This token can manage resources in its selected scope until it expires or you revoke it. Keep it only in your local `.env`. Don't import your whole `.env` into Vercel; Kickstart uploads only the runtime values your app needs. ## Connect GitHub [#connect-github] Vercel also needs its own connection to GitHub to read your app's code. Saving the two tokens does not create this connection. ### Link your GitHub account [#link-your-github-account] Open Vercel's [Authentication settings](https://vercel.com/account/settings/authentication). Under **Sign-in Methods**, connect **GitHub** using the same account as your GitHub token. If your GitHub username is already shown, this step is done.
GitHub row in Vercel Sign-in Methods showing an already connected GitHub username.
Vercel · Authentication → Sign-in Methods. Your own GitHub username should appear here.
### Allow access to your app's repository [#allow-access-to-your-apps-repository] Open the [Vercel GitHub App](https://github.com/apps/vercel/installations/new). Choose the account that will own your app's repository. For the automatic Kickstart setup, this is your personal GitHub account. Kickstart creates your app's repository later, so it doesn't exist yet. Choose one of these: * **All repositories** is the simplest path. Vercel can read the new repository as soon as Kickstart creates it. It also gives Vercel access to every current and future repository owned by that account. * **Only select repositories** keeps access narrower. GitHub requires at least one repository here, so select any existing one for now. When Kickstart creates your app's repository, it pauses with a link; add the new repository there and rerun the same command.
Vercel GitHub App repository permissions with Only select repositories selected and the Select repositories control visible.
GitHub · Vercel App → repository access. Use Select repositories to choose which code Vercel can read.
Review the permissions and click **Install** for a new installation or **Save** when updating one. If GitHub shows **Request**, an organization owner must approve it before you continue. If Kickstart later pauses for repository access, keep the generated `.env` values and `.kickstart` files so the rerun continues with the same resources. ## You are finished when [#you-are-finished-when] * `VERCEL_TOKEN` has one saved value in your root `.env`. * Its scope matches the Vercel team that will host your app. * GitHub is connected to Vercel, and the Vercel App is installed for your repository owner. Once the other [deployment prerequisites](/docs/start/deployment-kickstart#finish-local-setup-first) are ready, run: ```sh npm run kickstart:deploy -- --check ``` This checks access without creating provider resources. Access to a new repository is checked after Kickstart creates it during deployment. **If your team, token or repository is unavailable** If your team is missing from **Scope**, check that you're signed in to the right Vercel account and have access to that team. The token cannot grant permissions your account doesn't have. See [Vercel's token guide](https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token). If the token expires or is revoked, create a replacement for the same team and update `VERCEL_TOKEN`. Keep the generated project IDs and receipts. If Vercel cannot find the repository, check the GitHub connection, App installation owner and selected repositories. Follow [Vercel's repository access guide](https://vercel.com/kb/guide/unable-to-find-github-repository) if it is still missing. [Back to Accounts](/docs/accounts) Check the remaining deployment accounts, then deploy. --- # Plans, pricing and paid access URL: https://ship-and-scale.vercel.app/docs/billing/plans Author the catalog once and enforce your paid feature on the correct workspace. ## A subscription belongs to a workspace [#a-subscription-belongs-to-a-workspace] Each workspace has one separately paid subscription. All members share its product entitlement. Owners/admins manage billing; ordinary members do not access financial details or the portal. A person can belong to both a paid workspace and an unpaid one without the payment transferring between them. The starter's infrastructure is available across its example tiers. You add product differences deliberately. There are no seat charges, recurring AI grants, top-ups or usage billing. AI starts with one workspace credit grant independent of Stripe events. ## Initial seed and later authoring [#initial-seed-and-later-authoring] `config/billing-seed.json` defines the initial missing catalog. Kickstart creates/reuses the template-owned test products and monthly/yearly prices. Once initialized, **the owned Stripe test catalog is the authoring source**. The app's shared catalog reader uses installed data; unconnected UI can show seed values with purchase controls disabled. Do not maintain another editable price list in the frontend or this manual. Generated `STRIPE_*_PRODUCT_ID_*` fields bind the installation to resources; editing an ID is not a pricing change. Keep the `forma_catalog` and `forma_plan` metadata that provides stable identity across display-name edits. ## Change a catalog price [#change-a-catalog-price] In the intended Stripe test account, locate the owned plan. For a display-name/description edit, change those fields on that product. For an amount change, create a replacement flat recurring price for the intended interval, then archive the superseded active variant after reviewing existing subscribers. Keep exactly one active monthly and one active yearly price per plan, and one currency across the supported catalog. Multiple active eligible prices make setup ambiguous and should stop reconciliation. Run the relevant setup check/reconciliation and inspect the app's Billing tab. New prices do not silently move existing subscribers. Existing subscriptions retain their price until explicitly changed through the supported portal/provider flow. Deployment copies the current owned test catalog into live resources; it does not turn a test price ID into a live price ID. [Stripe price management](https://docs.stripe.com/products-prices/manage-prices). ## Display is not enforcement [#display-is-not-enforcement] The header's plan tag is a member-safe summary: Free means no current paid entitlement; a catalog lookup can yield Paid or Plan unavailable according to state. Hiding a button based on that tag does not authorize a paid operation. `requireSubscription()` in `lib/billing/server.ts` checks the currently selected workspace using `workspace_subscription` and the server-selected billing mode. It takes **no workspace argument** in this source snapshot. Do not invent `requireSubscription(orgId)` or use the cookie-selected workspace to authorize a stale form targeting another workspace. ## Make note creation paid [#make-note-creation-paid] This optional variant builds on [workspace notes](/docs/build/first-feature). It leaves existing notes readable but makes creation require an active subscription for the explicit form workspace. First apply this **new** migration after the base notes migration: **supabase/migrations/20260916090100_paid_notes.sql** ``` -- Optional paid-create variant. Existing notes remain readable through RLS. revoke insert(id, org_id, created_by, body) on public.workspace_notes from authenticated; drop policy notes_create on public.workspace_notes; grant select, insert on public.workspace_notes to service_role; -- Every create now goes through the verified server action and its paid check. ``` Revoking direct insert closes the ordinary Data API bypass. A paid check only in a button or action would be insufficient while the base table still allowed unpaid clients to insert directly. Keep the SELECT policy for member reads. Then replace `app/actions/notes.ts` with this complete optional action: **app/actions/notes.ts — optional paid variant** ``` 'use server'; import { revalidatePath } from 'next/cache'; import { getWorkspace, requireUser } from '@/lib/auth'; import { createAdminClient } from '@/lib/supabase/admin'; import { billingMode } from '@/lib/billing/environment.mjs'; import { noteInput, type NoteState } from '@/lib/notes-schema'; export async function createNote(_previous: NoteState, form: FormData): Promise { const parsed = noteInput.safeParse({ request_id: form.get('request_id'), org_id: form.get('org_id'), body: form.get('body') }); if (!parsed.success) return { error: 'Enter a note of 1–2,000 characters.' }; const { user, supabase } = await requireUser(); const { org_id, request_id, body } = parsed.data; try { const { role } = await getWorkspace(org_id); if (role === 'member') return { error: 'Only owners and admins can create notes.' }; } catch { return { error: 'Workspace access is no longer available.' }; } const subscription = await supabase.rpc('workspace_subscription', { target: org_id, target_mode: billingMode() }); if (subscription.error || subscription.data?.paid !== true) return { error: 'An active subscription for this workspace is required.' }; // Narrow privileged write: caller and explicit tenant were checked above. // Ordinary clients have no INSERT grant after the paid migration. const admin = createAdminClient(); const result = await admin.from('workspace_notes').insert({ id: request_id, org_id, created_by: user.id, body }); if (result.error?.code === '23505') { const existing = await admin.from('workspace_notes').select('body,created_by').eq('id', request_id).eq('org_id', org_id).maybeSingle(); if (existing.error || existing.data?.created_by !== user.id || existing.data?.body !== body) return { error: 'This request was already used. Start a new note.' }; } else if (result.error) return { error: 'The note could not be saved. Retry with the same request.' }; revalidatePath('/dashboard/notes'); return { success: 'Note saved.' }; } ``` The action authenticates, validates the explicit workspace, checks current manager role and calls the existing member-safe subscription RPC with that same target and server-chosen mode. Only then does a narrow privileged insert occur. Do not accept `paid`, Stripe IDs or billing mode from the browser. The elevated client here has one justified write boundary because ordinary clients cannot insert after the optional migration. Keep it server-only, scope replay lookups to the same workspace, and do not reuse it for the notes list or unrelated customer queries. An already-authorized request may finish during a concurrent entitlement change; stronger transactional entitlement/role locking would require a dedicated database operation and matching tests. ## Paid states and existing subscribers [#paid-states-and-existing-subscribers] Recognized `active` and `trialing` subscriptions grant entitlement. The default product does not create a trial, but the state reader recognizes it. Past-due, unpaid, paused, incomplete, canceled or unknown subscriptions do not grant access; no additional app grace period is invented. Cancellation normally takes effect at period end, so a still-active canceling subscription remains paid until its state changes. Checkout return URLs never grant access themselves. Only synchronized provider state does. Keep auth, account settings and billing management accessible so an unpaid user can resolve payment or manage data. ## Verify the gate [#verify-the-gate] Test an unpaid workspace, a paid workspace, a Member in a paid workspace and a user belonging to paid A/unpaid B. Submit a stale form for B while A is selected: it must check B. Attempt direct authenticated inserts after the optional migration: they must fail even for owners. Replay an accepted request and a changed body with the same request ID. Static sample validation does not prove paid synchronization. The optional action and grants need hosted tests with real synchronized Stripe state. No payment, SQL migration or entitlement mutation was performed while writing this manual. Continue with [Checkout and webhooks](/docs/billing/subscriptions). --- # Checkout, subscriptions and webhooks URL: https://ship-and-scale.vercel.app/docs/billing/subscriptions Follow a subscription from purchase to synchronized access, cancellation and safe deletion. ## Start from Settings → Billing [#start-from-settings--billing] Owners/admins select the workspace, choose monthly or yearly pricing and start Checkout. Members see a permission explanation. The server targets the explicit workspace from the form and verifies current access, preventing a stale tab from purchasing for a newly selected workspace silently. Checkout purchases one recurring price with quantity one. An existing subscriber uses **Manage subscription** to open Stripe Customer Portal for payment methods, invoices, plan changes and cancellation. Do not create a second subscription to implement a plan change. ## Subscription lifecycle [#subscription-lifecycle] | State/transition | Application behavior | | --------------------------------- | -------------------------------------------------------------------------- | | No subscription | Free/unsubscribed; core auth/settings remain available | | Checkout pending | Request identity prevents duplicate checkout creation; deletion is guarded | | Recognized active/trialing | Workspace receives paid entitlement | | Scheduled cancellation | Paid while still active through the paid period | | Past due/unpaid/incomplete/paused | No app-added paid grace period | | Canceled or expired incomplete | No entitlement; deletion may proceed after reconciliation | Upgrades use Stripe's immediate prorated invoice and confirmation. Decreases in amount or shorter intervals are scheduled for period end through the configured portal behavior. Review the real portal confirmation before submitting a change; the app does not implement a separate customer credit-adjustment panel. ## How webhooks reach local development [#how-webhooks-reach-local-development] Billing webhook handling lives in hosted Supabase Edge Functions. The endpoint is reachable before Next.js is deployed, so local test billing does not require ngrok or a manually exposed localhost webhook. Kickstart installs the mode-specific endpoint, function configuration, portal and reconciliation resources. `lib/billing/engine.mjs` and `lib/billing/server.ts` coordinate provider state and database synchronization. Incoming events must pass signature verification. Events can be duplicated or arrive out of order, so the engine uses durable account mapping, synchronization leases and idempotent state handling rather than trusting arrival order. The hosted source includes `supabase/functions/stripe-billing`. Keep test and live function/secrets separate according to installer configuration. Never send the setup environment wholesale into a function. ## Verify a test purchase [#verify-a-test-purchase] Use a dedicated authorized test account/workspace and the configured Stripe test environment: 1. Record the selected workspace and its initial unsubscribed state without exposing keys. 2. Start a plan purchase from Billing, confirm the hosted Stripe screen is in test mode, and use Stripe's documented test payment method. 3. Complete Checkout and return to the app. Wait for synchronization or use **Refresh status**. 4. Confirm the correct workspace shows the recognized subscription and that another unpaid workspace remains unpaid. 5. Open Manage subscription and inspect the corresponding portal subscription. Use Stripe's current [testing guide](https://docs.stripe.com/testing) for test payment details and failure scenarios. Do not enter real payment information in test documentation or represent a mocked Checkout URL as a completed payment. ## Exercise failure, changes and cancellation [#exercise-failure-changes-and-cancellation] Run a plan change through Portal and verify the effective timing and invoice result. Test payment failure using the documented sandbox flow and inspect the resulting subscription state. The absence of paid entitlement must match synchronized state, not the last optimistic UI selection. Schedule cancellation and confirm access remains while the subscription is still active. Once the paid period ends and the terminal state is synchronized, verify the tag and paid operation change. Use provider test tools where appropriate, but record whether the event originated from Stripe or from a fixture. ## Replay and reconcile [#replay-and-reconcile] Webhook replay must not duplicate customers, subscriptions or entitlement effects. Test an older event after a newer one and confirm the final state agrees with Stripe. If local subscription cache data is unavailable, durable customer/subscription lookup information supports recovery; do not erase that mapping while cleaning UI rows. Scheduled reconciliation and Refresh status help recover missed events. Inspect the configured mode and account before diagnosing a stale cache. A valid signature probe proves rejection/acceptance logic, not actual Stripe event delivery. A Checkout success query parameter is never sufficient proof of payment. ## Deletion guards [#deletion-guards] Pending Checkout and nonterminal subscriptions block workspace deletion. Account deletion also checks any workspaces it would delete. Cancel first, wait until the paid period ends, refresh the subscription and retry the explicit deletion flow. Transferred/shared workspaces and their subscriptions remain intact. Stripe financial records are not deleted as part of ordinary workspace removal. Durable billing records can survive without their organization link. Decide your actual retention policy for launch; do not promise a total financial-record purge or bypass the guard by deleting private rows. ## Diagnose unavailable billing [#diagnose-unavailable-billing] If the catalog shows disabled controls, inspect whether it is using display-only seed entries or an installed provider catalog. If Portal is unavailable, confirm the selected role, runtime key/mode and installed portal configuration. If the wrong price appears, inspect the owned catalog for multiple active eligible variants. If webhook creation succeeded but its signing secret was not saved, recover the matching secret from Stripe Workbench; the installer does not create another endpoint blindly. Keep receipts and resource IDs. The [troubleshooting guide](/docs/operations/troubleshooting) covers uncertain operations and origin/source guards. ## Evidence required before launch [#evidence-required-before-launch] Record actual purchase, plan change, failed payment, cancellation, duplicate/out-of-order events, cache recovery, workspace isolation and test-to-live promotion. A documentation build, fixture suite or provider configuration read does not prove these flows. No new purchase or Stripe-originated event was executed for this manual. Continue with [production launch](/docs/operations/launch). --- # Architecture URL: https://ship-and-scale.vercel.app/docs/build/architecture How requests, permissions and services fit together, with the commands you'll use while building features. These notes help you and your agent extend the starter without breaking workspace permissions or exposing secrets. Read them when you start building features; you don't need them for Setup or Kickstart. For what each tool does, start with [Tech stack](/docs/build/codebase). ## One application, hosted services [#one-application-hosted-services] Your app is one Next.js App Router application. Pages, server actions, HTTP routes and reusable UI live in the same repository. Hosted Supabase owns Postgres, Auth, Storage and Edge Functions. Stripe owns checkout and billing objects, Resend sends email, and AI requests go through OpenRouter from the server. Local development runs Next.js on your computer against those hosted services, with no local database or Docker container. The Ship & Scale manual you're reading is separate from the product documentation included in your app. ## Next.js, React and TypeScript [#nextjs-react-and-typescript] Next.js supplies the routing and server runtime. React components describe the interface, while TypeScript catches mismatched data shapes before a production build. App Router folders determine routes: `app/dashboard` owns protected product pages, and `app/api` owns HTTP endpoints. Server Components can load authorized data before rendering. Add a Client Component for interaction such as an editable form or a menu. A server action handles a submitted mutation, validates its input and checks access. You can follow a complete example in [Build your first workspace feature](/docs/build/first-feature). ## Server and browser boundaries [#server-and-browser-boundaries] Client Components use `'use client'` when they need state, events or browser APIs. Server actions use `'use server'` and still need authentication, validation and authorization: a form is an HTTP entry point, even if the UI hides it. Keep modules importing `server-only` out of client imports. Only intentionally public values use `NEXT_PUBLIC_`. Pass the minimum presentation data into client props; never pass a Supabase admin client, API secret or provider response containing private account details. ## Tailwind CSS and the component library [#tailwind-css-and-the-component-library] Tailwind provides utility classes; shadcn/ui places editable component source in `components/ui/`. The installed preset uses Base UI's interaction primitives with the Vega style, neutral colors, Inter typography and Lucide icons. All 61 component files are in your repository, so you can inspect and adapt their actual props. Shared design tokens in `app/globals.css` control colors, borders and spacing. Reusing those tokens and the existing buttons, dialogs and fields keeps a new feature consistent with the rest of the product. See [Branding and UI customization](/docs/build/customization) before replacing the component foundation. ## Hosted Supabase [#hosted-supabase] The browser and server use the Supabase client libraries with the current user's session. Row-level security, or RLS, checks which rows that user can access; workspace membership and roles determine the permitted reads and writes. Schema and permission changes live in versioned SQL files under `supabase/migrations/`. Kickstart applies them through the Supabase Management API and records a checksum for each file. The default workflow has no ORM schema generation, local database container or direct database password. [Database and migrations](/docs/core/database) explains how to extend it without bypassing that record. Auth handles sessions, recovery and provider sign-in. Storage holds private avatars; workspace logos use access-controlled database rows. Edge Functions run the hosted email and billing work. Everything stays in your own Supabase project. ## Stripe and Resend [#stripe-and-resend] Stripe owns checkout sessions, subscriptions, invoices and portal configuration. The app keeps a protected copy of each subscription's state, updated from verified webhook events. Billing belongs to a workspace, and the server decides whether that workspace has paid access. [Subscriptions and webhooks](/docs/billing/subscriptions) covers that lifecycle. Resend delivers transactional email. The starter publishes five source HTML templates and connects a signed Supabase email hook for Auth messages. Workspace invitations and other application emails use their own narrowly scoped server code. Editing a template file doesn't change sent emails until you publish it through Kickstart; see [email templates](/docs/email/templates-delivery). ## GitHub and Vercel [#github-and-vercel] Git tracks your changes, GitHub stores the private repository, and Vercel builds and runs the deployed app. Deployment Kickstart sets up their initial connection and uploads the runtime settings your app needs. After that, pushing to `main` deploys new versions. Your setup folder keeps `.kickstart` and `.secrets` records that Kickstart uses to recognize the resources it created. They stay out of the repository. See [Deployment Kickstart](/docs/start/deployment-kickstart) for the first deployment and [Updates and recovery](/docs/operations/deployment) for later changes. ## Fumadocs, validation and AI [#fumadocs-validation-and-ai] Fumadocs turns MDX into documentation pages with navigation, search, headings and Markdown actions. The product docs are files in your repository; the blog reads published posts from Supabase. Keep those two authoring paths separate when adding public content. Zod validates structured input at application boundaries. Next.js route handlers serve the REST API, and the MCP SDK exposes the same operations as assistant tools. Both authenticate the caller and enforce explicit workspace permissions. Adding a field to a schema doesn't grant access to another workspace. The AI example uses the Vercel AI SDK with the OpenRouter provider for streamed responses. The provider key stays on the server, alongside credit and request checks. The [AI guide](/docs/integrations/ai) covers configuration, enabling chat and removing it while keeping Auth, workspaces and billing. ## Follow a workspace mutation [#follow-a-workspace-mutation] Workspace invitations show how a change travels through the app: 1. A form in the workspace UI sends the explicit workspace `org_id` and the selected recipient and role. 2. `inviteMember()` in `app/actions/workspaces.ts` gets the verified user through `requireUser()` and validates the IDs and role. 3. The signed-in user's Supabase client calls `issue_workspace_invitation`. 4. The database checks current membership, role, rate limits and invitation state. Hidden buttons are not the permission boundary. 5. The server sends the saved invitation through `lib/email/invitation.mjs` and records the outcome of that send attempt. 6. `revalidatePath('/dashboard/workspace')` refreshes the page. Resend accepting a message doesn't confirm it reached the inbox. `lib/auth.ts` centralizes `requireUser()` and `getWorkspace(target?)`. The latter checks an explicit target against the user's current memberships. Never use a browser-supplied workspace ID in an elevated query without an equivalent authorization check. ## Daily commands [#daily-commands] | Command | Use | Effect on your services | | --------------------- | ------------------------------------------------------------ | --------------------------------------------------------------- | | `npm ci` | Install pinned dependencies and generate the docs collection | Downloads packages only | | `npm run dev` | Run the app locally | Using the app reads and writes your hosted services | | `npm run typecheck` | TypeScript validation | None | | `npm run lint` | Static code checks | None | | `npm run build` | Production compilation | None; a passing build doesn't test provider flows | | `npm test` | Setup and failure checks | Uses fake providers; no live calls | | `npm run test:ui` | Browser tests against a running server | Review what each suite does first | | `npm run test:hosted` | SQL isolation checks | Runs rolled-back test transactions on a chosen Supabase project | Restart the development server after changing `.env`. If you change `APP_URL`, change it consistently: `localhost` and `127.0.0.1` count as different addresses for cookies and sign-in links. ## Development previews [#development-previews] `/preview` renders development-only example states, and `/setup` helps with local configuration. They don't bypass `/dashboard` sign-in or show real provider behavior. For example, a preview of an active billing card has disabled payment controls. Use previews for layout work, and test real behavior with a signed-in test account. ## Work with a coding assistant [#work-with-a-coding-assistant] Give the assistant a concrete outcome, the relevant files and the permission rule, for example: "Add a note visible only to members of its workspace; owners and admins can create one; test that other workspaces can't read or write it." Ask it to inspect the current source and installed framework docs before choosing helpers. Keep credentials in `.env` rather than pasting them into the conversation. Your repository's `AGENTS.md` gives the assistant project rules; review it and add your own. Don't ask an assistant to reset a shared database or rewrite applied migrations to make a test pass. ## Dependencies, formatting and verification [#dependencies-formatting-and-verification] `package.json` and `package-lock.json` belong together. Review upgrades deliberately, update the lockfile through npm and rerun the affected checks. `npm run format` formats TypeScript files only; it doesn't touch SQL, JSON or MDX. The starter doesn't include a database-type generator or CI pipeline, so add your own if you need them. After a change, test the real path, including wrong roles, stale forms, failure states and recovery. The [first workspace feature](/docs/build/first-feature) is a worked example. --- # Tech stack URL: https://ship-and-scale.vercel.app/docs/build/codebase The tools behind your SaaS, what they do and why we use them. Ship & Scale brings your app, backend services and deployment workflow into one connected starting point. You get a single Next.js codebase with the integrations in place, ready to extend with your own features. We use this stack to keep product development in code you control, with hosted services handling the infrastructure behind it. Here's what each part contributes to your SaaS. ## App and interface [#app-and-interface] ### Next.js [#nextjs] [Next.js](https://nextjs.org/docs) is the React framework that runs your application. Its App Router keeps pages, server actions and API routes in one project, so a feature's interface and server logic can live together. You'll use it for both the public website and protected dashboard. ### React [#react] [React](https://react.dev/learn) builds the interface from reusable components. A form, sidebar or settings panel can be defined once and used across your app. This makes new screens easier to maintain as your product grows, and gives your coding agent existing patterns to build on. ### TypeScript [#typescript] [TypeScript](https://www.typescriptlang.org/docs/handbook/intro.html) adds type checking to JavaScript. It catches mismatched data and component props while you're working, helping you change code with clearer expectations about what each function receives and returns. It supports safer refactoring; runtime validation and tests still matter. ### Tailwind CSS [#tailwind-css] [Tailwind CSS](https://tailwindcss.com/docs/styling-with-utility-classes) styles layouts, spacing, colors and responsive behavior through utility classes. The starter combines those classes with shared design tokens, so you can change the brand and build new screens with a consistent appearance. Most visual work happens alongside the component you're editing. ### shadcn/ui [#shadcnui] [shadcn/ui](https://ui.shadcn.com/docs) supplies the source for buttons, dialogs, forms and other interface components. Those files live in your repository, so you and your agent can adapt their appearance and behavior. The starter uses Base UI interaction primitives and Lucide icons; [UI customization](/docs/build/customization) shows where to start. ## Data and services [#data-and-services] ### Supabase [#supabase] [Supabase](https://supabase.com/docs/guides/database/overview) provides hosted Postgres, Auth, Storage and Edge Functions. It gives your SaaS a relational database with sign-in and access controls connected to it. The starter uses server checks and database policies to isolate workspace data, giving you a permission model to follow when adding features. ### Stripe [#stripe] [Stripe](https://docs.stripe.com/billing/subscriptions/overview) handles subscription payments through hosted Checkout and the customer billing portal. Your app uses verified webhooks to keep each workspace's subscription state in sync. This gives you a billing workflow to extend with your own plans, while keeping payment collection with Stripe. See [subscriptions and webhooks](/docs/billing/subscriptions). ### Resend [#resend] [Resend](https://resend.com/docs/introduction) sends your app's transactional emails. The starter includes five editable templates for welcome, verification, password reset, magic-link sign-in and workspace invitations. Keeping these templates in source lets you review email changes alongside the app; Kickstart publishes them to your Resend account. ### OpenRouter [#openrouter] [OpenRouter](https://openrouter.ai/docs/quickstart) connects your app to AI models through one API. The chat example uses it with the Vercel AI SDK to stream responses, giving you a starting point for AI features and a shared integration for trying different models. Add a server-only key and fund your provider account using the [AI setup guide](/docs/integrations/ai). ## Documentation and publishing [#documentation-and-publishing] ### Fumadocs [#fumadocs] [Fumadocs](https://fumadocs.dev/docs) turns MDX files into product documentation with navigation, search and code examples. You can update guides in the same repository as the features they explain. The starter includes a [documentation site for your customers](/docs/website/documentation), separate from the Ship & Scale manual you're reading now. ### GitHub [#github] [GitHub](https://docs.github.com/en/repositories) stores your private repository and its Git history. It gives you a place to review changes, collaborate and trace how a feature evolved, including changes made by an agent. Deployment Kickstart creates that repository when you [deploy](/docs/start/deployment-kickstart). ### Vercel [#vercel] [Vercel](https://vercel.com/docs/git) builds and hosts your Next.js app from its connected GitHub repository. Deployment Kickstart configures that connection and the runtime settings; later pushes to the production branch trigger new deployments. This keeps releasing code part of your normal Git workflow. Follow [deployment](/docs/start/deployment-kickstart) after local setup. ## Where your app runs [#where-your-app-runs] During development, Next.js runs on your computer against your hosted services. After deployment, Vercel runs the app. Supabase holds your data and handles Auth in both cases, while Stripe, Resend and OpenRouter serve the features that use them. No local database or Docker setup is required. **Local and live share services** Your local app and your live app use the same Supabase project. That keeps setup simple, and it has two effects once you've deployed: * Accounts, workspaces and data you create while testing locally go into the same database as your customers' data. * Sign-in emails, Google sign-in and connected integrations return to your live address, even when you're running the app locally. Use clearly labeled test accounts, and don't delete or reset data you didn't create. Stripe is different: it keeps test and live settings separate. A fully separate staging environment needs a second Supabase project, which the default setup doesn't configure. The integrations give you a foundation for a production-ready SaaS. Your provider accounts have their own costs and limits, and your app still needs testing and the [launch checks](/docs/operations/launch). Exact dependency versions live in `package.json` and `package-lock.json`; install those versions when following Setup. ## Repository map [#repository-map] Use this map to find the main parts of your app when you open the starter. Most feature work will touch `app/`, `components/` and `lib/`; database changes belong in `supabase/migrations/`. ```text app/ (auth)/ sign-in, signup and account recovery pages (public)/ blog and public legal pages actions/ authenticated server mutations admin/ admin panel for app operators api/ HTTP, AI and integration endpoints dashboard/ protected product pages docs/ your customers' product-documentation UI oauth/ consent page for connected MCP clients onboarding/ account survey preview/ development-only UI fixtures setup/ development-only setup information components/ feature UI and all 61 installed UI primitives config/ site, marketing, SEO, billing seed, integrations content/docs/ your product docs in MDX emails/ source HTML email templates hooks/ shared React hooks lib/ auth, workspace, billing and feature services public/ images, icons and other public files scripts/kickstart/ account checks, provisioning and retry state supabase/migrations/ versioned hosted SQL changes supabase/functions/ hosted email/billing function source tests/ setup fixtures, database and browser checks docs/ engineering notes for you and your agent proxy.ts refreshes the signed-in session on each request ``` The root marketing pages also include `app/page.tsx`, `app/pricing`, `app/faq` and `app/contact`. Next.js folder groups in parentheses organize routes without adding URL segments. Generated `.source`, `.next`, setup records and dependencies are not code you edit. When you start building features, [Architecture](/docs/build/architecture) explains how requests, permissions and commands fit together. You don't need it before Setup. [Next: Setup](/docs/start/environment) Get access to the starter and open your own copy. --- # Configuration reference URL: https://ship-and-scale.vercel.app/docs/build/configuration-reference Find environment fields, source settings and the steps needed to apply a change. Use this reference when you need to look up a setting or change an installed app. For your first installation, start with [Configuration](/docs/build/configuration), which walks through the choices to make before Kickstart. ## Three places to configure behavior [#three-places-to-configure-behavior] Root `.env` holds installation inputs and generated runtime values. `config/` holds source-controlled product choices. Provider-owned resources hold state such as the initialized Stripe catalog. Choose the existing source of truth rather than creating another editable copy. Keep one assignment per environment name. Kickstart reads root `.env`; do not add conflicting `.env.local` values. A local file change does not automatically update deployed variables or hosted Auth/function settings. The action column below explains the next step. ## Source configuration [#source-configuration] | File | Responsibility | | -------------------------- | -------------------------------------------------------- | | `config/site.ts` | Brand name and base description | | `config/marketing.ts` | Public positioning and marketing content | | `config/seo.ts` | Real public pages, language/locale and social image | | `config/billing-seed.json` | Initial missing catalog seed | | `config/integrations.json` | REST/MCP/OAuth switches and enabled workspace operations | | `config/deployment.json` | Explicit source-export roots | | `lib/ai/config.mjs` | AI example model/context/cost policy | | `lib/onboarding.ts` | Survey choices and validation | Start with the file responsible for the behavior you want to change. For example, editing a page's marketing copy belongs in source; changing an API key belongs in the private environment; changing an initialized subscription price follows the Stripe catalog workflow. ## Change the product name and description [#change-the-product-name-and-description] Set your product name in the existing root `.env` entry: ```dotenv APP_NAME=Fieldnotes ``` The complete `config/site.ts` module can then keep that environment override and supply your product description: ```ts title="config/site.ts" export const site = { name: process.env.APP_NAME || 'Fieldnotes', description: 'Shared project notes for teams that work with customers.', } ``` Restart the development server after changing the environment and verify the header and public metadata. Update the page copy in `config/marketing.ts`, logos, social artwork and email wording as part of [branding](/docs/build/customization). `APP_NAME` is not a global search-and-replace tool and does not rename provider resources that already exist. ## Choose the integration surface [#choose-the-integration-surface] `config/integrations.json` owns the supported REST/MCP switches. This complete example keeps the REST API available and disables MCP and its OAuth connection flow: ```json title="config/integrations.json" { "apiEnabled": true, "mcpEnabled": false, "oauthEnabled": false, "operations": ["get_workspace", "rename_workspace"] } ``` Use the existing operation names; adding a string does not implement a new endpoint or permission. Check the server routes and connection UI after the change, and review issued credentials before retiring an integration. [REST API](/docs/integrations/api) and [MCP](/docs/integrations/mcp) explain the shared contracts. ## Common configuration tasks [#common-configuration-tasks] | You want to… | Change | Then verify | | -------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Brand the app | `APP_NAME`, `config/site.ts`, `config/marketing.ts` and shared assets | Header, page copy, metadata and emails | | Require email verification | `AUTH_EMAIL_VERIFICATION=true` | Reconcile Auth through kickstart, then test a new signup and inbox link | | Enable the AI example | `AI_ENABLED`, `OPENROUTER_API_KEY`, `AI_MODEL` | Follow the scoped AI installation and response checks | | Edit prices | `config/billing-seed.json` before initialization; owned Stripe test catalog afterward | Catalog synchronization, checkout and subscription events | | Publish discoverable pages | Real page entries in `config/seo.ts`, then `SEO_INDEXABLE=true` at launch | Production canonicals, robots, sitemap and public content | | Change the app's origin | Active `APP_URL` plus the documented origin workflow | Auth redirects, email links and MCP/OAuth callbacks | The sections below list the exact environment fields, including generated values you should preserve. They are a reference for your installed project; you only need the three credentials from [Accounts](/docs/accounts) for the first installation. ## Application and Supabase values [#application-and-supabase-values] | Field | Initial/default | Visibility and consumer | Change action | | -------------------------------------- | --------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------- | | `APP_NAME` | `Forma`; optional input | Site branding and setup naming | Restart/rebuild; review sample copy and email templates | | `APP_URL` | `http://localhost:3000` | Active runtime/setup origin | Reconcile Auth/email/MCP and rebuild; follow origin guards | | `APP_URL_DEV` | `http://127.0.0.1:3000` | Saved reference only | Editing alone changes no active origin | | `APP_URL_LIVE` | Generated; optional custom-domain input | Saved production reference; deployment uses it | Verify domain, then reconcile production | | `SEO_INDEXABLE` | `false` | Public discovery policy at build/runtime | Rebuild after deliberate launch choice | | `SUPABASE_ACCESS_TOKEN` | Required local input | Setup-only Management API | Check same account/target; never deploy as runtime | | `AUTH_EMAIL_VERIFICATION` | `false` | Hosted Auth and server behavior | Run kickstart for selected active origin | | `HOSTED_TEST_PROJECT_REF` | Blank; opt-in test target | Test runner guard | Set only to the explicitly authorized test reference | | `SUPABASE_PROJECT_REF` | Generated | Setup/recovery target identity | Preserve; changing target is a new installation decision | | `NEXT_PUBLIC_SUPABASE_URL` | Generated | Public browser/server project URL | Keep consistent with saved reference | | `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | Generated | Intentionally public browser/server key | Rebuild clients after an intentional provider rotation | | `SUPABASE_SECRET_KEY` | Generated | Server-only privileged operations | Coordinate private runtime updates and verify narrow consumers | | `SUPABASE_EMAIL_HOOK_SECRET` | Generated | Signed hosted Auth email hook | Coordinated hook/runtime reconciliation; never browser code | `SUPABASE_ORGANIZATION_SLUG` and `SUPABASE_REGION` are optional bootstrap overrides accepted by the parser even though the example file only links to them in comments. Organization selection uses the real slug. A region override must be available. Neither is another credential. ## Email values [#email-values] | Field | Initial/default | Consumer | Change action | | ----------------------------------------- | -------------------------- | ----------------------------------- | ------------------------------------------------------------------ | | `RESEND_API_KEY` | Required Full access input | Setup and server email | Preserve account; publish/reconcile and update runtime on rotation | | `RESEND_FROM_EMAIL` | Blank → sender discovery | Setup, hosted hook and server sends | Verify domain, reconcile, restart/redeploy | | `RESEND_TEST_MODE` | Blank → discovery | Recipient restrictions | `false` requires a verified sender | | `CONTACT_TO_EMAIL` | Blank; optional input | Public contact delivery recipient | Set monitored inbox and redeploy runtime | | `RESEND_TEMPLATE_WELCOME_ID` | Generated | Welcome delivery | Preserve installer ownership | | `RESEND_TEMPLATE_VERIFICATION_ID` | Generated | Hosted Auth email hook | Publish template changes through setup | | `RESEND_TEMPLATE_PASSWORD_RESET_ID` | Generated | Hosted recovery email hook | Publish/reconcile | | `RESEND_TEMPLATE_MAGIC_LINK_ID` | Generated | Hosted magic-link email hook | Publish/reconcile | | `RESEND_TEMPLATE_WORKSPACE_INVITATION_ID` | Generated | Server invitation delivery | Publish/reconcile | These IDs identify published templates; they are not interchangeable with API keys. Source HTML and installer variable definitions must stay aligned. See [email delivery](/docs/email/templates-delivery). ## Billing and AI values [#billing-and-ai-values] | Field | Initial/default | Consumer | Change action | | -------------------------------- | ------------------------------------- | ---------------------------------- | -------------------------------------------------------- | | `STRIPE_TEST_SECRET_KEY` | Required when billing enabled | Test setup/server billing | Check mode/account; reconcile after rotation | | `STRIPE_LIVE_SECRET_KEY` | Deployment input when billing enabled | Live setup/production billing | Update production privately and verify payment flow | | `STRIPE_TEST_WEBHOOK_SECRET` | Generated | Hosted test signature verification | Restore/rotate only for matching endpoint | | `STRIPE_LIVE_WEBHOOK_SECRET` | Generated | Hosted live signature verification | Coordinate endpoint/function state | | `STRIPE_TEST_PRODUCT_ID_STARTER` | Generated | Test catalog identity | Preserve | | `STRIPE_TEST_PRODUCT_ID_PRO` | Generated | Test catalog identity | Preserve | | `STRIPE_TEST_PRODUCT_ID_GROWTH` | Generated | Test catalog identity | Preserve | | `STRIPE_LIVE_PRODUCT_ID_STARTER` | Generated | Live catalog identity | Preserve | | `STRIPE_LIVE_PRODUCT_ID_PRO` | Generated | Live catalog identity | Preserve | | `STRIPE_LIVE_PRODUCT_ID_GROWTH` | Generated | Live catalog identity | Preserve | | `AI_ENABLED` | `false` | Server/UI feature availability | Supply model/key, install AI, restart/redeploy | | `OPENROUTER_API_KEY` | Optional server-only input | AI requests | Verify provider access/funding; update runtime privately | | `AI_MODEL` | `openai/gpt-4.1-mini` | AI server/provider configuration | Check supported model; restart/redeploy | Prices and seat/usage limits are not extra environment variables. Initial prices come from `config/billing-seed.json`; after initialization, the owned Stripe test catalog becomes the authoring source. This table deliberately does not reproduce an editable catalog. Workspace AI credits are one-time and independent of subscription prices. ## GitHub and Vercel values [#github-and-vercel-values] | Field | Initial/default | Consumer | Change action | | --------------------- | -------------------------------------- | ----------------------------- | --------------------------------------------------- | | `GITHUB_TOKEN` | Deployment input | Setup-only source publication | Rotate for same owner/repository; run check | | `GITHUB_OWNER` | Discovered; optional override | Repository ownership | Deliberately choose before first provisioning | | `GITHUB_REPO_NAME` | Derived; optional override | Repository name | Keep receipt/provider identity consistent | | `GITHUB_REPO_ID` | Generated or verified empty-repo input | Recovery identity | Preserve numeric ID | | `GITHUB_REPOSITORY` | Generated | Saved owner/name reference | Preserve | | `VERCEL_TOKEN` | Deployment input | Setup-only project management | Rotate for same intended team | | `VERCEL_TEAM_ID` | Discovered; optional override | Team identity | Use actual `team_` ID when needed | | `VERCEL_PROJECT_NAME` | Derived; optional override | Initial project name | Set before creation; generated state then preserved | | `VERCEL_PROJECT_ID` | Generated | Recovery identity | Preserve `prj_` ID | Google OAuth client ID and secret are configured only in hosted Supabase. Do not add them to this table's runtime environment. The production allowlist is `productionEnvironment()` in `scripts/kickstart/deploy-config.mjs`; it is the authoritative code for values that leave the setup computer. ## Apply a feature switch [#apply-a-feature-switch] To enable AI on an installed, authorized target, set `AI_ENABLED=true`, fill `OPENROUTER_API_KEY`, retain a supported `AI_MODEL`, and use the scoped [AI install procedure](/docs/integrations/ai#enable-and-install). Restart Next.js and verify a real response separately. Changing the visible menu is not equivalent to installing the schema. For integration switches, disable the corresponding server route and consent surface as defined by the shared config, not only a sidebar item. Database grants and issued credentials still deserve a review when removing a capability. Billing removal similarly requires handling existing subscriptions rather than hiding the Billing tab. ## Active and saved URLs [#active-and-saved-urls] `APP_URL` drives active application behavior. The two saved references do not create independent local/live Supabase environments. Once deployed, shared Auth/email/MCP settings use production and ordinary local setup stops to prevent accidental reversal. Read [origin recovery](/docs/operations/deployment#active-and-saved-origins) before changing them. ## Validate a configuration change [#validate-a-configuration-change] Identify its consumer, update one source of truth, run the relevant read-only check, then perform only the authorized provisioning/runtime action. Verify the actual UI and failure state. Keep generated identifiers and private receipts. A successful parser or TypeScript check proves neither permissions to write provider resources nor the availability of a configured model or sender. --- # Configuration URL: https://ship-and-scale.vercel.app/docs/build/configuration Choose the settings Kickstart will use to create your app. Review your app's name and setup defaults before Kickstart creates the hosted services. You can keep the defaults while exploring, or adjust the choices below for your product. ## Before you begin [#before-you-begin] Complete [Accounts](/docs/accounts) first. Your root `.env`, beside `package.json`, should already contain `SUPABASE_ACCESS_TOKEN`, `STRIPE_TEST_SECRET_KEY` (for the default billing setup) and `RESEND_API_KEY`. Edit that existing file and keep one line per setting. If you haven't created it or saved your keys, follow [where to save your keys](/docs/accounts#where-to-save-your-keys). Kickstart reads only the root `.env`, so avoid conflicting values in `.env.local`. The same [privacy rules](/docs/accounts#where-to-save-your-keys) apply to every setting in this file. ## Name your app [#name-your-app] Update these existing entries in `.env`: ```dotenv APP_NAME=Forma APP_URL=http://localhost:3000 ``` Replace `Forma` with your product's name, or keep it while exploring. `APP_NAME` sets the configured app name and helps name resources created during setup. It doesn't rename existing provider resources or replace logos, page copy and email wording; those belong in [branding](/docs/build/customization). Keep the local URL for your first run. Your browser address and the configured URL need to match for sign-in to work. ## Review the setup defaults [#review-the-setup-defaults] These defaults give you a local app with hosted services and Stripe test billing. Review the database location and initial billing plans before provisioning, because Kickstart uses those choices when it creates resources. | Choice | Default | When to change it | | ------------------ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Database location | US East, with another available US region as fallback | Set `SUPABASE_REGION` before creation if you need a specific available region. | | Email verification | `AUTH_EMAIL_VERIFICATION=false` | Set it to `true` to require new users to confirm their email before signing in. | | AI chat | `AI_ENABLED=false` | Set it to `true` and add `OPENROUTER_API_KEY` to include AI in your first installation. | | Billing | Enabled with sample Starter, Pro and Growth plans | Edit `config/billing-seed.json` before installation to change the initial plans or disable billing. | | Email sender | Automatic selection | Choose a verified sender if you already have a domain ready. | | Search indexing | `SEO_INDEXABLE=false` | Keep it off while building; enable it when your public pages are ready to launch. | If Resend has no verified sending domain, the default test sender delivers only to your Resend account email. Use that exact address for signup and recovery testing. Sending to other people requires a verified domain. **Choose a database region or organization** To choose a region, add one `SUPABASE_REGION` entry to `.env` using an available region code for your Supabase organization. The default code is `us-east-1`. Kickstart checks availability before creating the project. With one accessible Supabase organization, Kickstart selects it automatically. With several, the terminal asks you to choose. For unattended setup, add `SUPABASE_ORGANIZATION_SLUG` with the intended organization's actual slug. Make these choices before the first run. Changing a region or organization entry later does not move an existing database. Preserve the generated project reference and saved setup state when resuming an installation. **Require email verification** Set the existing `AUTH_EMAIL_VERIFICATION` entry to `true`. Kickstart applies it to hosted authentication, so new users must follow their verification email before signing in. If you change this after installation, follow [email verification](/docs/core/authentication#email-verification) to apply the setting and test the signup flow. Editing `.env` alone doesn't update hosted Auth. **Include AI chat** Add your key using the [OpenRouter guide](/docs/accounts/openrouter), then set `AI_ENABLED=true`. You can keep the supplied `AI_MODEL` for your first run. Kickstart checks the configured model and key before installation; provider funding and usage limits apply. To add AI after your app is installed, follow [enable and install AI](/docs/integrations/ai#enable-and-install). **Choose your initial billing plans** Open `config/billing-seed.json`. You can keep the sample plans while testing, or edit their names, descriptions, currency and monthly/yearly amounts before the first installation. Keep the supported `starter`, `pro` and `growth` plan keys. Amounts use the currency's smallest unit; for USD, `1900` means $19.00. The seed fills missing products and prices during setup. After initialization, the owned Stripe test catalog becomes the place to edit them; changing the seed won't overwrite existing prices. Follow [plans and pricing](/docs/billing/plans#initial-seed-and-later-authoring) for the catalog workflow. For a fresh app without billing, change the existing top-level `enabled` field to `false`, preserving the rest of the file. Kickstart then skips Stripe provisioning, and the Stripe test key is no longer required. Disabling this setting on an installed app does not cancel existing subscriptions; review [subscriptions](/docs/billing/subscriptions) before removing billing. **Choose an email sender** Leave `RESEND_FROM_EMAIL` and `RESEND_TEST_MODE` blank for automatic selection. Kickstart uses a verified sending domain when available and asks you to choose if there are several. Otherwise, local setup uses the restricted test sender described above. To choose a sender explicitly, set `RESEND_FROM_EMAIL` to an address under your verified domain and `RESEND_TEST_MODE=false`. Follow [verify a production sender](/docs/accounts/resend#verify-a-production-sender) if the domain isn't ready. `CONTACT_TO_EMAIL` is an optional inbox for public contact-form messages. ## Let Kickstart fill in the rest [#let-kickstart-fill-in-the-rest] On a fresh installation, leave the project keys, project IDs, email template IDs and webhook secrets in the **Kickstart fills in** sections blank. Kickstart saves those values as it creates and connects your services. The optional sender choices above are the exception when you want to override discovery. If you've already run Kickstart, preserve its generated values and private recovery files. Follow [safe reruns](/docs/start/project-kickstart#safe-reruns) to resume that installation. For individual environment fields and later source changes, use the [Configuration reference](/docs/build/configuration-reference). When your local app works, [Deployment Kickstart](/docs/start/deployment-kickstart) covers the extra settings for going live. ## Ready for Kickstart [#ready-for-kickstart] Save your changes and confirm: * Your required credentials are in the existing root `.env`. * Your app name, local URL and setup choices are ready, including an OpenRouter key if you enabled AI. * Generated fields are blank for a fresh installation, or preserved if you're resuming one. You can now check the accounts and install your app on the next page. [Next: Kickstart](/docs/start/project-kickstart) Check your settings, install the hosted services and run your app. --- # Branding, UI and dashboard customization URL: https://ship-and-scale.vercel.app/docs/build/customization Change the product identity and navigation while preserving the shared design and access boundaries. ## Start with product identity [#start-with-product-identity] Set `APP_NAME` for the configured name, then review `config/site.ts` and `config/marketing.ts`. The former supplies a default description and server-consumed name. It does not rewrite all sample headings, legal text, illustrations or email subjects. Search the source for sample product names before launch, excluding environments, dependencies and generated files. ```sh rg -n 'Forma|starter|sample' app components config content emails ``` Review each occurrence rather than doing an unrestricted replacement. Some references explain the starter to developers; others are customer-facing copy that should describe your product. Keep credentials and private setup state out of search output and screenshots. ## Logo, favicon and social identity [#logo-favicon-and-social-identity] `components/brand.tsx` renders the shared brand component. Replace or adapt its mark in that component; store approved public assets in `public/`. Change `app/favicon.ico` for browser tabs and the social image referenced by `config/seo.ts`. The [SEO metadata guide](/docs/website/seo-metadata) explains canonical origins, page titles and sharing previews. Workspace logos and personal avatars are user data, not public brand assets. Do not copy one customer's logo into the public directory as a global product icon. Their authenticated access paths are documented under [Storage](/docs/storage/overview). ## Preserve the neutral visual foundation [#preserve-the-neutral-visual-foundation] The installed shadcn preset is Base UI/Vega with neutral colors, Inter and Lucide. All 61 UI component files were intentionally installed. Use the existing primitives in `components/ui` and the semantic CSS variables in `app/globals.css`. For a subtle radius adjustment, edit the shared `--radius` token and inspect cards, inputs, dialogs and buttons together. For a color adjustment, review both `:root` and `.dark` definitions of the relevant semantic pair. For example, a primary background needs a readable primary foreground. Avoid hardcoding a light background in one component that becomes unreadable in dark mode. ```css title="Example token change in app/globals.css" :root { --radius: 0.75rem; } ``` This small example changes radius only. Keep the rest of the existing token declarations in place; do not replace the entire stylesheet with it. The documentation uses its own scoped `app/docs/docs.css` layered over shared tokens. ## Add a protected page [#add-a-protected-page] Create `app/dashboard/reports/page.tsx` for a product-specific page. This minimal example displays the verified selected workspace; it does not claim a shipped reporting backend: ```tsx title="app/dashboard/reports/page.tsx" import { getWorkspace } from '@/lib/auth'; export default async function ReportsPage() { const { organization } = await getWorkspace(); return (

Reports

Reports for {organization.name}.

No reports have been created yet.

); } ``` The dashboard layout already establishes the application shell and onboarding flow. `getWorkspace()` confirms current account/workspace access for the page's data. Add actual data access through user-scoped clients and database policies when the feature becomes functional; a protected route alone does not isolate records. ## Add the navigation item [#add-the-navigation-item] The sidebar entries live in `components/dashboard/app-shell.tsx`. Import the chosen Lucide icon, then add an item beside the existing entries with `href`, `label` and `icon`: ```tsx { href: '/dashboard/reports', label: 'Reports', icon: FileText } ``` Import `FileText` from `lucide-react` in the same file. Verify its route exists, the active state is correct and the collapsed sidebar still exposes an accessible name. Use [workspace notes](/docs/build/first-feature) for a complete create/list feature rather than leaving a sample page as a finished product claim. ## Loading, error and empty states [#loading-error-and-empty-states] A loading state should say what is loading. An empty state should explain the user's next permitted action. An error state should preserve entered data when possible and give a retry path. Avoid passing raw database/provider error payloads into customer UI. Use route `loading.tsx` and client `error.tsx` boundaries for data-loading transitions. Handle validation failures as inline form state instead of throwing every rejected input into a route-wide error boundary. Disable duplicate submission while pending, but keep server/database retry protection too. ## Responsive and keyboard checks [#responsive-and-keyboard-checks] Inspect desktop and narrow mobile layouts, including the sidebar drawer, long workspace names, dialogs and tables. Check 320px and 390px widths for page-level horizontal overflow. Code blocks can scroll inside their container; the entire page should not require sideways navigation. Use keyboard Tab and Shift+Tab, open dialogs and return focus to the invoking control. Inputs need labels and validation messages. Do not rely on an icon's shape or color alone to communicate state. Honor reduced-motion preferences if adding animation. ## Verify branding across surfaces [#verify-branding-across-surfaces] Check the public homepage, login, dashboard, account menu, email templates, product docs, metadata and social image. Some branding is rendered server-side from `APP_NAME`; a Client Component should receive necessary public text through explicit props or an intentional public config, not import a secret-reading server module. Run `npm run typecheck`, scoped lint and `npm run build`. Inspect changed UI in light/dark and mobile/desktop. These checks validate local presentation; they do not show a received email with the new brand. Complete that delivery check through [email templates](/docs/email/templates-delivery) when an authorized sender and recipient are available. --- # Build your first workspace feature URL: https://ship-and-scale.vercel.app/docs/build/first-feature Add workspace notes with a real migration, validated action, protected page and tenant tests. ## Result and prerequisites [#result-and-prerequisites] This tutorial adds **workspace notes** to your own copy: verified members can read the latest 50 notes, and owners/admins can create them. It is an original tutorial extension, not a feature already installed in the starter. Later guides add a [paid create operation](/docs/billing/plans#make-note-creation-paid) and explicit [REST](/docs/integrations/api#extend-with-workspace-notes)/[MCP](/docs/integrations/mcp#add-the-notes-tool) read access to this same domain. Use a clean source copy and a dedicated hosted test installation. The sample TypeScript is checked against this source snapshot; its SQL and live browser create/list path still need authorized hosted execution. Nothing in this documentation project installs the example in the main application. **Workspace notes request path** Browser form → verified server action → user-scoped Supabase client → column grants and workspace RLS → workspace-owned note. The list uses the same selected workspace and never chooses a tenant from a URL alone. ## File map [#file-map] Download or copy each complete source block into the following destination in your local project: | Sample | Destination | | --------------------------------------------------------------------------- | -------------------------------------------------------- | | [Migration](/docs-assets/examples/notes/20260916090000_workspace_notes.sql) | `supabase/migrations/20260916090000_workspace_notes.sql` | | [Validation and types](/docs-assets/examples/notes/notes-schema.ts) | `lib/notes-schema.ts` | | [Server action](/docs-assets/examples/notes/notes-action.ts) | `app/actions/notes.ts` | | [Form](/docs-assets/examples/notes/note-form.tsx) | `components/notes/note-form.tsx` | | [Page](/docs-assets/examples/notes/notes-page.tsx) | `app/dashboard/notes/page.tsx` | | [Loading](/docs-assets/examples/notes/notes-loading.tsx) | `app/dashboard/notes/loading.tsx` | | [Error boundary](/docs-assets/examples/notes/notes-error.tsx) | `app/dashboard/notes/error.tsx` | Choose a new timestamp later than your last migration if your project has advanced. Never rename or edit an already-applied migration. Keep the filenames referenced by imports exactly as shown in the destination column. ## Add the migration and policies [#add-the-migration-and-policies] **supabase/migrations/20260916090000_workspace_notes.sql** ``` -- Tutorial-only additive migration. Apply in a dedicated hosted test installation. create table public.workspace_notes ( id uuid primary key, org_id uuid not null references public.organizations(id) on delete cascade, created_by uuid references auth.users(id) on delete set null, body text not null check (char_length(btrim(body)) between 1 and 2000), created_at timestamptz not null default now() ); create index workspace_notes_org_created_idx on public.workspace_notes(org_id, created_at desc, id); alter table public.workspace_notes enable row level security; revoke all on public.workspace_notes from public, anon, authenticated; grant select on public.workspace_notes to authenticated; grant insert(id, org_id, created_by, body) on public.workspace_notes to authenticated; create policy notes_read on public.workspace_notes for select to authenticated using ((select private.is_org_member(org_id))); create policy notes_create on public.workspace_notes for insert to authenticated with check ((select private.can_manage_org(org_id)) and created_by = (select auth.uid())); -- No update/delete grants: this tutorial implements create/list only. ``` `org_id` is the tenant boundary. Deleting a workspace cascades to its notes. Deleting a creator leaves the workspace's note with a null author reference; choose a different retention rule only through a deliberate product decision and new migration. The body limit is enforced in SQL as well as the form. Row-level security (RLS) decides which rows a signed-in account may access. Grants separately decide which operations and columns that role can use. `private.is_org_member` and `private.can_manage_org` already exist in the template and check a current verified account. The migration permits select and limited insert only; clients cannot rewrite ownership, timestamps or existing notes. Run the installer check, then apply migrations through `npm run kickstart:migrate -- --check` and `npm run kickstart:migrate` on the authorized test target. Do not switch to Supabase CLI migration history. For an already deployed installation, the migration-only command uses its configured hosted project and does not restore local Auth or change its production origin. ## Validate input and retry identity [#validate-input-and-retry-identity] **lib/notes-schema.ts** ``` import { z } from 'zod'; export const noteInput = z.strictObject({ request_id: z.uuid(), org_id: z.uuid(), body: z.string().trim().min(1).max(2000), }); export type Note = { id: string; body: string; created_at: string }; export type NoteState = { error?: string; success?: string }; ``` The request UUID makes a retried submission identifiable. The organization UUID travels with the form so a stale tab retains its original workspace target. Both are validated on the server. The hidden field is a statement of intent, not authorization. ## Create through the signed-in user [#create-through-the-signed-in-user] **app/actions/notes.ts** ``` 'use server'; import { revalidatePath } from 'next/cache'; import { getWorkspace, requireUser } from '@/lib/auth'; import { noteInput, type NoteState } from '@/lib/notes-schema'; export async function createNote(_previous: NoteState, form: FormData): Promise { const parsed = noteInput.safeParse({ request_id: form.get('request_id'), org_id: form.get('org_id'), body: form.get('body') }); if (!parsed.success) return { error: 'Enter a note of 1–2,000 characters.' }; const { user, supabase } = await requireUser(); const { org_id, request_id, body } = parsed.data; try { const { role } = await getWorkspace(org_id); if (role === 'member') return { error: 'Only owners and admins can create notes.' }; } catch { return { error: 'Workspace access is no longer available.' }; } const result = await supabase.from('workspace_notes').insert({ id: request_id, org_id, created_by: user.id, body }); if (result.error?.code === '23505') { const existing = await supabase.from('workspace_notes').select('id,body,created_by').eq('org_id', org_id).eq('id', request_id).maybeSingle(); if (existing.error || existing.data?.created_by !== user.id || existing.data?.body !== body) return { error: 'This request was already used. Start a new note.' }; } else if (result.error) return { error: 'The note could not be saved. Retry with the same request.' }; revalidatePath('/dashboard/notes'); return { success: 'Note saved.' }; } ``` `requireUser()` verifies the session. `getWorkspace(org_id)` verifies that the supplied target is one of this account's current workspaces. The database independently enforces the role and author. Ordinary application writes use the signed-in user's client; no elevated client is needed for this base recipe. If an insert response is lost, the same request ID can be retried. A duplicate primary key counts as success only when the visible existing row has the same workspace, author and body. Reusing an ID for changed content returns an error. Other database errors return a safe retry message, not a raw query or provider payload. ## Render the form and list [#render-the-form-and-list] **components/notes/note-form.tsx** ``` 'use client'; import { useActionState } from 'react'; import { createNote } from '@/app/actions/notes'; import type { NoteState } from '@/lib/notes-schema'; export function NoteForm({ workspaceId, requestId }: { workspaceId: string; requestId: string }) { const [state, action, pending] = useActionState(createNote, {}); return