# TurboStarter documentation

> Full markdown snapshot of all public TurboStarter documentation. For the index only, see [llms.txt](https://www.turbostarter.dev/llms.txt).

# AI
Source: https://www.turbostarter.dev/docs/extension/ai

<Callout title="Looking for AI-assisted development?">
  TurboStarter includes a set of AI rules, skills, subagents, and commands for popular AI editors and tools - so the AI follows this repo's conventions and produces more consistent changes.

  See [AI-assisted development](/docs/extension/installation/ai-development) to set it up.
</Callout>

There are two approaches to AI in a browser extension:

* **Server + client**: Traditional implementation, same as for [web](/docs/web/ai/overview) and [mobile](/docs/mobile/ai), used to stream server-generated responses to the client.
* **Chrome built-in AI**: An [experimental implementation](https://developer.chrome.com/docs/ai/built-in) of [Gemini Nano](https://blog.google/technology/ai/google-gemini-ai/#performance) that's built into new versions of the Google Chrome browser.

We recommend the traditional server + client approach because it's more versatile and easier to implement. Chrome's built-in AI is a nice option, but it's still experimental and has limitations.

Of course, you can always implement a *hybrid* approach which combines both solutions to achieve the best results.

To expose the complete templates from the separate AI Kit repository, follow the [extension AI Kit integration recipe](/docs/extension/recipes/ai-kit). AI Kit has no extension app, so the recipe keeps all provider work on the Core web API and adds a thin WXT client.

## Server + client

The traditional AI setup in the browser extension is the same as for the [web app](/docs/web/ai/configuration#client-side) and the [mobile app](/docs/mobile/ai). We use the same [API endpoint](/docs/web/ai/configuration#api-endpoint) and leverage streaming to display answers incrementally as they're generated.

```tsx title="main.tsx"
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";

const Popup = () => {
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/ai/chat",
    }),
  });

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            switch (part.type) {
              case "text":
                return <div key={`${message.id}-${i}`}>{part.text}</div>;
            }
          })}
        </div>
      ))}
    </div>
  );
};

export default Popup;
```

This is the most reliable way to use AI in the browser extension. Feel free to reuse or modify it to suit your needs.

## Chrome built-in AI

<Callout type="warn">
  Chrome's implementation of [built-in AI with Gemini Nano](https://developer.chrome.com/docs/ai/built-in) is experimental and will change as they test and address feedback.
</Callout>

Chrome's built-in AI is a preview feature. To use it, you need Chrome version 127 or later and you must enable these flags:

* [chrome://flags/#prompt-api-for-gemini-nano](chrome://flags/#prompt-api-for-gemini-nano): `Enabled`
* [chrome://flags/#optimization-guide-on-device-model](chrome://flags/#optimization-guide-on-device-model): `Enabled BypassPrefRequirement`
* [chrome://components/](chrome://components/): Click `Optimization Guide On Device Model` to download the model.

Once enabled, you can use `window.ai` to access the built-in AI and do things like this:

![Chrome built-in AI](/images/docs/extension/ai.gif)

You can also use a [dedicated provider](https://sdk.vercel.ai/providers/community-providers/chrome-ai) from the Vercel AI SDK ecosystem to simplify usage. Keep in mind that this API is still in its early stages and may change in the future.

<Callout title="Available in every extension context!">
  You can use this API in any part of your extension (popup, background service worker, etc.).

  It's safe to use on the client side because it doesn't require exposing secrets to the user (like an API key in the traditional server + client approach).
</Callout>

To learn more, check the official [Chrome documentation](https://developer.chrome.com/docs/ai/built-in) and the articles below.

<Cards>
  <Card href="https://developer.chrome.com/docs/ai/built-in" title="Get started with built-in AI" description="developer.chrome.com" />

  <Card href="https://developer.chrome.com/docs/extensions/ai" title="Extensions and AI" description="developer.chrome.com" />
</Cards>


# Configuration
Source: https://www.turbostarter.dev/docs/extension/analytics/configuration

The `@workspace/analytics-extension` package offers a streamlined and flexible approach to tracking events in your TurboStarter extension using various analytics providers. It abstracts the complexities of different analytics services and provides a consistent interface for event tracking.

In this section, we'll guide you through the configuration process for each supported provider.

Note that the configuration is validated against a schema, so you'll see error messages in the console if anything is misconfigured.

## Providers

Below, you'll find detailed information on how to set up and use each supported provider. Choose the one that best suits your needs and follow the instructions in the respective accordion section.

<Accordions>
  <Accordion title="Google Analytics" id="google-analytics">
    To use Google Analytics as your analytics provider, you need to [create a Google Analytics account](https://analytics.google.com/) and [set up a property](https://support.google.com/analytics/answer/9304153).

    Next, add a data stream in your Google Analytics account settings:

    1. Navigate to [Google Analytics](https://analytics.google.com/).
    2. In the *Admin* section, under *Data collection and modification*, click on *Data Streams*.
    3. Click *Add stream*.
    4. Select *Web* as the platform.
    5. Enter the required details for the stream (at minimum, provide a name and website URL).
    6. Click *Create stream*.

    After creating the stream, you'll need two pieces of information:

    1. Your [Measurement ID](https://support.google.com/analytics/answer/12270356) (it should look like `G-XXXXXXXXXX`):

    ![Google Analytics Measurement ID](/images/docs/web/analytics/google/id.png)

    2. Your [Measurement Protocol API secret](https://support.google.com/analytics/answer/9814495):

    ![Google Analytics Measurement Protocol API secret](/images/docs/web/analytics/google/api-secret.png)

    Set these values in your `.env.local` file in the `apps/extension` directory and in your CI/CD provider secrets:

    ```dotenv
    VITE_GOOGLE_ANALYTICS_MEASUREMENT_ID="your-measurement-id"
    VITE_GOOGLE_ANALYTICS_SECRET="your-measurement-protocol-api-secret"
    ```

    Also, make sure to activate the Google Analytics provider as your analytics provider by updating the exports in:

    ```ts title="index.ts"
    // [!code word:google-analytics]
    export * from "./google-analytics";
    export * from "./google-analytics/env";
    ```

    To customize the provider, you can find its definition in `packages/analytics/extension/src/providers/google-analytics` directory.

    For more information, please refer to the [Google Analytics documentation](https://developers.google.com/analytics).

    ![Google Analytics dashboard](/images/docs/web/analytics/google/dashboard.jpg)
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout type="info" title="You can also use it for monitoring!">
      PostHog is also one of pre-configured providers for [monitoring](/docs/extension/monitoring/posthog) and [feature flags](/docs/extension/flags/configuration#posthog) in TurboStarter.
    </Callout>

    To use PostHog as your analytics provider, you need to configure a PostHog instance. You can obtain the [Cloud](https://app.posthog.com/signup) instance by [creating an account](https://app.posthog.com/signup) or [self-host](https://posthog.com/docs/self-host) it.

    Then, create a project and, based on your [project settings](https://app.posthog.com/project/settings), fill the following environment variables in your `.env.local` file in `apps/extension` directory and your CI/CD provider secrets:

    ```dotenv
    VITE_POSTHOG_KEY="your-posthog-api-key"
    VITE_POSTHOG_HOST="your-posthog-instance-host"
    ```

    Also, make sure to activate the PostHog provider as your analytics provider by updating the exports in:

    ```ts title="index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```

    To customize the provider, you can find its definition in `packages/analytics/extension/src/providers/posthog` directory.

    For more information, please refer to the [PostHog documentation](https://posthog.com/docs/advanced/browser-extension).

    ![PostHog dashboard](/images/docs/web/analytics/posthog.png)
  </Accordion>
</Accordions>


# Overview
Source: https://www.turbostarter.dev/docs/extension/analytics/overview

When it comes to extension analytics, we can distinguish between two types:

* **Store listing analytics**: Used to track the performance of your extension's store listing (e.g., how many people have viewed your extension in the store or how many have installed it).
* **In-extension analytics**: Tracks user actions within your extension (e.g., how many users triggered your popup, how many users modified extension settings, etc.).

The `@workspace/analytics-extension` package provides a set of tools to easily implement both types of analytics in your extension.

## Store listing analytics

Interpreting your extension's store listing metrics can help you evaluate how changes to your extension and store listing affect conversion rates. For example, you can identify countries with a high number of visitors to prioritize supporting languages for those regions.

While each store implements a different set of metrics, there are some common ones you should be aware of:

* **Active installs**: The number of users who have installed your extension.
* **Active users**: The number of users who have used your extension.
* **Page views**: The number of times users have viewed your extension's detail page on the respective store.

To track more detailed metrics, you can opt in to Google Analytics in the Chrome Web Store's developer dashboard.

You can find this option under *Additional metrics* on the *Store listing* tab of your extension's control panel:

![Chrome Web Store - Store listing - Additional metrics](/images/docs/extension/analytics/opt-in-analytics.png)

<Callout>
  The Chrome Web Store manages the account for you and makes the data available
  in the Google Analytics dashboard.
</Callout>

By enabling this feature, you can optimize your extension's store listing based on metrics such as bounce rate, time on page, and more. This can lead to more installs and ultimately more users for your extension.

To learn more about the limitations of this type of analytics and how to adjust event details, please refer to the following sections in the official documentation:

<Cards>
  <Card title="Analyze your store listing metrics" description="developer.chrome.com" href="https://developer.chrome.com/docs/webstore/metrics" />

  <Card title="Use your Google Analytics account with the Chrome Web Store" description="developer.chrome.com" href="https://developer.chrome.com/docs/webstore/google-analytics" />
</Cards>

## In-extension analytics

TurboStarter comes with built-in support for tracking in-extension analytics. To learn more about each supported provider and how to configure them, see their respective sections:

<Cards>
  <Card title="Google Analytics" href="/docs/extension/analytics/configuration#google-analytics" />

  <Card title="PostHog" href="/docs/extension/analytics/configuration#posthog" />
</Cards>

All configuration and setup is built-in with a unified API, allowing you to switch between providers by simply changing the exports. You can even introduce your own provider without breaking any tracking-related logic.

In the following sections, we'll cover how to set up each provider and how to track events in your extension.


# Tracking events
Source: https://www.turbostarter.dev/docs/extension/analytics/tracking

The strategy for tracking events that every provider has to implement is extremely simple:

```ts
export type AllowedPropertyValues = string | number | boolean;

type TrackFunction = (
  event: string,
  data?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderStrategy {
  track: TrackFunction;
}
```

<Callout>
  You don't need to worry much about this implementation, as all the providers are already configured for you. However, it's useful to be aware of this structure if you plan to add your own custom provider.
</Callout>

As shown above, each provider must supply the `track` function. This function is responsible for sending event data to the provider.

To track an event in any part of your extension, simply call the `track` method, passing the event name and an optional data object:

```tsx title="main.tsx"
import { track } from "@workspace/analytics-extension";

const Popup = () => {
  return (
    <button onClick={() => track("popup.button.click", { country: "US" })}>
      Track event
    </button>
  );
};

export default Popup;
```

## Identifying users

Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.

For identification purposes, we're extending the strategy with the `identify` and `reset` methods. They are optional and only needed if you want to identify users in your app and associate their actions with a specific user ID.

```ts
type IdentifyFunction = (
  userId: string,
  traits?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderClientStrategy {
  identify: IdentifyFunction;
  reset: () => void;
}
```

To identify users, call the `identify` method, passing the user's ID and an optional traits object:

```tsx
import { identify } from "@workspace/analytics-extension";

identify("user-123", { name: "John Doe" });
```

This will associate all future events with the user's ID, allowing you to track user behavior and gain valuable insights into your application's usage patterns.

<Callout title="Configured by default!">
  The `identify` method is configured out-of-the-box to react on changes to the user's authentication state.

  When the user is authenticated, the `identify` method will be called with the user's ID and the user's traits. When the user is logged out, the `reset` method will be called to clear the existing user identification.
</Callout>

Congratulations! You've now mastered event tracking in your TurboStarter extension. With this knowledge, you're well-equipped to analyze user behaviors and gain valuable insights into your extension's usage patterns. Happy analyzing! 📊


# Using API client
Source: https://www.turbostarter.dev/docs/extension/api/client

In browser extension code, you can only access the API client from the **client-side.**

When you create a new component or piece of your extension and want to fetch some data, you can use the API client to do so.

## Creating a client

We're creating a client-side API client in `apps/extension/src/lib/api/index.tsx` file. It's a simple wrapper around the [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) that fetches or mutates data from the API.

It also requires wrapping your views in a `QueryClientProvider` component to provide the API client to the rest of the components.

We recommend to create a separate layout file, which will be used to wrap your pages. TurboStarter comes with a `layout.tsx` file in the `modules/common/layout` folder, which you can use as a template:

```tsx title="layout.tsx"
export const Layout = ({
  children,
  loadingFallback,
  errorFallback,
}: LayoutProps) => {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={loadingFallback}>
        <QueryClientProvider>{children}</QueryClientProvider>
      </Suspense>
    </ErrorBoundary>
  );
};
```

Remember that every part of your extension will be mounted as a **separate** React component, so you need to wrap each of them in the `QueryClientProvider` component if you want to use the API client inside:

```tsx title="app/popup/main.tsx"
import { Layout } from "~/modules/common/layout/layout";

export default function Popup() {
  return <Layout>{/* your popup code here */}</Layout>;
}
```

<Callout type="warn" title="Ensure correct API url">
  Inside the `apps/extension/src/lib/api/index.tsx` we're calling a function to get base url of your api, so make sure it's set correctly (especially on production) and your web api endpoint is corresponding with the name there.

  ```tsx title="index.tsx"
  const getBaseUrl = () => {
    return env.VITE_SITE_URL;
  };
  ```

  As you can see we're mostly relying on the [environment variables](/docs/extension/configuration/environment-variables) to get it, so there shouldn't be any issues with it, but in case, please be aware where to find it 😉
</Callout>

## Queries

Of course, everything comes already configured for you, so you just need to start using `api` in your components/screens.

For example, to fetch the list of posts you can use the `useQuery` hook:

```tsx title="posts.tsx"
import { api } from "~/lib/api";

export const Posts = () => {
  const { data: posts, isLoading } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await api.posts.$get();

      if (!response.ok) {
        throw new Error("Failed to fetch posts!");
      }

      return response.json();
    },
  });

  if (isLoading) {
    return <p>Loading...</p>;
  }

  /* do something with the data... */
  return (
    <div>
      <p>{JSON.stringify(posts)}</p>
    </div>
  );
};
```

It's using the `@tanstack/react-query` [useQuery API](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery), so you shouldn't have any troubles with it.

<Cards>
  <Card title="Hono RPC" description="hono.dev" href="https://hono.dev/docs/guides/rpc" />

  <Card title="useQuery hook | Tanstack Query" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/reference/useQuery" />
</Cards>

## Mutations

If you want to perform a mutation in your extension code, you can use the `useMutation` hook that comes straight from the integration with [Tanstack Query](https://tanstack.com/query):

```tsx title="modules/popup/form.tsx"
import { api } from "~/lib/api";

export const CreatePost = () => {
  const queryClient = useQueryClient();
  const { mutate } = useMutation({
    mutationFn: async (post: PostInput) => {
      const response = await api.posts.$post(post);

      if (!response.ok) {
        throw new Error("Failed to create post!");
      },
    },
    onSuccess: () => {
      toast.success("Post created successfully!");
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return <form onSubmit={onSubmit(mutate)} />;
};
```

Here, we're also invalidating the query after the mutation is successful. This is a very important step to make sure that the data is updated in the UI.

<Cards>
  <Card title="useMutation hook" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/reference/useMutation" />

  <Card title="Query invalidation" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation" />
</Cards>

## Handling responses

As you can see in the examples above, the [Hono RPC](https://hono.dev/docs/guides/rpc) client returns a plain `Response` object, which you can use to get the data or handle errors. However, implementing this handling in every query or mutation can be tedious and will introduce unnecessary boilerplate in your codebase.

That's why we've developed the `handle` function that unwraps the response for you, handles errors, and returns the data in a consistent format. You can safely use it with any procedure from the API client:

<Tabs items={["Queries", "Mutations"]}>
  <Tab value="Queries">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api";

    export const Posts = () => {
      const { data: posts, isLoading } = useQuery({
        queryKey: ["posts"],
        queryFn: handle(api.posts.$get),
      });

      if (isLoading) {
        return <p>Loading...</p>;
      }

      /* do something with the data... */
      return (
        <div>
          <p>{JSON.stringify(posts)}</p>
        </div>
      );
    };
    ```
  </Tab>

  <Tab value="Mutations">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/client";

    export const CreatePost = () => {
      const queryClient = useQueryClient();
      const { mutate } = useMutation({
        mutationFn: handle(api.posts.$post),
        onSuccess: () => {
          toast.success("Post created successfully!");
          queryClient.invalidateQueries({ queryKey: ["posts"] });
        },
      });

      return <form onSubmit={onSubmit(mutate)} />;
    };
    ```
  </Tab>
</Tabs>

With this approach, you can focus on the business logic instead of repeatedly writing code to handle API responses in your browser extension components, making your extension's codebase more readable and maintainable.

The same error handling and response unwrapping benefits apply whether you're building web, mobile, or extension interfaces - allowing you to keep your data fetching logic consistent across all platforms.


# Overview
Source: https://www.turbostarter.dev/docs/extension/api/overview

<Callout type="error" title="API deployment required">
  To enable communication between your WXT extension and the server in a production environment, the API **must** be deployed first. By default, it's hosted together with the [web app](/docs/web/api/overview), but you can also [deploy it separately](/docs/web/deployment/api).

  <Cards>
    <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

    <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />

    <Card title="API deployment" description="Deploy your API as a standalone service." href="/docs/web/deployment/api" />
  </Cards>
</Callout>

TurboStarter is designed to be a scalable and production-ready full-stack starter kit. One of its core features is a dedicated and extensible API layer. To enable this in a type-safe way, we chose [Hono](https://hono.dev) as the API server and client library.

<Callout title="Why Hono?">
  Hono is a small, simple, and ultrafast web framework that gives you a way to
  define your API endpoints with full type safety. It provides built-in
  middleware for common needs like validation, caching, and CORS. It also
  includes an [RPC client](https://hono.dev/docs/guides/rpc) for making
  type-safe function calls from the frontend. Being edge-first, it's optimized
  for serverless environments and offers excellent performance.
</Callout>

All API endpoints and their resolvers live in the `packages/api/` package. Inside, the `modules` folder contains the API's feature modules. Each module has its own directory and exports its resolvers.

For each module, we create a separate Hono router and aggregate all sub-routers into one main router in the `packages/api/index.ts` file.

By default, the API is integrated with the [web app](/docs/web/api/overview) and exposed as a [Next.js route handler](https://nextjs.org/docs/app/getting-started/route-handlers):

```ts title="apps/web/src/app/api/[...route]/route.ts"
import { handle } from "hono/vercel";

import { appRouter } from "@workspace/api";

const handler = handle(appRouter);
export {
  handler as GET,
  handler as POST,
  handler as OPTIONS,
  handler as PUT,
  handler as PATCH,
  handler as DELETE,
  handler as HEAD,
};
```

Learn more about how to use the API in your browser extension code in the following sections:


# Overview
Source: https://www.turbostarter.dev/docs/extension/auth/overview

TurboStarter uses [Better Auth](https://better-auth.com) to handle authentication. It's a secure, production-ready authentication solution that integrates seamlessly with many frameworks and provides enterprise-grade security out of the box.

<Callout title="Why Better Auth?">
  One of the core principles of TurboStarter is to do things **as simple as possible**, and to make everything **as performant as possible**.

  Better Auth provides an excellent developer experience with minimal configuration while keeping enterprise-grade security. Its framework-agnostic approach and focus on performance make it the perfect choice for TurboStarter.

  Recently, Better Auth [announced](https://better-auth.com/blog/authjs-joins-better-auth) an incorporation of [Auth.js (28k+ stars on GitHub)](https://authjs.dev/), making it even more powerful and flexible.
</Callout>

![Better Auth](/images/docs/better-auth.png)

You can read more about Better Auth in the [official documentation](https://better-auth.com/docs).

<Callout type="info" title="IMPORTANT: Shared authentication">
  To keep things simple and secure, **the extension shares the same authentication session with your web app.**

  This is a common approach used by popular services like [Notion](https://www.notion.so) and [Google Workspace](https://workspace.google.com/). The benefits include:

  * Users only need to sign in once through the web app
  * The extension automatically inherits the authenticated session
  * Sign out actions are synchronized across platforms
  * Reduced security surface area and complexity
</Callout>

Before setting up extension authentication, make sure to first [configure authentication for your web app](/docs/web/auth/overview) and then head back to the extension code.

The following sections cover everything you need to know about authentication in your extension:

<Cards>
  <Card title="Configuration" description="Configure authentication for your application." href="/docs/web/auth/configuration" />

  <Card title="User flow" description="Discover the authentication flow in Turbostarter." href="/docs/web/auth/flow" />

  <Card title="OAuth" description="Get started with social authentication." href="/docs/web/auth/oauth" />

  <Card title="Session" description="Learn how to manage auth session in your extension." href="/docs/extension/auth/session" />

  <Card title="Security" description="Permissions, trusted origins, and content-script isolation." href="/docs/extension/security/overview" />
</Cards>


# Session
Source: https://www.turbostarter.dev/docs/extension/auth/session

We're not implementing fully-featured auth flow in the extension. Instead, **we're sharing the same auth session with the web app.**

It's a common practice in the industry used e.g. by [Notion](https://www.notion.so) and [Google Workspace](https://workspace.google.com/).

That way, when the user is signed in to the web app, the extension can use the same session to authenticate the user, so he doesn't have to sign in again. Also signing out from the extension will affect both platforms.

<Callout title="Remember to add your extension scheme as trusted origin">
  For browser extensions, we need to define an [authentication trusted origin](https://better-auth.com/docs/reference/security#trusted-origins) using an extension scheme.

  Extension schemes (like `chrome-extension://...`) are used for redirecting users to specific screens after authentication and sharing the auth session with the web app.

  To find your extension ID, open Chrome and go to `chrome://extensions/`, enable Developer Mode in the top right, and look for your extension's ID. Then add it to your auth server configuration:

  ```ts title="server.ts"
  export const auth = betterAuth({
    ...

    trustedOrigins: ["chrome-extension://your-extension-id"],

    ...
  });
  ```

  Adding your extension scheme to the trusted origins list is crucial for security - it prevents CSRF attacks and blocks malicious open redirects by ensuring only requests from approved origins (your extension) are allowed through.

  [Read more about auth security in Better Auth's documentation.](https://better-auth.com/docs/reference/security)
</Callout>

## Cookies

When the user signs in to the [web app](/docs/web) through our [Better Auth API](/docs/web/auth/configuration#api), web app is setting the cookie with the session token under your app's domain, which is later used to validate the session on the server.

You can find your cookie in *Cookies* tab in the browser's developer tools (remember to be logged in to the app to check it):

![Session cookie](/images/docs/extension/auth/cookie.png)

To enable your extension to read the cookie and that way share the session with the web app, you need to set the `cookies` permission in the `wxt.config.ts` under `manifest.permissions` field:

```ts title="wxt.config.ts"
export default defineConfig({
  manifest: {
    permissions: ["cookies"],
  },
});
```

And to be able to read the cookie from your app url, you need to set `host_permissions`, which will include your app url:

```ts title="wxt.config.ts"
export default defineConfig({
  manifest: {
    host_permissions: ["http://localhost/*", "https://your-app-url.com/*"],
  },
});
```

Then you would be able to share the cookie with API requests and also read its value using `browser.cookies` API.

<Callout title="Avoid &#x22;<all_urls>&#x22;" type="warn">
  Avoid using `<all_urls>` in `host_permissions`. It affects all urls and may cause security issues, as well as a [rejection](https://developer.chrome.com/docs/webstore/review-process#review-time-factors) from the destination store.
</Callout>

<Cards>
  <Card title="Declare permissions" href="https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions" description="developer.chrome.com" />

  <Card title="chrome.cookies" href="https://developer.chrome.com/docs/extensions/reference/api/cookies" description="developer.chrome.com" />
</Cards>

## Reading session

You **don't** need to worry about reading, parsing, or validating the session cookie. TurboStarter comes with a pre-built solution that ensures your session is correctly shared with the web app.

It also ensures that appropriate cookies are passed to [API](/docs/web/api/overview) requests, so you can safely use [protected endpoints](/docs/web/api/protected-routes) (that require authentication) in your extension.

To get session details in your extension code (e.g., inside a popup window), you can leverage the `useSession` hook provided by the [auth client](https://better-auth.com/docs/basic-usage#client-side) (which is also widely used in the web and mobile apps):

```tsx title="user.tsx"
import { authClient } from "~/lib/auth";

const User = () => {
  const session = authClient.useSession();

  if (session.isPending) {
    return <p>Loading...</p>;
  }

  /* do something with the session data... */
  return <p>{session.data?.user?.email}</p>;
};
```

That's how you can access user details right in your extension.

## Signing out

Signing out from the extension also involves using the well-known `signOut` function that is derived from our [auth client](https://better-auth.com/docs/basic-usage#signout):

```tsx title="logout.tsx"
import { authClient } from "~/lib/auth";

export const Logout = () => {
  return <button onClick={() => authClient.signOut()}>Log out</button>;
};
```

The session is automatically invalidated, so the next use of `useSession` or any other query that depends on the session will return `null`. The UI for both the extension and the web app will be updated to show the user as logged out.

<Callout title="This will sign out the user from the web app as well" type="warn">
  As web app is using the same session cookie, the user will be signed out from the web app as well. **This is intentional**, as your extension will most probably serves as an add-on for the web app and it doesn't make sense to keep the user signed in there if the extension is not used.
</Callout>

For the security lens on trusted origins, cookies, and what not to store locally, see [Session & origins](/docs/extension/security/session).

![Sign out](/images/docs/web/auth/sign-out.png)


# Billing
Source: https://www.turbostarter.dev/docs/extension/billing

Billing for the browser extension is intentionally handled through the [web app](/docs/web/billing/overview), not inside the extension UI itself.

That means the usual flow is:

1. a user upgrades or purchases a plan in the web app
2. TurboStarter syncs that billing data through the shared billing system
3. the extension reads the current billing summary and unlocks features accordingly

This keeps checkout, billing portals, invoices, and provider-specific flows in the web app, while the extension only needs to react to the user's current billing state.

## Extension capabilities

The extension does not create checkout sessions or host pricing tables. Instead, it reads billing state from the shared API and uses that data to:

* show the current plan
* gate premium features — see the [extension feature-based access recipe](/docs/extension/recipes/feature-based-access)
* decide whether a user or organization has access
* link users back to the web dashboard when they need to upgrade

If your app supports [organizations](/docs/extension/organizations), billing in the extension can also be organization-aware.

## Fetching customer data

When your user has purchased a plan from your landing page or web app, you can easily fetch their data using the [API](/docs/extension/api/client).

To do so, just invoke the `summary` query on the `billing` router to get the summary of the user's billing data:

```tsx title="customer-screen.tsx"
import { getActivePlan } from "@workspace/billing";

import { api } from "~/lib/api";

export default function CustomerScreen() {
  const summary = useQuery({
    queryKey: ["summary"],
    queryFn: handle(api.billing.summary.$get),
  });

  if (summary.isLoading) {
    return <p>Loading...</p>;
  }

  const plan = getActivePlan(summary.data);

  return <p>{plan}</p>;
}
```

You may also want to ensure that user is logged in before fetching their billing data to avoid unnecessary API calls.

```tsx title="header.tsx"
import { api } from "~/lib/api";
import { authClient } from "~/lib/auth";

export const User = () => {
  const session = authClient.useSession();

  const summary = useQuery({
    queryKey: ["summary"],
    queryFn: handle(api.billing.summary.$get),
    enabled: !!session.data?.user, // [!code highlight]
  });

  if (!session.data?.user || !summary.data) {
    return null;
  }

  return (
    <div>
      <p>{session.data.user.email}</p>
      <p>{summary.data.length}</p>
    </div>
  );
};
```

Read more about [auth in extension](/docs/extension/auth/overview).

<Callout title="Use the right billing reference" type="warn">
  If your extension supports both personal and organization workflows, make sure you fetch billing summary for the correct `referenceId`. The active organization should usually take precedence over the personal account.

  For step-by-step gating patterns, upgrade links, and API enforcement, follow the [feature-based access recipe](/docs/extension/recipes/feature-based-access).
</Callout>


# App configuration
Source: https://www.turbostarter.dev/docs/extension/configuration/app

The application configuration is set at `apps/extension/src/config/app.ts`. This configuration stores some overall variables for your application.

This allows you to host multiple apps in the same monorepo, as every application defines its own configuration.

The recommendation is to **not update this directly** - instead, please define the environment variables and override the default behavior. The configuration is strongly typed so you can use it safely accross your codebase - it'll be validated at build time.

```ts title="apps/extension/src/config/app.ts"
import env from "env.config";

export const appConfig = {
  name: env.VITE_PRODUCT_NAME,
  url: env.VITE_SITE_URL,
  locale: env.VITE_DEFAULT_LOCALE,
  theme: {
    mode: env.VITE_THEME_MODE,
    color: env.VITE_THEME_COLOR,
  },
} as const;
```

For example, to set the extension default theme color, you'd update the following variable:

```dotenv title=".env.local"
VITE_THEME_COLOR="yellow"
```

<Callout type="warn" title="Do NOT use process.env!">
  Do NOT use `process.env` to get the values of the variables. Variables
  accessed this way are not validated at build time, and thus the wrong variable
  can be used in production.
</Callout>

## WXT config

To configure framework-specific settings, you can use the `wxt.config.ts` file. You can configure a lot of options there, such as [manifest](/docs/extension/configuration/manifest), [project structure](https://wxt.dev/guide/essentials/project-structure.html) or even [underlying Vite config](https://wxt.dev/guide/essentials/config/vite.html):

```ts title="wxt.config.ts"
import { defineConfig } from "wxt";

export default defineConfig({
  srcDir: "src",
  entrypointsDir: "app",
  outDir: "build",
  modules: [],
  manifest: {
    // Put manifest changes here
  },
  vite: () => ({
    // Override config here, same as `defineConfig({ ... })`
    // inside vite.config.ts files
  }),
});
```

Make sure to setup it correctly, as it's the main source of config for your development, build and publishing process.


# Environment variables
Source: https://www.turbostarter.dev/docs/extension/configuration/environment-variables

Environment variables are defined in the `.env` file in the root of the repository and in the root of the `apps/extension` package.

* **Shared environment variables**: Defined in the **root** `.env` file. These are shared between environments (e.g., development, staging, production) and apps (e.g., web, extension).
* **Environment-specific variables**: Defined in `.env.development` and `.env.production` files. These are specific to the development and production environments.
* **App-specific variables**: Defined in the app-specific directory (e.g., `apps/extension`). These are specific to the app and are not shared between apps.
* **Bundle-specific variables**: Specific to the [bundle target](https://wxt.dev/guide/essentials/config/environment-variables.html#built-in-environment-variables) (e.g. `.env.safari`, `.env.firefox`) or [bundle tag](https://wxt.dev/guide/essentials/config/environment-variables.html#built-in-environment-variables) (e.g. `.env.testing`)
* **Build environment variables**: Not stored in the `.env` file. Instead, they are stored in the environment variables of the CI/CD system.
* **Secret keys**: They're not stored on the extension side, instead [they're defined on the web side.](/docs/web/configuration/environment-variables#secret-keys)

## Shared variables

Here you can add all the environment variables that are shared across all the apps.

To override these variables in a specific environment, please add them to the specific environment file (e.g. `.env.development`, `.env.production`).

```dotenv title=".env.local"
# Shared environment variables

# The database URL is used to connect to your database.
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"

# The name of the product. This is used in various places across the apps.
PRODUCT_NAME="TurboStarter"

# The url of the web app. Used mostly to link between apps.
URL="http://localhost:3000"

...
```

## App-specific variables

Here you can add all the environment variables that are specific to the app (e.g. `apps/extension`).

You can also override the shared variables defined in the root `.env` file.

```dotenv title="apps/extension/.env.local"
# App-specific environment variables

# Env variables extracted from shared to be exposed to the client in WXT (Vite) extension
VITE_SITE_URL="${URL}"
VITE_DEFAULT_LOCALE="${DEFAULT_LOCALE}"

# Theme mode and color
VITE_THEME_MODE="system"
VITE_THEME_COLOR="orange"

...
```

<Callout title="VITE_ prefix">
  To make environment variables available in the browser extension code, you need to prefix them with `VITE_`. They will be injected to the code during the build process.

  Only environment variables prefixed with `VITE_` will be injected.

  [Read more about Vite environment variables.](https://vite.dev/guide/env-and-mode.html#env-files)
</Callout>

## Bundle-specific variables

WXT also provides environment variables specific to a certain [build target](https://wxt.dev/guide/essentials/config/environment-variables.html#built-in-environment-variables) or [build tag](https://wxt.dev/guide/essentials/config/environment-variables.html#built-in-environment-variables) when creating the final bundle. Given the following build command:

```json title="package.json"
"scripts": {
  "build": "wxt build -b firefox --mode testing"
}
```

The following env files will be considered, ordered by priority:

* `.env.firefox`
* `.env.testing`
* `.env`

You shouldn't worry much about this, as TurboStarter comes with already configured build processes for all the major browsers.

## Build environment variables

To allow your extension to build properly on CI you need to define your environment variables on your CI/CD system (e.g. [Github Actions](https://docs.github.com/en/actions/learn-github-actions/environment-variables)).

TurboStarter comes with predefined Github Actions workflow used to build and submit your extension to the stores. It's located in `.github/workflows/publish-extension.yml` file.

To correctly set up the build environment variables, you need to define them under `env` section and then add them as a [secrets](http://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) to your repository.

```yaml title="publish-extension.yml"
...

jobs:
  extension:
    name: 🚀 Publish extension
    runs-on: ubuntu-latest
    environment: Production
    env:
      VITE_SITE_URL: ${{ secrets.SITE_URL }}

  ...

```

We'll go through the whole process of building and publishing the extension in the [publishing guide](/docs/extension/publishing/checklist).

## Secret keys

Secret keys and sensitive information are to be **never** stored on the extension app code.

<Callout title="What does this mean?">
  It means that you will need to add the secret keys to the **web app, where the API is deployed.**

  The browser extension should only communicate with the backend API, which is typically part of the web app. The web app is responsible for handling sensitive operations and storing secret keys securely.

  [See web documentation for more details.](/docs/web/configuration/environment-variables#secret-keys)

  This is not a TurboStarter-specific requirement, but a best practice for security for any
  application. Ultimately, it's your choice.
</Callout>


# Manifest
Source: https://www.turbostarter.dev/docs/extension/configuration/manifest

As a requirement from web stores, every extension must have a `manifest.json` file in its root directory that lists important information about the structure and behavior of that extension.

It's a JSON file that contains metadata about the extension, such as its name, version, and permissions.

You can read more about it in the [official documentation](https://developer.chrome.com/docs/extensions/reference/manifest).

## Where is the `manifest.json` file?

WXT **abstracts away** the manifest file. The framework generates the manifest under the hood based on your source files and configurations you export from your code, similar to how Next.js abstracts page routing and SSG with the file system and page components.

That way, you don't have to manually create the `manifest.json` file and worry about correctly setting all the fields.

Most of the common properties are taken from the `package.json` and `wxt.config.ts` files:

| Manifest Field           | Abstractions                                                  |
| ------------------------ | ------------------------------------------------------------- |
| icons                    | Auto generated with the `icon.png` in the `/assets` directory |
| action, browser\_actions | Popup window                                                  |
| options\_ui              | Options page                                                  |
| content\_scripts         | Content scripts                                               |
| background               | Background service worker                                     |
| version                  | set by the `version` field in `package.json`                  |
| name                     | set by the `name` field in `wxt.config.ts`                    |
| description              | set by the `description` field in `wxt.config.ts`             |
| author                   | set by the `author` field in `wxt.config.ts`                  |
| homepage\_url            | set by the `homepage` field in `wxt.config.ts`                |

WXT build process centralizes common metadata and resolves any static file references (such as popup, background, content scripts, and so on) automatically.

This enables you to focus on the metadata that matters, such as name, description, OAuth, and so on.

## Overriding manifest

Sometimes, you want to override the default manifest fields (e.g. because you need to add a new permission that is required for your extension to work).

You'll need to modify your project's `wxt.config.ts` like so:

```ts title="apps/extension/wxt.config.ts"
export default defineConfig({
  manifest: {
    permissions: ["activeTab"],
  },
});
```

Then, your settings will be merged with the settings auto-generated by WXT.

### Environment variables

You can use environment variables inside the manifest overrides:

```ts title="apps/extension/wxt.config.ts"
export default defineConfig({
  manifest: {
    browser_specific_settings: {
      gecko: {
        id: import.meta.env.VITE_FIREFOX_EXT_ID,
      },
    },
  },
});
```

If the environment variable could not be found, the field will be removed completely from the manifest.

### Locales

TurboStarter extension supports [extension localization](https://developer.chrome.com/docs/extensions/reference/api/i18n) out-of-the-box. You can customize e.g. your extension's name and description based on the language of the user's browser.

Locales are defined in the `/public/_locales` directory. The directory should contain a `messages.json` file for each language you want to support (e.g. `/public/_locales/en/messages.json` and `/public/_locales/es/messages.json`).

By default, the first locale alphabetically available is used as default. However, you can specify a `default_locale` in your manifest like so:

```ts title="apps/extension/wxt.config.ts"
export default defineConfig({
  manifest: {
    default_locale: "en",
  },
});
```

To reference a locale string inside your manifest overrides, wrap the key inside `__MSG_<key>__`:

```ts title="apps/extension/wxt.config.ts"
export default defineConfig({
  manifest: {
    name: "__MSG_extensionName__",
    description: "__MSG_extensionDescription__",
  },
});
```

Apart of this, we also configure [in-extension internationalization](/docs/extension/internationalization) out-of-the-box to easily translate your components and views.

For least-privilege permissions and store review risks, see [Security → Permissions](/docs/extension/security/permissions).

<Cards>
  <Card title="Manifest file format" href="https://developer.chrome.com/docs/extensions/reference/manifest" description="developer.chrome.com" />

  <Card title="WXT Manifest" href="https://wxt.dev/guide/essentials/config/manifest.html" description="wxt.dev" />

  <Card title="chrome.i18n" href="https://developer.chrome.com/docs/extensions/reference/api/i18n" description="developer.chrome.com" />

  <Card title="WXT i18n" href="https://wxt.dev/guide/essentials/i18n.html" description="wxt.dev" />

  <Card title="Security" href="/docs/extension/security/overview" description="Permissions, session sharing, and content scripts." />
</Cards>


# Adding apps
Source: https://www.turbostarter.dev/docs/extension/customization/add-app

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new app to your TurboStarter project within your monorepo and want to keep pulling updates from the TurboStarter repository.
</Callout>

In some ways - creating a new repository may be the easiest way to manage your application. However, if you want to keep your application within the monorepo and pull updates from the TurboStarter repository, you can follow these instructions.

To pull updates into a separate application outside of `extension` - we can use [git subtree](https://www.atlassian.com/git/tutorials/git-subtree).

Basically, we will create a subtree at `apps/extension` and create a new remote branch for the subtree. When we create a new application, we will pull the subtree into the new application. This allows us to keep it in sync with the `apps/extension` folder.

To add a new app to your TurboStarter project, you need to follow these steps:

<Steps>
  <Step>
    ## Create a subtree

    First, we need to create a subtree for the `apps/extension` folder. We will create a branch named `extension-branch` and create a subtree for the `apps/extension` folder.

    ```bash
    git subtree split --prefix=apps/extension --branch extension-branch
    ```
  </Step>

  <Step>
    ## Create a new app

    Now, we can create a new application in the `apps` folder.

    Let's say we want to create a new app `ai-chat` at `apps/ai-chat` with the same structure as the `apps/extension` folder (which acts as the template for all new apps).

    ```bash
    git subtree add --prefix=apps/ai-chat origin extension-branch --squash
    ```

    You should now be able to see the `apps/ai-chat` folder with the contents of the `apps/extension` folder.
  </Step>

  <Step>
    ## Update the app

    When you want to update the new application, follow these steps:

    ### Pull the latest updates from the TurboStarter repository

    The command below will update all the changes from the TurboStarter repository:

    ```bash
    git pull upstream main
    ```

    ### Push the `extension-branch` updates

    After you have pulled the updates from the TurboStarter repository, you can split the branch again and push the updates to the extension-branch:

    ```bash
    git subtree split --prefix=apps/extension --branch extension-branch
    ```

    Now, you can push the updates to the `extension-branch`:

    ```bash
    git push origin extension-branch
    ```

    ### Pull the updates to the new application

    Now, you can pull the updates to the new application:

    ```bash
    git subtree pull --prefix=apps/ai-chat origin extension-branch --squash
    ```
  </Step>
</Steps>

That's it! You now have a new application in the monorepo 🎉


# Adding packages
Source: https://www.turbostarter.dev/docs/extension/customization/add-package

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new package to your TurboStarter application instead of adding a folder to your application in `apps/web` or modify existing packages under `packages`. You don't need to do this to add a new page or component to your application.
</Callout>

To add a new package to your TurboStarter application, you need to follow these steps:

<Steps>
  <Step>
    ## Generate a new package

    First, enter the command below to create a new package in your TurboStarter application:

    ```bash
    turbo gen package
    ```

    Turborepo will ask you to enter the name of the package you want to create. Enter the name of the package you want to create and press enter.

    If you don't want to add dependencies to your package, you can skip this step by pressing enter.

    The command will have generated a new package under packages named `@workspace/<package-name>`. If you named it `example`, the package will be named `@workspace/example`.
  </Step>

  <Step>
    ## Export a module from your package

    By default, the package exports a single module using the `index.ts` file. You can add more exports by creating new files in the package directory and exporting them from the `index.ts` file or creating export files in the package directory and adding them to the `exports` field in the `package.json` file.

    ### From `index.ts` file

    The easiest way to export a module from a package is to create a new file in the package directory and export it from the `index.ts` file.

    ```ts title="packages/example/src/module.ts"
    export function example() {
      return "example";
    }
    ```

    Then, export the module from the `index.ts` file.

    ```ts title="packages/example/src/index.ts"
    export * from "./module";
    ```

    ### From `exports` field in `package.json`

    **This can be very useful for tree-shaking.** Assuming you have a file named `module.ts` in the package directory, you can export it by adding it to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./module": "./src/module.ts"
      }
    }
    ```

    **When to do this?**

    1. when exporting two modules that don't share dependencies to ensure better tree-shaking. For example, if your exports contains both client and server modules.
    2. for better organization of your package

    For example, create two exports `client` and `server` in the package directory and add them to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./client": "./src/client.ts",
        "./server": "./src/server.ts"
      }
    }
    ```

    1. The `client` module can be imported using `import { client } from '@workspace/example/client'`
    2. The `server` module can be imported using `import { server } from '@workspace/example/server'`
  </Step>

  <Step>
    ## Use the package in your extension

    You can now use the package in your extension by importing it using the package name:

    ```ts title="app/popup/index.tsx"
    import { example } from "@workspace/example";

    console.log(example());
    ```
  </Step>
</Steps>

Et voilà! You have successfully added a new package to your TurboStarter extension. 🎉


# Styling
Source: https://www.turbostarter.dev/docs/extension/customization/styling

To build the extension interface TurboStarter comes with [Tailwind CSS](https://tailwindcss.com/) and [Base UI](https://base-ui.com) pre-configured.

<Callout title="Why Tailwind CSS and Base UI?" type="info">
  The combination of Tailwind CSS and Base UI gives ready-to-use, accessible UI components that can be fully customized to match your brands design.
</Callout>

## Tailwind configuration

In the `packages/ui/shared/src/styles` directory, you will find shared CSS files with Tailwind CSS configuration. To change global styles, you can edit the files in this folder.

Here is an example of a shared CSS file that includes the Tailwind CSS configuration:

```css title="packages/ui/shared/src/styles/globals.css"
@import "tailwindcss";
@import "./themes.css";

@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.65rem;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);

  ...
}
```

For colors, we rely strictly on [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) in [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) format to allow for easy theme management without the need for any JavaScript.

Also, each app has its own `globals.css` file, which extends the shared config and allows you to override the global styles.

Here is an example of an extension's `globals.css` file:

```css title="apps/extension/src/assets/styles/globals.css"
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap");
@import "@workspace/ui-web/globals.css";

@theme {
  --font-sans: "Geist", sans-serif;
  --font-mono: "Geist Mono", monospace;
}
```

This way, we maintain a separation of concerns and a clear structure for the Tailwind CSS configuration.

## Themes

TurboStarter comes with **9+** predefined themes, which you can use to quickly change the look and feel of your app.

They're defined in the `packages/ui/shared/src/styles/themes` directory. Each theme is a set of variables that can be overridden:

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {
    background: [1, 0, 0],
    foreground: [0.141, 0.005, 285.823],
    card: [1, 0, 0],
    "card-foreground": [0.141, 0.005, 285.823],
    ...
  }
} satisfies ThemeColors;
```

Each variable is stored as a [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) array, which is then converted to a CSS variable at build time (by our custom build script). That way we can ensure full type-safety and reuse themes across different parts of our apps (e.g. use the same theme in emails).

Feel free to add your own themes or override the existing ones to match your brand's identity.

To apply a theme to your app, you can use the `data-theme` attribute on your layout wrapper for each part of the extension:

```tsx title="modules/common/layout/layout.tsx"
import { StorageKey, useStorage } from "~/lib/storage";

export const Layout = ({ children }: { children: React.ReactNode }) => {
  const { data } = useStorage(StorageKey.THEME);

  return (
    <div id="main" data-theme={data.color}>
      {children}
    </div>
  );
};
```

In TurboStarter, we're using [Storage API](/docs/extension/structure/storage) to persist the user's theme selection and then apply it to the `div#main` element.

## Dark mode

The starter kit comes with a built-in dark mode support.

Each theme has a corresponding dark mode variables which are used to change the theme to its dark mode counterpart.

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {},
  dark: {
    background: [0.141, 0.005, 285.823],
    foreground: [0.985, 0, 0],
    card: [0.21, 0.006, 285.885],
    "card-foreground": [0.985, 0, 0],
    ...
  }
} satisfies ThemeColors;
```

Because the dark variant is defined to use a class (`@custom-variant dark (&:is(.dark *))`) in the shared Tailwind configuration, we need to add the `dark` class to the root element to apply dark mode styles.

The same as for the theme color, we're using here the [Storage API](/docs/extension/structure/storage) to persist the user's dark mode selection and then apply correct class name to the root `div` element:

```tsx title="modules/common/layout/layout.tsx"
import { StorageKey, useStorage } from "~/lib/storage";

export const Layout = ({ children }: { children: React.ReactNode }) => {
  const { data } = useStorage(StorageKey.THEME);

  return (
    <div
      id="root"
      className={cn({
        dark:
          data.mode === THEME_MODE.DARK ||
          (data.mode === THEME_MODE.SYSTEM &&
            window.matchMedia("(prefers-color-scheme: dark)").matches),
      })}
    >
      {children}
    </div>
  );
};
```

You can also define the default theme mode and color in the [app configuration](/docs/extension/configuration/app).

<Cards>
  <Card title="Tailwind CSS" description="tailwindcss.com" href="https://tailwindcss.com/" />

  <Card title="Base UI" description="base-ui.com" href="https://base-ui.com/" />
</Cards>


# Database
Source: https://www.turbostarter.dev/docs/extension/database

<Callout type="error" title="API deployment required">
  To enable communication between your WXT extension and the server in a production environment, the web application with Hono API must be deployed first.

  <Cards>
    <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

    <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />
  </Cards>
</Callout>

As browser extensions use only client-side code, **there's no way to interact with the database directly**.

Also, you should avoid any workarounds to interact with the database directly, because it can lead to leaking your database credentials and other security issues.

## Recommended approach

You can safely use the [API](/docs/extension/api/overview) and invoke procedures which will run queries on the database.

To do this you need to set up the database on the [web, server side](/docs/web/database/overview) and then use the [API client](/docs/extension/api/client) to interact with it.

Learn more about its configuration in the web part of the docs, especially in the following sections:

<Cards>
  <Card title="Overview" description="Get started with the database" href="/docs/web/database/overview" />

  <Card title="Schema" description="Learn about the database schema." href="/docs/web/database/schema" />

  <Card title="Migrations" description="Migrate your changes to the database." href="/docs/web/database/migrations" />

  <Card title="Database client" description="Use database client to interact with the database." href="/docs/web/database/client" />

  <Card title="SQLite" description="Switch the project from PostgreSQL to SQLite." href="/docs/web/database/sqlite" />

  <Card title="MySQL" description="Switch the project from PostgreSQL to MySQL." href="/docs/web/database/mysql" />
</Cards>


# Configuration
Source: https://www.turbostarter.dev/docs/extension/flags/configuration

The `@workspace/flags-extension` package wraps OpenFeature providers behind a single client strategy. Swap the active provider by changing the re-exports in `packages/flags/extension/src/providers/index.ts`, then set any env vars that provider needs.

<Callout>
  The default provider is **in-memory**. You can evaluate `Flag.DEMO` while developing the extension with no third-party account. Connect PostHog or GrowthBook when you need remote targeting or a dashboard.
</Callout>

## Providers

You can configure feature flag providers according to your needs—local development, testing, or advanced remote rollouts. This guide walks you through switching providers and the configuration steps for each option.

Choose the provider that best matches your use case:

* **In-memory:** Best for fast local development or simple toggles.
* **PostHog:** Enables remote feature control and audience targeting.
* **GrowthBook:** Another powerful platform for remote flag management.

Read on to learn how to set up and activate each provider.

<Accordions>
  <Accordion title="In-memory" id="in-memory">
    Use this for local development and simple toggles that live in code. Flag definitions come from `packages/flags/shared/src/in-memory.ts`:

    ```ts title="packages/flags/shared/src/in-memory.ts"
    export const inMemoryConfig = {
      [Flag.DEMO]: {
        disabled: false,
        variants: {
          on: true,
          off: false,
        },
        defaultVariant: "on",
      },
    } as const;
    ```

    With this config, `Flag.DEMO` evaluates to `true`. Flip `defaultVariant` to `"off"` to hide the demo banner without changing UI code.

    Activate the provider (already the default):

    ```ts title="packages/flags/extension/src/providers/index.ts"
    // [!code word:in-memory]
    export * from "./in-memory";
    export * from "./in-memory/env";
    ```

    No environment variables are required. Customize under `packages/flags/extension/src/providers/in-memory`.
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout title="Reuse your PostHog project">
      If you already use PostHog for [analytics](/docs/extension/analytics/configuration#posthog) or [monitoring](/docs/extension/monitoring/posthog), the same `VITE_POSTHOG_KEY` and host power feature flags.
    </Callout>

    1. Create or open a [PostHog](https://app.posthog.com/signup) project (Cloud or [self-hosted](https://posthog.com/docs/self-host)).
    2. Copy the project API key and host from [project settings](https://app.posthog.com/project/settings).
    3. Create a feature flag whose key matches your app constant (for example `demo` for `Flag.DEMO`).

    Set the env vars in `apps/extension/.env` (or `.env.local`) and your build environment:

    ```dotenv
    VITE_POSTHOG_KEY="your-posthog-api-key"
    VITE_POSTHOG_HOST="https://us.i.posthog.com"
    ```

    Activate PostHog as the flags provider:

    ```ts title="packages/flags/extension/src/providers/index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```

    The extension uses PostHog's web OpenFeature provider with analytics-light init (autocapture and pageviews off, `localStorage` persistence). That keeps flag evaluation light inside popup and options pages.

    Customize under `packages/flags/extension/src/providers/posthog`.

    <Cards>
      <Card title="PostHog feature flags" href="https://posthog.com/docs/feature-flags" description="posthog.com" />

      <Card title="PostHog OpenFeature" href="https://posthog.com/docs/libraries/openfeature" description="posthog.com" />
    </Cards>

    ![PostHog feature flags dashboard](/images/docs/web/flags/posthog.png)
  </Accordion>

  <Accordion title="GrowthBook" id="growthbook">
    GrowthBook is a dedicated feature-flag and experimentation platform. Use it when you want rich targeting without tying flags to analytics.

    1. Create a [GrowthBook](https://app.growthbook.io/) account (or self-host).
    2. Create an SDK connection and copy the **client key**.
    3. Create a feature whose key matches your app constant (for example `demo`).

    Set the env vars in `apps/extension/.env` (or `.env.local`) and your build environment:

    ```dotenv
    VITE_GROWTHBOOK_CLIENT_KEY="your-growthbook-client-key"
    VITE_GROWTHBOOK_API_HOST="https://cdn.growthbook.io"
    ```

    Activate GrowthBook as the flags provider:

    ```ts title="packages/flags/extension/src/providers/index.ts"
    // [!code word:growthbook]
    export * from "./growthbook";
    export * from "./growthbook/env";
    ```

    Evaluation uses `@openfeature/growthbook-client-provider`. Customize under `packages/flags/extension/src/providers/growthbook`.

    <Card title="GrowthBook docs" href="https://docs.growthbook.io/" description="docs.growthbook.io" />

    ![GrowthBook features dashboard](/images/docs/web/flags/growthbook.png)
  </Accordion>
</Accordions>

## Flags provider

Hooks need OpenFeature in the React tree. The kit already mounts `FlagsProvider` from `apps/extension/src/lib/providers/flags.tsx` inside the shared layout providers. It also syncs the signed-in user into targeting context:

```tsx title="apps/extension/src/lib/providers/flags.tsx"
import { useEffect } from "react";

import {
  clearContext,
  FlagsProvider as Provider,
  setContext,
} from "@workspace/flags-extension";

import { authClient } from "~/lib/auth";

export const FlagsProvider = ({ children }: { children: React.ReactNode }) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    if (session.data?.user) {
      const { id, email, name } = session.data.user;
      void setContext({ targetingKey: id, email, name });
      return;
    }

    void clearContext();
  }, [session]);

  return <Provider>{children}</Provider>;
};
```

After that, hooks from `@workspace/flags` work in options, popup, and other React UI. More detail in [Usage](/docs/extension/flags/usage).


# Overview
Source: https://www.turbostarter.dev/docs/extension/flags/overview

Feature flags let you change extension behavior without waiting on store review. Gate a new options panel, roll out a content-script tweak to a cohort, or experiment on popup copy while the packaged build stays stable.

TurboStarter uses [OpenFeature](https://openfeature.dev/) so evaluation stays provider-agnostic. Swap between the built-in in-memory provider, [PostHog](https://posthog.com/docs/feature-flags), or [GrowthBook](https://docs.growthbook.io/) without rewriting your UI.

Out of the box boilerplate provides you with:

* Shared flag keys in `@workspace/flags` (`Flag.DEMO` ships as a working example)
* Platform package `@workspace/flags-extension` with React hooks for the extension UI
* A `FlagsProvider` that syncs targeting context from the signed-in user (`targetingKey`, `email`, `name`)
* In-memory provider as the default (no account required for local development)
* Optional PostHog and GrowthBook providers, swapped by changing provider exports

The demo flag is already evaluated on the **Options** page. With the default in-memory config it shows a banner that links back here, so you can verify evaluation before connecting a remote provider.

<Callout type="info" title="Client-only in the extension">
  Unlike the [web kit](/docs/web/flags/overview), the extension evaluates flags on the client. Prefer hooks such as `useBooleanFlagValue` in popup, options, and other React surfaces.
</Callout>

## Architecture

Flags live next to analytics in the monorepo:

<Files>
  <Folder name="packages/flags" defaultOpen>
    <Folder name="shared - Shared keys and hooks" defaultOpen>
      <File name="keys.ts - Flag key constants" />

      <File name="in-memory.ts - Default local definitions" />

      <File name="react.tsx - createFlagsReact + hooks" />
    </Folder>

    <Folder name="extension - @workspace/flags-extension" defaultOpen>
      <File name="index.tsx - FlagsProvider, setContext, clearContext" />

      <Folder name="providers - in-memory / posthog / growthbook" />
    </Folder>
  </Folder>
</Files>

App wiring sits in `apps/extension/src/lib/providers/flags.tsx` and mounts with the shared layout providers. When a session appears, context is set; on logout it is cleared. The PostHog strategy identifies users for person-based rules without turning on full web autocapture (better for extension surfaces).

## Providers

TurboStarter includes native support for multiple feature flag providers and offers a unified API for flag evaluation. This approach allows you to monitor feature usage and user behavior consistently throughout your extension.

For details on configuring each provider, refer to their sections below:

<Cards>
  <Card title="In-memory" href="/docs/extension/flags/configuration#in-memory" description="Local defaults, zero config. Great for development." />

  <Card title="PostHog" href="/docs/extension/flags/configuration#posthog" description="Flags next to analytics and monitoring." />

  <Card title="GrowthBook" href="/docs/extension/flags/configuration#growthbook" description="Dedicated experimentation and targeting." />
</Cards>

Configuration and setup are seamlessly integrated with a unified API, so you can switch providers just by changing the exports. You can also add custom providers without affecting any flags-related logic.

In the following sections, you'll learn how to set up each provider and how to evaluate flags in your application.


# Usage
Source: https://www.turbostarter.dev/docs/extension/flags/usage

Once a provider is active, reading a flag is a one-liner with OpenFeature hooks from `@workspace/flags`.

## Flag keys

Keys live in one place so web, mobile, and extension stay aligned:

```ts title="packages/flags/shared/src/keys.ts"
export const Flag = {
  DEMO: "demo",
} as const;
```

Import `Flag` from `@workspace/flags` and pass the constant into hooks.

## Evaluate in the UI

```tsx
import { Flag, useBooleanFlagValue } from "@workspace/flags";

export const BetaNotice = () => {
  const enabled = useBooleanFlagValue(Flag.DEMO, false);

  if (!enabled) {
    return null;
  }

  return <p>Beta features unlocked</p>;
};
```

The options page uses the same pattern for the demo banner:

```tsx title="apps/extension/src/app/options/main.tsx"
const DemoBanner = () => {
  const demo = useBooleanFlagValue(Flag.DEMO, false);

  if (!demo) {
    return null;
  }

  // …
};
```

Other value types:

| Hook                  | Typical use                          |
| --------------------- | ------------------------------------ |
| `useBooleanFlagValue` | On/off gates                         |
| `useStringFlagValue`  | Variant copy, theme names, URLs      |
| `useNumberFlagValue`  | Limits, percentages, experiment arms |
| `useObjectFlagValue`  | Structured payloads / config blobs   |

Always pass a sensible **default** as the second argument. That value is used while the provider loads, or if evaluation fails.

## Targeting context

`apps/extension/src/lib/providers/flags.tsx` already syncs auth state into OpenFeature:

```tsx title="apps/extension/src/lib/providers/flags.tsx"
if (session.data?.user) {
  const { id, email, name } = session.data.user;
  void setContext({ targetingKey: id, email, name });
  return;
}

void clearContext();
```

You rarely need to call `setContext` / `clearContext` yourself. Import them from `@workspace/flags-extension` when you do.

With PostHog, context sync maps to `identify` / `reset` so person-based rules match your signed-in user.

## Add a new flag

<Steps>
  <Step>
    ## Declare the key

    Add a constant in `packages/flags/shared/src/keys.ts`:

    ```ts
    export const Flag = {
      DEMO: "demo",
      NEW_SIDE_PANEL: "NEW_SIDE_PANEL", // [!code ++]
    } as const;
    ```
  </Step>

  <Step>
    ## Update in-memory defaults

    Give local development a known value in `packages/flags/shared/src/in-memory.ts`:

    ```ts
    [Flag.NEW_SIDE_PANEL]: {
      disabled: false,
      variants: { on: true, off: false },
      defaultVariant: "off",
    },
    ```
  </Step>

  <Step>
    ## Create it remotely (if needed)

    In PostHog or GrowthBook, create a flag with the **same key** (`NEW_SIDE_PANEL`). Configure rollouts and targeting there.
  </Step>

  <Step>
    ## Evaluate it in the UI

    ```tsx
    const showSidePanel = useBooleanFlagValue(Flag.NEW_SIDE_PANEL, false);
    ```
  </Step>
</Steps>

## Troubleshooting

| Symptom                      | What to check                                                                                                                                                           |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Demo banner never appears    | In-memory `defaultVariant` is `"on"` by default. For PostHog/GrowthBook, ensure a remote flag named `demo` exists and targets your user.                                |
| Flag stuck on the default    | Confirm the provider export in `packages/flags/extension/src/providers/index.ts` matches the env vars you set. Restart the Vite extension dev server after env changes. |
| Remote rules ignore the user | Sign in so `targetingKey` is set. Check PostHog person / GrowthBook attributes.                                                                                         |
| Wrong env prefix             | Extension uses `VITE_POSTHOG_*` / `VITE_GROWTHBOOK_*`, not `NEXT_PUBLIC_` or `EXPO_PUBLIC_`.                                                                            |

<Cards>
  <Card title="Configuration" href="/docs/extension/flags/configuration" description="Switch providers and set Vite env vars." />

  <Card title="OpenFeature React SDK" href="https://openfeature.dev/docs/reference/technologies/client/web/react" description="openfeature.dev" />
</Cards>


# Introduction
Source: https://www.turbostarter.dev/docs/extension

Welcome to the TurboStarter **browser extension** documentation. This guide covers the WXT + Vite extension - popup, side panel, content scripts, background worker, shared web sessions, and multi-store publishing.

<ThemedImage light="/images/docs/demo/light.webp" dark="/images/docs/demo/dark.webp" alt="TurboStarter demo" width={2311} height={1562} zoomable priority fetchPriority="high" />

The extension is intentionally thin. Auth and checkout live on [web](/docs/web); the extension inherits the session and calls the same API. [Mobile](/docs/mobile) shares that backend too - use those docs when you need native-only topics (push, IAP). Here we focus on what is unique to the extension runtime.

Looking to bootstrap quickly? Check out the [TurboStarter CLI guide](/blog/the-only-turbo-cli-you-need-to-start-your-next-project-in-seconds).

## Demo apps

Try the live Chrome, Firefox, and Edge extensions (web and mobile demos are available too):

<DemoBadges
  urls={{
  android:
    "https://play.google.com/store/apps/details?id=com.turbostarter.core",
  ios: "https://apps.apple.com/us/app/turbostarter/id6754278899",
  chrome:
    "https://chromewebstore.google.com/detail/turbostarter/bcjmonmlfbnngpkllpnpmnjajaciaboo",
  firefox: "https://addons.mozilla.org/en-US/firefox/addon/turbostarter_",
  edge: "https://microsoftedge.microsoft.com/addons/detail/turbostarter/ianbflanmmoeleokihabnmmcahhfijig",
  web: "https://demo.turbostarter.dev",
}}
/>

## Philosophy

* **As simple as possible** - easy to understand, easy to use, no overengineering.
* **As few dependencies as possible** - stay in control of every part of the project.
* **As performant as possible** - fast and light without unnecessary overhead.

## Features

Extension-first capabilities below. For AI surfaces that open from the browser, see [TurboStarter AI](/ai/docs) and the [extension AI guide](/docs/extension/ai).

### Extension structure

Every entrypoint lives under `apps/extension/src/app` - file-based routing for WXT.

<Cards>
  <Card title="Structure overview" description="Popup, options, side panel, new tab, content scripts, background, and more." href="/docs/extension/structure/overview" />

  <Card title="Pages & UI surfaces" description="Popup, options, side panel, new tab, and custom tab pages." href="/docs/extension/structure/pages" />

  <Card title="Content scripts & CSUI" description="Inject scripts and shadow-root React UI into web pages safely." href="/docs/extension/structure/content-scripts" />

  <Card title="Background worker" description="Service worker for long-lived logic, alarms, and event listeners." href="/docs/extension/structure/background" />

  <Card title="Message passing" description="Typed messaging between popup, content scripts, and background." href="/docs/extension/structure/messaging" />

  <Card title="Extension storage" description="Persist local state with browser.storage for first-run flags and caches." href="/docs/extension/structure/storage" />
</Cards>

### Shared session authentication

Users sign in once on the web app; the extension inherits that session - the same pattern as Notion and Google Workspace.

<Cards>
  <Card title="Auth overview" description="Why the extension shares Better Auth with the web app." href="/docs/extension/auth/overview" />

  <Card title="Session handling" description="Detect session state, open web login, and stay in sync on sign-out." href="/docs/extension/auth/session" />

  <Card title="Trusted origins" description="Wire extension IDs and schemes so cookies and CSRF stay valid." href="/docs/extension/auth/session" />

  <Card title="Web auth setup" description="Configure Better Auth on web first - required before extension auth works." href="/docs/web/auth/overview" />
</Cards>

### Billing & entitlements

Checkout stays on the web. The extension reads active plan and gates premium UI.

<Cards>
  <Card title="Extension billing" description="Plan badges, upgrade links, and how the extension consumes entitlements." href="/docs/extension/billing" />

  <Card title="Feature-based access" description="Gate popup and side-panel features by plan - enforce on the API too." href="/docs/extension/recipes/feature-based-access" />

  <Card title="Onboarding recipe" description="First-run welcome, web sign-in, and plan-gated empty states." href="/docs/extension/recipes/onboarding" />

  <Card title="Web billing (source of truth)" description="Subscriptions, providers, and webhooks that power plan state." href="/docs/web/billing/overview" />
</Cards>

### Configuration & manifest

<Cards>
  <Card title="App configuration" description="WXT config, branding, and extension-wide settings." href="/docs/extension/configuration/app" />

  <Card title="Environment variables" description="API URLs and secrets for local, staging, and production builds." href="/docs/extension/configuration/environment-variables" />

  <Card title="Manifest" description="Permissions, entry points, and store-ready metadata via WXT." href="/docs/extension/configuration/manifest" />

  <Card title="Multiple environments" description="Dev, staging, and production builds for multi-browser targets." href="/docs/extension/recipes/multiple-environments" />
</Cards>

### Publishing to stores

<Cards>
  <Card title="Publishing checklist" description="Privacy, permissions, and listing requirements before you submit." href="/docs/extension/publishing/checklist" />

  <Card title="Chrome Web Store" description="Package, upload, and publish to the Chrome Web Store." href="/docs/extension/publishing/chrome" />

  <Card title="Firefox Add-ons" description="Submit to AMO with Firefox-specific packaging notes." href="/docs/extension/publishing/firefox" />

  <Card title="Edge Add-ons" description="Publish to the Microsoft Edge Add-ons store." href="/docs/extension/publishing/edge" />

  <Card title="Updates" description="Ship new versions and update channels across browsers." href="/docs/extension/publishing/updates" />

  <Card title="Marketing & store listing" description="Listing copy, screenshots, and go-to-market tips for extensions." href="/docs/extension/marketing" />
</Cards>

### Organizations & API client

Same orgs and typed client as web - consumed from popup, options, and background.

<Cards>
  <Card title="Organizations" description="Active organization and membership in the extension UI." href="/docs/extension/organizations" />

  <Card title="API overview" description="How the extension talks to the shared serverless API." href="/docs/extension/api/overview" />

  <Card title="Typesafe client" description="Fully typed client for queries and mutations from any entrypoint." href="/docs/extension/api/client" />

  <Card title="Database" description="Shared schema via the API - the extension never talks to the DB directly." href="/docs/extension/database" />
</Cards>

### AI

<Cards>
  <Card title="Server-streamed AI" description="Same AI SDK chat patterns as web and mobile, inside the extension." href="/docs/extension/ai" />

  <Card title="Chrome built-in AI" description="Experimental Gemini Nano path that runs locally in Chrome." href="/docs/extension/ai" />

  <Card title="AI-assisted development" description="Rules and skills so AI editors follow WXT and monorepo conventions." href="/docs/extension/installation/ai-development" />
</Cards>

### Security

Extension-specific attack surface: permissions, content scripts, and storage.

<Cards>
  <Card title="Security overview" description="Threat model for browser extensions in TurboStarter." href="/docs/extension/security/overview" />

  <Card title="Permissions" description="Request the minimum host and API permissions you need." href="/docs/extension/security/permissions" />

  <Card title="Session security" description="Protect the shared web session inside the extension." href="/docs/extension/security/session" />

  <Card title="Content script safety" description="Isolate page context, avoid leaking privileged data to the DOM." href="/docs/extension/security/content-scripts" />

  <Card title="Storage security" description="What belongs in browser.storage vs the server." href="/docs/extension/security/storage" />

  <Card title="Security checklist" description="Ship-ready checks before store submission." href="/docs/extension/security/checklist" />
</Cards>

### Customization, analytics & monitoring

<Cards>
  <Card title="Styling & themes" description="Tailwind themes and dark mode across extension surfaces." href="/docs/extension/customization/styling" />

  <Card title="Components" description="Shared UI components for popup, options, and side panel." href="/docs/extension/customization/components" />

  <Card title="Internationalization" description="Locales, language switching, and store `_locales` metadata." href="/docs/extension/internationalization" />

  <Card title="Product analytics" description="Event tracking from popup, background, and content scripts." href="/docs/extension/analytics/overview" />

  <Card title="Store listing analytics" description="Chrome / Firefox / Edge dashboard metrics vs in-extension tracking." href="/docs/extension/analytics/overview" />

  <Card title="Monitoring" description="Error reporting across popup, background, and content scripts." href="/docs/extension/monitoring/overview" />

  <Card title="Feature flags" description="OpenFeature flags with in-memory, PostHog, or GrowthBook." href="/docs/extension/flags/overview" />
</Cards>

### Tests & recipes

<Cards>
  <Card title="Unit tests" description="Fast unit tests for messaging helpers, hooks, and components." href="/docs/extension/tests/unit" />

  <Card title="E2E tests (Playwright)" description="Load the unpacked MV3 extension and cover popup, auth, and content scripts." href="/docs/extension/tests/e2e" />

  <Card title="Build a feature" description="End-to-end pattern for shipping a new extension feature." href="/docs/extension/recipes/build-a-feature" />
</Cards>

## Use like LEGO blocks

Keep the entrypoints you need - popup and content scripts, or a full side panel - and remove the rest. Auth, billing, and orgs stay on the web API so the extension stays lean.

## Scope of this documentation

Focus here is the WXT extension: structure, shared sessions, permissions, multi-store publishing, and extension UX. Shared backend topics (schema, payment providers, admin) are covered in the [web docs](/docs/web). Native-only topics live in the [mobile docs](/docs/mobile).

## Enjoy!

Questions? Reach out at [hello@turbostarter.dev](mailto:hello@turbostarter.dev).

Ship to the stores, keep the extension thin, and have fun! 🚀


# Development
Source: https://www.turbostarter.dev/docs/extension/installation/development

## Prerequisites

To get started with TurboStarter, ensure you have the following installed and set up:

* [Node.js](https://nodejs.org/en) (24.x or higher)
* [Docker](https://www.docker.com) (only if you want to use local services e.g. database)
* [pnpm](https://pnpm.io)

## Project development

<Steps>
  <Step>
    ### Install dependencies

    Install the project dependencies by running the following command:

    ```bash
    pnpm i
    ```

    <Callout title="Why pnpm?">
      It is a fast, disk space efficient package manager that uses hard links and symlinks to save one version of a module only ever once on a disk. It also has a great [monorepo support](https://pnpm.io/workspaces). Of course, you can change it to use [Bun](https://bunpkg.com), [yarn](https://yarnpkg.com) or [npm](https://www.npmjs.com) with minimal effort.
    </Callout>
  </Step>

  <Step>
    ### Setup environment variables

    Create a `.env.local` files from `.env.example` files and fill in the required environment variables.

    You can use the following command to recursively copy the `.env.example` files to the `.env.local` files:

    <Tabs items={["Unix (MacOS/Linux)", "Windows"]}>
      <Tab value="Unix (MacOS/Linux)">
        ```bash
        find . -name ".env.example" -exec sh -c 'cp "$1" "${1%.example}.local"' _ {} \;
        ```
      </Tab>

      <Tab value="Windows">
        ```bash
        Get-ChildItem -Recurse -Filter ".env.example" | ForEach-Object {
            Copy-Item $_.FullName ($\_.FullName -replace '\.example$', '.local')
        }
        ```
      </Tab>
    </Tabs>

    Check [Environment variables](/docs/extension/configuration/environment-variables) for more details on setting up environment variables.
  </Step>

  <Step>
    ### Setup services

    If you want to use local services like database etc. (**recommended for development purposes**), ensure Docker is running, then setup them with:

    ```bash
    pnpm services:setup
    ```

    This command initiates the containers and runs necessary setup steps, ensuring your services are up to date and ready to use.
  </Step>

  <Step>
    ### Start development server

    To start the application development server, run:

    ```bash
    pnpm dev
    ```

    Your development server should now be running 🎉

    WXT will create a dev bundle for your extension and start a live-reloading development server, which will automatically update your extension bundle and reload your browser on source code changes.

    It also makes the icon grayscale to distinguish between development and production extension bundles.
  </Step>

  <Step>
    ### Load the extension

    <Tabs items={["Chrome", "Firefox"]}>
      <Tab value="Chrome">
        Head over to `chrome://extensions` and enable **Developer Mode**.

        ![Developer mode](/images/docs/extension/chrome/developer-mode.png)

        Click on "Load Unpacked" and navigate to your extension's `apps/extension/build/chrome-mv3` directory.

        To see your popup, click on the puzzle piece icon on the Chrome toolbar, and click on your extension.

        ![Pin to toolbar](/images/docs/extension/chrome/pin.png)

        <Callout title="Pro tip">
          Pin your extension to the Chrome toolbar for easy access by clicking the pin button.
        </Callout>
      </Tab>

      <Tab value="Firefox">
        Head over to `about:debugging` and click on "This Firefox".

        Click on "Load Temporary Add-on" and navigate to your extension's `apps/extension/build/firefox-mv2` directory. Pick any file to load the extension.

        ![Load temporary add-on](/images/docs/extension/firefox/load.png)

        The extension now installs, and remains installed until you restart Firefox.

        To see your popup, click on your extension icon on the Firefox toolbar.

        ![Popup](/images/docs/extension/firefox/popup.png)

        <Callout>
          Loaded extension starts as pinned on the Firefox toolbar. Don't remove it to easily access it later.
        </Callout>
      </Tab>
    </Tabs>

    <Callout title="Automatic browser startup">
      You can also configure your development server to automatically start the browser when you start the server. To do it, create a `web-ext.config.ts` file in a root of your extension and configure it with your browser [binaries](https://wxt.dev/guide/essentials/config/browser-startup.html#set-browser-binaries) and [argumens](https://wxt.dev/guide/essentials/config/browser-startup.html#persist-data).

      Learn more in the [official documentation](https://wxt.dev/guide/essentials/config/browser-startup.html).
    </Callout>
  </Step>

  <Step>
    ### Publish to stores

    When you're ready to publish the project to the stores, follow the [guidelines](/docs/extension/marketing) and [checklist](/docs/extension/publishing/checklist) to ensure everything is set up correctly.
  </Step>
</Steps>


# Project structure
Source: https://www.turbostarter.dev/docs/extension/installation/structure

The main directories in the project are:

* `apps` - the location of the main apps
* `packages` - the location of the shared code and the API

### `apps` Directory

This is where the apps live. It includes web app (Next.js), mobile app (React Native - Expo), and the browser extension (WXT - Vite + React). Each app has its own directory.

### `packages` Directory

This is where the shared code and the API for packages live. It includes the following:

* shared libraries (database, mailers, cms, billing, etc.)
* shared features (auth, mails, billing, ai etc.)
* UI components (buttons, forms, modals, etc.)

All apps can use and reuse the API exported from the packages directory. This makes it easy to have one, or many apps in the same codebase, sharing the same code.

## Repository structure

By default the monorepo contains the following apps and packages:

<Files>
  <Folder name="apps" defaultOpen>
    <Folder name="web - Web app (Next.js)" />

    <Folder name="mobile - Mobile app (React Native - Expo)" />

    <Folder name="extension - Browser extension (WXT - Vite + React)" />
  </Folder>

  <Folder name="packages" defaultOpen>
    <Folder name="analytics - Analytics setup" />

    <Folder name="api - API server (including all features logic)" />

    <Folder name="auth - Authentication setup" />

    <Folder name="billing - Billing config and providers" />

    <Folder name="cms - CMS setup and providers" />

    <Folder name="db - Database setup" />

    <Folder name="email - Mail templates and providers" />

    <Folder name="flags - Feature flags" />

    <Folder name="i18n - Internationalization setup" />

    <Folder name="monitoring - Monitoring setup" />

    <Folder name="shared - Shared utilities and helpers" />

    <Folder name="storage - Storage setup" />

    <Folder name="ui - Atomic UI components" />
  </Folder>

  <Folder name="tooling" defaultOpen>
    <Folder name="github - Github actions" />

    <Folder name="oxfmt - Oxfmt config" />

    <Folder name="oxlint - Oxlint config" />

    <Folder name="typescript - TypeScript config" />

    <Folder name="vitest - Vitest config" />
  </Folder>
</Files>

## Browser extension application structure

The browser extension application is located in the `apps/extension` folder. It contains the following folders:

<Files>
  <Folder name="src" defaultOpen>
    <Folder name="app" defaultOpen>
      <Folder name="background - Background service worker" />

      <Folder name="content - Content scripts" />

      <Folder name="devtools - Devtools page with custom panels" />

      <Folder name="newtab - New tab page" />

      <Folder name="options - Options page" />

      <Folder name="popup - Popup window" />

      <Folder name="sidepanel - Side panel" />

      <Folder name="tabs - Custom pages shipped with the extension" />
    </Folder>

    <Folder name="assets - Optimized static assets" />

    <Folder name="config - App config" />

    <Folder name="lib - Communication with packages" />

    <Folder name="modules - Application modules" />
  </Folder>

  <File name=".env.local" />

  <File name="env.config.ts" />

  <File name="oxlint.config.ts" />

  <File name="package.json" />

  <File name="tsconfig.json" />

  <File name="turbo.json" />

  <File name="wxt.config.ts" />
</Files>


# Internationalization
Source: https://www.turbostarter.dev/docs/extension/internationalization

Turbostarter's extension uses [i18next](https://www.i18next.com/) and web cookies to store the language preference of the user. This allows the extension to be fully internationalized.

<Callout title="Why this combination?">
  We use i18next because it's a robust and widely-adopted internationalization framework that works seamlessly with React.

  The combination with web cookies allows us to persistently store language preferences across all extension contexts and share it with the web app while maintaining excellent performance and browser compatibility.
</Callout>

![i18next logo](/images/docs/i18next.jpg)

## Configuration

The global configuration is defined in the `@workspace/i18n` package and shared across all applications. You can read more about it in the [web configuration](/docs/web/internationalization/configuration) documentation.

By default, the locale is automatically detected based on the user's device settings. You can override it and set the default locale of your mobile app in the [app configuration](/docs/extension/configuration/app) file.

Also, the locale configuration is **shared between the web app and the extension** (same as [session](/docs/extension/auth/session)), which means that changing the locale in one place will automatically update it in the other. It's a common pattern for modern apps, simplifying the user experience and reducing the maintenance effort.

### Cookies

When a user first opens the [web app](/docs/web), the locale is detected and a cookie is set. This cookie is used to remember the user's language preference.

You can find its value in the *Cookies* tab of the developer tools of your browser:

![Locale cookie](/images/docs/extension/locale-cookie.png)

To enable your extension to read the cookie and that way share the locale settings with the web app, you need to set the cookies permission in the `wxt.config.ts` under `manifest.permissions` field:

```ts
export default defineConfig({
  manifest: {
    permissions: ["cookies"],
  },
});
```

And to be able to read the cookie from your app url, you need to set host\_permissions, which will include your app url:

```ts
export default defineConfig({
  manifest: {
    host_permissions: ["http://localhost/*", "https://your-app-url.com/*"],
  },
});
```

Then you would be able to share the cookie between your apps and also read its value using `browser.cookies` API.

<Callout title="Avoid &#x22;<all_urls>&#x22;" type="warn">
  Avoid using `<all_urls>` in `host_permissions`. It affects all urls and may cause security issues, as well as a [rejection](https://developer.chrome.com/docs/webstore/review-process#review-time-factors) from the destination store.
</Callout>

<Cards>
  <Card title="Declare permissions" href="https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions" description="developer.chrome.com" />

  <Card title="chrome.cookies" href="https://developer.chrome.com/docs/extensions/reference/api/cookies" description="developer.chrome.com" />
</Cards>

## Translating extension

To translate individual components and screens, you can use the well-known `useTranslation` hook.

```tsx
import { useTranslation } from "@workspace/i18n";

export const Popup = () => {
  const { t } = useTranslation();

  return <div>{t("hello")}</div>;
};
```

That's the recommended way to translate stuff inside your extension.

### Store presence

As we saw in the [manifest](/docs/extension/configuration/manifest#locales) section, you can also localize your extension's store presence (like title, description, and other metadata). This allows you to customize how your extension appears in different web stores based on the user's language.

Each store has specific requirements for localization:

* [Chrome Web Store](https://developer.chrome.com/docs/webstore/cws-dashboard-listing/) requires a `_locales` directory with JSON files for each language
* [Firefox Add-ons](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization) uses a similar structure but with some differences in the manifest
* [Edge Add-ons](https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension#supporting-multiple-languages) uses the same structure as Chrome Web Store

Although most of the config is abstracted behind common structure, please follow the store-specific guides below for detailed instructions on setting up localization for your extension's store listing.

<Cards>
  <Card title="I18n - WXT" href="https://wxt.dev/guide/essentials/i18n.html" description="wxt.dev" />

  <Card title="Chrome Web Store" href="https://developer.chrome.com/docs/webstore/cws-dashboard-listing" description="developer.chrome.com" />

  <Card title="Firefox Add-ons" href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization" description="developer.mozilla.org" />

  <Card title="Edge Add-ons" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension#supporting-multiple-languages" description="developer.microsoft.com" />
</Cards>

## Language switcher

TurboStarter ships with a language customizer component that allows users to switch between languages in your extension. You can import and use the `LocaleCustomizer` component in your popup, options page, or any other extension view:

```tsx
import { LocaleCustomizer } from "@workspace/ui-web/i18n";

export const Popup = () => {
  return <LocaleCustomizer />;
};
```

<Callout title="This will change the locale of the web app as well" type="warn">
  As the web app and extension share the same i18n configuration (cookie), changing the language in one will affect the other. **This is intentional** and ensures a consistent experience across both platforms, since your extension likely serves as a companion to the web app and should maintain the same language preferences.
</Callout>

## Best practices

Here are key best practices for managing translations in your browser extension:

* Use descriptive, hierarchical translation keys

  ```ts
  // ✅ Good
  "popup.settings.language";
  "content.toolbar.save";

  // ❌ Bad
  "saveButton";
  "text1";
  ```

* Organize translations by extension views and features

  ```
  _locales/
  ├── en/
  │   ├── messages.json
  │   ├── popup.json
  │   └── options.json
  └── es/
      ├── messages.json
      ├── popup.json
      └── options.json
  ```

* Handle fallback languages gracefully

* Keep manifest descriptions localized for store listings

* Consider context in translations:

  ```ts
  // Context-aware messages
  t("button.save", { context: "document" }); // "Save document"
  t("button.save", { context: "settings" }); // "Apply changes"
  ```

* Use placeholders for dynamic content:

  ```ts
  // With variables
  t("status.saved", { time: "2 minutes ago" }); // "Last saved 2 minutes ago"

  // With plurals
  t("items", { count: 5 }); // "5 items"
  ```

* Keep translations in sync between extension views

* Cache translations for offline functionality


# Marketing
Source: https://www.turbostarter.dev/docs/extension/marketing

As you saw in the [Extras](/docs/extension/extras) section, TurboStarter comes with a lot of tips and tricks to make your product better and help you launch your extension faster with higher traffic.

The same applies to [submission tips](/docs/extension/extras#submission-tips) to help you get your extension approved by the browser stores faster.

We'll talk more about the whole process of deploying and publishing your extension in the [Publishing](/docs/extension/publishing/checklist) section, here we'll go through some guidelines that you need to follow to make your store's visibility higher.

## Before you submit

To help your extension approval go as smoothly as possible, review the common missteps listed below that can slow down the review process or trigger a rejection. This doesn't replace the official guidelines or guarantee approval, but making sure you can check every item on the list is a good start.

Make sure you:

* Test your extension thoroughly for crashes and bugs
* Ensure that all extension information and metadata is complete and accurate
* Update your contact information in case the review team needs to reach you
* Provide clear instructions on how to use your extension, including any special setup required
* If your extension requires an account, provide a demo account or a way to test all features without signing up
* Enable and test all backend services to ensure they're live and accessible during review
* Include detailed explanations of non-obvious features in the extension description
* Ensure your extension complies with the specific browser store's policies (e.g., [Chrome Web Store](https://developer.chrome.com/docs/webstore/program-policies/best-practices), [Firefox Add-ons](https://extensionworkshop.com/documentation/publish/add-on-policies/), [Edge Add-ons](https://learn.microsoft.com/en-us/legal/microsoft-edge/extensions/developer-policies) etc.)
* Remove any references to features not supported in browser extensions (e.g., in-app purchases)

Following these basic steps during development and before submission will help you get your extension approved faster and with fewer issues.

## Guidelines

Each store has slightly different guidelines, but some of them are general and can be applied to all stores:

* **Security**: Your extension must not contain malicious code or behavior that can harm users' devices or data.
* **Performance**: Your extension must be performant and stable, with a smooth user experience.
* **Privacy**: Your extension must respect user privacy and not collect unnecessary data without explicit consent.
* **Compliance**: Your extension must comply with all relevant laws and regulations.

You can read more about official guidelines for each store in the following links:

* [Chrome Web Store](https://developer.chrome.com/docs/webstore/program-policies/best-practices)
* [Firefox Add-ons](https://extensionworkshop.com/documentation/publish/add-on-policies/)
* [Edge Add-ons](https://learn.microsoft.com/en-us/microsoft-edge/extensions/developer-guide/best-practices)

## Common mistakes

There are a few common mistakes that you should avoid to make sure your extension can be accepted in the stores. The most common ones are:

* **Not enough description** - make sure to describe all the features of your extension and how it works in your store listing, that way users won't be confused about what your extension does. Also include detailed information in the single purpose field regarding your extension's primary functionality.
* **Privacy issues** - respect user privacy and require as least permissions as possible, don't ask for permissions that are not necessary for your extension to work
* **Customer support** - provide a way to contact you in case the user has any issues with your extension
* **Stay up-to-date** - keep your extension and its documentation up-to-date to ensure a smooth user experience and to prevent issues during the review process.

<Cards>
  <Card href="https://developer.chrome.com/docs/webstore/program-policies/best-practices" title="Best Practices and Guidelines" description="developer.chrome.com" />

  <Card href="https://extensionworkshop.com/documentation/publish/add-on-policies/" title="Add-on Policies" description="extensionworkshop.com" />

  <Card href="https://learn.microsoft.com/en-us/legal/microsoft-edge/extensions/developer-policies" title="Developer Policies" description="learn.microsoft.com" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/extension/monitoring/overview

TurboStarter includes powerful, provider-agnostic monitoring helpers for the browser extension so you can understand **what failed**, **where it failed** (popup, content script, background), and **who it impacted**. The API is intentionally designed for simplicity and extensibility, so you can swap providers without rewriting your extension code.

## Capturing exceptions

Extensions have multiple runtimes. To get good coverage, capture errors in the places users actually feel them:

* **Popup / options UI**: React pages where runtime errors break interactions.
* **Background (service worker)**: long-lived logic like alarms, message routing, and sync.
* **Content scripts**: page integrations where DOM differences and CSP can trigger failures.
* **Manual reporting**: wrap critical flows (auth, billing, webhooks-to-extension sync, imports) with `try/catch` and report with context.

<Tabs items={["Popup / options", "Background", "Content script"]}>
  <Tab value="Popup / options">
    ```tsx
    import { captureException } from "@workspace/monitoring-extension";

    export function ExampleButton() {
      const onPress = async () => {
        try {
          /* some risky operation */
        } catch (error) {
          captureException(error);
        }
      };

      return <button onClick={onPress}>Trigger Exception</button>;
    }
    ```
  </Tab>

  <Tab value="Background">
    ```ts
    import { captureException } from "@workspace/monitoring-extension";

    browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
      try {
        /* handle message */
        sendResponse({ ok: true });
      } catch (error) {
        captureException(error);
        sendResponse({ ok: false });
      }
    });
    ```
  </Tab>

  <Tab value="Content script">
    ```ts
    import { captureException } from "@workspace/monitoring-extension";

    try {
      /* interact with the page DOM */
    } catch (error) {
      captureException(error);
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn" title="Don't rely on a single runtime">
  An exception in a content script won't automatically show up in your background logs (and vice versa). Add capture points in each runtime you ship, especially if you do message passing between them.
</Callout>

## Identifying users

Monitoring becomes far more useful once reports can be tied to a stable identity. In extensions you often have two “identities”:

* **Anonymous, stable install id**: useful before sign-in (and to correlate issues with a device/install).
* **Signed-in user**: once the user authenticates, identify with their user id so issues map to a real account.

TurboStarter's monitoring layer supports identifying the current user when your auth session resolves. When signed out, pass `null` (or your provider's preferred anonymous identity strategy).

```tsx title="monitoring.tsx"
import { useEffect } from "react";
import { identify } from "@workspace/monitoring-extension";
import { authClient } from "~/lib/auth/client";

export const MonitoringProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    identify(session.data?.user ?? null);
  }, [session]);

  return <>{children}</>;
};
```

<Callout title="Privacy defaults" type="error">
  Prefer **stable IDs** over PII. Only attach traits that help debugging (plan, role, extension version) and avoid secrets (tokens, passwords) or sensitive fields unless you've explicitly chosen to send them.
</Callout>

## Providers

The starter supports multiple monitoring providers behind the same API, so you can start with one and switch later.

<Cards>
  <Card title="Sentry" href="/docs/extension/monitoring/sentry" />

  <Card title="PostHog" href="/docs/extension/monitoring/posthog" />
</Cards>

## Best practices

<Cards>
  <Card title="Include runtime + version context" className="shadow-none">
    Extension issues are often environment-specific. Make sure you can filter by
    runtime (popup/background/content script), extension version, and browser.
  </Card>

  <Card title="Capture actionable failures" className="shadow-none">
    Focus on crashes and failures that break core flows; skip “expected” states
    like validation errors or user cancellations.
  </Card>

  <Card title="Dedupe noisy loops" className="shadow-none">
    Background alarms, retries, and message loops can generate many identical
    errors. Guard your capture calls to keep signal high (and costs low).
  </Card>

  <Card title="Keep environments separate" className="shadow-none">
    Don't mix dev/beta/stable releases. Tag builds so you can correlate spikes
    with a rollout and verify fixes quickly.
  </Card>
</Cards>

With capture points in each runtime, user identification wired up, and a provider configured, extension monitoring becomes a tight feedback loop: you can spot regressions early, understand which surface area is failing and validate fixes confidently as you ship new versions.


# PostHog
Source: https://www.turbostarter.dev/docs/extension/monitoring/posthog

[PostHog](https://posthog.com/) is a product analytics platform that also supports monitoring capabilities like error tracking and session replay. In extensions, it's especially useful when you want to connect “what broke” with “what the user did” right before the issue occurred.

TurboStarter keeps monitoring behind a unified API, so you can route exception captures from your popup, background, and content scripts to PostHog without rewriting the call sites.

<Callout type="warn" title="Prerequisite: PostHog account">
  To use PostHog as your monitoring provider, you'll need a PostHog instance. You can use [PostHog Cloud](https://app.posthog.com/signup) or [self-host](https://posthog.com/docs/self-host).
</Callout>

<Callout type="info" title="You can also use it for extension analytics">
  PostHog is also supported as an analytics provider for the extension, and as a [feature flags](/docs/extension/flags/configuration#posthog) backend. If you want to track in-extension events, see the [analytics overview](/docs/extension/analytics/overview) and the [PostHog analytics configuration](/docs/extension/analytics/configuration#posthog).
</Callout>

![PostHog banner](/images/docs/web/monitoring/posthog/banner.jpg)

## Configuration

Here you'll configure PostHog as the monitoring provider for your extension so exceptions from the popup, background/service worker, and content scripts show up with enough context to debug.

<Steps>
  <Step>
    ### Create a project

    Create a PostHog [project](https://app.posthog.com/project/settings) for your extension. You can do this from the [PostHog dashboard](https://app.posthog.com) via the *New Project* action.
  </Step>

  <Step>
    ### Activate PostHog as your monitoring provider

    TurboStarter picks the extension monitoring provider through exports in the monitoring package. To route captures to PostHog, export the PostHog implementation from the extension monitoring entrypoint:

    ```ts title="index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```
  </Step>

  <Step>
    ### Set environment variables

    Add your PostHog project key (and host, if you're not using the default cloud region) to your extension env. Set these locally and in whatever build environment produces your extension bundles:

    ```dotenv title="apps/extension/.env.local"
    VITE_POSTHOG_KEY="your-posthog-project-api-key"
    VITE_POSTHOG_HOST="https://us.i.posthog.com"
    ```
  </Step>
</Steps>

That's it — load the extension, trigger a test error from the popup/background/content script, and confirm events are arriving in your PostHog project.

![PostHog error](/images/docs/web/monitoring/posthog/error.png)

If you want to go beyond basic capture (session replay, feature flags, richer context), follow PostHog's web/extension guidance.

<Cards>
  <Card title="Error tracking" href="https://posthog.com/docs/error-tracking" description="posthog.com" />

  <Card title="Web error tracking installation" href="https://posthog.com/docs/error-tracking/installation/web" description="posthog.com" />
</Cards>

## Uploading source maps

**Source maps** map the minified/bundled JavaScript shipped with your extension back to your original source code. Without them, stack traces in PostHog often point at compiled output, which makes debugging much slower.

<Callout>
  PostHog’s source map flow for web builds relies on injecting metadata into the bundled assets. You must deploy/ship the injected assets, otherwise PostHog can’t match captured errors to the uploaded symbol sets.
</Callout>

For extensions built with Vite (which [WXT](https://wxt.dev/) is using under the hood), the high-level flow is:

* generate `.map` files during the production build
* inject PostHog metadata into the built assets
* upload the injected source maps to PostHog

<Steps>
  <Step>
    ### Install the PostHog CLI

    Install the CLI globally:

    ```bash
    npm install -g @posthog/cli
    ```
  </Step>

  <Step>
    ### Authenticate the CLI

    Authenticate interactively:

    ```bash
    posthog-cli login
    ```

    In CI, you can authenticate with environment variables:

    ```dotenv
    POSTHOG_CLI_HOST="https://us.posthog.com"
    POSTHOG_CLI_ENV_ID="your-posthog-project-id"
    POSTHOG_CLI_TOKEN="your-personal-api-key"
    ```
  </Step>

  <Step>
    ### Build with source maps enabled

    Make sure your extension build outputs source maps by modifying your `wxt.config.ts` file.

    ```ts title="wxt.config.ts"
    import { defineConfig } from "wxt";

    export default defineConfig({
      /* existing WXT configuration options */
      vite: () => ({
        build: {
          sourcemap: "hidden", // [!code ++] Source map generation must be turned on ("hidden", true, etc.)
        },
      }),
    });
    ```

    After building, you should have `.js` and `.js.map` files in your output directory.
  </Step>

  <Step>
    ### Inject PostHog metadata into the built assets

    Inject release/chunk metadata so PostHog can associate uploaded maps with the shipped bundles:

    ```bash
    posthog-cli sourcemap inject --directory ./path/to/assets --project my-extension --version 1.2.3
    ```
  </Step>

  <Step>
    ### Upload source maps

    Upload the injected source maps to PostHog:

    ```bash
    posthog-cli sourcemap upload --directory ./path/to/assets
    ```
  </Step>

  <Step>
    ### Verify injection and uploads

    After deployment, confirm your production bundles include the injected comment (for example `//# chunkId=...`) and verify symbol sets exist in your PostHog project settings.
  </Step>
</Steps>

With this in place, PostHog can symbolicate extension errors (popup/options UI, background/service worker, and content scripts) so stack traces point back to your original source files.

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Upload source maps for web" href="https://posthog.com/docs/error-tracking/upload-source-maps/web" description="posthog.com" />
</Cards>


# Sentry
Source: https://www.turbostarter.dev/docs/extension/monitoring/sentry

[Sentry](https://sentry.io/welcome/) is a popular error monitoring and performance tracking platform. It helps you catch and debug issues by collecting exceptions, stack traces, and helpful context from production.

For browser extensions, that context matters even more: errors can happen in multiple runtimes (popup/options UI, background/service worker, and content scripts). Sentry makes it easier to see what failed and where it happened so you can ship fixes with confidence.

<Callout type="warn" title="Prerequisite: Sentry account">
  To use Sentry, create an account and a project first. You can sign up [here](https://sentry.io/signup).
</Callout>

![Sentry banner](/images/docs/web/monitoring/sentry/banner.png)

## Configuration

This section walks you through enabling Sentry for your extension and verifying that errors from the popup, background/service worker, and content scripts are captured reliably.

<Steps>
  <Step>
    ### Create a project

    Create a Sentry [project](https://docs.sentry.io/product/projects/) for the extension (JavaScript / browser). You can do this from the Sentry [projects dashboard](https://sentry.io/settings/account/projects/) via the *Create Project* flow.
  </Step>

  <Step>
    ### Activate Sentry as your monitoring provider

    TurboStarter picks the extension monitoring provider via exports in the monitoring package. To enable Sentry, export the Sentry implementation from the extension monitoring entrypoint:

    ```ts title="index.ts"
    // [!code word:sentry]
    export * from "./sentry";
    export * from "./sentry/env";
    ```

    If you need to customize behavior, the provider implementation lives under `packages/monitoring/extension/src/providers/sentry`.
  </Step>

  <Step>
    ### Set environment variables

    From your Sentry project settings, add the DSN and environment to your extension env file (and to any [CI/build step](/docs/extension/publishing/checklist#build-your-app) that produces your extension bundles):

    ```dotenv title="apps/extension/.env.local"
    VITE_SENTRY_DSN="your-sentry-dsn"
    VITE_SENTRY_ENVIRONMENT="your-project-environment"
    ```
  </Step>
</Steps>

That's it — load the extension, trigger a test error from the popup/background/content script, and confirm it shows up in your [Sentry dashboard](https://sentry.io/settings/account/projects/).

![Sentry error](/images/docs/web/monitoring/sentry/error.jpg)

For advanced options (sampling, releases, extra context), refer to [Sentry's JavaScript docs](https://docs.sentry.io/platforms/javascript/).

<Cards>
  <Card title="Quick Start" href="https://docs.sentry.io/platforms/javascript/" description="docs.sentry.io" />

  <Card title="Manual Setup" href="https://docs.sentry.io/platforms/javascript/install/npm/" description="docs.sentry.io" />
</Cards>

## Uploading source maps

**Source maps** map the bundled/minified JavaScript shipped with your extension back to your original source files. Without them, Sentry stack traces often point to compiled output, which makes debugging across popup/background/content-script runtimes much harder.

<Callout>
  Generating source maps can expose your source code if `.map` files are publicly accessible. Prefer hidden source maps and/or delete them after upload.
</Callout>

Sentry can automatically provide readable stack traces for errors using source maps, requiring a [Sentry auth token](https://docs.sentry.io/account/auth-tokens/).

<Steps>
  <Step>
    ### Install the Sentry Vite plugin

    Install the package `@sentry/vite-plugin` in `apps/extension/package.json` as a dev dependency.

    ```bash
    pnpm i @sentry/vite-plugin -D --filter extension
    ```
  </Step>

  <Step>
    ### Add an auth token for uploads

    Create an [auth token in Sentry](https://docs.sentry.io/account/auth-tokens/) and provide it as an environment variable during builds (locally and in your build environment):

    ```dotenv
    SENTRY_AUTH_TOKEN="your-sentry-auth-token"
    ```
  </Step>

  <Step>
    ### Enable source maps and configure the plugin

    Enable source map generation in your extension build and add `sentryVitePlugin` **after** your other Vite plugins:

    ```ts title="wxt.config.ts"
    import { defineConfig } from "wxt";
    import { sentryVitePlugin } from "@sentry/vite-plugin";

    export default defineConfig({
      /* existing WXT configuration options */
      vite: () => ({
        build: {
          sourcemap: "hidden", // [!code ++] Source map generation must be turned on ("hidden", true, etc.)
        },
        plugins: [
          sentryVitePlugin({
            org: "your-sentry-org",
            project: "your-sentry-project",
            authToken: process.env.SENTRY_AUTH_TOKEN,

            sourcemaps: {
              // As you're enabling client source maps, you probably want to delete them after they're uploaded to Sentry.
              // Set the appropriate glob pattern for your output folder - some glob examples below:
              filesToDeleteAfterUpload: [
                "./**/*.map",
                ".*/**/public/**/*.map",
                "./dist/**/client/**/*.map",
              ],
            },
          }),
        ],
      }),
    });
    ```
  </Step>

  <Step>
    ### Verify uploads with a production build

    The Sentry Vite plugin doesn't upload in dev/watch mode. Run a production build, then trigger a test error in the extension and confirm stack traces resolve to your original source.
  </Step>
</Steps>

Once this is in place, errors from your extension's compiled bundles (popup/options UI, background/service worker, content scripts) should show **readable stack traces** in Sentry, without shipping source maps to end users.

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Sentry Vite plugin" href="https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/vite/" description="docs.sentry.io" />
</Cards>


# Organizations/teams
Source: https://www.turbostarter.dev/docs/extension/organizations

TurboStarter extensions support organizations/teams out of the box by sharing the same authentication session as your web app. The active organization is stored in the session and available to your extension without re-implementing organizations logic.

<Callout type="info" title="Shared session and tenant context">
  The extension and web app use a single auth session powered by Better Auth. The session includes tenant context (for example, `activeOrganizationId`). When users sign in, switch organizations, or sign out in the web app, the extension picks up these changes automatically.

  Learn more: [Auth → Session](/docs/extension/auth/session).
</Callout>

## How it works

* **No separate auth flow** in the extension. We reuse the web session.
* **Active organization comes from the session** (e.g., `session.activeOrganizationId`).
* **Protected API calls** from the extension include the right cookies, so org‑scoped server logic works as expected.

![Shared authentication with organizations in extension](/images/docs/extension/organizations.png)

## Active organization

Use your existing auth client to read the active organization through the `useActiveOrganization` hook.

```tsx title="popup.tsx"
import { authClient } from "~/lib/auth";

export function Popup() {
  const organization = authClient.useActiveOrganization();

  return <>{organization?.name}</>;
}
```

<Callout title="Switching organizations">
  If a user switches organizations in the web app, the extension reflects the change through the shared session on the next interaction. For long-lived views, re-read the session or invalidate related queries when appropriate.
</Callout>

## Do more with organizations

Most organization features live in the web app and are exposed via APIs your extension can call. These guides explain the underlying concepts and server behavior your extension builds upon:

<Cards>
  <Card title="Overview" description="Concepts and architecture" href="/docs/web/organizations/overview" />

  <Card title="Data model" description="Tables and relationships" href="/docs/web/organizations/data-model" />

  <Card title="Active organization" description="How organization context is resolved" href="/docs/web/organizations/active-organization" />

  <Card title="RBAC" description="Roles and permissions" href="/docs/web/organizations/rbac" />

  <Card title="Invitations" description="Invite teammates and manage members" href="/docs/web/organizations/invitations" />
</Cards>

<Callout>
  Looking for the underlying auth setup? Start with [Auth →
  Overview](/docs/extension/auth/overview) and [Auth →
  Session](/docs/extension/auth/session).
</Callout>


# Checklist
Source: https://www.turbostarter.dev/docs/extension/publishing/checklist

When you're ready to publish your TurboStarter extension to stores, follow this checklist.

This process may take a few hours and some trial and error, so buckle up - you're almost there!

<Steps>
  <Step>
    ## Create database instance

    **Why it's necessary?**

    A production-ready database instance is essential for storing your application's data securely and reliably in the cloud. [PostgreSQL](https://www.postgresql.org/) is the recommended database for TurboStarter due to its robustness, features, and wide support.

    **How to do it?**

    You have several options for hosting your PostgreSQL database:

    * [Supabase](/docs/extension/recipes/supabase) - Provides a fully managed Postgres database with additional features
    * [Vercel Postgres](https://vercel.com/storage/postgres) - Serverless SQL database optimized for Vercel deployments
    * [Neon](https://neon.com/) - Serverless Postgres with automatic scaling
    * [Turso](https://turso.tech/) - Edge database built on libSQL with global replication
    * [DigitalOcean](https://www.digitalocean.com/products/managed-databases) - Managed database clusters with automated failover

    Choose a provider based on your needs for:

    * Pricing and budget
    * Geographic region availability
    * Scaling requirements
    * Additional features (backups, monitoring, etc.)
  </Step>

  <Step>
    ## Migrate database

    **Why it's necessary?**

    Pushing database migrations ensures that your database schema in the remote database instance is configured to match TurboStarter's requirements. This step is crucial for the application to function correctly.

    **How to do it?**

    You basically have two possibilities for doing a migration:

    <Tabs items={["Using GitHub Actions (recommended)", "Running locally"]}>
      <Tab value="Using GitHub Actions (recommended)">
        TurboStarter comes with a predefined GitHub Action to handle database migrations. You can find its definition in the `.github/workflows/publish-db.yml` file.

        What you need to do is set your `DATABASE_URL` as a [secret for your GitHub repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions).

        Then, you can run the workflow which will publish the database schema to your remote database instance.

        [Check how to run GitHub Actions workflow.](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)
      </Tab>

      <Tab value="Running locally">
        You can also run your migrations locally, although this is not recommended for production.

        To do so, set the `DATABASE_URL` environment variable to your database URL (that comes from your database provider) in the `.env.local` file and run the following command:

        ```bash
        pnpm with-env pnpm --filter @workspace/db db:migrate
        ```

        This command will run the migrations and apply them to your remote database.

        [Learn more about database migrations.](/docs/web/database/migrations)
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Set up web backend API

    **Why it's necessary?**

    Setting up the backend is necessary to have a place to store your data and to have other features work properly (e.g. authentication, billing or storage).

    **How to do it?**

    Please refer to the [web deployment checklist](/docs/web/deployment/checklist) on how to set up and deploy the web app backend to production.
  </Step>

  <Step>
    ## Environment variables

    **Why it's necessary?**

    Setting the correct environment variables is essential for the extension to function correctly. These variables include API keys, database URLs, and other configuration details required for your extension to connect to various services.

    **How to do it?**

    Use our `.env.example` files to get the correct environment variables for your project. Then add them to your CI/CD provider (e.g. [GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)) as a [secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions).
  </Step>

  <Step>
    ## Build your app

    **Why it's necessary?**

    Building your extension is necessary to create a standalone extension bundle that can be published to the stores.

    **How to do it?**

    You basically have two possibilities to build a bundle for your extension:

    <Tabs items={["Using GitHub Actions (recommended)", "Running locally"]}>
      <Tab value="Using GitHub Actions (recommended)">
        TurboStarter comes with a predefined GitHub Action to handle building your extension for submission. You can find its definition in the `.github/workflows/publish-extension.yml` file.

        [Check how to run GitHub Actions workflow.](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)

        This will also save the `.zip` file as an [artifact](https://docs.github.com/en/actions/guides/storing-workflow-data-as-artifacts) of the workflow run, so you can download it from there and submit your extension to stores (if configured).
      </Tab>

      <Tab value="Running locally">
        You can also run your build locally, although this is not recommended for production.

        To do it, run the following command:

        ```bash
        pnpm turbo build --filter=extension
        ```

        This will build the extension and package it into a `.zip` file. You can find the output in the `build` folder.
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Submit to stores

    **Why it's necessary?**

    Publishing your extension to the stores is required to make it discoverable and accessible to your users. This is the official distribution channel where users can find, install, and trust your extension.

    **How to do it?**

    We've prepared dedicated guides for each store that TurboStarter supports out-of-the-box, please refer to the following pages:

    <Cards>
      <Card title="Chrome Web Store" href="/docs/extension/publishing/chrome" description="Publish your extension to Google Chrome Web Store." />

      <Card title="Firefox Add-ons" href="/docs/extension/publishing/firefox" description="Publish your extension to Mozilla Firefox Add-ons." />

      <Card title="Edge Add-ons" href="/docs/extension/publishing/edge" description="Publish your extension to Microsoft Edge Add-ons." />
    </Cards>
  </Step>
</Steps>

That's it! Your extension is now live and accessible to your users, good job! 🎉

<Callout title="Other things to consider">
  * Run through the [security checklist](/docs/extension/security/checklist) (permissions, trusted origins, content scripts).
  * Optimize your store listing description, keywords, and other relevant information for the stores.
  * Remove the placeholder content in the extension or replace it with your own.
  * Update the favicon, scheme, store images, and logo with your own branding.
</Callout>


# Chrome Web Store
Source: https://www.turbostarter.dev/docs/extension/publishing/chrome

[Chrome Web Store](https://chromewebstore.google.com/) is the most popular store for browser extensions, as it makes them available in any Chromium-based browser, including Google Chrome, Edge, Brave, and many others.

To submit your extension to Chrome Web Store, you'll need to complete a few steps. Here, we'll go through them.

<Callout title="Prerequisite" type="warn">
  Make sure your extension follows the [guidelines](/docs/extension/marketing) and other requirements to increase your chances of getting approved.
</Callout>

## Developer account

Before you can publish items on the Chrome Web Store, you must register as a CWS developer and pay a one-time registration fee. You must provide a developer email when you create your developer account.

To register, just access the [developer console](https://chrome.google.com/webstore/devconsole). The first time you do this, the following registration screen will appear. First, agree to the developer agreement and policies, then pay the registration fee.

![Chrome registration fee](/images/docs/extension/chrome/fee.png)

Once you pay the registration fee and agree to the terms, your account will be created, and you'll be able to proceed to fill out additional information about it.

![Chrome developer account](/images/docs/extension/chrome/account.png)

There are a few fields that you'll need to fill in:

* **Publisher name**: Appears under the title of each of your extensions. If you are a verified publisher, you can display an official publisher URL instead.
* **Verified email**: Verifying your contact email address is required when you set up a new developer account. It's only displayed under your extensions' contact information. Any notifications will be sent to your Chrome Web Store developer account email.
* **Physical address**: Only items that offer functionality to purchase items, additional features, or subscriptions must include a physical address.

<Card title="Register your developer account" href="https://developer.chrome.com/docs/webstore/register" description="developer.chrome.com" />

## Submission

After registering your developer account, setting it up, and preparing your extension, you're ready to publish it to the store.

You can submit your extension in two ways:

* **Manually**: By uploading your extension's bundle directly to the store.
* **Automatically**: By using GitHub Actions to submit your extension to the stores.

**The first submission must be done manually, while subsequent updates can be submitted automatically.** We'll go through both approaches.

### Manual submission

To manually submit your extension to stores, you will first need to get your extension bundle. If you ran the build step locally, you should already have the `.zip` file in your extension's `build` folder.

If you used GitHub Actions to build your extension, you can find the results in the workflow run. Download the artifacts and save them on your local machine.

Then, use the following steps to upload your item:

1. Go to the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole/).
2. Sign in to your developer account.
3. Click on the *Add new item* button.
4. Click *Choose file* > *your zip file* > *Upload*. If your item's manifest and other contents are valid, you will see a new item in the dashboard.

![Chrome extension page](/images/docs/extension/chrome/extension-page.png)

After you upload the bundle, you'll need to fill in the extension's details, such as the icons, privacy settings, permissions justification, and other information.

Please refer to the official guides on how to set up your extension's details.

<Cards>
  <Card title="Complete your listing information" href="https://developer.chrome.com/docs/webstore/cws-dashboard-listing" description="developer.chrome.com" />

  <Card title="Fill out the privacy fields" description="developer.chrome.com" href="https://developer.chrome.com/docs/webstore/cws-dashboard-privacy" />

  <Card title="Declare payment and set visibility" description="developer.chrome.com" href="https://developer.chrome.com/docs/webstore/cws-dashboard-distribution" />
</Cards>

### Automated submission

<Callout title="First submission must be done manually" type="warn">
  The first submission of your extension to Chrome Web Store must be done manually because you need to provide the store's credentials and extension ID to automation, which will be available only after the first bundle upload.
</Callout>

TurboStarter comes with a pre-configured GitHub Actions workflow to submit your extension to web stores automatically. It's located in the `.github/workflows/publish-extension.yml` file.

What you need to do is fill the environment variables with your store's credentials and extension's details and set them as a [secrets in your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) under correct names:

```yaml title="publish-extension.yml"
env:
  CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
  CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
  CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
  CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
```

Please refer to the [official guide](https://github.com/PlasmoHQ/bms/blob/main/tokens.md#chrome-web-store-api) to learn how to get these credentials correctly.

That's it! You can [run the workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow) and it will submit your extension to the Chrome Web Store 🎉

<Callout title="Automated submission to review">
  This workflow will also try to send your extension to review, but it's not guaranteed to happen. You need to have all required information filled in your extension's details page to make it possible.

  Even then, when you introduce some **breaking change** (e.g. add another permission), you'll need to update your extension store metadata and automatic submit won't be possible.

  To opt out of this behavior (and use only automatic uploading to store, but not sending to review) you can set `--chrome-skip-submit-review` flag in the `publish-extension.yml` file for the `wxt submit` command:

  ```yaml title="publish-extension.yml"
  // [!code word:--chrome-skip-submit-review]
  - name: 💨 Publish!
    run: |
      npx wxt submit \
        --chrome-zip apps/extension/build/*-chrome.zip --chrome-skip-submit-review
  ```

  Then, your extension bundle will be uploaded to the store, but you will need to send it to review manually.

  Check out the [official documentation](https://wxt.dev/api/cli/wxt-submit) for more customization options.
</Callout>

<Cards>
  <Card title="Use the Chrome Web Store Publish API" href="https://developer.chrome.com/docs/webstore/using-api" description="developer.chrome.com" />

  <Card title="How to generate Google API tokens?" href="https://github.com/PlasmoHQ/chrome-webstore-api/blob/main/token.md" description="github.com" />
</Cards>

## Review

After filling out the information about your item, you are ready to send it to review. Click on *Submit for review* button and confirm that you want to submit your item in the following dialog:

![Chrome submit for review](/images/docs/extension/chrome/send-to-review.png)

The confirmation dialog shown above also lets you control the timing of your item's publishing. If you uncheck the checkbox, your item will **not** be published immediately after its review is complete. Instead, you'll be able to manually publish it at a time of your choosing once the review is complete.

After you submit the item for review, it will undergo a review process. The time for this review depends on the nature of your item. See [Understanding the review process](https://developer.chrome.com/docs/webstore/review-process) for more details.

There are important emails like take down or rejection notifications that are enabled by default. To receive an email notification when your item is published or staged, you can enable notifications on the *Account page*.

![Chrome notifications](/images/docs/extension/chrome/notifications.png)

The review status of your item appears in the [developer dashboard](https://chrome.google.com/webstore/devconsole) next to each item. The status can be one of the following:

* **Published**: Your item is available to all users.
* **Pending**: Your item is under review.
* **Rejected**: Your item was rejected by the store.
* **Taken Down**: Your item was taken down by the store.

![Chrome extension status](/images/docs/extension/chrome/review-status.png)

You'll receive an email notification when the status of your item changes.

<Callout title="Your submission might be rejected" type="error">
  If your extension has been determined to violate one or more terms or policies, you will receive an email notification that contains the violation description and instructions on how to rectify it.

  If you did not receive an email within a week, check the status of your item. If your item has been rejected, you can see the details on the *Status* tab of your item.

  ![Chrome extension rejected](/images/docs/extension/chrome/rejection.png)

  You'll need to fix the issues and upload a new version of your extension, make sure to follow the [guidelines](/docs/extension/marketing) or check [publishing troubleshooting](/docs/extension/troubleshooting/publishing) for more info.

  If you have been informed about a violation and you do not rectify it, your item will be taken down. See [Violation enforcement](https://developer.chrome.com/docs/webstore/review-process#enforcement) for more details.
</Callout>

You can learn more about the review process in the official guides listed below.

<Cards>
  <Card title="Chrome Web Store review process" href="https://developer.chrome.com/docs/webstore/review-process" description="developer.chrome.com" />

  <Card title="Troubleshooting Chrome Web Store violations" href="https://developer.chrome.com/docs/webstore/troubleshooting" description="developer.chrome.com" />
</Cards>


# Edge Add-ons
Source: https://www.turbostarter.dev/docs/extension/publishing/edge

[Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/) distributes extensions to Microsoft Edge users. If you already have a Chromium-based extension, you can submit it to Edge with minimal changes.

This guide walks you through manual submission and optional automation, aligned with the official process.

<Callout title="Prerequisite" type="warn">
  Make sure your extension follows the general [guidelines](/docs/extension/marketing) and the Edge Add-ons developer policies to increase your chances of approval.
</Callout>

## Developer account

To enroll in the Microsoft Edge program you need to have a Microsoft account. If you don't have one, you can create one [here](https://account.microsoft.com/account/signup?signin=1\&ru=https://account.microsoft.com/account/login?loginMethod=email).

![Microsoft account](/images/docs/extension/edge/create-microsoft-account.png)

Next, before you can publish your extension to Edge Add-ons, you need to register your developer account in [Partner Center](https://partner.microsoft.com/dashboard/microsoftedge/public/login?ref=dd). Fill out the required fields and submit the form with *Finish* button. Wait for the email that your account has been verified - you're ready to submit your extension!

![Partner Center](/images/docs/extension/edge/developer-account.png)

<Card title="Register as a Microsoft Edge extension developer" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/create-dev-account" description="learn.microsoft.com" />

## Submission

After your account is ready and the extension bundle is prepared, you can publish it. There are two paths:

* **Manually**: Upload your `.zip` package through Partner Center.
* **Automatically**: Use CI to upload new versions after the first manual submission.

**The first submission should be done manually.** Subsequent updates can be automated once you have your extension ID and required credentials.

### Manual submission

To manually submit your extension to stores, you will first need to get your extension bundle. If you ran the build step locally, you should already have the .zip file in your extension's build folder.

If you used GitHub Actions to build your extension, you can find the results in the workflow run. Download the artifacts and save them on your local machine.

Then, use the following steps to upload your item:

<Steps>
  <Step>
    #### Sign in to your developer account

    Go to the [Partner Center](https://partner.microsoft.com/dashboard/microsoftedge/public/login?ref=dd) and sign in to your developer account.
  </Step>

  <Step>
    #### Create new extension

    Click the *Create new extension* button to start a new submission.

    ![Create new extension](/images/docs/extension/edge/create.png)
  </Step>

  <Step>
    #### Upload the extension package

    The *Extension overview* page shows information for a specific extension:

    ![Extension overview](/images/docs/extension/edge/upload.png)

    To upload your extension package:

    1. Click *Packages* in the left sidebar.
    2. Drag and drop your `.zip` file or click *Browse your files* to select it.
    3. Wait for validation to complete. If it fails, fix any issues and re-upload.
    4. Review the extracted extension details and click *Continue*.
  </Step>

  <Step>
    #### Set availability

    Choose visibility:

    * `Public`: discoverable in the store and via search.
    * `Hidden`: not discoverable; accessible via direct listing URL only.

    Select markets where the extension is available. You can later add or remove markets; existing users retain access to installed versions.

    ![Availability](/images/docs/extension/edge/availability.png)
  </Step>

  <Step>
    #### Enter properties

    Provide category, privacy policy requirements, privacy policy URL (if applicable), website URL, and support contact.

    These are shown to users on the listing and must meet policy requirements.

    ![Properties](/images/docs/extension/edge/properties.png)

    Follow the [official documentation](https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension#step-4-enter-properties-describing-your-extension) for more details.
  </Step>

  <Step>
    #### Add store listing details

    Fill in the store listing details for your extension:

    * **Display name**: The name shown in the store (from your manifest file).
    * **Description**: A detailed description (250-5000 characters) explaining what your extension does and why users should install it.
    * **Extension Store logo**: A 300x300 pixel logo representing your extension.
    * **Screenshots**: Up to 10 screenshots (640x480 or 1280x800 pixels) showing your extension's functionality.
    * **Small/Large promotional tiles**: Optional promotional images for store featuring.
    * **YouTube video URL**: Optional promotional video.
    * **Search terms**: Keywords to help users discover your extension (up to 21 words total).

    You must provide the description and logo for each supported language. Other fields are optional but recommended for better discoverability.

    ![Store listing details](/images/docs/extension/edge/store-listing.png)

    Follow the [official documentation](https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension#step-5-add-store-listing-details-for-your-extension) for detailed requirements and best practices.
  </Step>

  <Step>
    #### Submit for review

    Complete the submission by providing testing notes to help certification testers understand your extension.

    Click the *Submit* button to open the submission page:

    ![Submit extension](/images/docs/extension/edge/submit.png)

    In the **Notes for certification** text box, provide additional information to help testers properly evaluate your extension. Include any relevant details such as:

    * Test account usernames and passwords
    * Steps to access hidden or locked features
    * Expected differences based on region or user settings
    * Information about changes if this is an update
    * Any other context testers need to understand your submission

    Once you've added your notes, click the *Publish* button to submit your extension for certification.

    Your extension will proceed to the certification step, which can take up to seven business days.

    After passing certification, your extension will be published to [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/) and the status in Partner Center will change to "In the Store".
  </Step>
</Steps>

<Cards>
  <Card title="Publish a Microsoft Edge extension" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension" description="learn.microsoft.com" />

  <Card title="Curation and review process for extensions at Microsoft Edge Add-ons" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/add-ons-curation" description="learn.microsoft.com" />
</Cards>

## Automated submission

<Callout title="First submission must be done manually" type="warn">
  The first submission of your extension to Microsoft Edge Add-ons must be done manually because you need to provide the store's credentials and extension ID to automation, which will be available only after the first bundle upload.
</Callout>

TurboStarter comes with a pre-configured GitHub Actions workflow to submit your extension to web stores automatically. It's located in the .github/workflows/publish-extension.yml file.

What you need to do is fill the environment variables with your store's credentials and extension's details and set them as a [secrets in your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) under correct names:

```yaml title="publish-extension.yml"
env:
  EDGE_PRODUCT_ID: ${{ secrets.EDGE_PRODUCT_ID }}
  EDGE_CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }}
  EDGE_API_KEY: ${{ secrets.EDGE_API_KEY }}
```

Please refer to the [official guide](https://github.com/PlasmoHQ/bms/blob/main/tokens.md#edge-add-ons-api-v11) to learn how to get these credentials correctly.

Once configured, you can manually [trigger the workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow) to upload the new version to Edge Add-ons 🎉

<Callout title="Automated submission to review">
  This workflow will also try to send your extension to review, but it's not guaranteed to happen. You need to have all required information filled in your extension's details page to make it possible.

  Even then, when you introduce some **breaking change** (e.g. add another permission), you'll need to update your extension store metadata and automatic submit won't be possible.

  To opt out of this behavior (and use only automatic uploading to store, but not sending to review) you can set `--edge-skip-submit-review` flag in the `publish-extension.yml` file for the `wxt submit` command:

  ```yaml title="publish-extension.yml"
  // [!code word:--edge-skip-submit-review]
  - name: 💨 Publish!
    run: |
      npx wxt submit \
        --edge-zip apps/extension/build/*-chrome.zip --edge-skip-submit-review
  ```

  Then, your extension bundle will be uploaded to the store, but you will need to send it to review manually.

  Check out the [official documentation](https://wxt.dev/api/cli/wxt-submit) for more customization options.
</Callout>

<Cards>
  <Card title="Alternative ways to distribute an extension" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/developer-guide/alternate-distribution-options" description="learn.microsoft.com" />

  <Card title="REST API for updating an extension" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/update/api/using-addons-api?tabs=v1-1" description="learn.microsoft.com" />
</Cards>

## Review

After you submit your extension, it enters Microsoft's certification and publishing pipeline.

1. Preprocessing
   * Uploaded packages are queued and scanned. If errors are detected during preprocessing, you'll see a message and must resolve issues before re-uploading.
2. Certification
   * Security tests: packages are checked for viruses and malware.
   * Content compliance: human review of your listing and content for policy adherence.
3. Release and publishing
   * If you selected publish immediately, publishing begins right away; otherwise schedule/hold options apply.
   * While publishing, the submission status page shows rollout details. When complete, the status changes from "Publishing" to "In the Store".
4. Edge Add-ons curation and ranking
   * Discovery is influenced by quality, relevancy (name, description, popularity, user experience), and popularity (ratings and averages). Security and policy compliance are verified per the developer policies.

Microsoft may also perform spot checks after publishing to ensure ongoing compliance.

The review status of your item appears in the [Partner Center](https://partner.microsoft.com/dashboard/microsoftedge/public/login?ref=dd) under the *Overview* page of your item.

![Edge extension review status](/images/docs/extension/edge/review-status.png)

You'll receive an email notification when the status of your item changes.

<Callout title="Your submission might be rejected" type="error">
  If your extension has been determined to violate one or more terms or policies, you will receive an email notification that contains the violation description and instructions on how to rectify it.

  ![Rejection email](/images/docs/extension/edge/rejection-email.png)

  You can also check the reason behind the rejection on the *Certification report* page of your item.

  ![Certification report](/images/docs/extension/edge/certification-report.png)

  You'll need to fix the issues and upload a new version of your extension. Make sure to follow the [guidelines](/docs/extension/marketing) or check [publishing troubleshooting](/docs/extension/troubleshooting/publishing) for more info.
</Callout>

You can learn more about the review process in the official guides listed below.

<Cards>
  <Card title="Microsoft Edge Add-ons developer policies" href="https://learn.microsoft.com/en-us/legal/microsoft-edge/extensions/developer-policies" description="learn.microsoft.com" />

  <Card title="The app certification process for add-on" href="https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/add-on/app-certification-process" description="learn.microsoft.com" />

  <Card title="Curation and review process for extensions at Microsoft Edge Add-ons" href="https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/add-ons-curation" description="learn.microsoft.com" />
</Cards>


# Firefox Add-ons
Source: https://www.turbostarter.dev/docs/extension/publishing/firefox

Mozilla Firefox doesn't share extensions with [Google Chrome](/docs/extension/publishing/chrome), so you'll need to publish your extension to it separately.

Here, we'll go through the process of publishing an extension to [Firefox Add-ons](https://addons.mozilla.org/).

<Callout title="Prerequisite" type="warn">
  Make sure your extension follows the [guidelines](/docs/extension/marketing) and other requirements to increase your chances of getting approved.
</Callout>

## Developer account

Before you can publish items on Firefox Add-ons, you must register a developer account. In comparison to the Chrome Web Store, Firefox Add-ons doesn't require a registration fee.

To register, go to [addons.mozilla.org](https://addons.mozilla.org/) and click on the *Register* button.

![Mozilla registration](/images/docs/extension/firefox/portal.png)

It's important to set at least a display name on your profile to increase transparency with users, add-on reviewers, and the greater community.

You can do it in the *Edit My Profile* section:

![Mozilla profile](/images/docs/extension/firefox/profile.png)

<Card title="Developer accounts" href="https://extensionworkshop.com/documentation/publish/developer-accounts/" description="extensionworkshop.com" />

## Submission

After registering your developer account, setting it up, and preparing your extension, you're ready to publish it to the store.

You can submit your extension in two ways:

* **Manually**: By uploading your extension's bundle directly to the store.
* **Automatically**: By using GitHub Actions to submit your extension to the stores.

**The first submission must be done manually, while subsequent updates can be submitted automatically.** We'll go through both approaches.

### Manual submission

To manually submit your extension to stores, you will first need to get your extension bundle. If you ran the build step locally, you should already have the `.zip` file in your extension's `build` folder.

If you used GitHub Actions to build your extension, you can find the results in the workflow run. Download the artifacts and save them on your local machine.

Then, use the following steps to upload your item:

<Steps>
  <Step>
    #### Sign in to your developer account

    Go to the [Add-ons Developer Hub](https://addons.mozilla.org/developers/) and sign in to your developer account.
  </Step>

  <Step>
    #### Choose distribution method

    You should reach the following page:

    ![Mozilla distribution](/images/docs/extension/firefox/distribution.png)

    Here, you have two ways of distributing your extension:

    * **On this site**, if you want your add-on listed on AMO (Add-ons Manager).
    * **On your own**, if you plan to distribute the add-on yourself and don't want it listed on AMO.

    We recommend going with the first option, as it will allow you to reach more users and get more feedback. If you decide to go with the second option, please refer to the [official documentation](https://extensionworkshop.com/documentation/publish/self-distribution/) for more details.
  </Step>

  <Step>
    #### Submit your extension

    On the next page, click on *Select file* and choose your extension's `.zip` bundle.

    ![Mozilla upload](/images/docs/extension/firefox/upload.png)

    Once you upload the bundle, the validator checks the add-on for issues and the page updates:

    ![Mozilla validation](/images/docs/extension/firefox/validation.png)

    If your add-on passes all the checks, you can proceed to the next step.

    <Callout type="warn">
      You may receive a message that you only have warnings. It's advisable to address these warnings, particularly those flagged as security or privacy issues, as they may result in your add-on failing review. However, **you can continue with the submission**.
    </Callout>

    If the validation fails, you'll need to address the issues and upload a new version of your add-on.
  </Step>

  <Step>
    #### Submit source code (if needed)

    You'll need to indicate whether you need to provide the source code of your extension:

    ![Mozilla source code](/images/docs/extension/firefox/source-code.png)

    If you select *Yes*, a section displays describing what you need to submit. Click *Browse* and locate and upload your source code package. See [Source code submission](https://extensionworkshop.com/documentation/publish/source-code-submission/) for more information.

    <Callout type="warn">
      You may receive a message that you only have warnings. It's advisable to address these warnings, particularly those flagged as security or privacy issues, as they may result in your add-on failing review. However, **you can continue with the submission**.
    </Callout>

    If the validation fails, you'll need to address the issues and upload a new version of your add-on.
  </Step>

  <Step>
    #### Add metadata

    On the next page, you'll need to provide the following additional information about your extension:

    ![Mozilla additional information](/images/docs/extension/firefox/additional-info.png)

    * **Name**: Your add-on's name.
    * **Add-on URL**: The URL for your add-on on AMO. A URL is automatically assigned based on your add-on's name. To change this, click Edit. The URL must be unique. You will be warned if another add-on is using your chosen URL, and you must enter a different one.
    * **Summary**: A useful and descriptive short summary of your add-on.
    * **Description**: A longer description that provides users with details of the extension's features and functionality.
    * **This add-on is experimental**: Indicate if your add-on is experimental or otherwise not ready for general use. The add-on will be listed but with reduced visibility. You can remove this flag when your add-on is ready for general use.
    * **This add-on requires payment, non-free services or software, or additional hardware**: Indicate if your add-on requires users to make an additional purchase for it to work fully.
    * **Select up to 2 Firefox categories for this add-on**: Select categories that describe your add-on.
    * **Select up to 2 Firefox for Android categories for this add-on**: Select categories that describe your add-on.
    * **Support email and Support website**: Provide an email address and website where users can get in touch when they have questions, issues, or compliments.
    * **License**: Select the appropriate license for your add-on. Click Details to learn more about each license.
    * **This add-on has a privacy policy**: If any data is being transmitted from the user's device, a privacy policy explaining what is being sent and how it's used is required. Check this box and provide the privacy policy.
    * **Notes for Reviewers**: Provide information to assist the AMO reviewer, such as login details for a dummy account, source code information, or similar.
  </Step>

  <Step>
    #### Finalize the process

    Once you're ready, click on the *Submit Version* button.

    ![Mozilla submit](/images/docs/extension/firefox/submit.png)

    You can still edit your add-on's details from the dedicated page after submission.
  </Step>
</Steps>

<Cards>
  <Card title="Resources for publishers" href="https://extensionworkshop.com/documentation/manage/resources-for-publishers/" description="extensionworkshop.com" />

  <Card title="Source code submission" href="https://extensionworkshop.com/documentation/publish/source-code-submission/" description="extensionworkshop.com" />
</Cards>

### Automated submission

<Callout title="First submission must be done manually" type="warn">
  The first submission of your extension to Firefox Add-ons must be done manually because you need to provide the store's credentials and extension ID to automation, which will be available only after the first bundle upload.
</Callout>

TurboStarter comes with a pre-configured GitHub Actions workflow to submit your extension to web stores automatically. It's located in the `.github/workflows/publish-extension.yml` file.

What you need to do is fill the environment variables with your store's credentials and extension's details and set them as a [secrets in your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) under correct names:

```yaml title="publish-extension.yml"
env:
  FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }}
  FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
  FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}
```

Please refer to the [official guide](https://github.com/PlasmoHQ/bms/blob/main/tokens.md#firefox-add-ons-api) to learn how to get these credentials correctly.

That's it! You can [run the workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow) and it will submit your extension to the Firefox Add-ons 🎉

<Callout title="Automated submission to review">
  This workflow will also try to send your extension to review, but it's not guaranteed to happen. You need to have all required information filled in your extension's details page to make it possible.

  Even then, when you introduce some **breaking change** (e.g., add another permission), you'll need to update your extension store metadata and automatic submission won't be possible.
</Callout>

<Cards>
  <Card title="A new API for submitting and updating add-ons" href="https://blog.mozilla.org/addons/2022/03/17/new-api-for-submitting-and-updating-add-ons/" description="blog.mozilla.org" />

  <Card title="Mozilla API keys" href="https://addons.mozilla.org/en-US/developers/addon/api/key/" description="addons.mozilla.org" />
</Cards>

## Review

Once you submit your extension bundle, it's automatically sent to review and will undergo a review process. The time for this review depends on the nature of your item.

The add-on review process includes the following phases:

1. **Automatic Review**: Upon upload, the add-on undergoes several automatic validation steps to ensure its general safety.
2. **Content Review**: Shortly after submission, a human reviewer inspects the add-on to ensure that the listing adheres to content review guidelines, including metadata such as the add-on name and description.
3. **Technical Code Review**: The add-on's source code is examined to ensure compliance with review policies.
4. **Basic Functionality Testing**: After the source code is verified as safe, the add-on undergoes basic functionality testing to confirm it operates as described.

There are important emails like takedown or rejection notifications that are enabled by default. To receive an email notification when your item is published or staged, you can enable notifications in the *Account Settings*.

![Mozilla notifications](/images/docs/extension/firefox/notifications.png)

The review status of your item appears in the [developer hub](https://addons.mozilla.org/en-US/firefox/) next to each item.

![Mozilla review status](/images/docs/extension/firefox/review-status.png)

You'll receive an email notification when the status of your item changes.

<Callout title="Your submission might be rejected" type="error">
  If your extension has been determined to violate one or more terms or policies, you will receive an email notification that contains the violation description and instructions on how to rectify it.

  You can also check the reason behind the rejection on the *Status* page of your item.

  ![Mozilla extension rejected](/images/docs/extension/firefox/rejection.png)

  You'll need to fix the issues and upload a new version of your extension. Make sure to follow the [guidelines](/docs/extension/marketing) or check [publishing troubleshooting](/docs/extension/troubleshooting/publishing) for more info.
</Callout>

You can learn more about the review process in the official guides listed below.

<Cards>
  <Card title="Add-ons/Reviewers/Guide/Reviewing" href="https://wiki.mozilla.org/Add-ons/Reviewers/Guide/Reviewing" description="wiki.mozilla.org" />

  <Card title="Add-ons/Reviewers/Content Review Guidelines" href="https://wiki.mozilla.org/Add-ons/Reviewers/Content_Review_Guidelines" description="wiki.mozilla.org" />
</Cards>


# Updates
Source: https://www.turbostarter.dev/docs/extension/publishing/updates

After publishing your extension to the stores, you can release updates to deliver new features and bug fixes to your users.

TurboStarter provides a ready-to-use process for updating your extensions. Let's quickly review how it works.

## Uploading a new version

The recommended way to update your extension is to submit a new version to the stores. This method is the most reliable, although it may take some time for the new version to be approved and become available to users.

To submit a new version, simply update the version number in your `package.json` file:

```json title="package.json"
{
    ...
    "version": "1.0.0", // [!code --]
    "version": "1.0.1", // [!code ++]
    ...
}
```

Next, follow the exact same steps as [when you initially published your extension](/docs/extension/publishing/checklist). When submitting your extension for review, be sure to provide release notes describing the new version.


# Integrate AI Kit
Source: https://www.turbostarter.dev/docs/extension/recipes/ai-kit

[AI Kit](/ai/docs) does not ship a browser extension. Integrate its server packages and routes into Core Kit first, then build the extension as a thin client of that API.

<Callout type="info" title="The web app remains the backend">
  Complete the [web AI Kit integration](/docs/web/recipes/ai-kit) before adding an extension surface. Model providers, storage writes, database access, rate limits, and billing checks belong in the web API.
</Callout>

An extension is best for short, contextual actions: summarize the current page, rewrite selected text, ask about a tab, save a prompt, or continue a recent conversation. Keep model administration, history management, uploads, and billing in the web dashboard unless they are essential to the extension workflow.

## Choose the execution context

| Work                                 | Extension context                         |
| ------------------------------------ | ----------------------------------------- |
| Form state and streamed output       | Popup, side panel, new tab, or options UI |
| Read page text or selection          | Content script                            |
| Coordinate long-lived work           | Background entry                          |
| Invoke a model or write the database | Core web API only                         |

Content scripts should collect the smallest required page context and send it to an extension UI or background handler. They should never receive provider credentials.

<Steps>
  <Step>
    ## Confirm the shared API

    The Core extension client already points to `appConfig.url` and tags requests with `Platform.EXTENSION`:

    ```tsx title="apps/extension/src/lib/api/index.tsx"
    export const { api } = hc<AppRouter>(getBaseUrl(), {
      headers: {
        "x-client-platform": Platform.EXTENSION,
      },
    });
    ```

    After the web recipe, the typed client exposes the selected endpoints below `api.ai`. For AI Kit chat, the streaming endpoint is available from `api.ai.chat.chats.$url()`.

    Verify the production web origin is present in extension host permissions and allowed by the API's CORS and CSRF configuration. Keep Core's extension origin in Better Auth's `trustedOrigins`.
  </Step>

  <Step>
    ## Add an extension-specific AI module

    Do not copy AI Kit's Next.js screen into WXT. Create a small extension module that uses Core's API URL, auth session, and `@workspace/ui-web` components:

    ```text
    apps/extension/src/modules/ai/
      chat/
        composer.tsx
        conversation.tsx
        use-chat.ts
    ```

    Add `@ai-sdk/react` and `ai` to `apps/extension/package.json` if you want to consume AI SDK UI streams directly. Keep model-provider packages in `packages/ai`, not in the extension.

    A transport should use the Hono-generated URL and include extension credentials:

    ```tsx title="apps/extension/src/modules/ai/chat/use-chat.ts"
    import { useChat } from "@ai-sdk/react";
    import { DefaultChatTransport } from "ai";

    import { Platform } from "@workspace/shared/constants";

    import { api } from "~/lib/api";

    export const useExtensionChat = () =>
      useChat({
        transport: new DefaultChatTransport({
          api: api.ai.chat.chats.$url().toString(),
          credentials: "include",
          headers: {
            "x-client-platform": Platform.EXTENSION,
          },
        }),
      });
    ```

    If your auth setup stores the session outside the browser cookie jar, use the same authenticated fetch or cookie forwarding strategy as the rest of your extension instead of relying on `credentials: "include"`.
  </Step>

  <Step>
    ## Mount it in the right surface

    Use the popup for a single quick action. Prefer the side panel or new-tab page for a multi-turn conversation because closing the popup unmounts its React tree and interrupts local UI state.

    Wrap the chosen entry with Core's existing `Layout` so auth, TanStack Query, error handling, theme, analytics, and monitoring remain consistent:

    ```tsx title="apps/extension/src/app/sidepanel/main.tsx"
    import { Layout } from "~/modules/common/layout/layout";
    import { Chat } from "~/modules/ai/chat/conversation";

    export default function Sidepanel() {
      return (
        <Layout>
          <Chat />
        </Layout>
      );
    }
    ```

    Use the background entry when work must survive closing a popup. Persist only identifiers and resumable state in extension storage, not full provider responses or secrets.
  </Step>

  <Step>
    ## Pass page context deliberately

    For page-aware tools, define a typed WXT message between the content script and the extension UI. Send only what the server-side prompt needs, such as:

    * Canonical URL and page title
    * Selected text
    * A bounded excerpt of readable page content
    * User-confirmed metadata

    Validate and size-limit that payload again in the Hono route. Web page content is untrusted input, so do not concatenate it into system instructions and do not let it override tool or authorization rules.
  </Step>

  <Step>
    ## Test browser lifecycle and access

    ```bash
    pnpm --filter extension dev
    ```

    Check the cases that differ from a normal browser tab:

    1. Signed-in and signed-out requests across the extension origin.
    2. A stream interrupted by closing the popup.
    3. Side-panel state after switching tabs.
    4. Content from a hostile page that contains prompt-injection text.
    5. Requests above the plan, credit, or rate limit.
    6. Chrome and Firefox production builds with the final API origin.

    Build both targets before shipping:

    ```bash
    pnpm --filter extension build:chrome
    pnpm --filter extension build:firefox
    ```
  </Step>
</Steps>

<Cards>
  <Card title="Web AI Kit integration" description="Add the server packages, schemas, routes, and provider configuration." href="/docs/web/recipes/ai-kit" />

  <Card title="Extension API client" description="Review the typed Hono client and platform headers." href="/docs/extension/api/client" />

  <Card title="Extension authentication" description="Keep Better Auth sessions working across extension origins." href="/docs/extension/auth/overview" />
</Cards>


# Build a production feature
Source: https://www.turbostarter.dev/docs/extension/recipes/build-a-feature

Extensions are not a third backend. They are **another client** for the same Hono API, optimized for quick actions in the popup, side panel, or toolbar. Full management UI usually lives on the web when the surface is too cramped.

This recipe adds a **feedback entry point** to the extension: read the user's session, submit feedback through `POST /api/feedback`, and link out to the dashboard for anything heavier (admin inbox, attachments, long threads).

Complete the shared backend in the [web feature recipe](/docs/web/recipes/build-a-feature) first.

<Callout title="What you'll ship in the extension">
  * Typed `feedback` mutations in `modules/feedback/lib/api.ts`
  * A compact form in the popup (or side panel)
  * Session-aware UI. Hide email when `authClient.useSession()` returns a user.
  * Optional link to the web app for admin / history views
</Callout>

## Where extensions fit

The popup mounts `FeedbackForm`, which reads the Better Auth session and calls `lib/api.ts`. Requests hit `POST /api/feedback` with the `EXTENSION` platform header. For admin or history views, link out to the web dashboard.

TurboStarter's extension already demonstrates session and API usage in the header and billing badge. Study `apps/extension/src/modules/common/layout/header.tsx` and `modules/billing/lib/api.ts` for production patterns.

## Extension vs web vs mobile

| Concern      | Extension                                                          |
| ------------ | ------------------------------------------------------------------ |
| API client   | `x-client-platform: EXTENSION`. No cookie jar like web.            |
| Auth state   | `authClient.useSession()` / `useActiveOrganization()`              |
| Full CRUD UI | Usually deferred to web (`appConfig.url` + paths)                  |
| Entry points | `popup`, `sidepanel`, `options`, `newtab`. Each mounts separately. |
| React Query  | Wrap each entry in `QueryClientProvider` via shared `Layout`       |

<Steps>
  <Step>
    ## API client setup

    The extension client is minimal. Platform header only:

    ```tsx title="apps/extension/src/lib/api/index.tsx"
    export const { api } = hc<AppRouter>(getBaseUrl(), {
      headers: {
        "x-client-platform": Platform.EXTENSION,
      },
    });
    ```

    Session cookies are handled by Better Auth's extension integration (same `authClient` as other surfaces). If protected routes return 401, confirm the user completed extension auth. See [Auth overview](/docs/extension/auth/overview).
  </Step>

  <Step>
    ## Feature API module

    Identical shape to web and mobile. Only the import path for `api` differs:

    ```ts title="apps/extension/src/modules/feedback/lib/api.ts"
    import { mutationOptions } from "@tanstack/react-query";

    import { createFeedbackResponseSchema } from "@workspace/api/schema";
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api";

    const KEY = "feedback";

    export const feedback = {
      mutations: {
        create: mutationOptions({
          mutationKey: [KEY, "create"],
          mutationFn: (json: { message: string; type: string; email?: string }) =>
            handle(api.feedback.$post, {
              schema: createFeedbackResponseSchema,
            })({ json }),
        }),
      },
    };
    ```
  </Step>

  <Step>
    ## Build a compact popup form

    Extension UI uses `@workspace/ui-web` (same primitives as the web app) inside a narrow viewport. Keep fields minimal. Message and type are enough for v1.

    ```tsx title="apps/extension/src/modules/feedback/feedback-form.tsx"
    import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
    import { useMutation } from "@tanstack/react-query";
    import { Controller, useForm } from "react-hook-form";

    import { createFeedbackInputSchema } from "@workspace/api/schema";
    import { useTranslation } from "@workspace/i18n";
    import { Button } from "@workspace/ui-web/button";
    import { Field, FieldError, FieldLabel } from "@workspace/ui-web/field";
    import { Textarea } from "@workspace/ui-web/textarea";
    import { toast } from "sonner";

    import { authClient } from "~/lib/auth";

    import { feedback } from "./lib/api";

    import type { CreateFeedbackInput } from "@workspace/api/schema";

    export const FeedbackForm = () => {
      const { t } = useTranslation(["common", "feedback"]);
      const session = authClient.useSession();
      const user = session.data?.user;

      const create = useMutation({
        ...feedback.mutations.create,
        onSuccess: () => {
          form.reset();
          toast.success(t("feedback:success"));
        },
        onError: () => toast.error(t("feedback:error")),
      });

      const form = useForm<CreateFeedbackInput>({
        resolver: standardSchemaResolver(createFeedbackInputSchema),
        defaultValues: { message: "", type: "general" },
      });

      if (session.isPending) {
        return null;
      }

      return (
        <form
          className="flex w-80 flex-col gap-3 p-4"
          onSubmit={form.handleSubmit((data) =>
            create.mutate({
              ...data,
              email: user ? undefined : data.email,
            }),
          )}
        >
          <Controller
            name="message"
            control={form.control}
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid}>
                <FieldLabel>{t("feedback:message.label")}</FieldLabel>
                <Textarea {...field} className="min-h-20" />
                {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
              </Field>
            )}
          />

          <Button type="submit" size="sm" disabled={create.isPending}>
            {t("feedback:submit")}
          </Button>
        </form>
      );
    };
    ```
  </Step>

  <Step>
    ## Mount in an entry point

    Every extension surface is its own React root. Wrap with the shared `Layout` so React Query and error boundaries are available:

    ```tsx title="apps/extension/src/app/popup/main.tsx"
    import { Layout } from "~/modules/common/layout/layout";
    import { Header } from "~/modules/common/layout/header";
    import { FeedbackForm } from "~/modules/feedback/feedback-form";

    export default function Popup() {
      return (
        <Layout>
          <Header />
          <FeedbackForm />
        </Layout>
      );
    }
    ```

    The same `FeedbackForm` can be imported into `sidepanel/main.tsx` or tucked behind a tab in `options` if you want a settings-style layout.
  </Step>

  <Step>
    ## Link out for the heavy UI

    Extensions excel at **capture**; web excels at **management**. Pattern from billing and organizations:

    ```tsx title="apps/extension/src/modules/feedback/feedback-footer.tsx"
    import { useTranslation } from "@workspace/i18n";
    import { Button } from "@workspace/ui-web/button";

    import { appConfig } from "~/config/app";
    import { pathsConfig } from "~/config/paths";

    export const FeedbackFooter = () => {
      const { t } = useTranslation("feedback");

      return (
        <Button
          variant="link"
          size="sm"
          onClick={() => {
            void chrome.tabs.create({
              url: new URL(pathsConfig.dashboard.index, appConfig.url).href,
            });
          }}
        >
          {t("viewInDashboard")}
        </Button>
      );
    };
    ```

    Add `viewInDashboard` to `feedback.json` when you build the admin inbox on web.
  </Step>

  <Step>
    ## Test the extension

    **Local dev**

    1. Run the web API: `pnpm dev` (extension calls `appConfig.url`)
    2. Build / load the extension: `pnpm --filter extension dev`
    3. Open the popup, submit feedback, verify the database row
    4. Test signed-out behavior. Validation errors should surface in the popup.

    **Playwright E2E (optional)**

    Extension E2E lives under `apps/extension/e2e/`. Reuse auth storage state and assert the popup form. See [E2E testing](/docs/extension/tests/e2e).
  </Step>
</Steps>

## File map

<Files>
  <Folder name="apps/extension/src - Browser extension" defaultOpen>
    <Folder name="modules/feedback - Feature UI + client API" defaultOpen>
      <File name="feedback-form.tsx - Compact popup form" />

      <File name="feedback-footer.tsx - Link to web dashboard" />

      <Folder name="lib - TanStack Query layer" defaultOpen>
        <File name="api.ts - Mutations and query keys" />
      </Folder>
    </Folder>

    <Folder name="app/popup - Extension entry point" defaultOpen>
      <File name="main.tsx - Mounts Layout + FeedbackForm" />
    </Folder>
  </Folder>
</Files>

## Ship checklist

| Layer         | Done when…                                            |
| ------------- | ----------------------------------------------------- |
| **Layout**    | Popup wrapped in `QueryClientProvider` via `Layout`   |
| **API**       | Mutation succeeds with `EXTENSION` platform header    |
| **Session**   | Signed-in users skip guest-only fields                |
| **UX**        | Toast on success; form resets for another submission  |
| **Deep link** | Optional web URL opens for admin/history (when built) |

## When to go extension-native vs web-only

| Build in extension                       | Keep on web                        |
| ---------------------------------------- | ---------------------------------- |
| Quick capture (feedback, bookmark, note) | Data tables, filters, bulk actions |
| Session / plan badge                     | Billing checkout and portal        |
| Theme + locale toggles                   | Organization settings and RBAC     |
| Open-current-tab helpers                 | File uploads with large previews   |

## Related guides

<Cards>
  <Card title="Web feature recipe" description="Database, API, and full UI. Required first step." href="/docs/web/recipes/build-a-feature" />

  <Card title="Mobile feature recipe" description="Native Bottom Sheet version of the same API." href="/docs/mobile/recipes/build-a-feature" />

  <Card title="Extension API client" description="QueryClientProvider, typed routes, and auth from the popup." href="/docs/extension/api/client" />

  <Card title="Organizations in extension" description="Active organization context without duplicating dashboard UI." href="/docs/extension/organizations" />
</Cards>


# Feature-based access
Source: https://www.turbostarter.dev/docs/extension/recipes/feature-based-access

Browser extensions sit between a lightweight UI and your full web app. Customers expect premium extension features to match what they purchased on the web — without exposing billing secrets in the extension bundle.

TurboStarter extensions call the same billing API as the web dashboard. This recipe shows how to resolve the active plan, gate extension UI, and send users to the web app when they need to upgrade.

<Callout title="TL;DR">
  1. Reuse feature keys from `packages/billing/shared/src/config/features.ts`.
  2. Fetch `billing.queries.summary` with the user or organization `referenceId`.
  3. Call `getActivePlan()` and `isFeatureAvailable()` — same helpers as web.
  4. Hide or disable locked UI; open the web dashboard for checkout.
  5. Enforce access on API routes — the extension is not a trust boundary.
</Callout>

## What is different in the extension?

| Concern          | Web app                | Extension                          |
| ---------------- | ---------------------- | ---------------------------------- |
| Billing checkout | In-app Stripe / portal | Link to web pricing or portal      |
| State            | Full React Query cache | Thin summary query                 |
| Entitlements     | DB subscriptions       | API summary (no local store SDK)   |
| Upgrade UX       | Modal or `/pricing`    | `target="_blank"` to web dashboard |

Extensions do **not** run RevenueCat or Superwall. If a user subscribes on mobile, webhooks sync to your database and the extension sees the updated plan on the next `summary` fetch.

For shared concepts — `features.ts`, billing config, `isFeatureAvailable()`, and API middleware — start with the [web recipe](/docs/web/recipes/feature-based-access).

<Steps>
  <Step>
    ## Fetch billing summary from the API

    The extension billing client mirrors web queries:

    ```ts title="apps/extension/src/modules/billing/lib/api.ts"
    const queries = {
      summary: {
        get: (referenceId: string) =>
          queryOptions({
            queryKey: [KEY, "summary", referenceId],
            queryFn: () =>
              handle(api.billing.summary.$get)({
                query: { referenceId },
              }),
          }),
      },
    };
    ```

    Use the authenticated user's id, or the active organization's id when the extension is organization-aware:

    ```tsx title="apps/extension/src/modules/user/user-navigation.tsx"
    const summary = useQuery({
      ...billing.queries.summary.get(organization?.id ?? user?.id ?? ""),
      enabled: !!organization || !!user,
    });
    const activePlan = getActivePlan(summary.data);
    ```

    The user navigation component already displays the resolved plan badge — reuse that `referenceId` everywhere you gate features.
  </Step>

  <Step>
    ## Add a reusable access hook

    Centralize plan logic so popup, options page, and content scripts stay consistent:

    ```tsx title="apps/extension/src/modules/billing/hooks/use-billing-access.ts"
    import { useQuery } from "@tanstack/react-query";
    import {
      getActivePlan,
      isFeatureAvailable,
      getHigherPlans,
    } from "@workspace/billing";

    import { billing } from "~/modules/billing/lib/api";

    import type { Feature } from "@workspace/billing";

    export const useBillingAccess = (referenceId: string | undefined) => {
      const summary = useQuery({
        ...billing.queries.summary.get(referenceId ?? ""),
        enabled: !!referenceId,
      });

      const activePlan = getActivePlan(summary.data);

      const hasFeature = (feature: Feature) =>
        isFeatureAvailable(summary.data ?? [], feature);

      const nextUpgradePlan = getHigherPlans(activePlan)[0];

      return {
        activePlan,
        hasFeature,
        nextUpgradePlan,
        isLoading: summary.isLoading,
        isError: summary.isError,
      };
    };
    ```
  </Step>

  <Step>
    ## Gate extension UI

    ### Popup actions

    Disable premium actions and explain why:

    ```tsx title="apps/extension/src/modules/popup/premium-action.tsx"
    import { FEATURES, BillingPlan } from "@workspace/billing";
    import { Button } from "@workspace/ui-web/button";

    import { appConfig } from "~/config/app";
    import { useBillingAccess } from "~/modules/billing/hooks/use-billing-access";

    export const PremiumAction = ({ userId }: { userId: string }) => {
      const { hasFeature, isLoading } = useBillingAccess(userId);
      const canExport = hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS);

      if (isLoading) {
        return <Button disabled>Loading…</Button>;
      }

      if (!canExport) {
        return (
          <Button
            variant="outline"
            onClick={() => {
              window.open(`${appConfig.url}/pricing`, "_blank");
            }}
          >
            Upgrade to export
          </Button>
        );
      }

      return <Button onClick={runExport}>Export</Button>;
    };
    ```

    ### Content scripts

    If a content script needs plan data, pass it from the background service worker via `chrome.runtime.sendMessage` after fetching summary once — avoid duplicating auth cookies in injected scripts.

    ### Options page

    Show `FeaturesList` for the current plan and compare with `getHigherPlans()` so users know what an upgrade unlocks:

    ```tsx
    import { FeaturesList } from "~/modules/billing/features-list";

    <FeaturesList planId={activePlan} />;
    ```
  </Step>

  <Step>
    ## Link to web checkout and portal

    Extensions cannot host Stripe Checkout inline. Send users to the web app:

    ```tsx
    const pricingUrl = new URL("/pricing", appConfig.url);
    pricingUrl.searchParams.set("redirectTo", chrome.runtime.getURL("popup.html"));

    window.open(pricingUrl.toString(), "_blank");
    ```

    For existing subscribers, link to dashboard billing settings where the web app opens the provider portal:

    ```tsx
    const billingUrl = `${appConfig.url}/dashboard/settings/billing`;
    window.open(billingUrl, "_blank");
    ```

    Match `appConfig.url` to your deployed web origin in extension environment config.
  </Step>

  <Step>
    ## Handle unauthenticated users

    The extension already shows a login button when `user` is null:

    ```tsx title="apps/extension/src/modules/user/user-navigation.tsx"
    if (!user) {
      return <AnonymousUser />;
    }
    ```

    Treat unauthenticated the same as "feature locked" for premium actions — prompt login first, then evaluate plan:

    ```tsx
    if (!user) {
      return (
        <Button
          onClick={() => window.open(`${appConfig.url}/auth/login`, "_blank")}
        >
          Sign in to continue
        </Button>
      );
    }
    ```
  </Step>

  <Step>
    ## Enforce on the API

    Extension UI gating improves UX but is not security. Any API route the extension calls must use `enforceAuth` and, where needed, `enforceFeatureAvailable()` — see the [web recipe](/docs/web/recipes/feature-based-access).

    When the API returns `402` with `error.upgradeRequired`, map it in the extension client:

    ```tsx
    try {
      await api.feature.export.$post();
    } catch (error) {
      if (error.code === "error.upgradeRequired") {
        window.open(`${appConfig.url}/pricing`, "_blank");
        return;
      }
      throw error;
    }
    ```
  </Step>

  <Step>
    ## Cache and refresh strategy

    Billing state changes after checkout completes in another tab:

    * Set a modest `staleTime` on the summary query (e.g. 30–60 seconds).
    * Refetch when the popup opens (`refetchOnMount: "always"`).
    * Listen for `chrome.storage` events if the background worker detects a successful web session change.

    After the user upgrades on the web, reopening the popup should show unlocked features without reinstalling the extension.
  </Step>
</Steps>

## Architecture sketch

```
User opens extension popup
        │
        ▼
  Session valid? ──no──► Login link → web auth
        │
       yes
        ▼
  GET /billing/summary?referenceId=...
        │
        ▼
  getActivePlan(summary)
        │
        ▼
  isFeatureAvailable(summary, FEATURE)
        │
   ┌────┴────┐
  yes       no
   │         │
 Feature   Upgrade link
   UI      → web /pricing
```

## Checklist

* `useBillingAccess` used in popup, options, and background worker
* `referenceId` matches user or organization context
* Upgrade links point to production web `appConfig.url`
* API routes enforce features server-side
* Summary refetches when popup opens
* Login flow tested for anonymous users

<Cards>
  <Card title="Web feature-based access" href="/docs/web/recipes/feature-based-access" description="features.ts, middleware, limits, and isFeatureAvailable." />

  <Card title="Mobile feature-based access" href="/docs/mobile/recipes/feature-based-access" description="Store entitlements merged with the same billing summary." />

  <Card title="Protected routes" href="/docs/web/api/protected-routes" description="Auth and feature middleware for API endpoints." />

  <Card title="Billing configuration" href="/docs/web/billing/configuration" description="Plan features and limits shared across all apps." />
</Cards>


# Multiple environments
Source: https://www.turbostarter.dev/docs/extension/recipes/multiple-environments

Browser extensions are shipped as static bundles, so any value exposed to the extension can be inspected by users. Treat extension environment variables as public configuration and keep secrets behind your web/API server.

The safe pattern is:

* use modes for environments, such as `development`, `staging`, and `production`
* use browser-specific files only when Chrome, Firefox, Edge, or Safari need different values
* expose runtime values with `VITE_` or `WXT_`
* keep signing keys and store credentials in CI secrets, not extension env files

<Callout title="No extension secrets" type="warn">
  Do not put private API keys, database URLs, webhook secrets, or service-role tokens in the extension. If the extension needs a privileged action, call your authenticated web/API endpoint.
</Callout>

<Steps>
  <Step>
    ## Choose your environment values

    Start with the values the extension needs to know:

    ```dotenv title="apps/extension/.env.example"
    VITE_APP_ENV="development"
    VITE_SITE_URL="http://localhost:3000"
    VITE_DEFAULT_LOCALE="en"
    VITE_THEME_MODE="system"
    VITE_THEME_COLOR="orange"
    ```

    Use the same names in every environment so your code does not need environment-specific branches.
  </Step>

  <Step>
    ## Create mode-specific files

    WXT follows Vite-style env loading and supports mode-specific files:

    ```dotenv title="apps/extension/.env.development.local"
    VITE_APP_ENV="development"
    VITE_SITE_URL="http://localhost:3000"
    VITE_DEFAULT_LOCALE="en"
    VITE_THEME_MODE="system"
    VITE_THEME_COLOR="orange"
    ```

    ```dotenv title="apps/extension/.env.staging.local"
    VITE_APP_ENV="staging"
    VITE_SITE_URL="https://staging.example.com"
    VITE_DEFAULT_LOCALE="en"
    VITE_THEME_MODE="system"
    VITE_THEME_COLOR="orange"
    ```

    ```dotenv title="apps/extension/.env.production.local"
    VITE_APP_ENV="production"
    VITE_SITE_URL="https://example.com"
    VITE_DEFAULT_LOCALE="en"
    VITE_THEME_MODE="system"
    VITE_THEME_COLOR="orange"
    ```

    Use `.local` files for machine-specific or sensitive values and keep them ignored.
  </Step>

  <Step>
    ## Add browser-specific overrides only when needed

    If a browser needs different values, add the browser to the filename:

    ```dotenv title="apps/extension/.env.production.firefox.local"
    VITE_SITE_URL="https://example.com"
    VITE_FIREFOX_EXTENSION_ID="extension@example.com"
    ```

    WXT can load files by mode and browser, for example:

    * `.env.production`
    * `.env.production.local`
    * `.env.firefox`
    * `.env.production.firefox`
    * `.env.production.firefox.local`

    Keep the shared values in mode files and use browser files only for browser-specific IDs, permissions, or store behavior.
  </Step>

  <Step>
    ## Use env values in extension code

    Read public configuration through `import.meta.env`:

    ```ts title="apps/extension/utils/config.ts"
    export const config = {
      appEnv: import.meta.env.VITE_APP_ENV,
      siteUrl: import.meta.env.VITE_SITE_URL,
      defaultLocale: import.meta.env.VITE_DEFAULT_LOCALE,
    };
    ```

    When using env values inside the manifest, use the function form so WXT can load env files first:

    ```ts title="apps/extension/wxt.config.ts"
    import { defineConfig } from "wxt";

    export default defineConfig({
      manifest: ({ mode }) => ({
        name: mode === "production" ? "Acme" : `Acme (${mode})`,
        host_permissions: [`${import.meta.env.VITE_SITE_URL}/*`],
      }),
    });
    ```
  </Step>

  <Step>
    ## Add scripts for each target

    Use `--mode` for the environment and `-b` for the browser target:

    ```json title="apps/extension/package.json"
    {
      "scripts": {
        "dev:chrome": "wxt -b chrome --mode development",
        "dev:firefox": "wxt -b firefox --mode development",
        "build:chrome:staging": "wxt build -b chrome --mode staging",
        "build:firefox:staging": "wxt build -b firefox --mode staging",
        "build:chrome:production": "wxt build -b chrome --mode production",
        "build:firefox:production": "wxt build -b firefox --mode production"
      }
    }
    ```

    Then run:

    ```bash
    pnpm --filter extension build:chrome:staging
    pnpm --filter extension build:firefox:production
    ```
  </Step>

  <Step>
    ## Configure CI secrets separately

    Environment variables that build the bundle are different from secrets used to publish it.

    Keep bundle-safe values as normal CI environment variables:

    ```yaml title=".github/workflows/publish-extension.yml"
    env:
      VITE_APP_ENV: production
      VITE_SITE_URL: https://example.com
    ```

    Keep store credentials and signing keys as CI secrets:

    ```yaml title=".github/workflows/publish-extension.yml"
    env:
      CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
      CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
      CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
      CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
    ```

    Before release, verify:

    * `VITE_SITE_URL` points to the matching web/API environment
    * extension origins are allowed by auth and CORS settings
    * browser-specific IDs match the store listing
    * staging builds use staging API, analytics, and auth settings
    * no private values are prefixed with `VITE_` or `WXT_`
  </Step>
</Steps>

## Useful references

* [Environment variables](/docs/extension/configuration/environment-variables)
* [WXT environment variables](https://wxt.dev/guide/essentials/config/environment-variables.html)
* [Vite env variables and modes](https://vite.dev/guide/env-and-mode)


# Onboarding flow
Source: https://www.turbostarter.dev/docs/extension/recipes/onboarding

Browser extensions should stay thin. TurboStarter already authenticates the extension against the **web** Better Auth session and shows plan badges from the same billing API. There is no built-in multi-step wizard in the extension - and you usually should not build a full one inside the popup.

This recipe covers a practical first-run path: **welcome → sign in on the web → optional hard empty-state until the user has a paid plan**.

<Callout title="TL;DR">
  1. Detect first open with `chrome.storage` / `browser.storage` (or skip if session exists).
  2. Send unauthenticated users to the web `/auth/login` (existing pattern).
  3. Prefer completing rich onboarding on **web**; sync completion via the API/session.
  4. Soft monetization: show upgrade links to web pricing. Hard: block premium UI until `getActivePlan()` is paid.
  5. Never trust the extension alone - enforce plans on the API.
</Callout>

## Web vs extension responsibilities

| Concern                               | Web app                                              | Extension                     |
| ------------------------------------- | ---------------------------------------------------- | ----------------------------- |
| Multi-step profile / workspace wizard | Yes - [web onboarding](/docs/web/recipes/onboarding) | Link out or skip              |
| Auth                                  | Full Better Auth UI                                  | Opens web login               |
| Checkout                              | Stripe / Lemon / Polar / …                           | Link to web pricing or portal |
| First-run chrome                      | Dashboard onboarding route                           | Popup / options empty state   |
| Source of truth                       | Postgres + session                                   | API summary + storage flags   |

If you already require onboarding completion on the web, the extension only needs to handle **logged-out** and **logged-in-but-unpaid** states.

When the popup opens:

1. **No session** - show a short welcome and a Sign in button that opens web `/auth/login`.
2. **Session + soft mode** - render the normal popup / sidepanel.
3. **Session + hard mode + Free plan** - show a “Choose a plan” empty state that links to web `/pricing` or `/dashboard/choose-plan`.

<Steps>
  <Step>
    ## Add a first-run flag (optional)

    Use extension storage for UI that should show once per install (tooltips, permission explanations). Do **not** use it as the only “onboarding completed” signal if the same user might finish setup on the web first.

    ```ts title="apps/extension/src/modules/onboarding/storage.ts"
    const KEY = "extension.firstRunSeen";

    export const getFirstRunSeen = async () => {
      const result = await chrome.storage.local.get(KEY);
      return Boolean(result[KEY]);
    };

    export const setFirstRunSeen = async () => {
      await chrome.storage.local.set({ [KEY]: true });
    };
    ```

    Show a short welcome card in the popup when `!session && !firstRunSeen`, then set the flag when they dismiss or click Sign in.
  </Step>

  <Step>
    ## Reuse web auth

    The extension auth client already talks to the web app. Login UX should open the web login page (same as the user menu / e2e flows):

    ```ts
    const loginUrl = `${webAppUrl}/auth/login`;
    // chrome.tabs.create({ url: loginUrl }) or window.open in the popup
    ```

    After login, the session cookie / token flow used by `apps/extension/src/lib/auth` hydrates `authClient.useSession()`. Prefer redirecting new users to **web onboarding** when you need profile data:

    ```ts
    const loginUrl = `${webAppUrl}/auth/login?redirectTo=/dashboard/onboarding`;
    ```

    Only build in-extension forms when the answer is extension-specific (e.g. “which sites to enable”).
  </Step>

  <Step>
    ## Soft empty state for logged-out users

    Keep the popup useful even before auth: brand, one sentence, primary CTA.

    ```tsx title="apps/extension/src/modules/onboarding/welcome.tsx"
    import { Button } from /* your extension UI */;

    interface WelcomeProps {
      readonly onSignIn: () => void;
    }

    export const Welcome = ({ onSignIn }: WelcomeProps) => {
      return (
        <div className="flex flex-col gap-3 p-4">
          <h1 className="text-base font-semibold">Welcome to TurboStarter</h1>
          <p className="text-muted-foreground text-sm">
            Sign in to sync your account and unlock extension features.
          </p>
          <Button onClick={onSignIn}>Sign in</Button>
        </div>
      );
    };
    ```

    Render `Welcome` when `!session`; otherwise render the normal popup content (header already shows the user when signed in).
  </Step>

  <Step>
    ## Optional: hard paywall empty state

    Extensions rarely run native IAP. “Hard paywall” means: **hide product UI** until the billing summary shows a paid plan, and send the user to the web to checkout.

    ```tsx title="apps/extension/src/modules/onboarding/choose-plan.tsx"
    interface ChoosePlanProps {
      readonly pricingUrl: string;
    }

    export const ChoosePlanEmpty = ({ pricingUrl }: ChoosePlanProps) => {
      return (
        <div className="flex flex-col gap-3 p-4">
          <h1 className="text-base font-semibold">Subscription required</h1>
          <p className="text-muted-foreground text-sm">
            Choose a plan on the web to use this extension.
          </p>
          <a href={pricingUrl} target="_blank" rel="noreferrer">
            View plans
          </a>
        </div>
      );
    };
    ```

    Resolve the plan with the same summary query as [feature-based access](/docs/extension/recipes/feature-based-access), then compose in the popup root:

    ```tsx
    import { BillingPlan, getActivePlan } from "@workspace/billing";

    const summary = useQuery(billing.queries.summary.get(user.id));
    const activePlan = getActivePlan(summary.data);

    if (!session) {
      return <Welcome onSignIn={openLogin} />;
    }

    if (REQUIRE_PAID_PLAN && activePlan === BillingPlan.FREE) {
      return <ChoosePlanEmpty pricingUrl={pricingUrl} />;
    }

    return <PopupApp />;
    ```

    Point `pricingUrl` at marketing `/pricing` or your web `choose-plan` route from the [web onboarding](/docs/web/recipes/onboarding) recipe.

    Prefer feature gates when only **some** actions are paid - hard-blocking the whole popup is for paid-only products.
  </Step>

  <Step>
    ## Keep content scripts honest

    Content scripts and background workers can call your API with the user’s session. Treat them like any other client:

    * Soft: degrade UI when Free.
    * Hard: do not inject premium behaviors until the plan check passes.
    * Always enforce on the server with the same middleware as web.

    If onboarding answers live only on the web, fetch them via API when the extension needs them - do not re-collect in the popup.
  </Step>
</Steps>

## Checklist

* [ ] Logged-out popup has a clear Sign in path to the web app
* [ ] Rich profile/workspace onboarding lives on web when possible
* [ ] First-run storage is UX-only, not the security boundary
* [ ] Hard mode links to web checkout; plan resolved with `getActivePlan(summary)`
* [ ] API enforces paid features ([feature-based access](/docs/extension/recipes/feature-based-access))

## Other platforms

<Cards>
  <Card title="Web onboarding" href="/docs/web/recipes/onboarding" description="Server-backed wizard and optional choose-plan hard paywall." />

  <Card title="Mobile onboarding" href="/docs/mobile/recipes/onboarding" description="Built-in welcome, steps, and store paywalls." />

  <Card title="Extension feature gating" href="/docs/extension/recipes/feature-based-access" description="Plan checks and upgrade links for premium actions." />

  <Card title="Extension auth" href="/docs/extension/auth/overview" description="How the extension session ties to the web app." />
</Cards>


# Versioning
Source: https://www.turbostarter.dev/docs/extension/recipes/versioning

A browser extension is a packaged artifact users install and keep until a **new version** passes the store and the browser updates them. You cannot silently overwrite what is already installed the way you redeploy a website. The version string in the manifest is the shared id between your zip, the store listing, and the browser.

That makes versioning less of a nice changelog habit and more of a release gate.

## Why stores care so much?

* **Uniqueness** - Chrome, Edge, and Firefox reject an upload whose version was already used on that listing. There is no reuse, and you should not decrease.
* **Review unit** - each submission is a version. Notes, permission diffs, and reviewer questions all attach to it.
* **Update delivery** - users only move forward when a higher version is published and the browser fetches it.
* **Support** - "broken on 1.2.4, fine on 1.2.5" is how you bisect permission and host-access bugs.

Treat every upload as permanent history. Burned a version on a bad zip? Bump again. Do not try to resurrect the old number.

## Version meaning

[Semantic Versioning](https://semver.org/) still works as a team language:

* **Patch** - fixes and polish that do not change the trust boundary
* **Minor** - new capabilities, especially optional permissions handled carefully
* **Major** - breaking UX or required new permissions that change what the extension can access

The number is not only for engineers. Store dashboards, rollout tools, and users who inspect `chrome://extensions` all see it. Prefer clean `MAJOR.MINOR.PATCH` on production uploads. Keep alpha/beta labels for internal builds if you need them.

## Permissions considerations

Code size is not the only signal. Adding or widening host permissions / required permissions often means:

* another review pass
* a new consent prompt for existing users
* support questions that only make sense if you know which version introduced the change

When the trust boundary moves, bump at least minor and say so in the listing notes. A silent patch that suddenly asks for `<all_urls>` is how listings get delayed or users churn.

## Release process

1. Decide what this release is (fix, feature, permission change).
2. Choose the next version accordingly.
3. Build one artifact from that version and upload **that** zip to every store you ship.
4. Write short release notes aimed at reviewers and users, not only at your changelog file.

Automate the upload if you want; do not automate away the version decision. The store will remember every number you burn.

## In TurboStarter

The extension app version lives in `apps/extension/package.json`. WXT writes it into the generated manifest on build (including `version_name` when you use pre-release labels). The same value is available as `appConfig.version` in the UI.

Bump `package.json`, rebuild the zip, then follow [Updates](/docs/extension/publishing/updates) / the [publishing checklist](/docs/extension/publishing/checklist). You do not need a separate version field in `wxt.config.ts` for normal releases.

More on how WXT maps package version → manifest: [WXT manifest config](https://wxt.dev/guide/essentials/config/manifest.html).


# Checklist
Source: https://www.turbostarter.dev/docs/extension/security/checklist

Use this checklist before submitting to Chrome Web Store, Firefox Add-ons, or Edge Add-ons, and again after permission or content-script changes. Pair it with the [publishing checklist](/docs/extension/publishing/checklist).

## Permissions

* [ ] Only required permissions are declared in `wxt.config.ts`
* [ ] `host_permissions` are limited to your app origins (no `<all_urls>`)
* [ ] Store privacy / permission justifications match the manifest
* [ ] Removed features no longer leave unused permissions behind

## Session & origins

* [ ] Production `chrome-extension://` (and other store ids) are in Better Auth `trustedOrigins`
* [ ] Cookie + host permissions match the production web origin
* [ ] Sign-out clears the shared session across web and extension
* [ ] Session cookies / tokens are not logged to monitoring tools

## Content scripts & messaging

* [ ] Content-script `matches` are as narrow as possible
* [ ] No secrets or raw session tokens in page-facing scripts
* [ ] Message handlers validate payloads before acting
* [ ] Privileged work runs in background or on the Hono API

## Storage

* [ ] Extension storage holds prefs/cache only - not server secrets
* [ ] Account-related cache is cleared on sign-out when appropriate

## API & process

* [ ] Sensitive actions call protected API routes (see [Web security](/docs/web/security/overview))
* [ ] Dependencies updated for known extension / bundler advisories
* [ ] Monitoring covers popup, background, and content scripts without leaking cookies

<Callout title="Ship with confidence">
  You do not need every optional hardening step on day one, but you **do** need least-privilege permissions, correct trusted origins, isolated content scripts, and server-side enforcement for anything sensitive.
</Callout>


# Content scripts & messaging
Source: https://www.turbostarter.dev/docs/extension/security/content-scripts

Content scripts run on web pages in an **isolated world**: they can see the DOM, but they do not share the page’s JavaScript scope. That isolation is a core security boundary - do not punch holes in it casually.

## Match patterns

Define content scripts with the narrowest `matches` that still work:

```ts title="src/app/content/index.ts"
export default defineContentScript({
  matches: ["https://app.example.com/*"],
  async main(ctx) {
    // ...
  },
});
```

Avoid `<all_urls>` unless the product truly requires it (same store and security concerns as host permissions).

See [Content scripts](/docs/extension/structure/content-scripts) for CSUI and layout details.

## Isolation rules

* Never put API secrets, webhook secrets, or raw session tokens into page-injected scripts
* Prefer extension pages (popup, options, sidepanel) for privileged UI
* If you must bridge to `window`, expose the smallest API and assume the page can call it
* Sanitize anything you read from the DOM before sending it to your API

## Messaging

Popup, background, and content scripts talk through typed messaging. Treat every message like an external request:

1. Validate the message shape (Zod or equivalent)
2. Confirm the sender context when the action is sensitive
3. Perform privileged work in the **background** (or via your Hono API), not in the content script
4. Do not trust a content script claiming `userId` / `organizationId` / `isAdmin` without server verification

```ts title="Pattern"
// Background: validate, then call the protected API with the real session
```

See [Messaging](/docs/extension/structure/messaging) for the WXT helpers.

## XSS and page trust

A compromised or hostile page can try to:

* Trick your CSUI into displaying phishing chrome
* Spam your message handlers
* Exfiltrate data you write into the DOM

Keep secrets out of the DOM, keep privileged actions server-side, and rate-limit / validate handlers.

## Practical rules

* Default to **no** content script until a feature needs the page
* Prefer `activeTab` + scripting on user gesture over always-on scripts
* Review match patterns in the same PR as the script
* Capture errors with [monitoring](/docs/extension/monitoring/overview) without logging cookies or tokens

<Cards>
  <Card title="Content scripts" href="/docs/extension/structure/content-scripts" description="Isolated world, CSUI, and file layout." />

  <Card title="Messaging" href="/docs/extension/structure/messaging" description="Typed messages across extension runtimes." />

  <Card title="Background" href="/docs/extension/structure/background" description="Service worker responsibilities." />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/extension/security/overview

The browser extension is a **client** with a smaller auth UI surface: it [shares the web app session](/docs/extension/auth/session) instead of reinventing sign-in. Most of its risk sits in **permissions**, **host access**, and **content scripts**.

This section is a **security playbook** - what to allow in the manifest, how session sharing stays safe, and what to double-check before you submit to the stores.

<Callout title="Be mindful">
  Security is not a one-time setup. Revisit these practices whenever you add permissions, content-script matches, messaging handlers, or store listing claims.
</Callout>

## Security model

Defense in the extension is permission- and isolation-focused. The API still owns authorization:

| Layer                    | What it protects                                               | Where it lives                                               |
| ------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------ |
| Shared session           | Users sign in once on the web; extension reuses the cookie     | Better Auth + `cookies` / host permissions                   |
| Trusted origins          | Only your extension id can participate in auth redirects       | Better Auth `trustedOrigins`                                 |
| Least privilege          | Manifest grants only what you need                             | `wxt.config.ts` manifest                                     |
| Content script isolation | Page JS cannot read extension privileges directly              | Isolated world + narrow `matches`                            |
| Messaging boundaries     | Popup / background / content treat messages as untrusted input | Typed WXT messaging                                          |
| API enforcement          | Mutations still go through protected Hono routes               | Shared API (see [Web security](/docs/web/security/overview)) |

Server-side rules (middleware, Zod, webhooks, secrets) stay in [Web security](/docs/web/security/overview). The extension should call that API - not embed secret keys in the bundle.

Each of these topics is covered in more detail in the following guides:


# Permissions
Source: https://www.turbostarter.dev/docs/extension/security/permissions

Every permission expands what a compromised or malicious build can do. TurboStarter starts from what session sharing needs - then you add only what your features require.

## Default needs for auth

Shared web session typically requires:

```ts title="wxt.config.ts"
export default defineConfig({
  manifest: {
    permissions: ["cookies"],
    host_permissions: ["http://localhost/*", "https://your-app-url.com/*"],
  },
});
```

* `cookies` - read the Better Auth session cookie for your app domain
* `host_permissions` - only your app origins (local + production)

See [Session](/docs/extension/auth/session) for the full cookie setup.

## Least privilege rules

1. Prefer a **specific** permission over a powerful one
2. Prefer **exact host patterns** over wildcards
3. Document each permission in your store listing privacy / justification text
4. Remove permissions when a feature ships without them

| Permission           | Typical use                   | Caution                            |
| -------------------- | ----------------------------- | ---------------------------------- |
| `cookies`            | Shared web session            | Limit hosts via `host_permissions` |
| `storage`            | Theme / prefs via WXT storage | Do not store long-lived secrets    |
| `tabs` / `activeTab` | Know the current tab          | Prefer `activeTab` when possible   |
| `scripting`          | Inject scripts on demand      | Pair with tight matches            |

## Avoid `<all_urls>`

<Callout type="warn" title="Avoid &#x22;<all_urls>&#x22;">
  Do not use `<all_urls>` in `host_permissions` or content-script `matches` unless you truly need it. It raises the blast radius and often triggers longer [Chrome Web Store review](https://developer.chrome.com/docs/webstore/review-process#review-time-factors) (or rejection).
</Callout>

Use patterns scoped to your product:

```ts
host_permissions: ["https://app.example.com/*"];
```

## Adding permissions

Override manifest fields in `wxt.config.ts` when a feature needs more access. Keep the change in the same PR as the feature, and update the store privacy disclosure at the same time.

<Cards>
  <Card title="Manifest" href="/docs/extension/configuration/manifest" description="How WXT generates and overrides manifest.json." />

  <Card title="Declare permissions" href="https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions" description="Chrome documentation on permission types." />
</Cards>


# Session & origins
Source: https://www.turbostarter.dev/docs/extension/security/session

TurboStarter does **not** implement a full sign-in UI in the extension. Users authenticate on the [web app](/docs/web/auth/overview); the extension reuses that session.

That shrinks phishing surface and keeps one place for password, OAuth, and 2FA policies.

## Trusted origins

Add your extension id to Better Auth `trustedOrigins` on the server:

```ts title="packages/auth/src/server.ts"
export const auth = betterAuth({
  trustedOrigins: [
    "chrome-extension://your-extension-id",
    // Firefox / Edge ids as needed
  ],
});
```

Find the id under `chrome://extensions` (Developer mode). Production builds get a stable id from the store - update `trustedOrigins` when it changes from your local unpacked id.

<Callout type="warn" title="Why this matters">
  Trusted origins block CSRF-style abuse and open redirects. Only extension ids you control should be listed.
</Callout>

## Cookie sharing

1. User signs in on the web app (HTTPS in production)
2. Better Auth sets the session cookie on your app domain
3. The extension reads that cookie with the `cookies` permission and your app `host_permissions`
4. API calls from the extension attach the session the same way the web client would

Details and config live in [Auth session](/docs/extension/auth/session).

## What not to do

* Do not store the session token in `chrome.storage` as a long-lived substitute for httpOnly cookies when cookie sharing already works
* Do not build a parallel password form in the popup “for convenience”
* Do not log cookie values to analytics or Sentry
* Do not grant `host_permissions` for unrelated sites “just in case”

## Sign-out

Sign-out from the extension should clear the shared session so web and extension stay in sync. Verify both surfaces after you change auth plugins or cookie names.

<Cards>
  <Card title="Auth session" href="/docs/extension/auth/session" description="Cookie permissions, host access, and reading the session." />

  <Card title="Auth overview" href="/docs/extension/auth/overview" description="Why the extension shares web authentication." />

  <Card title="Web access control" href="/docs/web/security/access-control" description="How the API validates the session on every request." />
</Cards>


# Storage
Source: https://www.turbostarter.dev/docs/extension/security/storage

TurboStarter uses WXT storage for persistent preferences (for example theme). It syncs across popup, background, content scripts, and pages when the storage permission is available.

## What belongs in extension storage

| Good fit                         | Bad fit                                             |
| -------------------------------- | --------------------------------------------------- |
| Theme mode / color               | `BETTER_AUTH_SECRET` or any server secret           |
| UI flags and dismissible banners | Long-lived session tokens when cookie sharing works |
| Non-sensitive drafts             | Raw webhook payloads or PII you do not need offline |

```ts title="wxt.config.ts"
export default defineConfig({
  manifest: {
    permissions: ["storage"],
  },
});
```

Only add `storage` when you actually persist data. See [Structure → Storage](/docs/extension/structure/storage).

## Session data

Prefer the shared **httpOnly cookie** session from the web app over copying tokens into `chrome.storage`.

If you cache a display name or avatar for faster popup paint, treat it as untrusted UX cache - refresh from the API when security-sensitive actions run.

## Content scripts and storage

Content scripts can use storage APIs, but anything they write may sit next to hostile page contexts. Do not store secrets there. Prefer sending data to the background worker or your API.

## Practical rules

* Encrypting a secret in extension storage is still weaker than keeping it on the server
* Clear storage on sign-out when it holds account-related cache
* Do not log storage dumps to analytics
* Document the `storage` permission in your store privacy text

<Cards>
  <Card title="Extension storage" href="/docs/extension/structure/storage" description="WXT storage helpers and patterns." />

  <Card title="Session & origins" href="/docs/extension/security/session" description="Cookie-based session sharing with the web app." />
</Cards>


# Tech Stack
Source: https://www.turbostarter.dev/docs/extension/stack

## Turborepo

[Turborepo](https://turborepo.dev/) is a monorepo tool that helps you manage your project's dependencies and scripts. We chose a monorepo setup to make it easier to manage the structure of different features and enable code sharing between different packages.

<Card href="https://turborepo.dev/" title="Turborepo - Make Ship Happen" description="turbo.build" icon={<Turborepo />} />

## WXT (Vite)

> It's like Next.js for browser extensions.

[WXT](https://www.wxt.dev/) is a very lightweight and powerful framework (based on [Vite](https://vite.dev/)) for building browser extensions using most popular frontend tools. It provides a modern development experience with features like hot module reloading, TypeScript support, and automatic manifest generation.

WXT simplifies the process of creating cross-browser extensions, allowing you to focus on your extension's functionality rather than boilerplate setup.

<Cards>
  <Card href="https://www.wxt.dev/" title="WXT" description="wxt.dev" icon={<Wxt />} />

  <Card href="https://www.vite.dev/" title="Vite" description="vite.dev" icon={<Vite />} />
</Cards>

## React

[React](https://react.dev/) is a JavaScript library for building user interfaces. It's the core technology we use for creating the UI of our browser extension, allowing for efficient updates and rendering of components.

<Card href="https://react.dev/" title="React" description="react.dev" icon={<React />} />

## Tailwind CSS

[Tailwind CSS](https://tailwindcss.com) is a utility-first CSS framework that helps you build custom designs without writing any CSS. We also use [Base UI](https://base-ui.com) for our headless components library and [shadcn/ui](https://ui.shadcn.com) which enables you to generate pre-designed components with a single command.

<Cards className="grid-cols-2 sm:grid-cols-3">
  <Card href="https://tailwindcss.com" title="Tailwind CSS" description="tailwindcss.com" icon={<Tailwind />} />

  <Card href="https://base-ui.com" title="Base UI" description="base-ui.com" icon={<BaseUI />} />

  <Card href="https://ui.shadcn.com" title="shadcn/ui" description="ui.shadcn.com" icon={<Shadcn />} />
</Cards>

## Hono & React Query

[Hono](https://hono.dev) is a small, simple, and ultrafast web framework for the edge. It provides tools to help you build APIs and web applications faster. It includes an RPC client for making type-safe function calls from the frontend. We use Hono to build our serverless API endpoints.

To make data fetching and caching from our API easy and reliable, we pair Hono with [React Query](https://tanstack.com/query/latest). It helps manage asynchronous data, caching, and state synchronization between the client and backend, delivering a fast and seamless UX.

<Cards>
  <Card href="https://hono.dev" title="Hono" description="hono.dev" icon={<Hono />} />

  <Card href="https://tanstack.com/query/latest" title="React Query" description="tanstack.com" icon={<Tanstack />} />
</Cards>

## Better Auth

[Better Auth](https://better-auth.com) is a modern authentication library for fullstack applications. It provides ready-to-use snippets for features like email/password login, magic links, OAuth providers, and more. We use Better Auth to handle all authentication flows in our application.

<Card href="https://better-auth.com" title="Better Auth" description="better-auth.com" icon={<BetterAuth />} />

## Drizzle

[Drizzle](https://orm.drizzle.team/) is a super fast [ORM](https://orm.drizzle.team/docs/overview) (Object-Relational Mapping) tool for databases. It helps manage databases, generate TypeScript types from your schema, and run queries in a fully type-safe way.

We use [PostgreSQL](https://www.postgresql.org) as our default database, but thanks to Drizzle's flexibility, you can easily switch to MySQL, SQLite or any [other supported database](https://orm.drizzle.team/docs/connect-overview) by updating a few configuration lines.

<Cards>
  <Card href="https://orm.drizzle.team/" title="Drizzle" description="orm.drizzle.team" icon={<Drizzle />} />

  <Card href="https://www.postgresql.org" title="PostgreSQL" description="postgresql.org" icon={<Postgres />} />
</Cards>


# Background service worker
Source: https://www.turbostarter.dev/docs/extension/structure/background

An extension's service worker is a powerful script that runs in the background, separate from other parts of the extension. It's loaded when it is needed, and unloaded when it goes dormant.

Once loaded, an extension service worker generally runs as long as it is actively receiving events, though it [can shut down](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle#idle-shutdown). Like its web counterpart, an extension service worker cannot access the DOM, though you can use it if needed with [offscreen documents](https://developer.chrome.com/docs/extensions/reference/api/offscreen).

Extension service workers are more than network proxies (as web service workers are often described), they run in a separate [service worker context](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers). For example, when in this context, you no longer need to worry about CORS and can fetch resources from any origin.

In addition to the [standard service worker events](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope#events), they also respond to extension events such as navigating to a new page, clicking a notification, or closing a tab. They're also registered and updated differently from web service workers.

**It's common to offload heavy computation to the background service worker**, so you should always try to do resouce-expensive operations there and send results using [Messages API](/docs/extension/structure/messaging) to other parts of the extension.

Code for the background service worker is located at `src/app/background` directory - you need to use `defineBackground` within `index.ts` file inside to allow WXT to include your script in the build.

```ts title="src/app/background/index.ts"
import { defineBackground } from "wxt/sandbox";

const main = () => {
  console.log(
    "Background service worker is running! Edit `src/app/background` and save to reload.",
  );
};

export default defineBackground(main);
```

To see the service worker in action, reload the extension, then open its "Service Worker inspector":

![Service Worker inspector](/images/docs/extension/structure/sw-inspector.png)

You should see what we've logged in the console:

![Service Worker console](/images/docs/extension/structure/sw-log.png)

To communicate with the service worker from other parts of the extension, you can use the [Messaging API](/docs/extension/structure/messaging).

## Persisting state

<Callout>
  Service workers in `dev` mode always remain in `active` state.
</Callout>

The worker becomes idle after a few seconds of inactivity, and the browser will kill its process entirely after 5 minutes. This means all state (variables, etc.) is lost unless you use a storage engine.

The simplest way to persist your background service worker's state is to use the [storage API](/docs/extension/structure/storage).

The more advanced way is to send the state to a remote database via our [backend API](/docs/extension/api/overview).

<Cards>
  <Card title="Using service workers" href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers" description="developer.mozilla.org" />

  <Card title="Migrate to a service worker" href="https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers" description="developer.chrome.com" />

  <Card title="Extension service worker basics" href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics" description="developer.chrome.com" />

  <Card title="The extension service worker lifecycle" href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle" description="developer.chrome.com" />
</Cards>


# Content scripts
Source: https://www.turbostarter.dev/docs/extension/structure/content-scripts

Content scripts run in the context of web pages in an isolated world. This allows multiple content scripts from various extensions to coexist without conflicting with each other's execution and to stay isolated from the page's JavaScript.

A script that ends with `.ts` will not have front-end runtime (e.g. react) bundled with it and won't be treated as a ui script, while a script that ends in `.tsx` will be.

There are many use cases for content scripts:

* Injecting a custom stylesheet into the page
* Scraping data from the current web page
* Selecting, finding, and styling elements from the current web page
* Injecting UI elements into current web page

Code for the content scripts is located in `src/app/content` directory - you need to define `.ts` or `.tsx` file inside and use `defineContentScript` to allow WXT to include your script in the build.

```ts title="src/app/content/index.ts"
export default defineContentScript({
  matches: ["<all_urls>"],
  async main(ctx) {
    console.log(
      "Content script is running! Edit `app/content` and save to reload.",
    );
  },
});
```

Reload your extension, open a web page, then open its inspector:

![Content Script](/images/docs/extension/structure/content-script.png)
To learn more about content scripts, e.g. how to configure only specific pages to load content scripts, how to inject them into `window` object or how to fetch data inside, please check [the official documentation](https://wxt.dev/guide/essentials/content-scripts.html).

## UI scripts

WXT has first-class support for mounting React components into the current webpage. This feature is called content scripts UI (CSUI).

![CSUI](/images/docs/extension/structure/csui.png)

An extension can have as many CSUI as needed, with each CSUI targeting a group of webpages or a specific webpage.

To get started with CSUI, create a `.tsx` file in `src/app/content` directory and use `defineContentScript` allow WXT to include your script in the build and mount your component into the current webpage:

```tsx title="src/app/content/index.tsx"
const ContentScriptUI = () => {
  return (
    <Button onClick={() => alert("This is injected UI!")}>
      Content script UI
    </Button>
  );
};

export default defineContentScript({
  matches: ["<all_urls>"],
  cssInjectionMode: "ui",
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "turbostarter-extension",
      position: "overlay",
      anchor: "body",
      onMount: (container) => {
        const app = document.createElement("div");
        container.append(app);

        const root = ReactDOM.createRoot(app);
        root.render(<ContentScriptUI />);
        return root;
      },
      onRemove: (root) => {
        root?.unmount();
      },
    });

    ui.mount();
  },
});
export default ContentScriptUI;
```

<Callout title="File extensions matters!" type="warn">
  The `.tsx` extension is essential to differentiate between Content Scripts UI and regular Content Scripts. Make sure to check if you're using appropriate type of content script for your use case.
</Callout>

To learn more about content scripts UI, e.g. how to inject custom styles, fonts or the whole lifecycle of a component, please check [the official documentation](https://wxt.dev/guide/essentials/content-scripts.html#ui).

<Callout title="How does it work?">
  Under the hood, the component is wrapped inside the component that implements the Shadow DOM technique, together with many helpful features. This isolation technique prevents the web page's style from affecting your component's styling and vice-versa.

  [Read more about the lifecycle of CSUI](https://docs.plasmo.com/framework/content-scripts-ui/life-cycle)
</Callout>


# Messaging
Source: https://www.turbostarter.dev/docs/extension/structure/messaging

Messaging API makes communication between different parts of your extension easy. To make it simple and scalable, we're leveraging `@webext-core/messaging` library.

It provides a declarative, type-safe, functional, promise-based API for sending, relaying, and receiving messages between your extension components.

## Handling messages

Based on our convention, we implemented a little abstraction on top of `@webext-core/messaging` to make it easier to use. That's why all types and keys are stored inside `lib/messaging` directory:

```ts title="lib/messaging/index.ts"
import { defineExtensionMessaging } from "@webext-core/messaging";

export const Message = {
  HELLO: "hello",
} as const;

export type Message = (typeof Message)[keyof typeof Message];

interface Messages {
  [Message.HELLO]: (message: string) => string;
}

export const { onMessage, sendMessage } = defineExtensionMessaging<Messages>();
```

There you need to define what will be handled under each key. To make it more secure, only `Message` enum and `onMessage` and `sendMessage` functions are exported from the module.

All message handlers are located in `src/app/background/messaging` directory under respective subdirectories.

To create a message handler, create a TypeScript module in the `background/messaging` directory. Then, include your handlers for all keys related to the message:

```ts title="app/background/messaging/hello.ts"
import { onMessage, Message } from "~/lib/messaging";

onMessage(Message.HELLO, (req) => {
  const result = await querySomeApi(req.body.id);

  return result;
});
```

<Callout title="Don't forget to import!" type="warn">
  To make your handlers available across your extension, you need to import them
  in the `background/index.ts` file. That way they could be interpreted by the
  build process facilitated by WXT.
</Callout>

## Sending messages

Extension pages, content scripts, or tab pages can send messages to the handlers using the `sendMessage` function. Since we orchestrate your handlers behind the scenes, the message names are typed and will enable autocompletion in your editor:

```tsx title="app/popup/index.tsx"
import { sendMessage, Message } from "~/lib/messaging";

...

const response = await sendMessage(Message.HELLO, "Hello, world!");

console.log(response);

...
```

As it's an asynchronous operation, it's advisable to use [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) integration to handle the response on the client side.

We're already doing it that way when fetching auth session in the `User` component:

```tsx title="hello.tsx"
export const Hello = () => {
  const { data, isLoading } = useQuery({
    queryKey: [Message.HELLO],
    queryFn: () => sendMessage(Message.HELLO, "Hello, world!"),
  });

  if (isLoading) {
    return <p>Loading...</p>;
  }

  /* do something with the data... */
  return <p>{data?.message}</p>;
};
```

<Cards>
  <Card href="https://webext-core.aklinker1.io/messaging/installation/" title="Messaging API" description="webext-core.aklinker1.io" />

  <Card title="Message passing" description="developer.chrome.com" href="https://developer.chrome.com/docs/extensions/develop/concepts/messaging" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/extension/structure/overview

Every browser extension is different and can include different parts, removing the ones that are not needed.

TurboStarter ships with all the things you need to start developing your own extension including:

* **Popup window** - a small window that appears when the user clicks the extension icon.
* **Options page** - a page that appears when user enters extension settings.
* **Side panel** - a panel that appears when the user clicks sidepanel.
* **New tab page** - a page that appears when the user opens a new tab.
* **Devtools page** - a page that appears when the user opens the browser's devtools.
* **Tab pages** - custom pages shipped with the extension.
* **Content scripts** - injected scripts that run in the browser page.
* **Background scripts** - scripts that run in the background.
* **Message passing** - a way to communicate between different parts of the extension.
* **Storage** - a way to store data in the extension.

All the entrypoints are defined in `apps/extension/src/app` directory (it's similar to file-based routing in Next.js and Expo).

This directory acts as a source for WXT framework which is used to build the extension. It has the following structure:

<Files>
  <Folder name="app" defaultOpen>
    <Folder name="background - Background service worker" />

    <Folder name="content - Content scripts" />

    <Folder name="devtools - Devtools page with custom panels" />

    <Folder name="newtab - New tab page" />

    <Folder name="options - Options page" />

    <Folder name="popup - Popup window" />

    <Folder name="sidepanel - Side panel" />

    <Folder name="tabs - Custom pages shipped with the extension" />
  </Folder>
</Files>

By structurizing it this way, we can easily add new entrypoints in the future and extend rest of the extension independently from each other.

We'll go through each part and explain the purpose of it, check following sections for more details:


# Pages
Source: https://www.turbostarter.dev/docs/extension/structure/pages

Extension pages are built-in pages recognized by the browser. They include the extension's popup, options, sidepanel and newtab pages.

<Callout>
  As WXT is based on Vite, it has very powerful [HMR support](https://vite.dev/guide/features#hot-module-replacement). This means that you don't need to refresh the extension manually when you make changes to the code.
</Callout>

## Popup

The popup page is a small dialog window that opens when a user clicks on the extension's icon in the browser toolbar. It is the most common type of extension page.

![Popup window](/images/docs/extension/structure/popup.png)

<Cards>
  <Card title="Add a popup" href="https://developer.chrome.com/docs/extensions/develop/ui/add-popup" description="developer.chrome.com" />

  <Card title="Entrypoints" href="https://wxt.dev/guide/essentials/entrypoints.html" description="wxt.dev" />
</Cards>

## Options

The options page is meant to be a dedicated place for the extension's settings and configuration.

![Options page](/images/docs/extension/structure/options.png)

<Card title="Give users options" href="https://developer.chrome.com/docs/extensions/develop/ui/options-page" description="developer.chrome.com" />

## Devtools

The devtools page is a custom page (including panels) that opens when a user opens the extension's devtools panel.

![Devtools page](/images/docs/extension/structure/devtools.png)

<Card title="Extend devtools" href="https://developer.chrome.com/docs/extensions/how-to/devtools/extend-devtools" description="developer.chrome.com" />

## New tab

The new tab page is a custom page that opens when a user opens a new tab in the browser.

![New tab page](/images/docs/extension/structure/newtab.png)

<Card title="Override Chrome pages" href="https://developer.chrome.com/docs/extensions/develop/ui/override-chrome-pages" description="developer.chrome.com" />

## Side panel

The side panel is a custom page that opens when a user clicks on the extension's icon in the browser toolbar.

![Side panel](/images/docs/extension/structure/sidepanel.png)

<Card title="Side panel" href="https://developer.chrome.com/docs/extensions/reference/api/sidePanel" description="developer.chrome.com" />

## Tabs

Unlike traditional extension pages, tab (unlisted) pages are just regular web pages shipped with your extension bundle. Extensions generally redirect to or open these pages programmatically, but you can link to them as well.

They could be useful for following cases:

* when you want to show a some page when user first installs your extension
* when you want to have dedicated pages for authentication
* when you need more advanced routing setup

![Tab page](/images/docs/extension/structure/tabs.png)

Your tab page will be available under the `/tabs` path in the extension bundle. It will be accessible from the browser under the URL:

```
chrome-extension://<your-extension-id>/tabs/your-tab-page.html
```

<Card title="Unlisted pages" href="https://wxt.dev/guide/essentials/entrypoints.html#unlisted-pages" description="wxt.dev" />


# Storage
Source: https://www.turbostarter.dev/docs/extension/structure/storage

TurboStarter leverages `wxt/storage` library to handle persistent storage for your extension. It's a utility library from that abstracts the persistent storage API available to browser extensions.

It falls back to localStorage when the extension storage API is unavailable, allowing for state sync between extension pages, content scripts, background service workers and web pages.

<Callout>
  To use the `wxt/storage` API, the "storage" permission **must** be added to the manifest:

  ```ts title="wxt.config.ts"
  export default defineConfig({
    manifest: {
      permissions: ["storage"],
    },
  });
  ```
</Callout>

## Storing data

The base Storage API is designed to be easy to use. It is usable in every extension runtime such as background service workers, content scripts and extension pages.

TurboStarter ships with predefined storage used to handle [theming](/docs/extension/customization/styling) in your extension, but you can create your own storage as well.

All storage-related methods and types are located in `lib/storage` directory.

```ts title="lib/storage/index.ts"
export const StorageKey = {
  THEME: "local:theme",
} as const;

export type StorageKey = (typeof StorageKey)[keyof typeof StorageKey];
```

Then, to make it available around your extension, we're setting it up and providing default values:

```ts title="lib/storage/index.ts"
import { storage as browserStorage } from "wxt/storage";

import { appConfig } from "~/config/app";

import type { ThemeConfig } from "@workspace/ui";

const storage = {
  [StorageKey.THEME]: browserStorage.defineItem<ThemeConfig>(StorageKey.THEME, {
    fallback: appConfig.theme,
  }),
} as const;
```

To learn more about customizing your storage, syncing state or setup automatic backups please refer to the [official documentation](https://wxt.dev/storage.html).

## Consuming storage

To consume storage in your extension, you can use the `useStorage` React hook that is automatically provided to every part of the extension. The hook API is designed to streamline the state-syncing workflow between the different pieces of an extension.

Here is an example on how to consume our theme storage in `Layout` component:

```tsx title="modules/common/layout/layout.tsx"
import { StorageKey, useStorage } from "~/lib/storage";

export const Layout = ({ children }: { children: React.ReactNode }) => {
  const { data } = useStorage(StorageKey.THEME);

  return (
    <div id="root" data-theme={data.color}>
      {children}
    </div>
  );
};
```

Congrats! You've just learned how to persist and consume global data in your extension 🎉

For more advanced use cases, please refer to the [official documentation](https://wxt.dev/storage.html).

### Usage with Firefox

To use the storage API on Firefox during development you need to add an addon ID to your manifest, otherwise, you will get this error:

> Error: The storage API will not work with a temporary addon ID. Please add an explicit addon ID to your manifest. For more information see [https://mzl.la/3lPk1aE](https://mzl.la/3lPk1aE)

To add an addon ID to your manifest, add this to your package.json:

```ts title="wxt.config.ts"
export default defineConfig({
  manifest: {
    browser_specific_settings: {
      gecko: {
        id: "your-id@example.com",
      },
    },
  },
});
```

During development, you may use any ID. If you have published your extension, you need to use the ID assigned by [Firefox Add-ons](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons).

<Cards>
  <Card title="Storage API" href="https://wxt.dev/storage.html" description="wxt.dev" />

  <Card title="chrome.storage" href="https://developer.chrome.com/docs/extensions/reference/api/storage" description="developer.chrome.com" />
</Cards>


# E2E tests
Source: https://www.turbostarter.dev/docs/extension/tests/e2e

Extension E2E tests verify that your [Chrome MV3](https://developer.chrome.com/docs/extensions/mv3/intro/) extension works correctly: popup rendering, sign-in through the web app, authenticated state, and content script injection. Tests use [Playwright](https://playwright.dev) with a custom [fixture](https://playwright.dev/docs/test-fixtures) that loads your unpacked extension build.

<Callout title="Why test the extension in a real browser?">
  Extension behavior depends on service workers, `chrome-extension://` origins, and cross-context messaging. Playwright launches Chromium with your extension pre-loaded via [`--load-extension`](https://playwright.dev/docs/chrome-extensions), giving you a real MV3 environment without manual Chrome profile setup.
</Callout>

![Extension E2E test](/images/docs/extension/tests/e2e/popup.png)

## Prerequisites

1. **Start services**:

```bash
pnpm services:setup
```

2. **Environment files**:

```bash
cp .env.example .env
cp apps/web/.env.example apps/web/.env.local
cp apps/extension/.env.example apps/extension/.env
```

3. **Install Playwright** (first time only):

```bash
pnpm --filter extension exec playwright install chromium
```

The global setup builds the extension automatically on first run. Locally, it skips the rebuild if `manifest.json` already exists in the build output.

## Test structure

E2E tests live in `apps/extension/e2e/`:

```
apps/extension/e2e/
├── constants.ts              # Test user, auth paths, build output path
├── env.ts                    # Loads env before Playwright starts
├── fixtures/
│   └── extension.ts          # Custom fixture: loads MV3 build in Chromium
├── pages/
│   ├── popup.page.ts         # Extension popup interactions
│   └── login.page.ts         # Web app login (opened from popup)
├── setup/
│   ├── global-setup.ts       # Migrate DB, seed users, build extension
│   └── auth.setup.ts         # Persist authenticated session
├── specs/
│   ├── popup.smoke.spec.ts           # Popup renders correctly
│   ├── auth.sign-in.spec.ts          # Sign in via popup → web app
│   ├── popup.authenticated.spec.ts   # Authenticated popup state
│   └── content-script.spec.ts        # Content script UI injection
├── .playwright/              # Generated auth state (gitignored)
├── playwright-report/
└── test-results/
```

## Extension fixture

The custom Playwright fixture in `fixtures/extension.ts` is the core of extension testing. It:

1. Launches Chromium in **headed mode** with `--load-extension` pointing to your WXT build output
2. Resolves the extension ID from the service worker URL
3. Opens the popup at `chrome-extension://<id>/popup.html`
4. Applies [`storageState`](https://playwright.dev/docs/auth#reuse-signed-in-state) for authenticated tests

```ts title="apps/extension/e2e/fixtures/extension.ts"
export const test = base.extend<ExtensionFixtures>({
  context: async ({ storageState }, use) => {
    const context = await chromium.launchPersistentContext("", {
      channel: "chromium",
      headless: false,
      args: [
        `--disable-extensions-except=${extensionBuildPath}`,
        `--load-extension=${extensionBuildPath}`,
      ],
    });
    await use(context);
    await context.close();
  },
  popupPage: async ({ context, extensionId }, use) => {
    const popup = await context.newPage();
    await popup.goto(`chrome-extension://${extensionId}/popup.html`);
    await use(popup);
  },
});
```

Extension tests must run **headed** (not headless) because Chromium's headless mode does not support extensions. In CI, tests run inside `xvfb-run` to provide a virtual display.

## Configuration

`apps/extension/playwright.config.ts` is configured to:

* **Production web server**: builds and starts the Next.js app for API and auth
* **Global setup**: migrates DB, seeds users, and builds the extension (WXT Chrome build)
* **Setup + authenticated projects**: auth state is [persisted and reused](https://playwright.dev/docs/auth#basic-shared-account-in-all-tests) across specs
* **Single worker**: extension tests run sequentially to avoid profile conflicts

```ts title="apps/extension/playwright.config.ts"
export default defineConfig({
  fullyParallel: false,
  workers: 1,
  globalSetup: "./e2e/setup/global-setup.ts",
  projects: [
    { name: "setup", testMatch: /setup\/.*\.setup\.ts/ },
    { name: "chrome-extension", testMatch: /specs\/.*\.spec\.ts/ },
    {
      name: "chrome-extension-authenticated",
      dependencies: ["setup"],
      use: { storageState: authStatePath },
    },
  ],
});
```

## Example specs

### Popup smoke test

```ts title="apps/extension/e2e/specs/popup.smoke.spec.ts"
test("popup renders and background messaging responds", async ({
  popupPage,
  extensionId,
}) => {
  const popup = new PopupPage(popupPage, extensionId);
  await popup.waitForReady();
  await expect(
    popupPage.getByRole("link", { name: "Learn more" }),
  ).toBeVisible();
});
```

### Sign in via the web app

When a user clicks "Sign in" in the popup, a new browser tab opens with the web app login. The test handles this cross-context flow:

```ts title="apps/extension/e2e/specs/auth.sign-in.spec.ts"
test("user can sign in from the popup via the web app", async ({
  context,
  popupPage,
  extensionId,
}) => {
  const popup = new PopupPage(popupPage, extensionId);
  const loginPagePromise = context.waitForEvent("page");
  await popup.signInLink().click();

  const loginPage = await loginPagePromise;
  const login = new LoginPage(loginPage);
  await login.signIn(e2eUser.email, e2eUser.password);
  await loginPage.close();

  await popupPage.reload();
  await expect(popup.userMenu()).toBeVisible({ timeout: 15_000 });
});
```

### Content script injection

[Content scripts](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) run in the context of web pages. This spec verifies the injected UI appears on a target page:

```ts title="apps/extension/e2e/specs/content-script.spec.ts"
test("content script injects UI on a web page", async ({ context }) => {
  const page = await context.newPage();
  await page.goto("https://example.com");
  await expect(
    page.getByRole("button", { name: "Content script UI" }),
  ).toBeVisible();
});
```

## Running tests

### All extension tests

```bash
pnpm --filter extension test:e2e
```

This builds the web app, starts the production server, builds the extension, and runs all specs.

### Single spec

```bash
pnpm --filter extension exec playwright test popup.smoke
```

### Interactive UI mode

```bash
pnpm --filter extension exec playwright test --ui
```

### View the HTML report

```bash
pnpm --filter extension exec playwright show-report e2e/playwright-report
```

<Callout title="Local rebuild skip">
  Global setup skips the WXT build when `manifest.json` already exists in the build output and you are not in CI. Delete the build folder or set `CI=true` to force a rebuild.
</Callout>

## CI

The `CI / E2E / Extension` workflow runs on pull requests labeled `e2e` or `e2e-extension`. It:

1. Starts Docker services (Postgres, Mailpit)
2. Copies example env files
3. Installs Playwright with Chromium
4. Runs tests inside `xvfb-run` (virtual display for headed Chromium)
5. Uploads HTML reports and failure artifacts

## Writing new tests

### Unauthenticated spec

Import the extension fixture instead of the default Playwright `test`:

```ts
import { test } from "../fixtures/extension";
import { PopupPage } from "../pages/popup.page";

test("my new popup feature", async ({ popupPage, extensionId }) => {
  const popup = new PopupPage(popupPage, extensionId);
  await popup.waitForReady();
  // ...
});
```

### Authenticated spec

Name the file `*.authenticated.spec.ts` to run with persisted auth state:

```ts
import { expect } from "@playwright/test";
import { test } from "../fixtures/extension";

test("authenticated popup shows user info", async ({
  popupPage,
  extensionId,
}) => {
  const popup = new PopupPage(popupPage, extensionId);
  await popup.waitForReady();
  await expect(popup.userMenu()).toBeVisible();
});
```

### Content script spec

Use `context.newPage()` to open a regular web page where your content script injects UI:

```ts
test("content script appears on target page", async ({ context }) => {
  const page = await context.newPage();
  await page.goto("https://your-target-site.com");
  // Assert injected UI
  await page.close();
});
```

## Best practices

* **Test cross-context flows**: popup → web app tab → back to popup is the most common extension pattern; use [`context.waitForEvent("page")`](https://playwright.dev/docs/events#waiting-for-event) to handle new tabs
* **Run headed locally**: extension tests require a visible Chromium instance; use `--headed` if debugging outside the fixture
* **Keep workers at 1**: multiple extension instances can conflict on the same profile
* **Add `data-testid` hooks**: stable selectors for sign-in links, user menus, and content script UI

## Next steps

* [Extension development setup](/docs/extension/installation/development): local extension development
* [Unit tests](/docs/extension/tests/unit): fast Vitest tests for extension packages
* [Authentication](/docs/extension/auth/overview): how auth works in the extension


# Installation
Source: https://www.turbostarter.dev/docs/extension/troubleshooting/installation

## Cannot clone the repository

Issues related to cloning the repository are usually related to a Git misconfiguration in your local machine. The commands displayed in this guide using SSH: these will work only if you have setup your SSH keys in Github.

If you run into issues, [please make sure you follow this guide to set up your SSH key in Github.](https://docs.github.com/en/authentication/connecting-to-github-with-ssh)

If this also fails, please use HTTPS instead. You will be able to see the commands in the repository's Github page under the "Clone" dropdown.

Please also make sure that the account that accepted the invite to TurboStarter, and the locally connected account are the same.

## Local database doesn't start

If you cannot run the local database container, it's likely you have not started [Docker](https://docs.docker.com/get-docker/) locally. Our local database requires Docker to be installed and running.

Please make sure you have installed Docker (or compatible software such as [Colima](https://github.com/abiosoft/colima), [Orbstack](https://github.com/orbstack/orbstack)) and that is running on your local machine.

Also, make sure that you have enough [memory and CPU allocated](https://docs.docker.com/engine/containers/resource_constraints/) to your Docker instance.

## Permissions issues

If some feature of your extension is not working, it's possible that you're missing a permission in the manifest config.

Make sure to check the [permissions](/docs/extension/configuration/manifest#overriding-manifest) section in the manifest config file.

## I don't see my translations

If you don't see your translations appearing in the application, there are a few common causes:

1. Check that your translation `.json` files are properly formatted and located in the correct directory
2. Verify that the language codes in your configuration match your translation files
3. Enable debug mode (`debug: true`) in your i18next configuration to see detailed logs

[Read more about configuration for translations](/docs/extension/internationalization#configuration)

## "Module not found" error

This issue is mostly related to either dependency installed in the wrong package or issues with the file system.

The most common cause is incorrect dependency installation. Here's how to fix it:

1. Clean the workspace:

   ```bash
   pnpm clean
   ```

2. Reinstall the dependencies:
   ```bash
   pnpm i
   ```

If you're adding new dependencies, make sure to install them in the correct package:

```bash
# For main app dependencies
pnpm install --filter mobile my-package

# For a specific package
pnpm install --filter @workspace/ui my-package
```

If the issue persists, please check the file system for any issues.

### Windows OneDrive

OneDrive can cause file system issues with Node.js projects due to its file syncing behavior. If you're using Windows with OneDrive, you have two options to resolve this:

1. Move your project to a location outside of OneDrive-synced folders (recommended)
2. Disable OneDrive sync specifically for your development folder

This prevents file watching and symlink issues that can occur when OneDrive tries to sync Node.js project files.


# Publishing
Source: https://www.turbostarter.dev/docs/extension/troubleshooting/publishing

## My extension submission was rejected

If your extension submission was rejected, you probably got an email with the reason. You'll need to fix the issues and upload a new build of your extension to the store and send it for review again.

Make sure to follow the [guidelines](/docs/extension/marketing) when submitting your extension to ensure that everything is setup correctly.

## Version number mismatch

If you get version number conflicts when submitting:

1. Ensure your `manifest.json` version matches what's in the store
2. Increment the version number appropriately for each new submission
3. Make sure the version follows semantic versioning (e.g., `1.0.1`)

## Missing permissions in manifest

If your extension is rejected due to permission issues:

1. Review the permissions declared in your `manifest.json`
2. Ensure all permissions are properly justified in your submission
3. Remove any unused permissions that aren't essential
4. Consider using optional permissions where possible

[Learn more about permissions](/docs/extension/configuration/manifest#permissions)

## Content Security Policy (CSP) violations

If your extension is rejected due to CSP issues:

1. Check your manifest's `content_security_policy` field
2. Ensure all external resources are properly whitelisted
3. Remove any unsafe inline scripts or eval usage
4. Use more secure alternatives like `browser.scripting.executeScript`

## My extension crashes on production build

If the extension works during development but crashes after publishing or when loaded unpacked in production mode, check these common causes:

1. **Uncaught runtime errors** in the background service worker or content scripts. Open `chrome://extensions` (or `about:debugging` in Firefox) → enable Developer mode → Inspect the service worker/content script and check the console for stack traces.
2. **Missing permissions or host permissions** causing APIs to throw (e.g., network calls, tabs access). Ensure required `permissions` and `host_permissions` are declared in `manifest.json`.
3. **CSP blocking resources** (inline scripts/styles, remote fonts, or endpoints). Verify `content_security_policy` and update code to avoid unsafe patterns.
4. **Missing assets or incorrect paths** referenced in `manifest.json` (`icons`, `web_accessible_resources`, `action.default_popup`, etc.). Confirm files exist in the final build output and paths match.
5. **Build-time variables not resolved**. If you rely on environment variables, ensure they’re inlined at build time or have safe fallbacks at runtime. Example:
   ```js
   const apiUrl = env.VITE_SITE_URL ?? "https://api.example.com";
   ```
6. **Module format or bundler config issues** (MV3 service worker must be ESM if `type: 'module'`). Align bundler output with your manifest expectations and rebuild.

Try this:

1. Reproduce with a production bundle locally and load it as an unpacked extension; inspect background and content script logs for errors.
2. Validate `manifest.json` and ensure all referenced files are present in the build output.
3. Temporarily relax CSP locally to confirm whether CSP is the cause; then apply a compliant fix (don’t ship relaxed CSP).
4. Add fallbacks for any build-time variables and rebuild.


# AI
Source: https://www.turbostarter.dev/docs/mobile/ai

<Callout title="Looking for AI-assisted development?">
  TurboStarter includes a set of AI rules, skills, subagents, and commands for popular AI editors and tools - so the AI follows this repo's conventions and produces more consistent changes.

  See [AI-assisted development](/docs/mobile/installation/ai-development) to set it up.
</Callout>

AI integration on [web](/docs/web/ai/overview), [extension](/docs/extension/ai), and mobile uses the same battle-tested [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction), so the overall approach is similar across platforms.

In this section, we'll focus on consuming AI responses in the mobile app. For server-side implementation details, refer to the [web documentation](/docs/web/ai/overview).

## Features

The most common AI integration features are also supported in the mobile app:

* **Chat**: Build chat interfaces inside native mobile apps.
* **Streaming**: Receive AI responses as soon as the model starts generating them, without waiting for the full response.
* **Image generation**: Generate images based on a given prompt.

You can easily compose your application using these building blocks or extend them to suit your specific needs.

For persisted chat, RAG, image, text-to-speech, and voice templates from the separate AI Kit repository, follow the [mobile AI Kit integration recipe](/docs/mobile/recipes/ai-kit). It reuses the same Core-hosted API and session.

## Usage

AI integration in the mobile app works the same way as in the [web app](/docs/web/ai/configuration#client-side) and the [browser extension](/docs/extension/ai#server--client). We use the same [API endpoint](/docs/web/ai/configuration#api-endpoint), and since TurboStarter ships with built-in streaming support on mobile, we can display answers incrementally as they're generated.

```tsx title="ai.tsx"
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Text, View } from "react-native";

const AI = () => {
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/ai/chat",
    }),
  });

  return (
    <View>
      {messages.map((message) => (
        <Text key={message.id}>
          {message.parts.map((part, i) => {
            switch (part.type) {
              case "text":
                return <Text key={`${message.id}-${i}`}>{part.text}</Text>;
            }
          })}
        </Text>
      ))}
    </View>
  );
};

export default AI;
```

By leveraging this integration, we can easily manage the state of the AI request and update the UI as soon as the response is ready.

TurboStarter ships with a ready-to-use implementation of AI chat, allowing you to see this solution in action. Feel free to reuse or modify it according to your needs.


# Configuration
Source: https://www.turbostarter.dev/docs/mobile/analytics/configuration

The `@workspace/analytics-mobile` package offers a streamlined and flexible approach to tracking events in your TurboStarter mobile app using various analytics providers. It abstracts the complexities of different analytics services and provides a consistent interface for event tracking.

In this section, we'll guide you through the configuration process for each supported provider.

Note that the configuration is validated against a schema, so you'll see error messages in the console if anything is misconfigured.

## Permissions

First and foremost, to start tracking any metrics from your app (and to do so legally), you need to ask your users for permission. It's [required](https://support.apple.com/en-us/102420), and you're not allowed to collect any data without it.

To make this process as simple as possible, TurboStarter comes with a `useTrackingPermissions` hook that you can use to access the user's consent status. It will handle asking for permission automatically as well as process updates made through the general phone settings.

```tsx
import { useTrackingPermissions } from "@workspace/analytics-mobile";

export const MyComponent = () => {
  const granted = useTrackingPermissions();

  if (granted) {
    // Start tracking
  } else {
    // Disable tracking
  }
};
```

Also, for Apple, you must declare the tracking justification via [App Tracking Transparency](https://developer.apple.com/documentation/apptrackingtransparency). It comes pre-configured in TurboStarter via the [Expo Config Plugin](https://docs.expo.dev/versions/latest/config/app/#plugins), where you can provide a custom message to the user:

```ts title="app.config.ts"
export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: [
    [
      "expo-tracking-transparency",
      {
        /* 🍎 Describe why you need access to the user's data */
        userTrackingPermission:
          "This identifier will be used to deliver personalized ads to you.",
      },
    ],
  ],
});
```

This way, we ensure that the user is aware of the data we collect and can make an informed decision. If you don't provide this information, your app is likely to be rejected by Apple and/or Google during the [review process](/docs/mobile/publishing/checklist#send-to-review).

## Providers

TurboStarter supports multiple analytics providers, each with its own unique configuration. Below, you'll find detailed information on how to set up and use each supported provider. Choose the one that best suits your needs and follow the instructions in the respective accordion section.

<Accordions>
  <Accordion title="Google Analytics" id="google-analytics">
    To use Google Analytics as your analytics provider, you need to [configure and link a Firebase project to your app](/docs/mobile/installation/firebase).

    After that, you can proceed with the installation of the analytics package:

    ```bash
    pnpm add --filter @workspace/analytics-mobile @react-native-firebase/analytics
    ```

    Also, make sure to activate the Google Analytics provider as your analytics provider by updating the exports in:

    ```ts title="index.ts"
    // [!code word:google-analytics]
    export * from "./google-analytics";
    export * from "./google-analytics/env";
    ```

    To customize the provider, you can find its definition in `packages/analytics/mobile/src/providers/google-analytics` directory.

    For more information, please refer to the [React Native Firebase documentation](https://rnfirebase.io/analytics/usage).

    ![Google Analytics dashboard](/images/docs/web/analytics/google/dashboard.jpg)
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout type="info" title="You can also use it for monitoring!">
      PostHog is also one of pre-configured providers for [monitoring](/docs/mobile/monitoring/posthog) and [feature flags](/docs/mobile/flags/configuration#posthog) in TurboStarter mobile apps.
    </Callout>

    To use PostHog as your analytics provider, you need to configure a PostHog instance. You can obtain the [Cloud](https://app.posthog.com/signup) instance by [creating an account](https://app.posthog.com/signup) or [self-host](https://posthog.com/docs/self-host) it.

    Then, create a project and, based on your [project settings](https://app.posthog.com/project/settings), fill the following environment variables in your `.env.local` file in `apps/mobile` directory and your `eas.json` file:

    ```dotenv
    EXPO_PUBLIC_POSTHOG_KEY="your-posthog-api-key"
    EXPO_PUBLIC_POSTHOG_HOST="your-posthog-instance-host"
    ```

    Also, make sure to activate the PostHog provider as your analytics provider by updating the exports in:

    ```ts title="index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```

    To customize the provider, you can find its definition in `packages/analytics/mobile/src/providers/posthog` directory.

    For more information, please refer to the [PostHog documentation](https://posthog.com/docs).

    ![PostHog dashboard](/images/docs/web/analytics/posthog.png)
  </Accordion>

  <Accordion title="Mixpanel" id="mixpanel">
    To use Mixpanel as your analytics provider, you need to [create an account](https://mixpanel.com/home/) and [obtain your project token](https://help.mixpanel.com/hc/en-us/articles/115004502806-Find-Project-Token).

    Then, set it as an environment variable in your `.env.local` file in the `apps/mobile` directory and your `eas.json` file:

    ```dotenv
    EXPO_PUBLIC_MIXPANEL_TOKEN="your-project-token"
    ```

    Also, make sure to activate the Mixpanel provider as your analytics provider by updating the exports in:

    ```ts title="index.ts"
    // [!code word:mixpanel]
    export * from "./mixpanel";
    export * from "./mixpanel/env";
    ```

    To customize the provider, you can find its definition in `packages/analytics/mobile/src/providers/mixpanel` directory.

    For more information, please refer to the [Mixpanel documentation](https://docs.mixpanel.com/).
  </Accordion>
</Accordions>

## Context

To enable tracking events, capturing screen views and other analytics features, you need to wrap your app with the `Provider` component that's implemented by every provider and available through the `@workspace/analytics-mobile` package:

```tsx title="providers.tsx"
// [!code word:AnalyticsProvider]
import { memo } from "react";

import { Provider as AnalyticsProvider } from "@workspace/analytics-mobile";

interface ProvidersProps {
  readonly children: React.ReactNode;
}

export const Providers = memo<ProvidersProps>(({ children }) => {
  return (
    <OtherProviders>
      <AnalyticsProvider>{children}</AnalyticsProvider>
    </OtherProviders>
  );
});

Providers.displayName = "Providers";
```

By implementing this setup, you ensure that all analytics events are properly tracked from your mobile app code. This configuration allows you to safely utilize the [Analytics API](/docs/mobile/analytics/tracking) within your components, enabling comprehensive event tracking and data collection.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/analytics/overview

When it comes to mobile app analytics, we can distinguish between two types:

* **Store listing analytics**: Used to track the performance of your mobile app's store listing (e.g., how many people have viewed your app in the store or how many have installed it).
* **In-app analytics**: Tracks user actions within your mobile app (e.g., how many users entered a specific screen, how many users clicked on a specific button, etc.).

The `@workspace/analytics-mobile` package provides a set of tools to easily implement both types of analytics in your mobile app.

## Store listing analytics

Interpreting your mobile app's store listing metrics can help you evaluate how changes to your app and store listing affect conversion rates. For example, you can identify keywords that users are searching for to optimize your app's store listing.

While each store implements a different set of metrics, there are some common ones you should be aware of:

* **Downloads**: The total number of times your app was downloaded, including both first-time downloads and re-downloads.
* **Sales**: The total number of pre-orders, first-time app downloads, in-app purchases, and their associated sales.
* **Usage**: A variety of user engagement metrics, such as installations, sessions, crashes, and active devices.

To learn more about these or other metrics (e.g., how to create custom reports or KPIs), please refer to the official documentation of the store you're publishing to:

<Cards>
  <Card title="Overview of reporting tools" description="developer.apple.com" href="https://developer.apple.com/help/app-store-connect/measure-app-performance/overview-of-reporting-tools" />

  <Card title="View app statistics" description="support.google.com" href="https://support.google.com/googleplay/android-developer/answer/139628?hl=en&co=GENIE.Platform%3DDesktop&oco=1" />
</Cards>

## In-app analytics

TurboStarter comes with built-in analytics support for multiple providers as well as a unified API for tracking events. This API enables you to easily and consistently track user behavior and app usage across your mobile application.

To learn more about each provider and how to configure them, see their respective sections:

<Cards>
  <Card title="Google Analytics" href="/docs/mobile/analytics/configuration#google-analytics" />

  <Card title="PostHog" href="/docs/mobile/analytics/configuration#posthog" />

  <Card title="Mixpanel" href="/docs/mobile/analytics/configuration#mixpanel" />
</Cards>

All configuration and setup is built-in with a unified API, allowing you to switch between providers by simply changing the exports. You can even introduce your own provider without breaking any tracking-related logic.

In the following sections, we'll cover how to set up each provider and how to track events in your application.


# Tracking events
Source: https://www.turbostarter.dev/docs/mobile/analytics/tracking

The strategy for tracking events that every provider has to implement is extremely simple:

```ts
export type AllowedPropertyValues = string | number | boolean;

type TrackFunction = (
  event: string,
  data?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderStrategy {
  Provider: ({ children }: { children: React.ReactNode }) => React.ReactNode;
  track: TrackFunction;
}
```

<Callout>
  You don't need to worry much about this implementation, as all the providers are already configured for you. However, it's useful to be aware of this structure if you plan to add your own custom provider.
</Callout>

As shown above, each provider must supply two key elements:

1. `Provider` - a component that [wraps your app](/docs/mobile/analytics/configuration#context).
2. `track` - a function responsible for sending event data to the provider.

To track an event, you simply need to invoke the `track` method, passing the event name and an optional data object:

```tsx
import { track } from "@workspace/analytics-mobile";

export const MyComponent = () => {
  return (
    <Pressable onPress={() => track("button.click", { country: "US" })}>
      Track event
    </Pressable>
  );
};
```

In most mobile apps, you'll only ever need to use the `track` method to track events. You can use it anywhere in your app code—such as in response to user interactions, navigation events, or custom actions - by simply calling `track` with an event name and optional properties.

## Identifying users

Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.

For identification purposes, we're extending the strategy with the `identify` and `reset` methods. They are optional and only needed if you want to identify users in your app and associate their actions with a specific user ID.

```ts
type IdentifyFunction = (
  userId: string,
  traits?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderClientStrategy {
  identify: IdentifyFunction;
  reset: () => void;
}
```

To identify users, call the `identify` method, passing the user's ID and an optional traits object:

```tsx
import { identify } from "@workspace/analytics-mobile";

identify("user-123", { name: "John Doe" });
```

This will associate all future events with the user's ID, allowing you to track user behavior and gain valuable insights into your application's usage patterns.

<Callout title="Configured by default!">
  The `identify` method is configured out-of-the-box to react on changes to the user's authentication state.

  When the user is authenticated, the `identify` method will be called with the user's ID and the user's traits. When the user is logged out, the `reset` method will be called to clear the existing user identification.
</Callout>

Congratulations! You've now mastered event tracking in your TurboStarter mobile app. With this knowledge, you're well-equipped to analyze user behaviors and gain valuable insights into your application's usage patterns. Happy analyzing!


# Using API client
Source: https://www.turbostarter.dev/docs/mobile/api/client

In mobile app code, you can only access the API client from the **client-side.**

When you create a new component or screen and want to fetch some data, you can use the API client to do so.

## Creating a client

We're creating a client-side API client in `apps/mobile/src/lib/api/index.tsx` file. It's a simple wrapper around the [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) that fetches or mutates data from the API.

It also requires wrapping your app in a `QueryClientProvider` component to provide the API client to the rest of the app:

```tsx title="_layout.tsx"
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <QueryClientProvider>
      <SafeAreaProvider>
        <Stack>
          ...
          <Stack.Screen name="index" />
          ...
        </Stack>
        <StatusBar barStyle="light-content" />
      </SafeAreaProvider>
    </QueryClientProvider>
  );
}
```

<Callout type="warn" title="Ensure correct API url">
  Inside the `apps/mobile/src/lib/api/utils.ts` file we're calling a function to get base url of your api, so make sure it's set correctly (especially on production) and your web api endpoint is corresponding with the name there.

  ```tsx title="utils.ts"
  const getBaseUrl = () => {
    /**
     * Gets the IP address of your host-machine. If it cannot automatically find it,
     * you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
     * you don't have anything else running on it, or you'd have to change it.
     *
     * **NOTE**: This is only for development. In production, you'll want to set the
     * baseUrl to your production API URL.
     */
    const debuggerHost = Constants.expoConfig?.hostUri;
    const localhost = debuggerHost?.split(":")[0];

    if (!localhost) {
      console.warn("Failed to get localhost. Pointing to production server...");
      return env.EXPO_PUBLIC_SITE_URL;
    }
    return `http://${localhost}:3000`;
  };
  ```

  As you can see we're relying on your machine IP address for local development (in case you want to open the app from another device) or on the [environment variables](/docs/mobile/configuration/environment-variables) in production to get it, so there shouldn't be any issues with it, but in case, please be aware where to find it 😉
</Callout>

## Queries

Of course, everything comes already configured for you, so you just need to start using `api` in your components/screens.

For example, to fetch the list of posts you can use the `useQuery` hook:

```tsx title="app/(tabs)/tab-one.tsx"
import { api } from "~/lib/api";

export default function TabOneScreen() {
  const { data: posts, isLoading } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await api.posts.$get();

      if (!response.ok) {
        throw new Error("Failed to fetch posts!");
      }

      return response.json();
    },
  });

  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  /* do something with the data... */
  return (
    <View>
      <Text>{JSON.stringify(posts)}</Text>
    </View>
  );
}
```

It's using the `@tanstack/react-query` [useQuery API](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery), so you shouldn't have any troubles with it.

<Cards>
  <Card title="Hono RPC" description="hono.dev" href="https://hono.dev/docs/guides/rpc" />

  <Card title="useQuery hook | Tanstack Query" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/reference/useQuery" />
</Cards>

## Mutations

If you want to perform a mutation in your mobile code, you can use the `useMutation` hook that comes straight from the integration with [Tanstack Query](https://tanstack.com/query):

```tsx title="form.tsx"
import { api } from "~/lib/api";

export function CreatePost() {
  const queryClient = useQueryClient();
  const { mutate } = useMutation({
    mutationFn: async (post: PostInput) => {
      const response = await api.posts.$post(post);

      if (!response.ok) {
        throw new Error("Failed to create post!");
      },
    },
    onSuccess: () => {
      toast.success("Post created successfully!");
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return (
    <Form>
      <Button onPress={onSubmit(mutate)}>Submit</Button>
    </Form>
  );
}
```

Here, we're also invalidating the query after the mutation is successful. This is a very important step to make sure that the data is updated in the UI.

<Cards>
  <Card title="useMutation hook" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/reference/useMutation" />

  <Card title="Query invalidation" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation" />
</Cards>

## Handling responses

As you can see in the examples above, the [Hono RPC](https://hono.dev/docs/guides/rpc) client returns a plain `Response` object, which you can use to get the data or handle errors. However, implementing this handling in every query or mutation can be tedious and will introduce unnecessary boilerplate in your codebase.

That's why we've developed the `handle` function that unwraps the response for you, handles errors, and returns the data in a consistent format. You can safely use it with any procedure from the API client:

<Tabs items={["Queries", "Mutations"]}>
  <Tab value="Queries">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api";

    export default function TabOneScreen() {
      const { data: posts, isLoading } = useQuery({
        queryKey: ["posts"],
        queryFn: handle(api.posts.$get),
      });

      if (isLoading) {
        return <Text>Loading...</Text>;
      }

      /* do something with the data... */
      return (
        <View>
          <Text>{JSON.stringify(posts)}</Text>
        </View>
      );
    }
    ```
  </Tab>

  <Tab value="Mutations">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/client";

    export default function CreatePost() {
      const queryClient = useQueryClient();
      const { mutate } = useMutation({
        mutationFn: handle(api.posts.$post),
        onSuccess: () => {
          toast.success("Post created successfully!");
          queryClient.invalidateQueries({ queryKey: ["posts"] });
        },
      });

      return (
        <Form>
          <Button onPress={onSubmit(mutate)}>Submit</Button>
        </Form>
      );
    }
    ```
  </Tab>
</Tabs>

With this approach, you can focus on the business logic instead of repeatedly writing code to handle API responses in your browser extension components, making your extension's codebase more readable and maintainable.

The same error handling and response unwrapping benefits apply whether you're building web, mobile, or extension interfaces - allowing you to keep your data fetching logic consistent across all platforms.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/api/overview

<Callout type="error" title="API deployment required">
  To enable communication between your Expo app and the server in a production environment, the API **must** be deployed first. By default, it's hosted together with the [web app](/docs/web/api/overview), but you can also [deploy it separately](/docs/web/deployment/api).

  <Cards>
    <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

    <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />

    <Card title="API deployment" description="Deploy your API as a standalone service." href="/docs/web/deployment/api" />
  </Cards>
</Callout>

TurboStarter is designed to be a scalable and production-ready full-stack starter kit. One of its core features is a dedicated and extensible API layer. To enable this in a type-safe way, we chose [Hono](https://hono.dev) as the API server and client library.

<Callout title="Why Hono?">
  Hono is a small, simple, and ultrafast web framework that gives you a way to
  define your API endpoints with full type safety. It provides built-in
  middleware for common needs like validation, caching, and CORS.

  It also includes an [RPC client](https://hono.dev/docs/guides/rpc) for making
  type-safe function calls from the frontend. Being edge-first, it's optimized
  for serverless environments and offers excellent performance.
</Callout>

All API endpoints and their resolvers live in the `packages/api/` package. Inside, the `modules` folder contains the API's feature modules. Each module has its own directory and exports its resolvers.

For each module, we create a separate Hono router and aggregate all sub-routers into one main router in the `packages/api/index.ts` file.

By default, the API is integrated with the [web app](/docs/web/api/overview) and exposed as a [Next.js route handler](https://nextjs.org/docs/app/getting-started/route-handlers):

```ts title="apps/web/src/app/api/[...route]/route.ts"
import { handle } from "hono/vercel";

import { appRouter } from "@workspace/api";

const handler = handle(appRouter);
export {
  handler as GET,
  handler as POST,
  handler as OPTIONS,
  handler as PUT,
  handler as PATCH,
  handler as DELETE,
  handler as HEAD,
};
```

Learn more about how to use the API in your mobile app in the following sections:


# Two-Factor Authentication (2FA)
Source: https://www.turbostarter.dev/docs/mobile/auth/2fa

TurboStarter uses [Better Auth's 2FA plugin](https://better-auth.com/docs/plugins/2fa) to provide multi-factor authentication (MFA) capabilities in your mobile app. Two-factor authentication adds an extra layer of security by requiring users to provide a second form of verification alongside their password.

## Available methods

TurboStarter supports multiple 2FA verification methods through Better Auth:

* **TOTP (Time-based One-Time Password)** - codes generated by authenticator apps
* **OTP (One-Time Password)** - codes sent via email or SMS
* **Backup codes** - single-use recovery codes for account recovery

You can use any TOTP-compatible authenticator app, such as:

* [Google Authenticator](https://support.google.com/accounts/answer/1066447)
* [Authy](https://authy.com/)
* [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app)
* [1Password](https://1password.com/features/authenticator/)
* [Bitwarden](https://bitwarden.com/help/authenticator-keys/)

## Enabling 2FA

<Steps>
  <Step>
    ### Enable in settings

    Users enable two-factor authentication in their account security settings within the mobile app.

    ![Enable 2FA](/images/docs/mobile/auth/two-factor/enable.png)
  </Step>

  <Step>
    ### Setup authenticator

    A QR code is displayed in the mobile app for users to scan with their authenticator app. Users can also manually enter the setup key if needed.

    ![Setup authenticator](/images/docs/mobile/auth/two-factor/authenticator-app.png)
  </Step>

  <Step>
    ### Verify setup

    Users enter a verification code from their authenticator to confirm setup directly in the mobile app.
  </Step>

  <Step>
    ### Backup codes

    Users receive single-use backup codes for account recovery, which can be saved or shared from the mobile app.

    ![Backup codes](/images/docs/mobile/auth/two-factor/backup-codes.png)
  </Step>
</Steps>

<Callout type="info">
  Recovery codes are essential for account recovery if users lose access to
  their authenticator device. Make sure to educate users about safely storing
  their backup codes, and consider providing options to save them to the device
  or share them securely.
</Callout>

## Using 2FA

<Steps>
  <Step>
    ### Sign in normally

    Users enter their email and password or use other authentication methods (biometric, social login) as usual in the mobile app.
  </Step>

  <Step>
    ### 2FA prompt

    After successful password verification, users are prompted for their 2FA code in a native mobile interface.

    ![2FA prompt](/images/docs/mobile/auth/two-factor/sign-in-prompt.png)
  </Step>

  <Step>
    ### Enter verification code

    Users input the 6-digit code from their authenticator app using the mobile keyboard.
  </Step>

  <Step>
    ### Access granted

    Upon successful verification, users gain access to their account and are navigated to the main app screen.
  </Step>
</Steps>

### Trusted devices

Users can mark their mobile device as trusted during 2FA verification. Trusted devices won't require 2FA verification for 60 days, providing a balance between security and convenience. This is particularly useful for personal mobile devices.

## Mobile-specific considerations

### Biometric integration

On mobile devices, 2FA can be enhanced with biometric authentication (fingerprint, face recognition) for added security and convenience.

### App switching

The mobile app should handle switching between your app and authenticator apps seamlessly, maintaining the authentication state when users return.

### Offline support

Consider implementing offline backup code verification for scenarios where users may have limited connectivity.

### Push notifications

For OTP delivery via SMS or email, ensure your app handles incoming notifications gracefully during the authentication flow.

## Configuration

2FA is configured through Better Auth's plugin system. The plugin handles:

* Secure secret generation and storage
* QR code generation for authenticator setup
* TOTP code validation
* Backup code generation and management
* Trusted device management
* Mobile-specific session handling

For detailed implementation instructions, refer to the [Better Auth 2FA documentation](https://better-auth.com/docs/plugins/2fa).


# Configuration
Source: https://www.turbostarter.dev/docs/mobile/auth/configuration

TurboStarter supports multiple authentication methods on mobile:

* **Password** - the traditional email/password method
* **Magic Link** - passwordless email link authentication
* **OTP** - one-time passwords sent to email or phone
* **Anonymous** - guest mode for unauthenticated users
* **OAuth** - OAuth providers; [Apple](https://better-auth.com/docs/authentication/apple), [Google](https://better-auth.com/docs/authentication/google), and [GitHub](https://better-auth.com/docs/authentication/github) are set up by default

All methods are enabled by default; you can enable, disable, or configure any of them to your needs.

<Callout>
  You can mix and match these methods or add new ones - for example, password
  and magic link at the same time - so users have flexibility in how they sign
  in.
</Callout>

Authentication configuration can be customized through a simple configuration file. The following sections explain the available options and how to configure each authentication method based on your requirements.

## API

To enable a new authentication method or add a plugin, update the shared API configuration. See [web authentication configuration](/docs/web/auth/configuration) for details; the server setup is shared between web and mobile.

<Callout title="Remember to add your app scheme as trusted origin">
  For mobile apps, we need to define an [authentication trusted origin](https://better-auth.com/docs/reference/security#trusted-origins) using a mobile app scheme instead.

  App schemes (like `turbostarter://`) are used for [deep linking](/docs/mobile/deep-linking) users to specific screens in your app after authentication.

  To find your app scheme, take a look at `apps/mobile/app.config.ts` file and then add it to your auth server configuration:

  ```ts title="server.ts"
  export const auth = betterAuth({
    ...

    trustedOrigins: ["turbostarter://**"],

    ...
  });
  ```

  Adding your app scheme to trusted origins is required for security - it prevents CSRF and open redirects by allowing only requests from your app.

  [Read more about auth security in Better Auth's documentation.](https://better-auth.com/docs/reference/security)

  For the full mobile security playbook (secrets, API trust, billing), see [Security](/docs/mobile/security/overview).
</Callout>

## UI

Separate configuration controls what is shown in the **UI**. It lives in `apps/mobile/config/auth.ts`.

```ts title="apps/mobile/config/auth.ts"
import { Platform } from "react-native";

import { authConfigSchema, type AuthConfig } from "@workspace/auth";

export const authConfig = authConfigSchema.parse({
  providers: {
    password: true,
    emailOtp: false,
    magicLink: false,
    anonymous: true,
    oAuth: [
      Platform.select({
        android: "google",
        ios: "apple",
      }),
      "github",
    ],
  },
}) satisfies AuthConfig;
```

The configuration is validated with a Zod schema, so invalid values surface as errors at startup.

<Callout title="Use environment variables instead of inline configuration">
  **Avoid editing the config file directly.** Prefer environment variables to override the defaults.

  For example, to switch from password to magic link, set:

  ```dotenv title=".env.local"
  EXPO_PUBLIC_AUTH_PASSWORD=false
  EXPO_PUBLIC_AUTH_MAGIC_LINK=true
  ```
</Callout>

To show third-party providers in the UI, add the provider to the `oAuth` array. Defaults: Google and GitHub (with platform-specific Apple on iOS).

```tsx title="apps/mobile/config/auth.ts"
providers: {
    ...
    oAuth: [
      Platform.select({
        android: SocialProvider.GOOGLE,
        ios: SocialProvider.APPLE,
      }),
      SocialProvider.GITHUB,
    ],
    ...
},
```

You can even display specific providers for specific platforms - for example, you can display Google authentication for Android and Apple authentication for iOS.

## Third-party providers

To enable third-party authentication providers, you'll need to:

1. Create an OAuth application in the provider’s developer console ([Apple](https://developer.apple.com/account/), [Google Cloud Console](https://console.cloud.google.com/), [GitHub](https://github.com/settings/developers), or another supported provider).
2. Set the matching environment variables in your TurboStarter API (shared with web).

Each provider needs its own credentials and environment variables. See the [Better Auth OAuth docs](https://better-auth.com/docs/concepts/oauth) for step-by-step setup per provider.

<Callout title="Multiple environments">
  Make sure to set both development and production environment variables
  appropriately. Your OAuth provider may require different callback URLs for
  each environment.
</Callout>


# User flow
Source: https://www.turbostarter.dev/docs/mobile/auth/flow

TurboStarter ships with a fully functional authentication system. Most screens and components are preconfigured and easy to customize.

Here you will find a quick walkthrough of the authentication flow.

## Sign up

The sign-up screen is where users can create an account. They need to provide their email address and password.

![Sign up](/images/docs/mobile/auth/sign-up.png)

Once successful, users are asked to confirm their email address. This is enabled by default - and due to security reasons, it's not possible to disable it.

<Callout type="warn" title="Sending authentication emails">
  Make sure to configure the [email provider](/docs/web/emails/configuration) together with the [auth hooks](/docs/web/emails/sending#authentication-emails) to be able to send emails from your app.
</Callout>

![Confirm email](/images/docs/mobile/auth/confirm-email.png)

## Sign in

The sign-in screen lets users log in with email and password, magic link (if enabled), OTP (if enabled), or third-party providers.

![Sign in](/images/docs/mobile/auth/sign-in.png)

## Sign out

The sign-out button is in the user account settings.

![Settings](/images/docs/mobile/auth/settings.png)

## Forgot password

The forgot-password screen lets users request a reset. They enter their email and follow the instructions sent to them.

The reset-password screen is where users land from the password-reset email. They set a new password and confirm it.

![Forgot password](/images/docs/mobile/auth/forgot-password.png)

## Two-factor authentication

Two-factor authentication adds a second step: users enter a code sent to their email or phone (or from an authenticator app) in addition to their password when signing in.

![Two-factor authentication](/images/docs/mobile/auth/two-factor/sign-in-prompt.png)


# Apple
Source: https://www.turbostarter.dev/docs/mobile/auth/oauth/apple

**"Sign in with Apple"** provides a native, privacy-preserving SSO experience on iOS. Use the system Apple button and the Apple Authentication APIs to sign users in, then verify the identity token on your backend and create a session with your auth server.

<Callout title="Apple ID authentication is available on iOS only">
  Native Apple ID authentication is available on iOS only. You are advised to
  present the official system button (or our custom component - also compliant!)
  and follow [Apple's Human Interface
  Guidelines](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple)
  for best practices.
</Callout>

![Sign in with Apple](/images/docs/mobile/auth/sign-in-with-apple.png)

## Why use native Apple ID authentication?

<Cards>
  <Card title="First-class native UX">
    System sheet + official button, aligned with [Apple's Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple) for trust and conversion.
  </Card>

  <Card title="Privacy-forward">
    Private relay email and limited data by design, ensuring your users' privacy is protected and compliant with App Store guidelines.
  </Card>

  <Card title="Fewer passwords">
    Fast, low-friction sign-in on iOS enabling your users to sign in without the need to remember or create additional passwords.
  </Card>

  <Card title="Secure by default">
    JWT verification on the server with [Better Auth](https://better-auth.com/docs/authentication/apple), keeping your users' credentials secure.
  </Card>

  <Card title="Seamless sessions">
    We exchange Apple credentials for an app session and persist it in the app.
  </Card>
</Cards>

## Requirements

* Enable the "Sign in with Apple" capability for your bundle identifier in the [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list)
* Add the entitlement and build with [EAS](/docs/mobile/publishing/checklist) (or configure natively)
* Ensure your app's deep link scheme is added to the auth server's [trusted origins configuration](/docs/mobile/auth/configuration)

Check the [Better Auth documentation](https://better-auth.com/docs/authentication/apple) for more details on how to configure all the required keys and certificates.

## High-level flow

1. Check availability with `AppleAuthentication.isAvailableAsync()`.
2. Render the system `AppleAuthenticationButton` or custom TurboStarter component.
3. Call `AppleAuthentication.signInAsync()` requesting `FULL_NAME` and/or `EMAIL` as needed.
4. Send the returned `idTokeb` identifier to the API powered by [Better Auth](https://better-auth.com/docs/authentication/apple) to verify and establish a session.
5. Optionally track credential state with `AppleAuthentication.getCredentialStateAsync(user)`.

<Callout type="warn" title="Verify on the server">
  Always verify the JWT signature from `idToken` on your backend using Apple's
  public keys before creating a session.
</Callout>

For a more in-depth overview of Apple ID authentication—including implementation details, platform caveats, and advanced configuration—see the following resources:

<Cards>
  <Card title="Expo AppleAuthentication" href="https://docs.expo.dev/versions/latest/sdk/apple-authentication/" description="docs.expo.dev" />

  <Card title="Login with Apple" href="https://better-auth.com/docs/authentication/apple" description="better-auth.com" />

  <Card title="Sign in with Apple" href="https://developer.apple.com/documentation/sign_in_with_apple" description="developer.apple.com" />
</Cards>


# Google
Source: https://www.turbostarter.dev/docs/mobile/auth/oauth/google

**"Sign in with Google"** enables a fast account-chooser experience on mobile (especially on Android). Configure your platform credentials, prompt the native account picker, then exchange the returned token on your backend to create a session with your auth server.

<Callout title="Platform support">
  On Android, Google Sign‑In uses [Google Identity
  Services](https://developers.google.com/identity?hl=pl) and integrates with
  the system account chooser. On iOS, the recommended Expo flow uses
  [expo-auth-session](https://docs.expo.dev/versions/latest/sdk/auth-session/)
  with Google for a native, web-based sign-in experience.
</Callout>

![Sign in with Google](/images/docs/mobile/auth/sign-in-with-google.png)

## Why use Google authentication?

<Cards>
  <Card title="First-class native UX">
    Account picker and token storage integrated with the OS for speed and familiarity.
  </Card>

  <Card title="Seamless across platforms">
    Android native chooser; iOS polished experience via Expo.
  </Card>

  <Card title="Secure by default">
    Tokens are verified server-side with [Better Auth](https://better-auth.com/docs/authentication/google) before a session is issued.
  </Card>

  <Card title="Faster onboarding">
    Reduce friction with one-tap sign-in and fewer passwords to remember.
  </Card>

  <Card title="Scalable">
    Built on [Google Identity Services](https://developers.google.com/identity?hl=pl) and best-practice OAuth flows.
  </Card>
</Cards>

## Requirements

* Configure [Google Cloud OAuth Client IDs](https://react-native-google-signin.github.io/docs/setting-up/get-config-file) (Android package + SHA-1, iOS bundle ID) in the [Google Cloud Console](https://console.cloud.google.com/)
* Build with [EAS](/docs/mobile/publishing/checklist) to ensure native credentials are embedded correctly
* Add your app deep link scheme to the auth server's [trusted origins configuration](/docs/mobile/auth/configuration)

Check the [Better Auth documentation](https://better-auth.com/docs/authentication/google) and [`@react-native-google-signin/google-signin` documentation](https://react-native-google-signin.github.io) for steps to configure your server verification, client IDs and more.

## High-level flow

1. Configure Google OAuth Client IDs for Android and iOS in [Google Cloud Console](https://console.cloud.google.com/).
2. Initialize the Google auth request in your app and render a "Sign in with Google" button.
3. Prompt the account chooser; on success you receive an `idToken` and/or `accessToken`.
4. Send the tokens to the API powered by [Better Auth](https://better-auth.com/docs/authentication/google) to verify and establish a session.
5. Persist the session and proceed to the app.

For a more in-depth overview of Google authentication, including implementation details, platform caveats, and advanced configuration, see the following resources:

<Cards>
  <Card title="Use Google Authentication" href="https://docs.expo.dev/guides/google-authentication/" description="docs.expo.dev" />

  <Card title="Login with Google" href="https://better-auth.com/docs/authentication/google" description="better-auth.com" />

  <Card title="React Native Google Sign In" href="https://react-native-google-signin.github.io/" description="react-native-google-signin.github.io" />

  <Card title="Authenticate users with Sign in with Google" href="https://developer.android.com/identity/sign-in/credential-manager-siwg" description="developer.android.com" />
</Cards>


# OAuth
Source: https://www.turbostarter.dev/docs/mobile/auth/oauth

Better Auth supports almost **30** (!) different [OAuth providers](https://better-auth.com/docs/concepts/oauth). They can be easily configured and enabled in the kit without any additional configuration needed.

<Callout title="Everything configured!">
  TurboStarter provides you with all the configuration required to handle OAuth providers responses from your app:

  * redirects
  * middleware
  * confirmation API routes

  You just need to configure one of the below providers on their side and set correct credentials as environment variables in your TurboStarter app.
</Callout>

![OAuth providers](/images/docs/web/auth/social-providers.png)

Third Party providers need to be configured, managed and enabled fully on the provider's side. TurboStarter just needs the correct credentials to be set as environment variables in your app and passed to the [authentication API configuration](/docs/web/auth/configuration#api).

To enable OAuth providers in your TurboStarter app, you need to:

1. Set up an OAuth application in the provider's developer console (like [Apple Developer Portal](https://developer.apple.com/account/), [Google Cloud Console](https://console.cloud.google.com/), [Github Developer Settings](https://github.com/settings/developers) or any other provider you want to use)
2. Configure the provider's credentials as environment variables in your app. For example, for Google OAuth:

```dotenv title="apps/web/.env.local"
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```

Then, pass it to the authentication configuration in `packages/auth/src/server.ts`:

```ts title="server.ts"
export const auth = betterAuth({
  ...

  socialProviders: {
    [SocialProvider.GOOGLE]: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },

  ...
});
```

<Callout title="Remember to add your app scheme as trusted origin">
  For mobile apps, we need to define a trusted origin using an app scheme instead of a classic URL. App schemes (like `turbostarter://`) are used for [deep linking](https://docs.expo.dev/guides/linking/) users to specific screens in your app after authentication.

  To find your app scheme, take a look at `apps/mobile/app.config.ts` file and then add it to your auth server configuration:

  ```ts title="server.ts"
  export const auth = betterAuth({
    ...

    trustedOrigins: ["turbostarter://**"],

    ...
  });
  ```

  Adding your app scheme to the trusted origins list is crucial for security - it prevents CSRF attacks and blocks malicious open redirects by ensuring only requests from approved origins (your app) are allowed through.

  [Read more about auth security in Better Auth's documentation.](https://better-auth.com/docs/reference/security)
</Callout>

Also, we included some native integrations (["Sign in with Apple"](/docs/mobile/auth/oauth/apple) for iOS and ["Sign in with Google"](/docs/mobile/auth/oauth/google) for Android) to make the sign-in process smoother and faster for the user.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/auth/overview

TurboStarter uses [Better Auth](https://better-auth.com) to handle authentication. It's a secure, production-ready authentication solution that integrates seamlessly with many frameworks and provides enterprise-grade security out of the box.

<Callout title="Why Better Auth?">
  One of the core principles of TurboStarter is to do things **as simple as possible**, and to make everything **as performant as possible**.

  Better Auth provides an excellent developer experience with minimal configuration while keeping enterprise-grade security. Its framework-agnostic approach and focus on performance make it the perfect choice for TurboStarter.

  Recently, Better Auth [announced](https://better-auth.com/blog/authjs-joins-better-auth) an incorporation of [Auth.js (28k+ stars on GitHub)](https://authjs.dev/), making it even more powerful and flexible.
</Callout>

![Better Auth](/images/docs/better-auth.png)

You can read more about Better Auth in the [official documentation](https://better-auth.com/docs).

TurboStarter supports multiple authentication methods:

* **Password** - the traditional email/password method
* **Magic Link** - magic links with [deep linking](/docs/mobile/deep-linking)
* **OTP** - one-time passwords sent to email or phone
* **Anonymous** - allowing users to proceed anonymously
* **OAuth** - social providers ([Apple](https://better-auth.com/docs/authentication/apple), [Google](https://better-auth.com/docs/authentication/google), and [GitHub](https://better-auth.com/docs/authentication/github) preconfigured)
* **Native Apple authentication** - [Sign in with Apple](/docs/mobile/auth/oauth/apple) for iOS
* **Native Google authentication** - [Sign in with Google](/docs/mobile/auth/oauth/google) for Android

As well as common applications flows, with ready-to-use views and components:

* **Sign in** - sign in with email/password, magic link, one-time password, or OAuth providers
* **Sign up** - sign up with email/password or OAuth providers
* **Sign out** - end session by signing out
* **Password recovery** - forgot and reset password
* **Email verification** - verify email address

You can **build your auth flow like LEGO bricks** - plug in the parts you need and customize them.


# Configuration
Source: https://www.turbostarter.dev/docs/mobile/billing/configuration

Mobile billing configuration consists of a few key components that must be set up correctly to work across platforms.

If you're new to in-app purchases, the most important thing to understand is that **native stores are the source of truth** for your products (what can be purchased, how much it costs, and where it's available). Your billing provider (and your app) can only show and sell what you've configured correctly in [App Store Connect](https://developer.apple.com/help/app-store-connect) (iOS) or the [Google Play Console](https://developer.android.com/studio/publish/preparing) (Android).

As a rule of thumb, set things up in this order:

* **Store**: complete agreements, create products in the native stores, and make sure they're available for testing
* **Offerings**: organize products into purchasable choices for your users (monthly/yearly, tiers, etc.) and specify which entitlements each option grants
* **Paywall**: present offerings in-app and trigger purchases/restores

All of these components are tightly connected, and each must be configured correctly for the billing flow to work smoothly. The sections below guide you through configuring each one.

## Billing reference

In the mobile app, billing can be resolved against either:

* the **current user** for B2C/self-serve billing
* the [active organization](/docs/mobile/organizations/active-organization) for organization-aware B2B billing

The current mobile billing setup identifies the customer with the active billing reference at runtime. If the user has an active organization selected, billing can be associated with that organization. Otherwise, it falls back to the personal user account.

This means you can support both:

* personal subscriptions for individual users
* team-oriented billing where access belongs to the organization

This is especially useful when your mobile app shares billing state with the web app and uses the same user or organization account model across platforms.

<Callout title="Important distinction">
  Native mobile purchases still go through Apple or Google billing systems. The B2B part here is about which app-level account owns the billing state and access after purchase: the user or the organization.
</Callout>

## Store

Store configuration is the foundation: every in-app purchase ultimately goes through the native store ([App Store](https://apple.com/app-store) on iOS, [Google Play](https://play.google.com/) on Android). Complete this first—otherwise your paywall won't be able to display the correct products and prices.

Follow the official guides to make sure you've created and configured your products in the native stores correctly.

<Cards>
  <Card title="iOS Product Setup" href="https://www.revenuecat.com/docs/getting-started/entitlements/ios-products" description="Setting up your in-app purchases in App Store Connect." />

  <Card title="Google Play Product Setup" href="https://www.revenuecat.com/docs/getting-started/entitlements/android-products" description="Setting up your in-app purchases in Google Play Billing." />
</Cards>

Although these links come from RevenueCat's documentation, the steps apply to any provider - the key part is knowing what's relevant for correct store setup.

## Offerings

Offerings are what your app presents to the user. They typically map store products into a set of purchasable options (for example: monthly vs. yearly), plus the “what do I unlock?” logic.

They're primarily configured remotely in your provider's dashboard. See the dedicated setup guides below to learn how to configure offerings for each provider.

<Cards>
  <Card title="RevenueCat" href="/docs/mobile/billing/revenuecat" description="Integrate your mobile application with RevenueCat." />

  <Card title="Superwall" href="/docs/mobile/billing/superwall" description="Implement paywalls, subscriptions, and revenue sharing with Superwall." />
</Cards>

The source of truth for the offerings is the native store products data, so make sure to first complete the store configuration above.

<Callout title="Entitlements">
  In mobile apps terminology, you often can see the term **entitlements** used together with offerings. Entitlements define what content or features users have access to after making a purchase and are tied to specific products.

  For instance, a user who purchases a premium subscription could be granted access to exclusive features through entitlements.
</Callout>

### Cross-platform support

To show purchased plan details in your app (such as on a subscription overview screen) and make them accessible on both mobile and web apps, you'll need to include that plan in your shared billing configuration. Follow the [web configuration schema](/docs/web/billing/configuration) for consistency across platforms.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      variants: [
        {
          /* WEB */
          ...

          /* MOBILE */
          /* 👇 This is the product identifier from the store (e.g. App Store Connect, Google Play) */
          id: "core_premium_recurring_month_flat",
          cost: 1900,
          currency: "usd",
          model: BillingModel.RECURRING,
          interval: RecurringInterval.MONTH,
          trialDays: 7,
          hidden: true, // [!code highlight]
        },
      ],
    },
  ],
  ...
}) satisfies BillingConfig;
```

Make sure to set the `hidden` flag to `true` to prevent the variant from being displayed in the pricing table on the web app. This is because the web app will display variants from the shared billing configuration, while the mobile app displays variants from offerings configured for the specific paywall in the provider's dashboard.

This way, you stay compliant with in-app purchase requirements, keep a native mobile experience, and can still show plan details in both the web app and the mobile app.

If your app supports organizations, this shared configuration also helps keep B2B billing behavior consistent across platforms. The web app and mobile app can both resolve billing against the same organization reference, even though the mobile purchase itself still happens through the native store flow.

## Paywall

The paywall is the UI users interact with to purchase an offering. It's configured remotely in the provider's dashboard, letting you change the paywall UI, behavior, and displayed offerings without needing to release a new version of your app.

![Paywall](/images/docs/mobile/billing/paywall.png)

To make paywall setup easier, we have a dedicated guide for each provider:

<Cards>
  <Card title="RevenueCat" href="/docs/mobile/billing/revenuecat" description="Integrate your mobile application with RevenueCat." />

  <Card title="Superwall" href="/docs/mobile/billing/superwall" description="Implement paywalls, subscriptions, and revenue sharing with Superwall." />
</Cards>

Test your paywall in a sandbox environment first to confirm everything works as expected and that products from the native stores display correctly.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/billing/overview

Implementing mobile billing can be challenging, especially when you need to handle cross-platform compatibility and comply with the different requirements of the App Store and Google Play.

<Callout title="Be cautious!" type="warn">
  Apple has strict guidelines regarding external payment systems and **may reject your app** if you aggressively redirect users to web-based payment flows. Make sure to review the [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/#payments) carefully and consider implementing native in-app purchases for iOS users to ensure compliance.

  TurboStarter's mobile billing is designed around native in-app purchases, so it's fully compliant and ready to use out of the box. However, please be mindful when modifying payment-related features in your mobile app.
</Callout>

TurboStarter makes this easier by providing **native in-app billing** through [RevenueCat](/docs/mobile/billing/revenuecat) and [Superwall](/docs/mobile/billing/superwall). These providers abstract the native store APIs, so you can sell subscriptions and manage entitlements without integrating each store SDK yourself or relying on web-based checkout flows.

![Billing Providers](/images/docs/mobile/billing/providers.png)

## Providers

To support both iOS and Android, TurboStarter includes the following providers for mobile billing:

<Cards>
  <Card title="RevenueCat" href="/docs/mobile/billing/revenuecat" description="Manage in-app subscriptions and customer entitlements." />

  <Card title="Superwall" href="/docs/mobile/billing/superwall" description="Build paywalls and optimize conversion with experiments." />
</Cards>

Each provider is configured and set up behind a unified API. You can switch providers by changing the exports, or introduce your own provider without breaking billing-related logic.

Depending on the provider you choose, you'll need to set the corresponding environment variables. By default, the billing package uses [RevenueCat](/docs/mobile/billing/revenuecat). Alternatively, you can use [Superwall](/docs/mobile/billing/superwall).

## B2C vs B2B

Mobile billing naturally starts as a **B2C** flow, because native purchases are completed through the user's App Store or Google Play account.

At the same time, TurboStarter can also support an **organization-aware B2B billing model** inside the mobile app:

* if the user is acting on their own behalf, billing is resolved against the user
* if the user is working inside an active organization, billing can be resolved against that organization
* the mobile app can then show billing state, subscriptions, orders, and access rules for either scope

In other words, mobile purchases are still native-store purchases, but the app-level billing reference can be either:

* a **user** for B2C/self-serve access
* an [organization](/docs/mobile/organizations/overview) for team-oriented B2B access

This works well for mobile apps where:

* individuals can subscribe for themselves
* teams share access through an organization
* premium features, seats, credits, or usage belong to the organization

<Callout title="Mobile B2B is organization-aware, not a separate store flow">
  TurboStarter does not create a separate enterprise-only purchase mechanism inside the mobile app. Instead, it uses the active billing reference to decide whether billing and entitlements should belong to the user or the organization.
</Callout>

## Configuration

Most configuration is done **provider-side**, following the philosophy that you should be able to change plan configuration (and other settings) without having to release a new version of your app. This is especially useful for A/B testing to determine which offering performs better.

To learn more about configuring products, offerings, and cross-platform support, check the following sections:

<Cards>
  <Card title="Configuration" href="/docs/mobile/billing/configuration" description="Set up billing configuration and provider settings." />

  <Card title="Webhooks" href="/docs/mobile/billing/webhooks" description="Keep customer status in sync via provider webhooks." />
</Cards>

## Displaying a paywall

The paywall is a crucial part of the billing flow - it displays offerings to the user and lets you trigger purchases/restores at different points in the user journey.

To present a paywall in your app, use the `usePaywall` hook from the `@workspace/billing-mobile` package. This hook returns the paywall result directly from the configured provider. The setup wizard already uses trigger `"onboarding"` - see the [onboarding recipe](/docs/mobile/recipes/onboarding) to customize that step or turn it into a hard paywall.

```tsx title="paywall.tsx"
import { usePaywall } from "@workspace/billing-mobile";

export default function Paywall() {
  const { present, result } = usePaywall();

  return (
    <>
      <Pressable
        onPress={() =>
          present({
            trigger: "onboarding",
          })
        }
      >
        <Text>Present paywall</Text>
      </Pressable>
      <Text>{result.status}</Text>
    </>
  );
}
```

Don't forget to pass the `trigger` parameter, as it's used to identify the template/campaign that needs to be triggered on the provider's side.

If you want to react to paywall lifecycle events, you can pass additional callbacks to `usePaywall`:

```tsx title="paywall.tsx"
import { usePaywall } from "@workspace/billing-mobile";

const { present, result } = usePaywall({
  onPresent: () => {},
  onDismiss: () => {},
  onPurchase: () => {},
  onRestore: () => {},
  onSkip: () => {},
  onError: (error) => {},
});
```

They're called automatically when the paywall enters a specific state - for example, when the user purchases a plan, `onPurchase` will be called.

## Fetching customer status

After a user purchases a plan in-app, you'll often want to fetch their current billing summary (subscription status, orders, current plan) to:

* gate features in your UI - see the [mobile feature-based access recipe](/docs/mobile/recipes/feature-based-access)
* show “Current plan” / “Manage subscription” states
* keep the app in sync across sessions and devices

You can do this via the billing `summary` endpoint (`/api/billing/summary`) using the mobile [API client](/docs/mobile/api/client).

To do so, call `/api/billing/summary` to fetch the current billing summary:

```tsx title="customer-screen.tsx"
import { handle } from "@workspace/api/utils";
import { getActivePlan } from "@workspace/billing";

import { api } from "~/lib/api";

export default function CustomerScreen() {
  const summary = useQuery({
    queryKey: ["billing", "summary"],
    queryFn: () => handle(api.billing.summary.$get)(),
  });

  if (!summary.data) {
    return null;
  }

  const plan = getActivePlan(summary.data);

  return (
    <View>
      <Text>{plan}</Text>
    </View>
  );
}
```

Alternatively, you can treat the **provider as the source of truth** - for example, if you only need to check whether a user has a specific entitlement and want to delegate the rest to the native store handling. To do this, use the `useCustomer` hook, which returns customer data from the configured provider (RevenueCat or Superwall).

```tsx title="customer-screen.tsx"
import { useCustomer } from "@workspace/billing-mobile";

export default function CustomerScreen() {
  const { entitlements } = useCustomer();

  const hasPremium = entitlements.some(
    (entitlement) => entitlement.id === "premium" && entitlement.active,
  );

  /* ... */
}
```

Which approach you choose depends on how much you want to handle in your backend vs. the native store/provider layer. By default, we recommend using the API to handle billing-related logic because it gives you the most flexibility and control. If you need something native-specific, use the built-in hooks (like `useCustomer` and `usePaywall`) to communicate directly with the configured provider.


# RevenueCat
Source: https://www.turbostarter.dev/docs/mobile/billing/revenuecat

[RevenueCat](https://www.revenuecat.com/) is a popular platform for managing in-app purchases and subscriptions. It's a great choice for mobile billing because it's fully compliant with App Store and Google Play guidelines.

It's the default billing provider for mobile apps in TurboStarter. This guide walks you through configuring RevenueCat and wiring it up to your app.

<Callout type="warn" title="Prerequisite: Store configuration">
  First complete the [store configuration](/docs/mobile/billing/configuration#store) and create your products in the native stores before configuring RevenueCat.
</Callout>

<Steps>
  <Step>
    ## Configure a new project

    RevenueCat projects are top-level containers for your apps, products, entitlements, paywalls, and more. If you don't already have a project for your app, create one in the dashboard.

    To create a project, click the *+ Create new project* button in the *Projects* dropdown at the top of the RevenueCat dashboard.

    You can also set a name and configure global [restore behavior](https://www.revenuecat.com/docs/getting-started/restoring-purchases).

    ![Project settings](/images/docs/mobile/billing/revenuecat/project-settings.png)

    <Card title="RevenueCat Projects" href="https://www.revenuecat.com/docs/projects/overview" description="revenuecat.com" />
  </Step>

  <Step>
    ## Connect to a store

    Depending on which platform you're building for, you'll need to connect your RevenueCat project to one or more stores.

    Each [project](https://www.revenuecat.com/docs/projects/overview) comes with a [Test Store](https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store) where you can create products, configure offerings, and test the complete purchase flow—without connecting to any app store or payment provider.

    When you're ready to submit your app for review, connect it to the real stores and payment providers you want to support and set up [Server Notifications](https://www.revenuecat.com/docs/platform-resources/server-notifications). After you've connected your app, you can import your products and start configuring offerings.

    Add an app configuration in the *Apps & providers* section of your app settings.

    ![Connect to a store](/images/docs/mobile/billing/revenuecat/connect-store.png)

    To learn more about how to obtain all the required API keys and secrets, refer to the [official documentation](https://www.revenuecat.com/docs/projects/connect-a-store).

    <Callout type="warn" title="Switching from Test Store to Production">
      If you've been using the Test Store during development, switch from your Test Store API key to your platform-specific API key before submitting for app review.
    </Callout>
  </Step>

  <Step>
    ## Get API keys

    After you've connected to a store, you'll need the API keys and secrets for the SDK. You can find them under *API Keys* in the dashboard.

    ![API keys](/images/docs/mobile/billing/revenuecat/api-keys.png)

    To make server-side API requests work, create a *Secret API key* for your project. Pick `v1` as the API version so the server can fetch customer billing data on webhook requests.

    For local development, you can use the Test Store API key (sandbox).

    <Card title="API Keys & Authentication" href="https://www.revenuecat.com/docs/projects/authentication" description="revenuecat.com" />
  </Step>

  <Step>
    ## Set environment variables

    You need to set the following environment variables:

    ```dotenv title="apps/mobile/.env.local"
    EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY="" # Your RevenueCat Apple API key
    EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY="" # Your RevenueCat Google API key
    ```

    Additionally, set the secret API key as an environment variable for your **web app**:

    ```dotenv title="apps/web/.env.local"
    REVENUECAT_API_KEY="" # Your RevenueCat secret API key
    ```

    This is required to fetch customer billing data on webhook requests, and it should only be available on the server.

    **Don't commit secret keys.** During development, put them in `.env.local` (it's not committed). In production, set them as environment variables in your hosting provider.
  </Step>

  <Step>
    ## Create products

    For each store you're supporting, you'll need to add the products you plan on offering to your customers.

    To streamline setup, RevenueCat can import products you've already created in the app stores. This keeps your catalog consistent and saves manual work.

    ![Import products](/images/docs/mobile/billing/revenuecat/import-products.png)

    You can also create products manually in the dashboard, although it's not recommended—those products still need to exist in the app stores.

    Read more about product setup in the [official documentation](https://www.revenuecat.com/docs/offerings/products/setup-index).
  </Step>

  <Step>
    ## Create an entitlement

    RevenueCat entitlements represent a level of access, features, or content that a user is "entitled" to. Entitlements are scoped to a [project](https://www.revenuecat.com/docs/projects/overview) and are typically unlocked after a user purchases a [product](https://www.revenuecat.com/docs/offerings/products-overview).

    To create a new entitlement, click *Product catalog* in the left menu, open the *Entitlements* tab, and click *+ New entitlement*. Enter a unique identifier you'll reference in your app, like `pro`.

    Most apps only need one entitlement, but create as many as your product requires. For example, a navigation app might have a subscription for `pro` access and one-time purchases to unlock specific map regions - one `pro` entitlement plus additional entitlements for each region.

    ![Entitlements](/images/docs/mobile/billing/revenuecat/entitlements.png)

    ### Attach products to an entitlement

    After you create entitlements, attach products to them. This tells RevenueCat which entitlement(s) to unlock after a customer purchases a product.

    When viewing an entitlement, click *Attach* to attach a product. If you've already added your products, you'll be able to select one from the list.

    ![Attach products to an entitlement](/images/docs/mobile/billing/revenuecat/attach-products.png)

    When a customer buys a product attached to an entitlement, that entitlement becomes active for the duration of the product. Subscription products unlock entitlements for the subscription period. Non-consumable purchases can unlock content permanently.

    If you have non-subscription products, whether you attach them to entitlements depends on your use case. If a product is non-consumable (e.g. lifetime access to `pro`), you usually want an entitlement. If it's consumable (e.g. buying more lives), you usually don't.

    <Callout>
      Attaching an entitlement to a product will grant that entitlement to any customers that have previously purchased that product. Likewise, detaching an entitlement from a product will remove it for any customers that have previously purchased that product.
    </Callout>

    When designing your Entitlement structure, keep in mind that a single product can unlock multiple entitlements, and multiple products may unlock the same entitlement.

    <Card title="RevenueCat Entitlements" href="https://www.revenuecat.com/docs/getting-started/entitlements" description="revenuecat.com" />
  </Step>

  <Step>
    ## Create an offering

    Offerings are the selection of products that are "offered" to a user on your paywall. Think of an offering as the product group your paywall will display.

    Offerings are created and configured in the RevenueCat dashboard. When using RevenueCat Paywalls, you'll configure a single paywall that is paired to a single Offering.

    To create an offering, go to the *Offerings* tab in your project settings and click *+ New*.

    You'll be prompted to enter an Identifier and Description for your offering. Note that the offering identifier cannot be changed later. Once you've entered this information, click Save.

    ![Create an offering](/images/docs/mobile/billing/revenuecat/create-offering.png)

    Each Offering you create should contain at least one Package that holds cross-platform products.

    To create a package, open your new offering and click *+ Add package* in the *Packages* section. Choose an identifier that matches the package duration. If a duration isn't suitable (e.g. consumables), choose a custom identifier. Add a description.

    Attach the relevant products (i.e., the products with the same duration you chose) for this Offering, then click Save.

    ![Add package](/images/docs/mobile/billing/revenuecat/add-package.png)

    Any product can be added to an Offering, even if it's not part of any Entitlement. This can come in handy if your app's paywall contains a combination of subscription products that unlock Entitlements, and consumable products that do not.

    <Card title="RevenueCat Offerings" href="https://www.revenuecat.com/docs/offerings/overview" description="revenuecat.com" />
  </Step>

  <Step>
    ## Configure a paywall

    RevenueCat Paywalls let you configure your paywall UI remotely - without code changes or app updates. They're great for iterating on designs and running experiments.

    To get started, click *+ New Paywall* on the Paywalls page for your project:

    ![New paywall](/images/docs/mobile/billing/revenuecat/new-paywall.png)

    Next, you'll need to select the Offering you want to add a Paywall to. Or, if you don't have any Offerings without Paywalls, you'll have the option to duplicate an existing one or create a new one.

    Unless you have a very specific custom design in mind, start with a template. You can customize everything after you pick one - it's just a starting point.

    ![Paywall templates](/images/docs/mobile/billing/revenuecat/paywall-templates.png)

    To customize your paywall, you can edit components in the dedicated editor.

    ![Editor](/images/docs/mobile/billing/revenuecat/editor.png)

    When you're ready to publish your paywall, click *Publish* in the top-right corner of the editor. Check [Overview](/docs/mobile/billing/overview#displaying-a-paywall) for details on how to display the paywall in your app.

    <Callout title="Preview paywall before publishing">
      You can preview your paywall on your phone before publishing by clicking the Preview button in the top-right corner of the editor.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync subscription status (and other purchase events) to your database, you need to set up a webhook.

    TurboStarter includes the webhook handler out of the box - you just need to create the webhook in RevenueCat and paste in your callback URL.

    To configure a new webhook, go to the *Integrations* tab and choose the *Webhooks* option.

    ![Webhooks option](/images/docs/mobile/billing/revenuecat/webhooks-option.png)

    Click on the *Add new configuration* button to create a new webhook configuration.

    ![Webhook configuration](/images/docs/mobile/billing/revenuecat/webhook.png)

    It's also recommended to set an `Authorization` header that will be sent with every request. Your server can verify it to ensure the request is coming from a trusted source.

    You can get it by running the following command in your terminal:

    ```bash
    openssl rand -base64 32
    ```

    Copy the generated string and paste it into the Authorization header.

    You also need to add this secret to your environment variables for your **web app**:

    ```dotenv title="apps/web/.env.local"
    REVENUECAT_WEBHOOK_SECRET=<your-generated-secret>
    ```

    This secret is used by your server to verify incoming webhook requests.

    To get the callback URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    If you want to test the webhook locally, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine. Ngrok will then give you a URL that you can use to test the webhook locally.

    To do so, install ngrok and run it with the following command (while your TurboStarter **web** development server is running):

    ```bash
    ngrok http 3000
    ```

    ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

    This will give you a URL (see the *Forwarding* output) that you can use to create a webhook in RevenueCat. Use that URL and append `/api/billing/webhook/revenuecat`.

    <Card title="RevenueCat Webhooks" description="revenuecat.com" href="https://www.revenuecat.com/docs/integrations/webhooks" />

    ### Production deployment

    When going to production, you will need to set the webhook URL and choose which events you want to listen to in RevenueCat.

    The webhook path is `/api/billing/webhook/revenuecat`. If your app is hosted at `https://myapp.com` then you need to enter `https://myapp.com/api/billing/webhook/revenuecat` as the URL.

    All the relevant events are automatically handled by TurboStarter, so you don't need to do anything else. If you want to handle more events, check [Webhooks](/docs/mobile/billing/webhooks) for more information.

    <Callout type="error" title="API deployment required">
      To handle billing webhooks in production (and allow your Expo app to talk to your backend), you must first deploy the Hono API.

      <Cards>
        <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

        <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />
      </Cards>
    </Callout>
  </Step>
</Steps>

That's it! 🎉 You have now set up RevenueCat as a billing provider for your app.

Feel free to add more products, variants, and promotional offers, and manage your customer data and subscriptions using RevenueCat.


# Superwall
Source: https://www.turbostarter.dev/docs/mobile/billing/superwall

[Superwall](https://superwall.com/) is a paywall experimentation platform for mobile apps. You can build and deploy paywalls without coding, run targeted A/B tests, and track conversion and revenue analytics - all while using native in-app purchases. It's a great choice when you want to iterate on monetization quickly.

To switch to Superwall, update the exports in the `@workspace/billing-mobile` package:

<Tabs items={["index.ts", "server.ts", "env.ts"]}>
  <Tab value="index.ts">
    ```ts
    // [!code word:superwall]
    export * from "./superwall";
    ```
  </Tab>

  <Tab value="server.ts">
    ```ts
    // [!code word:superwall]
    export * from "./superwall/server";
    ```
  </Tab>

  <Tab value="env.ts">
    ```ts
    // [!code word:superwall]
    export * from "./superwall/server/env";
    ```
  </Tab>
</Tabs>

These exports tell TurboStarter to use the Superwall implementation (instead of the default provider) for mobile billing.

In the sections below, you'll configure Superwall and set it up as the billing provider for your app.

<Callout type="warn" title="Prerequisite: Store configuration">
  First complete the [store configuration](/docs/mobile/billing/configuration#store) and create your products in the native stores before configuring Superwall.
</Callout>

<Steps>
  <Step>
    ## Configure a new project

    Start by creating a new project in the [Superwall dashboard](https://superwall.com/dashboard). Projects let you manage your apps, paywalls, entitlements, and integrations in one place.

    ![Create a new project](/images/docs/mobile/billing/superwall/create-project.png)
  </Step>

  <Step>
    ## Connect to a store

    Link your app to the appropriate stores (Apple App Store and/or Google Play) in Superwall. This lets Superwall access your in-app purchase products and keep paywalls and entitlements in sync across platforms.

    You can find the required keys (and how to obtain them) under *Revenue Tracking* in your project's *Settings*.

    Superwall uses this connection to attribute revenue and to help validate purchase data coming from the stores.

    ![Revenue Tracking](/images/docs/mobile/billing/superwall/revenue-tracking.png)
  </Step>

  <Step>
    ## Get API keys

    Open your project settings and copy the API keys you need to integrate Superwall in your app. You'll use these keys to initialize the SDK and connect your app to your Superwall project.

    ![API keys](/images/docs/mobile/billing/superwall/api-keys.png)

    Make sure to copy keys for both iOS and Android. To test the purchase flow, see Superwall's [blog post](https://superwall.com/blog/testing-subscriptions-and-in-app-purchases-for-ios-apps-before-launch/).
  </Step>

  <Step>
    ## Set environment variables

    Add the Superwall API keys as environment variables for your app so they're available securely at build and runtime.

    ```dotenv title="apps/mobile/.env.local"
    EXPO_PUBLIC_SUPERWALL_APPLE_API_KEY="" # Your Superwall Apple API key
    EXPO_PUBLIC_SUPERWALL_GOOGLE_API_KEY="" # Your Superwall Google API key
    ```

    Even though these are used on the client, keep them out of git. During development, put them in `.env.local` (it's not committed). In production, set them via your hosting provider (e.g. EAS).
  </Step>

  <Step>
    ## Create an entitlement

    Define entitlements in Superwall to represent what a user gets after purchasing a product or subscription. Your app uses entitlements to gate premium features/content.

    ![New entitlement](/images/docs/mobile/billing/superwall/new-entitlement.png)
  </Step>

  <Step>
    ## Create products

    In the Superwall dashboard, create the same products (subscriptions, consumables, etc.) as you have in the App Store / Play Store. Make sure the product identifiers match exactly.

    ![Create products](/images/docs/mobile/billing/superwall/create-products.png)

    When you connect your app to a store, Superwall can automatically import products. That's usually the fastest way to get started.

    For each product, select the entitlement it should unlock after purchase.

    After creating/importing products, make sure they're all in the **Active** state so they can be shown on your paywall.

    ![Active products](/images/docs/mobile/billing/superwall/active-products.png)

    <Card title="Products | Superwall Docs" description="superwall.com" href="https://superwall.com/docs/dashboard/dashboard-creating-paywalls/paywall-editor-products" />
  </Step>

  <Step>
    ## Create a paywall

    Design and configure paywalls in the Superwall editor. Paywalls control how products are presented to users, including A/B tests, price localization, promotions, and other monetization experiments.

    ![New paywall](/images/docs/mobile/billing/superwall/new-paywall.png)

    It's recommended to start with a template and customize it later, but you can also build one from scratch.

    ![Paywall templates](/images/docs/mobile/billing/superwall/paywall-templates.png)

    When you're ready to publish your paywall, click *Publish* in the top-right corner of the editor. Check [Overview](/docs/mobile/billing/overview#displaying-a-paywall) for details on how to display the paywall in your app.

    <Callout title="Preview paywall before publishing">
      You can preview your paywall on your phone before publishing by clicking the Preview button in the top-right corner of the editor.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync subscription status (and other purchase events) to your database, you need to set up a webhook.

    TurboStarter includes the webhook handler out of the box - you just need to create the webhook in Superwall and paste in your callback URL.

    To configure a webhook, go to the *Integrations* tab and choose *Webhooks*.

    ![Webhooks option](/images/docs/mobile/billing/superwall/webhooks-option.png)

    Click on the *Create Webhook* button to create a new webhook configuration.

    ![Webhook configuration](/images/docs/mobile/billing/superwall/webhook.png)

    After creating the webhook, copy the generated secret and add it to your environment variables for your **web app**:

    ```dotenv title="apps/web/.env.local"
    SUPERWALL_WEBHOOK_SECRET=<your-generated-secret>
    ```

    This secret is used by your server to verify incoming webhook requests.

    To get the callback URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    If you want to test the webhook locally, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine. Ngrok will then give you a URL that you can use to test the webhook locally.

    To do so, install ngrok and run it with the following command (while your TurboStarter **web** development server is running):

    ```bash
    ngrok http 3000
    ```

    ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

    This will give you a URL (see the *Forwarding* output) that you can use to create a webhook in Superwall. Use that URL and append `/api/billing/webhook/superwall`.

    <Card title="Webhooks | Superwall Docs" description="superwall.com" href="https://superwall.com/docs/integrations/webhooks" />

    ### Production deployment

    When going to production, you will need to set the webhook URL and choose which events you want to listen to in Superwall.

    The webhook path is `/api/billing/webhook/superwall`. If your app is hosted at `https://myapp.com` then you need to enter `https://myapp.com/api/billing/webhook/superwall` as the URL.

    All the relevant events are automatically handled by TurboStarter, so you don't need to do anything else. If you want to handle more events, check [Webhooks](/docs/mobile/billing/webhooks) for more information.

    <Callout type="error" title="API deployment required">
      To handle billing webhooks in production (and allow your Expo app to talk to your backend), you must first deploy the Hono API.

      <Cards>
        <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

        <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />
      </Cards>
    </Callout>
  </Step>
</Steps>

That's it! 🎉 You've successfully configured Superwall as your billing provider.

You can now add additional products, variants, or promotional offers, and manage your customers and subscriptions through Superwall.


# Webhooks
Source: https://www.turbostarter.dev/docs/mobile/billing/webhooks

<Callout type="error" title="API deployment required">
  To handle billing webhooks in production (and allow your Expo app to talk to your backend), you must first deploy the Hono API.

  <Cards>
    <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

    <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />
  </Cards>
</Callout>

TurboStarter uses billing webhooks to keep customer data in sync based on events sent by your billing provider.

However, sometimes you may want to perform custom actions when specific events arrive.

In that case, customize the billing webhook handler in your API backend (the endpoint that receives billing webhooks).

By default, the webhook handler is configured to be **as straightforward as possible**:

```ts title="router.ts"
import { webhookHandler, provider } from "@workspace/billing-mobile/server";

export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
  webhookHandler(c.req.raw),
);
```

However, you can extend it using the callbacks provided by the `@workspace/billing-mobile` package:

```ts title="router.ts"
import { webhookHandler, provider } from "@workspace/billing-mobile/server";

export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
  webhookHandler(c.req.raw, {
    onOneTimePurchaseSucceeded: (orderId) => {},
    onSubscriptionCreated: (subscriptionId) => {},
    onSubscriptionUpdated: (subscriptionId) => {},
    onSubscriptionDeleted: (subscriptionId) => {},
    onEvent: (rawEvent) => {},
  }),
);
```

You can provide one or more of the callbacks to handle the events you are interested in.

<Callout title="Don't mix up web and mobile billing" type="warn">
  Mobile billing webhooks are set up using the same method as [in the web app](/docs/web/billing/webhooks). Make sure to keep your configurations organized and confirm that events are handled properly for each provider on both mobile and web platforms.
</Callout>


# App configuration
Source: https://www.turbostarter.dev/docs/mobile/configuration/app

When configuring your app, you'll need to define settings in different places depending on which provider will use them (e.g., Expo, EAS).

## App configuration

Let's start with the core settings for your app. These settings are **crucial** as they're used by Expo and EAS to build your app, determine its store presence, prepare updates, and more.

This configuration includes essential details like the official name, description, scheme, store IDs, splash screen configuration, and more.

You'll define these settings in `apps/mobile/app.config.ts`. Make sure to follow the [Expo config schema](https://docs.expo.dev/versions/latest/config/app/) when setting this up.

Here is an example of what the config file looks like:

```ts title="apps/mobile/app.config.ts"
import { ExpoConfig } from "expo/config";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: "TurboStarter",
  slug: "turbostarter",
  scheme: "turbostarter",
  version: "0.1.0",
  orientation: "portrait",
  icon: "./assets/images/icon.png",
  userInterfaceStyle: "automatic",
  assetBundlePatterns: ["**/*"],
  sdkVersion: "51.0.0",
  platforms: ["ios", "android"],
  updates: {
    fallbackToCacheTimeout: 0,
  },
  newArchEnabled: true,
  ios: {
    bundleIdentifier: "your.bundle.identifier",
    supportsTablet: false,
  },
  android: {
    package: "your.bundle.identifier",
    adaptiveIcon: {
      monochromeImage: "./public/images/icon/android/monochrome.png",
      foregroundImage: "./public/images/icon/android/adaptive.png",
      backgroundColor: "#0D121C",
    },
  },
  extra: {
    eas: {
      projectId: "your-project-id",
    },
  },
  experiments: {
    tsconfigPaths: true,
    typedRoutes: true,
  },
  plugins: ["expo-router", ["expo-splash-screen", SPLASH]],
});
```

Make sure to replace the values with your own and take your time to set everything correctly.

<Card title="Configure with app config" description="docs.expo.dev" href="https://docs.expo.dev/workflow/configuration/" />

### Internal configuration

The same as for the [web app](/docs/web/configuration/app), and [extension](/docs/extension/configuration/app), we're defining the internal app config, which stores some overall variables for your application (that can't be read from Expo config).

The recommendation is to **not update this directly** - instead, please define the environment variables and override the default behavior. The configuration is strongly typed so you can use it safely accross your codebase - it'll be validated at build time.

```ts title="apps/mobile/src/config/app.ts"
import env from "env.config";

export const appConfig = {
  locale: env.EXPO_PUBLIC_DEFAULT_LOCALE,
  url: env.EXPO_PUBLIC_SITE_URL,
  theme: {
    mode: env.EXPO_PUBLIC_THEME_MODE,
    color: env.EXPO_PUBLIC_THEME_COLOR,
  },
} as const;
```

For example, to set the mobile app default theme color, you'd update the following variable:

```dotenv title=".env.local"
EXPO_PUBLIC_THEME_COLOR="yellow"
```

<Callout type="warn" title="Do NOT use process.env!">
  Do NOT use `process.env` to get the values of the variables. Variables
  accessed this way are not validated at build time, and thus the wrong variable
  can be used in production.
</Callout>

## EAS configuration

To properly build and publish your app, you need to define settings for the EAS build service.

This is done in `apps/mobile/eas.json` and it must follow the [EAS config scheme](https://docs.expo.dev/eas/json/).

Here is an example of what the config file looks like:

```json title="apps/mobile/eas.json"
{
  "cli": {
    "version": ">= 4.1.2"
  },
  "build": {
    "base": {
      "node": "20.15.0",
      "pnpm": "9.6.0",
      "ios": {
        "resourceClass": "m-medium"
      },
      "env": {
        "EXPO_PUBLIC_DEFAULT_LOCALE": "en",
        "EXPO_PUBLIC_AUTH_PASSWORD": "true",
        "EXPO_PUBLIC_AUTH_MAGIC_LINK": "false",
        "EXPO_PUBLIC_THEME_MODE": "system",
        "EXPO_PUBLIC_THEME_COLOR": "orange"
      }
    },
    ...
    "preview": {
      "extends": "base",
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      },
      "env": {
        "APP_ENV": "test",
      }
    },
    "production": {
      "extends": "base",
      "env": {
        "APP_ENV": "production",
      }
    }
    ...
  },
}
```

Make sure to also fill all the [environment variables](/docs/mobile/configuration/environment-variables) with the correct values for your project and correct environment, otherwise your app won't build and you won't be able to publish it.

<Card title="Configure EAS Build with eas.json" description="docs.expo.dev" href="https://docs.expo.dev/build/eas-json/" />


# Environment variables
Source: https://www.turbostarter.dev/docs/mobile/configuration/environment-variables

Environment variables are defined in the `.env` file in the root of the repository and in the root of the `apps/mobile` package.

* **Shared environment variables**: Defined in the **root** `.env` file. These are shared between environments (e.g., development, staging, production) and apps (e.g., web, mobile).
* **Environment-specific variables**: Defined in `.env.development` and `.env.production` files. These are specific to the development and production environments.
* **App-specific variables**: Defined in the app-specific directory (e.g., `apps/web`). These are specific to the app and are not shared between apps.
* **Build environment variables**: Not stored in the `.env` file. Instead, they are stored in `eas.json` file used to build app on [Expo Application Services](https://expo.dev/eas).
* **Secret keys**: They're not stored on mobile side, instead [they're defined on the web side.](/docs/web/configuration/environment-variables#secret-keys)

## Shared variables

Here you can add all the environment variables that are shared across all the apps.

To override these variables in a specific environment, please add them to the specific environment file (e.g. `.env.development`, `.env.production`).

```dotenv title=".env.local"
# Shared environment variables

# The database URL is used to connect to your database.
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"

# The name of the product. This is used in various places across the apps.
PRODUCT_NAME="TurboStarter"

# The url of the web app. Used mostly to link between apps.
URL="http://localhost:3000"

...
```

## App-specific variables

Here you can add all the environment variables that are specific to the app (e.g. `apps/mobile`).

You can also override the shared variables defined in the root `.env` file.

```dotenv title="apps/mobile/.env.local"
# App-specific environment variables

# Env variables extracted from shared to be exposed to the client in Expo app
EXPO_PUBLIC_SITE_URL="${URL}"
EXPO_PUBLIC_DEFAULT_LOCALE="${DEFAULT_LOCALE}"

# Theme mode and color
EXPO_PUBLIC_THEME_MODE="system"
EXPO_PUBLIC_THEME_COLOR="orange"

# Use this variable to enable or disable password-based authentication. If you set this to true, users will be able to sign up and sign in using their email and password. If you set this to false, the form won't be shown.
EXPO_PUBLIC_AUTH_PASSWORD="true"

...
```

<Callout title="EXPO_PUBLIC_ prefix">
  To make environment variables available in the Expo app code, you need to prefix them with `EXPO_PUBLIC_`. They will be injected to the code during the build process.

  Only environment variables prefixed with `EXPO_PUBLIC_` will be injected.

  [Read more about Expo environment variables.](https://docs.expo.dev/guides/environment-variables/)
</Callout>

## Build environment variables

To allow your app to build properly on [EAS](https://expo.dev/eas) you need to define your environment variables either in your `eas.json` file under corresponding profile (e.g. `preview` or `production`) or directly in the [EAS platform](https://docs.expo.dev/eas/environment-variables/):

![EAS environment variables](/images/docs/mobile/eas-environment-variables.png)

Then, when you trigger build, correct environment variables will be injected to your mobile app code ensuring that everything is working correctly.

[Check EAS documentation for more details.](https://docs.expo.dev/eas/environment-variables/)

## Secret keys

Secret keys and sensitive information are to be **never** stored on the mobile app code.

<Callout title="What does this mean?">
  It means that you will need to add the secret keys to the **web app, where the API is deployed.**

  The mobile app should only communicate with the backend API, which is typically part of the web app. The web app is responsible for handling sensitive operations and storing secret keys securely.

  [See web documentation for more details.](/docs/web/configuration/environment-variables#secret-keys)

  This is not a TurboStarter-specific requirement, but a best practice for security for any
  application. Ultimately, it's your choice.
</Callout>

For a security-focused view of `EXPO_PUBLIC_`, EAS, and what must stay on the API, see [Secrets & environment](/docs/mobile/security/secrets).


# Paths configuration
Source: https://www.turbostarter.dev/docs/mobile/configuration/paths

The paths configuration is set at `apps/mobile/config/paths.ts`. This configuration stores all the paths that you'll be using in your application. It is a convenient way to store them in a central place rather than scatter them in the codebase using magic strings.

It is **unlikely you'll need to change** this unless you're heavily editing the codebase.

```ts title="apps/mobile/config/paths.ts"
const pathsConfig = {
  index: "/",
  setup: {
    welcome: "/welcome",
    auth: {
      login: `${AUTH_PREFIX}/login`,
      register: `${AUTH_PREFIX}/register`,
      forgotPassword: `${AUTH_PREFIX}/password/forgot`,
      updatePassword: `${AUTH_PREFIX}/password/update`,
      error: `${AUTH_PREFIX}/error`,
      join: `${AUTH_PREFIX}/join`,
    },
    steps: {
      start: `${STEPS_PREFIX}/start`,
      required: `${STEPS_PREFIX}/required`,
      skip: `${STEPS_PREFIX}/skip`,
      final: `${STEPS_PREFIX}/final`,
    },
  },
  dashboard: {
    user: {
      index: DASHBOARD_PREFIX,
      ai: `${DASHBOARD_PREFIX}/ai`,
      ...
    }
    ...
  }
} as const;
```

<Callout title="Fully type-safe">
  By declaring the paths as constants, we can use them safely throughout the
  codebase. There is no risk of misspelling or using magic strings.
</Callout>


# Adding apps
Source: https://www.turbostarter.dev/docs/mobile/customization/add-app

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new app to your TurboStarter project within your monorepo and want to keep pulling updates from the TurboStarter repository.
</Callout>

In some ways - creating a new repository may be the easiest way to manage your application. However, if you want to keep your application within the monorepo and pull updates from the TurboStarter repository, you can follow these instructions.

To pull updates into a separate application outside of `mobile` - we can use [git subtree](https://www.atlassian.com/git/tutorials/git-subtree).

Basically, we will create a subtree at `apps/mobile` and create a new remote branch for the subtree. When we create a new application, we will pull the subtree into the new application. This allows us to keep it in sync with the `apps/mobile` folder.

To add a new app to your TurboStarter project, you need to follow these steps:

<Steps>
  <Step>
    ## Create a subtree

    First, we need to create a subtree for the `apps/mobile` folder. We will create a branch named `mobile-branch` and create a subtree for the `apps/mobile` folder.

    ```bash
    git subtree split --prefix=apps/mobile --branch mobile-branch
    ```
  </Step>

  <Step>
    ## Create a new app

    Now, we can create a new application in the `apps` folder.

    Let's say we want to create a new app `ai-chat` at `apps/ai-chat` with the same structure as the `apps/mobile` folder (which acts as the template for all new apps).

    ```bash
    git subtree add --prefix=apps/ai-chat origin mobile-branch --squash
    ```

    You should now be able to see the `apps/ai-chat` folder with the contents of the `apps/mobile` folder.
  </Step>

  <Step>
    ## Update the app

    When you want to update the new application, follow these steps:

    ### Pull the latest updates from the TurboStarter repository

    The command below will update all the changes from the TurboStarter repository:

    ```bash
    git pull upstream main
    ```

    ### Push the `mobile-branch` updates

    After you have pulled the updates from the TurboStarter repository, you can split the branch again and push the updates to the mobile-branch:

    ```bash
    git subtree split --prefix=apps/mobile --branch mobile-branch
    ```

    Now, you can push the updates to the `mobile-branch`:

    ```bash
    git push origin mobile-branch
    ```

    ### Pull the updates to the new application

    Now, you can pull the updates to the new application:

    ```bash
    git subtree pull --prefix=apps/ai-chat origin mobile-branch --squash
    ```
  </Step>
</Steps>

That's it! You now have a new application in the monorepo 🎉


# Adding packages
Source: https://www.turbostarter.dev/docs/mobile/customization/add-package

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new package to your TurboStarter application instead of adding a folder to your application in `apps/mobile` or modify existing packages under `packages`. You don't need to do this to add a new screen or component to your application.
</Callout>

To add a new package to your TurboStarter application, you need to follow these steps:

<Steps>
  <Step>
    ## Generate a new package

    First, enter the command below to create a new package in your TurboStarter application:

    ```bash
    turbo gen package
    ```

    Turborepo will ask you to enter the name of the package you want to create. Enter the name of the package you want to create and press enter.

    If you don't want to add dependencies to your package, you can skip this step by pressing enter.

    The command will have generated a new package under packages named `@workspace/<package-name>`. If you named it `example`, the package will be named `@workspace/example`.
  </Step>

  <Step>
    ## Export a module from your package

    By default, the package exports a single module using the `index.ts` file. You can add more exports by creating new files in the package directory and exporting them from the `index.ts` file or creating export files in the package directory and adding them to the `exports` field in the `package.json` file.

    ### From `index.ts` file

    The easiest way to export a module from a package is to create a new file in the package directory and export it from the `index.ts` file.

    ```ts title="packages/example/src/module.ts"
    export function example() {
      return "example";
    }
    ```

    Then, export the module from the `index.ts` file.

    ```ts title="packages/example/src/index.ts"
    export * from "./module";
    ```

    ### From `exports` field in `package.json`

    **This can be very useful for tree-shaking.** Assuming you have a file named `module.ts` in the package directory, you can export it by adding it to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./module": "./src/module.ts"
      }
    }
    ```

    **When to do this?**

    1. when exporting two modules that don't share dependencies to ensure better tree-shaking. For example, if your exports contains both client and server modules.
    2. for better organization of your package

    For example, create two exports `client` and `server` in the package directory and add them to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./client": "./src/client.ts",
        "./server": "./src/server.ts"
      }
    }
    ```

    1. The `client` module can be imported using `import { client } from '@workspace/example/client'`
    2. The `server` module can be imported using `import { server } from '@workspace/example/server'`
  </Step>

  <Step>
    ## Use the package in your application

    You can now use the package in your application by importing it using the package name:

    ```ts title="apps/mobile/src/app/index.tsx"
    import { example } from "@workspace/example";

    console.log(example());
    ```
  </Step>
</Steps>

Et voilà! You have successfully added a new package to your TurboStarter application. 🎉


# Components
Source: https://www.turbostarter.dev/docs/mobile/customization/components

For the components part, we're using [react-native-reusables](https://reactnativereusables.com//getting-started/introduction/) for atomic, accessible and highly customizable components.

> It's like shadcn/ui, but for mobile apps.

<Callout type="info" title="Why react-native-reusables?">
  react-native-reusables is a powerful tool that allows you to generate
  pre-designed components with a single command. It's built with Uniwind (like
  Tailwind CSS for mobile) and accessibility in mind, it's also highly
  customizable.
</Callout>

TurboStarter defines two packages that are responsible for the UI part of your app:

* `@workspace/ui` - shared styles, [themes](/docs/mobile/customization/styling#themes) and assets (e.g. icons)
* `@workspace/ui-mobile` - pre-built UI mobile components, ready to use in your app

## Adding a new component

There are basically two ways to add a new component:

<Tabs items={["Using the CLI", "Copy-pasting"]}>
  <Tab value="Using the CLI">
    TurboStarter is fully compatible with [react-native-reusables CLI](https://www.npmjs.com/package/@react-native-reusables/cli), so you can generate new components with single command.

    Run the following command from the **root** of your project:

    ```bash
    pnpm --filter @workspace/ui-mobile ui:add
    ```

    This will launch an interactive command-line interface to guide you through the process of adding a new component where you can pick which component you want to add.

    ```bash
    Which components would you like to add? > Space to select. A to toggle all.
    Enter to submit.

    ◯  accordion
    ◯  alert
    ◯  alert-dialog
    ◯  aspect-ratio
    ◯  avatar
    ◯  badge
    ◯  button
    ◯  calendar
    ◯  card
    ◯  checkbox
    ```

    Newly created components will appear in the `packages/ui/mobile/src` directory.
  </Tab>

  <Tab value="Copy-pasting">
    You can always copy-paste a component from the [react-native-reusables](https://reactnativereusables.com//getting-started/introduction/) website and modify it to your needs.

    This is possible, because the components are headless and don't need (in most cases) any additional dependencies.

    Copy code from the website, create a new file in the `packages/ui/mobile/src` directory and paste the code into the file.
  </Tab>
</Tabs>

<Callout title="Keep it atomic" type="warn">
  Keep in mind that you should always try to keep shared components as atomic as possible. This will make it easier to reuse them and to build specific views by composition.

  E.g. include components like `Button`, `Input`, `Card`, `Dialog` in shared package, but keep specific components like `LoginForm` in your app directory.
</Callout>

## Using components

Each component is a standalone entity which has a separate export from the package. It helps to keep things modular, avoid unnecessary dependencies and make tree-shaking possible.

To import a component from the UI package, use the following syntax:

```tsx title="apps/mobile/src/modules/common/my-component.tsx"
// [!code word:card]
import {
  Card,
  CardContent,
  CardHeader,
  CardFooter,
  CardTitle,
  CardDescription,
} from "@workspace/ui-mobile/card";
```

Then you can use it to build a component specific to your app:

```tsx title="apps/mobile/src/modules/common/my-component.tsx"
export function MyComponent() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>My Component</CardTitle>
      </CardHeader>
      <CardContent>
        <Text>My Component Content</Text>
      </CardContent>
      <CardFooter>
        <Button>Click me</Button>
      </CardFooter>
    </Card>
  );
}
```

<Callout title="Think of it the same as for the web">
  Most of the components are the same as for the [web app](/docs/web/customization/components).

  It means that you can basically migrate existing web components to the mobile app with just an import change!
</Callout>

<Card href="https://reactnativereusables.com//getting-started/introduction/" title="react-native-reusables" description="reactnativereusables.com" />


# Styling
Source: https://www.turbostarter.dev/docs/mobile/customization/styling

To build the mobile user interface, TurboStarter comes with [Uniwind](https://uniwind.dev/) pre-configured.

<Callout title="Why Uniwind?" type="info">
  Uniwind brings Tailwind CSS utilities to React Native. It lets you style with familiar classes while keeping native performance and platform-appropriate primitives.
</Callout>

## Tailwind configuration

In the `packages/ui/shared/src/styles` directory, you will find shared CSS files with Tailwind configuration. To change global styles, edit the files in this folder.

Here is an example of a shared CSS file that includes the Tailwind CSS configuration:

```css title="packages/ui/shared/src/styles/globals.css"
@import "tailwindcss";
@import "./themes.css";

@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.65rem;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  ...
}
```

For colors, we rely strictly on [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) in [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) format to allow for easy theme management without the need for any JavaScript.

Also, each app has its own `globals.css` file, which extends the shared config and allows you to override global styles.

Here is an example of an app's `globals.css` file:

```css title="apps/mobile/src/assets/styles/globals.css"
@import "@workspace/ui-mobile/globals.css";

@theme inline {
  --font-sans: "Geist_400Regular";
  --font-sans-medium: "Geist_500Medium";
  --font-sans-semibold: "Geist_600SemiBold";
  --font-sans-bold: "Geist_700Bold";
  --font-mono: "GeistMono_400Regular";
}
```

This keeps a clear separation of concerns and a consistent structure for the Tailwind CSS configuration across apps.

## Themes

TurboStarter comes with **9+** predefined themes, which you can use to quickly change the look and feel of your app.

They're defined in the `packages/ui/shared/src/styles/themes` directory. Each theme is a set of variables that can be overridden:

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {
    background: [1, 0, 0],
    foreground: [0.141, 0.005, 285.823],
    card: [1, 0, 0],
    "card-foreground": [0.141, 0.005, 285.823],
    ...
  }
} satisfies ThemeColors;
```

Each variable is stored as a [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) array, which is then converted to a CSS variable at build time (by our custom build script). That way we can ensure full type-safety and reuse themes across different parts of our apps (e.g. use the same theme in emails).

These variables are consumed across platforms. On mobile, the theme provider injects the shared variables into the app, so Uniwind utility classes like `bg-background` and `text-foreground` resolve correctly.

Feel free to add your own themes or override the existing ones to match your brand's identity.

To apply a custom theme to your app, use a `useTheme` hook to modify the config:

```tsx title="apps/mobile/src/lib/providers/theme.tsx"
import { ThemeColor, ThemeMode } from "@workspace/ui";

import { useTheme } from "~/modules/common/hooks/use-theme";

export const ThemeSwitcher = () => {
  const { setConfig } = useTheme();

  return (
    <Pressable
      onPress={() =>
        setConfig({ mode: ThemeMode.DARK, color: ThemeColor.BLUE })
      }
    >
      <Text>Change the theme to dark blue</Text>
    </Pressable>
  );
};
```

Under the hood, the `useTheme` hook uses [Uniwind.setTheme](https://docs.uniwind.dev/theming/basics#switch-to-a-specific-theme) and [updateCSSVariables](https://docs.uniwind.dev/theming/update-css-variables) utilities to apply the correct theme to the app together with its variables.

## Dark mode

TurboStarter comes with built-in dark mode support.

Each theme has a corresponding set of dark mode variables, which are used to switch the theme to its dark mode counterpart.

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {},
  dark: {
    background: [0.141, 0.005, 285.823],
    foreground: [0.985, 0, 0],
    card: [0.21, 0.006, 285.885],
    "card-foreground": [0.985, 0, 0],
    ...
  }
} satisfies ThemeColors;
```

Our custom implementation reads the system color scheme via `useColorScheme` and applies `dark:` variants automatically. With the provider injecting shared variables, dark mode works out of the box.

You can also define the default theme mode and color in the [app configuration](/docs/mobile/configuration/app).

<Cards>
  <Card title="Uniwind" description="uniwind.dev" href="https://uniwind.dev/" />

  <Card title="Theming Basics | Uniwind" description="docs.uniwind.dev" href="https://docs.uniwind.dev/theming/basics" />

  <Card title="Custom Themes | Uniwind" description="docs.uniwind.dev" href="https://docs.uniwind.dev/theming/custom-themes" />
</Cards>


# Database
Source: https://www.turbostarter.dev/docs/mobile/database

<Callout type="error" title="API deployment required">
  To enable communication between your Expo app and the server in a production environment, the web application with Hono API must be deployed first.

  <Cards>
    <Card title="API" description="Learn more about the API." href="/docs/web/api/overview" />

    <Card title="Web deployment" description="Deploy your web application to production." href="/docs/web/deployment/checklist" />
  </Cards>
</Callout>

As a mobile app uses only client-side code, **there's no way to interact with the database directly**.

Also, you should avoid any workarounds to interact with the database directly, because it can lead to leaking your database credentials and other security issues.

## Recommended approach

You can safely use the [API](/docs/mobile/api/overview) and call the endpoints which will run queries on the database.

To do this you need to set up the database on the [web, server side](/docs/web/database/overview) and then use the [API client](/docs/mobile/api/client) to interact with it.

Learn more about its configuration in the web part of the docs, especially in the following sections:

<Cards>
  <Card title="Overview" description="Get started with the database" href="/docs/web/database/overview" />

  <Card title="Schema" description="Learn about the database schema." href="/docs/web/database/schema" />

  <Card title="Migrations" description="Migrate your changes to the database." href="/docs/web/database/migrations" />

  <Card title="Database client" description="Use database client to interact with the database." href="/docs/web/database/client" />

  <Card title="SQLite" description="Switch the project from PostgreSQL to SQLite." href="/docs/web/database/sqlite" />

  <Card title="MySQL" description="Switch the project from PostgreSQL to MySQL." href="/docs/web/database/mysql" />
</Cards>


# Deep linking
Source: https://www.turbostarter.dev/docs/mobile/deep-linking

Deep links take users from email, push notifications, or the web into a specific screen in your Expo app. TurboStarter already ships a custom scheme, Expo Router routes, and auth callbacks that build app URLs with `expo-linking`. You add Universal Links (iOS) and App Links (Android) when you want `https://` URLs on your domain to open the app instead of the browser.

<Callout title="Development build for production links" type="warn">
  Custom schemes work in development builds. **Universal Links and App Links require a development build** (or store build) - Expo Go cannot embed your associated domains. See [development builds](https://docs.expo.dev/develop/development-builds/introduction/).
</Callout>

## Architecture

Out of the box you get:

* Custom scheme `turbostarter` in `apps/mobile/app.config.ts`
* [Expo Router](https://docs.expo.dev/router/introduction/) with deep linking enabled for every file-based route
* Canonical paths in `apps/mobile/src/config/paths.ts` (`pathsConfig`)
* Auth and invite flows that pass `Linking.createURL(...)` / `x-url` so emails open in the app
* Better Auth `trustedOrigins` that allow the `turbostarter://` scheme

Still on you for production HTTPS links: `associatedDomains`, Android `intentFilters`, and `.well-known` verification files on the web domain.

## Linking flow

Incoming URLs map to Expo Router paths. Paths mirror `pathsConfig`:

| URL path                    | App route                                |
| --------------------------- | ---------------------------------------- |
| `/auth/join`                | `pathsConfig.setup.auth.join`            |
| `/auth/password/update`     | `pathsConfig.setup.auth.updatePassword`  |
| `/dashboard/*`              | matching `pathsConfig.dashboard` entries |
| `/dashboard/organization/*` | org dashboard routes                     |

Two URL shapes:

* **Custom scheme** - `turbostarter://auth/join?invitationId=…` (already configured)
* **HTTPS** - `https://yourdomain.com/auth/join?invitationId=…` (Universal / App Links after you finish the steps below)

Auth emails prefer the scheme URL today via `Linking.createURL` and the `x-url` header. Universal Links are for marketing, shared web URLs, and any flow where you want the same `https` link to open the app when installed (and the website when not).

## Custom scheme

The scheme is already set:

```ts title="apps/mobile/app.config.ts"
export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  scheme: "turbostarter",
  // ...
});
```

If you rename it:

1. Update `scheme` in `app.config.ts`
2. Update Better Auth `trustedOrigins` in `packages/auth/src/server.ts`
3. Rebuild the native app (`eas build` or local prebuild) - scheme changes are not applied by OTA alone

Trusted origin (required so auth redirects are not treated as open redirects):

```ts title="packages/auth/src/server.ts"
trustedOrigins: [
  "chrome-extension://",
  "turbostarter://",
  "https://appleid.apple.com",
  // ...
],
```

See [Auth & deep links](/docs/mobile/security/auth) for the security checklist.

## Existing deep links

Mobile clients pass an app URL so the API can build email links that open the native app:

```ts title="apps/mobile/src/modules/auth/form/password/forgot.tsx"
redirectTo: Linking.createURL(pathsConfig.setup.auth.updatePassword),
```

```ts title="apps/mobile/src/modules/organization/lib/api.ts"
authClient.organization.inviteMember(params, {
  headers: {
    "x-url": Linking.createURL(pathsConfig.setup.auth.join),
  },
});
```

On the server, `getUrl()` in `@workspace/auth` prefers the `x-url` (or `expo-origin`) header so reset, invite, and verification emails resolve to `turbostarter://…` instead of a web-only URL.

The auth client also sends the scheme origin on every request:

```ts title="apps/mobile/src/lib/auth/index.ts"
fetchOptions: {
  headers: {
    "x-client-platform": Platform.MOBILE,
    origin: Linking.createURL(""),
  },
},
```

Keep `pathsConfig` and these `createURL` / `x-url` call sites in sync when you add screens that should be reachable from email.

## Universal Links (iOS)

Two-way association: the website proves it owns the app, and the app declares the domain.

### 1. Associated domains

Add your production (and staging) host **without** `https://`:

```ts title="apps/mobile/app.config.ts"
ios: {
  bundleIdentifier: "com.turbostarter.core",
  associatedDomains: ["applinks:yourdomain.com"],
  // ...
},
```

Rebuild with EAS so the Associated Domains entitlement is registered. The host should match `EXPO_PUBLIC_SITE_URL` / `appConfig.url`.

### 2. Apple App Site Association

Serve an `apple-app-site-association` file (no file extension) from your web app:

```json title="apps/web/public/.well-known/apple-app-site-association"
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.turbostarter.core",
        "paths": ["/auth/*", "/dashboard/*"]
      }
    ]
  }
}
```

Replace `TEAMID` with your [Apple Team ID](https://expo.fyi/apple-team) and keep the bundle ID in sync with `ios.bundleIdentifier`. Scope `paths` to routes you actually want in-app (v1: auth + dashboard is enough).

The file must be reachable over HTTPS at:

`https://yourdomain.com/.well-known/apple-app-site-association`

Validate with an [AASA validator](https://branch.io/resources/aasa-validator/) after deploy. iOS caches the AASA aggressively - path changes for store builds often need a new App Store version before every device refreshes.

<Card title="iOS Universal Links" href="https://docs.expo.dev/linking/ios-universal-links/" description="docs.expo.dev" />

## App Links (Android)

### 1. Intent filters

```ts title="apps/mobile/app.config.ts"
android: {
  package: "com.turbostarter.core",
  intentFilters: [
    {
      action: "VIEW",
      autoVerify: true,
      data: [
        {
          scheme: "https",
          host: "yourdomain.com",
          pathPrefix: "/",
        },
      ],
      category: ["BROWSABLE", "DEFAULT"],
    },
  ],
  // ...
},
```

`autoVerify: true` is required for verified App Links. Narrow `pathPrefix` (for example `/auth` or `/dashboard`) if you do not want every path claimed by the app.

### 2. Digital Asset Links

```json title="apps/web/public/.well-known/assetlinks.json"
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.turbostarter.core",
      "sha256_cert_fingerprints": ["XX:XX:…"]
    }
  }
]
```

Get the SHA-256 fingerprint from EAS:

```bash
eas credentials -p android
# Select the build profile → copy SHA256 Fingerprint
```

Or from Google Play Console → **Release → Setup → App Signing**. Include both upload and app-signing fingerprints when Play App Signing is enabled.

Serve the file as `application/json` over HTTPS at:

`https://yourdomain.com/.well-known/assetlinks.json`

<Card title="Android App Links" href="https://docs.expo.dev/linking/android-app-links/" description="docs.expo.dev" />

## Push notification taps

Push payloads can carry a route in `data`. Wire navigation in `NotificationsProvider` (or a dedicated hook) when the user taps:

```ts title="apps/mobile/src/lib/providers/notifications.tsx"
Notifications.addNotificationResponseReceivedListener((response) => {
  const url = response.notification.request.content.data?.url;
  // router.push(url) — validate against pathsConfig / an allowlist
});
```

Only put non-sensitive identifiers in `data`. Validate the path before navigating - treat notification URLs like any other untrusted entry point ([Auth & deep links](/docs/mobile/security/auth)).

Example payload when sending from your backend:

```json
{
  "to": "ExponentPushToken[…]",
  "title": "Invitation",
  "body": "Join the organization",
  "data": { "url": "/auth/join?invitationId=…" }
}
```

See [Push notifications](/docs/mobile/push-notifications#deep-links-and-tap-handling) for delivery setup.

## Test deep links

### Custom scheme

With a development or store build installed:

```bash
# iOS
npx uri-scheme open "turbostarter://dashboard" --ios

# Android
npx uri-scheme open "turbostarter://dashboard" --android
```

Auth paths to smoke-test:

```bash
npx uri-scheme open "turbostarter://auth/join?invitationId=test" --ios
npx uri-scheme open "turbostarter://auth/password/update" --ios
```

### Universal / App Links

On a physical device with a build that includes associated domains / intent filters:

1. Deploy `.well-known` files to the production (or staging) domain
2. Install the matching native build
3. Open `https://yourdomain.com/auth/join` from Notes / Messages (not only the browser address bar)
4. Confirm cold start (app killed) and warm start (app backgrounded)

Android intent smoke test:

```bash
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://yourdomain.com/dashboard"
```

In-app browsers (some social apps) may open HTTPS links inside a webview and skip Universal / App Links - document that limitation for marketing links.

## Multiple environments

Each domain needs its own AASA / `assetlinks.json`. If you use `staging.yourdomain.com`:

* Add `applinks:staging.yourdomain.com` to `associatedDomains`
* Add a matching Android intent filter `host`
* Host `.well-known` on the staging host as well

Prefer separate application IDs / EAS projects for production vs preview so signing fingerprints stay isolated. See [Multiple environments](/docs/mobile/recipes/multiple-environments).

## Troubleshooting

| Symptom                                 | What to check                                                                                                                                |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Scheme does nothing                     | Rebuild after changing `scheme`; confirm with `npx uri-scheme` on a device/simulator                                                         |
| Auth email opens browser only           | Mobile request sent `x-url` / `Linking.createURL`; `trustedOrigins` includes your scheme                                                     |
| HTTPS opens site, not app (iOS)         | AASA valid and public; `associatedDomains` uses `applinks:host` without `https`; Team ID + bundle ID match; rebuild after entitlement change |
| HTTPS opens chooser / browser (Android) | `autoVerify: true`; `assetlinks.json` fingerprint matches the installed build; wait for verification (can take a minute+)                    |
| Works in debug, fails in Play/App Store | Production signing fingerprint / App Store AASA cache; issue a new store build after path changes                                            |
| Notification tap does nothing           | `data.url` present; response listener navigates; path allowlisted                                                                            |

<Cards>
  <Card title="Linking overview" href="https://docs.expo.dev/linking/overview/" description="docs.expo.dev" />

  <Card title="Linking into your app" href="https://docs.expo.dev/linking/into-your-app/" description="docs.expo.dev" />

  <Card title="Auth configuration" href="/docs/mobile/auth/configuration" description="Trusted origins and providers" />

  <Card title="Push notifications" href="/docs/mobile/push-notifications" description="Tap handling and payloads" />

  <Card title="Auth & deep links security" href="/docs/mobile/security/auth" description="Validate links as untrusted entry points" />
</Cards>


# Configuration
Source: https://www.turbostarter.dev/docs/mobile/flags/configuration

The `@workspace/flags-mobile` package wraps OpenFeature providers behind a single client strategy. Swap the active provider by changing the re-exports in `packages/flags/mobile/src/providers/index.ts`, then set any env vars that provider needs.

<Callout>
  The default provider is **in-memory**. You can evaluate `Flag.DEMO` in the simulator with no third-party account. Connect PostHog or GrowthBook when you need remote targeting or a dashboard.
</Callout>

## Providers

Feature flags let you control product rollouts, experiments, and UI toggles—without shipping new code. In Expo, you can choose a simple in-memory provider for hardcoded defaults, or connect a remote provider (like PostHog or GrowthBook) to target segments and manage flags centrally.

This guide explains each provider and how to enable it for your app.

<Accordions>
  <Accordion title="In-memory" id="in-memory">
    Use this for local development and simple toggles that live in code. Flag definitions come from `packages/flags/shared/src/in-memory.ts`:

    ```ts title="packages/flags/shared/src/in-memory.ts"
    export const inMemoryConfig = {
      [Flag.DEMO]: {
        disabled: false,
        variants: {
          on: true,
          off: false,
        },
        defaultVariant: "on",
      },
    } as const;
    ```

    With this config, `Flag.DEMO` evaluates to `true`. Flip `defaultVariant` to `"off"` to hide the demo banner without changing UI code.

    Activate the provider (already the default):

    ```ts title="packages/flags/mobile/src/providers/index.ts"
    // [!code word:in-memory]
    export * from "./in-memory";
    export * from "./in-memory/env";
    ```

    No environment variables are required. Customize under `packages/flags/mobile/src/providers/in-memory`.
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout title="Reuse your PostHog project">
      If you already use PostHog for [analytics](/docs/mobile/analytics/configuration#posthog) or [monitoring](/docs/mobile/monitoring/posthog), the same `EXPO_PUBLIC_POSTHOG_KEY` and host power feature flags.
    </Callout>

    1. Create or open a [PostHog](https://app.posthog.com/signup) project (Cloud or [self-hosted](https://posthog.com/docs/self-host)).
    2. Copy the project API key and host from [project settings](https://app.posthog.com/project/settings).
    3. Create a feature flag whose key matches your app constant (for example `demo` for `Flag.DEMO`).

    Set the env vars in `apps/mobile/.env.local` and your EAS / deployment config:

    ```dotenv
    EXPO_PUBLIC_POSTHOG_KEY="your-posthog-api-key"
    EXPO_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
    ```

    Activate PostHog as the flags provider:

    ```ts title="packages/flags/mobile/src/providers/index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```

    Mobile uses a React Native OpenFeature provider on top of `posthog-react-native`. When targeting context is set, the strategy identifies the user and reloads feature flags so rules apply on the next evaluation.

    Customize under `packages/flags/mobile/src/providers/posthog`.

    <Cards>
      <Card title="PostHog feature flags" href="https://posthog.com/docs/feature-flags" description="posthog.com" />

      <Card title="PostHog React Native" href="https://posthog.com/docs/libraries/react-native" description="posthog.com" />
    </Cards>

    ![PostHog feature flags dashboard](/images/docs/web/flags/posthog.png)
  </Accordion>

  <Accordion title="GrowthBook" id="growthbook">
    GrowthBook is a dedicated feature-flag and experimentation platform. Use it when you want rich targeting without tying flags to analytics.

    1. Create a [GrowthBook](https://app.growthbook.io/) account (or self-host).
    2. Create an SDK connection and copy the **client key**.
    3. Create a feature whose key matches your app constant (for example `demo`).

    Set the env vars in `apps/mobile/.env.local` and your EAS / deployment config:

    ```dotenv
    EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY="your-growthbook-client-key"
    EXPO_PUBLIC_GROWTHBOOK_API_HOST="https://cdn.growthbook.io"
    ```

    Activate GrowthBook as the flags provider:

    ```ts title="packages/flags/mobile/src/providers/index.ts"
    // [!code word:growthbook]
    export * from "./growthbook";
    export * from "./growthbook/env";
    ```

    Evaluation uses `@openfeature/growthbook-client-provider`. Customize under `packages/flags/mobile/src/providers/growthbook`.

    <Card title="GrowthBook docs" href="https://docs.growthbook.io/" description="docs.growthbook.io" />

    ![GrowthBook features dashboard](/images/docs/web/flags/growthbook.png)
  </Accordion>
</Accordions>

## Flags provider

Hooks need OpenFeature in the React tree. The kit already mounts `FlagsProvider` from `apps/mobile/src/lib/providers/flags.tsx` inside the root providers. It also syncs the signed-in user into targeting context:

```tsx title="apps/mobile/src/lib/providers/flags.tsx"
import { useEffect } from "react";

import {
  clearContext,
  FlagsProvider as Provider,
  setContext,
} from "@workspace/flags-mobile";

import { authClient } from "~/lib/auth";

export const FlagsProvider = ({ children }: { children: React.ReactNode }) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    if (session.data?.user) {
      const { id, email, name } = session.data.user;
      void setContext({ targetingKey: id, email, name });
      return;
    }

    void clearContext();
  }, [session]);

  return <Provider>{children}</Provider>;
};
```

After that, `useBooleanFlagValue` and the other hooks from `@workspace/flags` are safe to use in screens. More detail in [Usage](/docs/mobile/flags/usage).


# Overview
Source: https://www.turbostarter.dev/docs/mobile/flags/overview

Feature flags let you change mobile behavior without waiting on an App Store or Play Store release. Gate unfinished screens, run cohort rollouts, or tune copy from a dashboard while the binary stays the same.

TurboStarter uses [OpenFeature](https://openfeature.dev/) so evaluation stays provider-agnostic. Swap between the built-in in-memory provider, [PostHog](https://posthog.com/docs/feature-flags), or [GrowthBook](https://docs.growthbook.io/) without rewriting your screens.

Out of the box starter kit provides you with:

* Shared flag keys in `@workspace/flags` (`Flag.DEMO` ships as a working example)
* Platform package `@workspace/flags-mobile` with React hooks for Expo
* A `FlagsProvider` that syncs targeting context from the signed-in user (`targetingKey`, `email`, `name`)
* In-memory provider as the default (no account required for local development)
* Optional PostHog and GrowthBook providers, swapped by changing provider exports

The demo flag is already evaluated on **Dashboard → Settings**. With the default in-memory config it shows a banner that links back here, so you can verify evaluation before connecting a remote provider.

<Callout type="info" title="Client-only on mobile">
  Unlike the [web kit](/docs/web/flags/overview), mobile evaluates flags on the client. There is no `@workspace/flags-mobile/server` export. Prefer hooks such as `useBooleanFlagValue` in your screens.
</Callout>

## Architecture

Flags live next to analytics in the monorepo:

<Files>
  <Folder name="packages/flags" defaultOpen>
    <Folder name="shared - Shared keys and hooks" defaultOpen>
      <File name="keys.ts - Flag key constants" />

      <File name="in-memory.ts - Default local definitions" />

      <File name="react.tsx - createFlagsReact + hooks" />
    </Folder>

    <Folder name="mobile - @workspace/flags-mobile" defaultOpen>
      <File name="index.tsx - FlagsProvider, setContext, clearContext" />

      <Folder name="providers - in-memory / posthog / growthbook" />
    </Folder>
  </Folder>
</Files>

App wiring sits in `apps/mobile/src/lib/providers/flags.tsx` and mounts with your root providers. When a session appears, context is set; on logout it is cleared. The PostHog strategy also reloads flags after identify so targeting updates quickly on device.

## Providers

TurboStarter includes native support for multiple feature flag providers and offers a unified API for flag evaluation. This approach allows you to monitor feature usage and user behavior consistently throughout your mobile app.

For details on configuring each provider, refer to their sections below:

<Cards>
  <Card title="In-memory" href="/docs/mobile/flags/configuration#in-memory" description="Local defaults, zero config. Great for development." />

  <Card title="PostHog" href="/docs/mobile/flags/configuration#posthog" description="Flags next to analytics and monitoring." />

  <Card title="GrowthBook" href="/docs/mobile/flags/configuration#growthbook" description="Dedicated experimentation and targeting." />
</Cards>

Configuration and setup are seamlessly integrated with a unified API, so you can switch providers just by changing the exports. You can also add custom providers without affecting any flags-related logic.

In the following sections, you'll learn how to set up each provider and how to evaluate flags in your application.


# Usage
Source: https://www.turbostarter.dev/docs/mobile/flags/usage

Once a provider is active, reading a flag is a one-liner with OpenFeature hooks from `@workspace/flags`.

## Flag keys

Keys live in one place so web, mobile, and extension stay aligned:

```ts title="packages/flags/shared/src/keys.ts"
export const Flag = {
  DEMO: "demo",
} as const;
```

Import `Flag` from `@workspace/flags` and pass the constant into hooks.

## Evaluate in a screen

```tsx
import { Flag, useBooleanFlagValue } from "@workspace/flags";
import { Text } from "@workspace/ui-mobile/text";

export const BetaBadge = () => {
  const enabled = useBooleanFlagValue(Flag.DEMO, false);

  if (!enabled) {
    return null;
  }

  return <Text>Beta features unlocked</Text>;
};
```

The settings screen uses the same pattern for the demo banner:

```tsx title="apps/mobile/src/app/dashboard/(user)/settings/index.tsx"
const demo = useBooleanFlagValue(Flag.DEMO, false);
```

Other value types:

| Hook                  | Typical use                           |
| --------------------- | ------------------------------------- |
| `useBooleanFlagValue` | On/off gates                          |
| `useStringFlagValue`  | Variant copy, theme names, deep links |
| `useNumberFlagValue`  | Limits, percentages, experiment arms  |
| `useObjectFlagValue`  | Structured payloads / config blobs    |

Always pass a sensible **default** as the second argument. That value is used while the provider loads, or if evaluation fails.

## Targeting context

`apps/mobile/src/lib/providers/flags.tsx` already syncs auth state into OpenFeature:

```tsx title="apps/mobile/src/lib/providers/flags.tsx"
if (session.data?.user) {
  const { id, email, name } = session.data.user;
  void setContext({ targetingKey: id, email, name });
  return;
}

void clearContext();
```

You rarely need to call `setContext` / `clearContext` yourself. Import them from `@workspace/flags-mobile` when you do.

With PostHog, context sync identifies the person and reloads feature flags so cohort rules apply promptly after sign-in.

## Add a new flag

<Steps>
  <Step>
    ## Declare the key

    Add a constant in `packages/flags/shared/src/keys.ts`:

    ```ts
    export const Flag = {
      DEMO: "demo",
      NEW_ONBOARDING: "NEW_ONBOARDING", // [!code ++]
    } as const;
    ```
  </Step>

  <Step>
    ## Update in-memory defaults

    Give local development a known value in `packages/flags/shared/src/in-memory.ts`:

    ```ts
    [Flag.NEW_ONBOARDING]: {
      disabled: false,
      variants: { on: true, off: false },
      defaultVariant: "off",
    },
    ```
  </Step>

  <Step>
    ## Create it remotely (if needed)

    In PostHog or GrowthBook, create a flag with the **same key** (`NEW_ONBOARDING`). Configure rollouts and targeting there.
  </Step>

  <Step>
    ## Evaluate it in a screen

    ```tsx
    const showOnboarding = useBooleanFlagValue(Flag.NEW_ONBOARDING, false);
    ```
  </Step>
</Steps>

## Troubleshooting

| Symptom                      | What to check                                                                                                                                |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Demo banner never appears    | In-memory `defaultVariant` is `"on"` by default. For PostHog/GrowthBook, ensure a remote flag named `demo` exists and targets your user.     |
| Flag stuck on the default    | Confirm the provider export in `packages/flags/mobile/src/providers/index.ts` matches the env vars you set. Restart Metro after env changes. |
| Remote rules ignore the user | Sign in so `targetingKey` is set. With PostHog, wait for flags to reload after identify.                                                     |
| Wrong env prefix             | Mobile uses `EXPO_PUBLIC_POSTHOG_*` / `EXPO_PUBLIC_GROWTHBOOK_*`, not `NEXT_PUBLIC_` or `VITE_`.                                             |

<Cards>
  <Card title="Configuration" href="/docs/mobile/flags/configuration" description="Switch providers and set Expo env vars." />

  <Card title="OpenFeature React SDK" href="https://openfeature.dev/docs/reference/technologies/client/web/react" description="openfeature.dev" />
</Cards>


# Introduction
Source: https://www.turbostarter.dev/docs/mobile

Welcome to the TurboStarter **mobile** documentation. This guide covers the Expo + React Native app - native auth, in-app purchases, push notifications, store publishing, and everything else that lives on the device.

<ThemedImage light="/images/docs/demo/light.webp" dark="/images/docs/demo/dark.webp" alt="TurboStarter demo" width={2311} height={1562} zoomable priority fetchPriority="high" />

The mobile app talks to the same API and database as [web](/docs/web). Shared product features (organizations, plan entitlements, analytics) are documented here with mobile-specific setup; deep backend details live in the web docs when they apply to every platform.

Looking to bootstrap quickly? Check out the [TurboStarter CLI guide](/blog/the-only-turbo-cli-you-need-to-start-your-next-project-in-seconds).

## Demo apps

Try the live iOS and Android demos (web and extension demos are available too):

<DemoBadges
  urls={{
  android:
    "https://play.google.com/store/apps/details?id=com.turbostarter.core",
  ios: "https://apps.apple.com/us/app/turbostarter/id6754278899",
  chrome:
    "https://chromewebstore.google.com/detail/turbostarter/bcjmonmlfbnngpkllpnpmnjajaciaboo",
  firefox: "https://addons.mozilla.org/en-US/firefox/addon/turbostarter_",
  edge: "https://microsoftedge.microsoft.com/addons/detail/turbostarter/ianbflanmmoeleokihabnmmcahhfijig",
  web: "https://demo.turbostarter.dev",
}}
/>

## Philosophy

* **As simple as possible** - easy to understand, easy to use, no overengineering.
* **As few dependencies as possible** - stay in control of every part of the project.
* **As performant as possible** - fast and light without unnecessary overhead.

## Features

Mobile-first capabilities below. For AI chatbots and agents on native, see [TurboStarter AI](/ai/docs) and the [mobile AI guide](/docs/mobile/ai).

### Native authentication

Better Auth powers sign-in on mobile, with deep links and native SSO that store reviewers expect.

<Cards>
  <Card title="Auth overview" description="Password, magic link, OTP, anonymous, and OAuth - shared with your web app." href="/docs/mobile/auth/overview" />

  <Card title="Auth flow & screens" description="Ready-made sign-in, sign-up, recovery, and verification screens." href="/docs/mobile/auth/flow" />

  <Card title="Sign in with Apple" description="Native Apple Authentication on iOS with Better Auth verification." href="/docs/mobile/auth/oauth/apple" />

  <Card title="Sign in with Google" description="Native Google sign-in on Android with identity token verification." href="/docs/mobile/auth/oauth/google" />

  <Card title="Deep linking" description="Custom schemes, Universal Links, App Links, and invite/auth callbacks." href="/docs/mobile/deep-linking" />

  <Card title="Two-factor authentication" description="TOTP and recovery flows adapted for native UX." href="/docs/mobile/auth/2fa" />
</Cards>

### In-app purchases & billing

Store-compliant billing is the mobile default - RevenueCat and Superwall on top of App Store / Play products, synced with your shared entitlements API.

<Cards>
  <Card title="Billing overview" description="How mobile billing connects stores, RevenueCat, and your backend." href="/docs/mobile/billing/overview" />

  <Card title="Store configuration" description="Create products in App Store Connect and Google Play Console." href="/docs/mobile/billing/configuration" />

  <Card title="RevenueCat" description="Default IAP provider - entitlements, paywalls, and webhooks." href="/docs/mobile/billing/revenuecat" />

  <Card title="Superwall" description="Remote paywalls and experiments without shipping a new binary." href="/docs/mobile/billing/superwall" />

  <Card title="Billing webhooks" description="Keep server-side plan state in sync with purchase events." href="/docs/mobile/billing/webhooks" />

  <Card title="Feature-based access" description="Gate screens and quotas by plan on the client and API." href="/docs/mobile/recipes/feature-based-access" />
</Cards>

### Push notifications

Requires a [Firebase project](/docs/mobile/installation/firebase) for Android FCM (and optionally Google Analytics).

<Cards>
  <Card title="Permissions & push tokens" description="Request permission, create Android channels, and obtain a device token." href="/docs/mobile/push-notifications" />

  <Card title="Foreground & tap handling" description="Show banners while the app is open and react when users tap a notification." href="/docs/mobile/push-notifications" />

  <Card title="Local & remote testing" description="Send a local test from settings, or paste the token into the push tool." href="/docs/mobile/push-notifications" />

  <Card title="FCM & APNs credentials" description="Wire Android FCM and iOS APNs through EAS for production delivery." href="/docs/mobile/push-notifications" />

  <Card title="Firebase project" description="Required for FCM on Android and optional Google Analytics." href="/docs/mobile/installation/firebase" />
</Cards>

### Onboarding

<Cards>
  <Card title="First-run flow" description="Welcome → auth → setup steps → optional paywall → dashboard." href="/docs/mobile/recipes/onboarding" />

  <Card title="Soft & hard paywalls" description="Skippable paywall by default, or require a purchase before the product." href="/docs/mobile/recipes/onboarding" />

  <Card title="Marketing & ASO" description="Store listing tips, review readiness, and acquisition basics." href="/docs/mobile/marketing" />
</Cards>

### Publishing & updates

<Cards>
  <Card title="Publishing checklist" description="Everything to verify before you submit to Apple and Google." href="/docs/mobile/publishing/checklist" />

  <Card title="iOS (App Store)" description="Certificates, profiles, TestFlight, and App Store submission." href="/docs/mobile/publishing/ios" />

  <Card title="Android (Play Store)" description="Signing, tracks, and Play Console submission." href="/docs/mobile/publishing/android" />

  <Card title="Over-the-air updates" description="Push JS-only fixes instantly via EAS Update without a store review." href="/docs/mobile/publishing/updates" />

  <Card title="Multiple environments" description="Dev, staging, and production configs for Expo and EAS." href="/docs/mobile/recipes/multiple-environments" />
</Cards>

### Organizations & API client

Shared multi-tenancy and a typed client - same contracts as web, wired for Expo.

<Cards>
  <Card title="Organizations" description="Multi-tenant orgs, membership, and active organization on mobile." href="/docs/mobile/organizations/overview" />

  <Card title="Invitations" description="Accept invites and join teams from the mobile app." href="/docs/mobile/organizations/invitations" />

  <Card title="Roles & permissions" description="RBAC scoped to each organization." href="/docs/mobile/organizations/rbac" />

  <Card title="API overview" description="How the mobile app talks to the shared serverless API." href="/docs/mobile/api/overview" />

  <Card title="Typesafe client" description="Fully typed API client for queries and mutations." href="/docs/mobile/api/client" />

  <Card title="Database" description="Shared schema via the API - the client never talks to the DB directly." href="/docs/mobile/database" />
</Cards>

### AI

<Cards>
  <Card title="AI on mobile" description="Streaming chat and AI SDK patterns wired for Expo." href="/docs/mobile/ai" />

  <Card title="AI-assisted development" description="Rules and skills so AI editors follow Expo and monorepo conventions." href="/docs/mobile/installation/ai-development" />
</Cards>

### Customization & i18n

<Cards>
  <Card title="Styling & themes" description="Uniwind / Tailwind-on-RN, themes, and dark mode for the mobile UI." href="/docs/mobile/customization/styling" />

  <Card title="Components" description="Shared and mobile-specific UI components (RN Reusables)." href="/docs/mobile/customization/components" />

  <Card title="Internationalization" description="Device locale, RTL, language switching, and translated screens." href="/docs/mobile/internationalization" />

  <Card title="Add a package or app" description="Extend the monorepo with new packages or Expo apps." href="/docs/mobile/customization/add-package" />
</Cards>

### Analytics & monitoring

<Cards>
  <Card title="Analytics overview" description="Session and event tracking on iOS and Android." href="/docs/mobile/analytics/overview" />

  <Card title="App Tracking Transparency" description="ATT permissions required before collecting analytics on iOS." href="/docs/mobile/analytics/configuration" />

  <Card title="Event tracking" description="Custom events and user identification." href="/docs/mobile/analytics/tracking" />

  <Card title="Monitoring" description="Crash reporting and performance with Sentry and PostHog." href="/docs/mobile/monitoring/overview" />

  <Card title="Source maps" description="Upload Expo source maps for readable production stack traces." href="/docs/mobile/monitoring/sentry" />
</Cards>

### Feature flags

<Cards>
  <Card title="OpenFeature API" description="Provider-agnostic flag evaluation in Expo screens." href="/docs/mobile/flags/overview" />

  <Card title="Multiple providers" description="In-memory defaults, PostHog, or GrowthBook via export swap." href="/docs/mobile/flags/configuration" />

  <Card title="Evaluate in screens" description="useBooleanFlagValue and friends from @workspace/flags." href="/docs/mobile/flags/usage" />

  <Card title="Targeting context" description="Signed-in user id, email, and name synced automatically." href="/docs/mobile/flags/usage#targeting-context" />
</Cards>

### Security, tests & recipes

<Cards>
  <Card title="Security overview" description="SecureStore sessions, secrets, billing trust, and API boundaries." href="/docs/mobile/security/overview" />

  <Card title="Security checklist" description="Ship-ready checks before you go to production." href="/docs/mobile/security/checklist" />

  <Card title="Unit tests" description="Fast unit tests for hooks, utils, and components." href="/docs/mobile/tests/unit" />

  <Card title="E2E tests (Maestro)" description="Device and simulator flows for critical mobile paths." href="/docs/mobile/tests/e2e" />

  <Card title="Build a feature" description="End-to-end pattern for shipping a new mobile feature." href="/docs/mobile/recipes/build-a-feature" />
</Cards>

## Use like LEGO blocks

Keep the native pieces you need - push, IAP, onboarding - and drop the rest. The mobile app stays thin; the API and web kit remain the source of truth for accounts, orgs, and entitlements.

## Scope of this documentation

Focus here is the Expo app: installation, native auth, store billing, push, publishing, and mobile UX. Shared backend topics (schema design, webhook providers, admin) are covered in depth in the [web docs](/docs/web) when they apply across platforms.

## Enjoy!

Questions? Reach out at [hello@turbostarter.dev](mailto:hello@turbostarter.dev).

Ship to the stores, iterate with OTA, and have fun! 🚀


# Development
Source: https://www.turbostarter.dev/docs/mobile/installation/development

## Prerequisites

To get started with TurboStarter, ensure you have the following installed and set up:

* [Node.js](https://nodejs.org/en) (24.x or higher)
* [Docker](https://www.docker.com) (only if you want to use local services e.g. database)
* [pnpm](https://pnpm.io)
* [Firebase](https://firebase.google.com) project (optional for some features - check [Firebase project](/docs/mobile/installation/firebase) section for more details)

## Project development

<Steps>
  <Step>
    ### Set up environment

    We won't copy the official docs, as there is quite a bit of setup you need to make to get started with iOS and Android development and it also depends what approach you want to take.

    [Check this official setup guide to get started](https://docs.expo.dev/get-started/set-up-your-environment/). After you're done with the setup, go back to this guide and continue with the next step.

    You can pick if you want to develop the app for iOS or Android by using the real device or the simulator.

    <Callout title="Recommendation">
      We recommend using the simulators and [development builds](https://docs.expo.dev/develop/development-builds/create-a-build/) for development, as it is more real and reliable approach. It also won't limit you in terms of native dependencies (required for e.g. [analytics](/docs/mobile/analytics/overview)).

      Of course, you can start with the simplest approach (using [Expo Go](https://expo.dev/go)) and when you iterate further, switch to different approach.
    </Callout>
  </Step>

  <Step>
    ### Install dependencies

    Install the project dependencies by running the following command:

    ```bash
    pnpm i
    ```

    <Callout title="Why pnpm?">
      It is a fast, disk space efficient package manager that uses hard links and symlinks to save one version of a module only ever once on a disk. It also has a great [monorepo support](https://pnpm.io/workspaces). Of course, you can change it to use [Bun](https://bunpkg.com), [yarn](https://yarnpkg.com) or [npm](https://www.npmjs.com) with minimal effort.
    </Callout>
  </Step>

  <Step>
    ### Setup environment variables

    Create a `.env.local` files from `.env.example` files and fill in the required environment variables.

    You can use the following command to recursively copy the `.env.example` files to the `.env.local` files:

    <Tabs items={["Unix (MacOS/Linux)", "Windows"]}>
      <Tab value="Unix (MacOS/Linux)">
        ```bash
        find . -name ".env.example" -exec sh -c 'cp "$1" "${1%.example}.local"' _ {} \;
        ```
      </Tab>

      <Tab value="Windows">
        ```bash
        Get-ChildItem -Recurse -Filter ".env.example" | ForEach-Object {
            Copy-Item $_.FullName ($\_.FullName -replace '\.example$', '.local')
        }
        ```
      </Tab>
    </Tabs>

    Check [Environment variables](/docs/web/configuration/environment-variables) for more details on setting up environment variables.
  </Step>

  <Step>
    ### Setup services

    If you want to use local services like database etc. (**recommended for development purposes**), ensure Docker is running, then setup them with:

    ```bash
    pnpm services:setup
    ```

    This command initiates the containers and runs necessary setup steps, ensuring your services are up to date and ready to use.
  </Step>

  <Step>
    ### Start development server

    To start the application development server, run:

    ```bash
    pnpm dev
    ```

    Your development server should now be running at `http://localhost:8081`.

    ![Metro server](/images/docs/mobile/metro-server.png)

    Scan the QR code with your mobile device to start the app or press the appropriate key on your keyboard to run it on simulator. In case of any issues check the [Troubleshooting](https://docs.expo.dev/troubleshooting/overview/) section.
  </Step>

  <Step>
    ### Publish to stores

    When you're ready to publish the project to the stores, follow [guidelines](/docs/mobile/marketing) and [checklist](/docs/mobile/publishing/checklist) to ensure everything is set up correctly.
  </Step>
</Steps>


# Firebase project
Source: https://www.turbostarter.dev/docs/mobile/installation/firebase

For some features of your mobile app, you will need to set up a Firebase project. It's a requirement enforced by how these features are implemented under the hood and we cannot change it.

You would need a Firebase project to use the following features:

* [Analytics](/docs/mobile/analytics/overview) with [Google Analytics](/docs/mobile/analytics/configuration#google-analytics) provider
* [Push notifications](/docs/mobile/push-notifications) on Android (FCM credentials via `google-services.json`)

Here, we'll go through the steps to set up a Firebase project and link it to your mobile app.

<Callout title="Development build required" type="warn">
  In development environment, the integration with Firebase is possible only when using a [development build](https://docs.expo.dev/workflow/overview/#development-builds). It means that **it won't work in the [Expo Go](https://expo.dev/go) app**.
</Callout>

<Steps>
  <Step>
    ## Create a Firebase project

    First things first, you need to create a Firebase project. You can do this by going to the [Firebase console](https://console.firebase.google.com/) and clicking on "Add Project":

    ![Create a Firebase project](/images/docs/mobile/installation/firebase/create-project.png)

    Name it as you want, and proceed to the dashboard.
  </Step>

  <Step>
    ## Install Firebase SDK

    To install React Native Firebase's base app module, run the following command in your mobile app directory:

    ```bash
    npx expo install @react-native-firebase/app
    ```
  </Step>

  <Step>
    ## Configure Firebase modules

    The recommended approach to configure React Native Firebase is to use [Expo Config Plugins](https://docs.expo.dev/config-plugins/introduction/).

    To enable Firebase on the native Android and iOS platforms, create and download Service Account files for each platform from your Firebase project.

    You can find them in the dashboard under the Firebase project settings:

    ![Download Service Account files](/images/docs/mobile/installation/firebase/config-files.png)

    For Android, it will be a `google-services.json` file, and for iOS it will be a `GoogleService-Info.plist` file.

    Then provide paths to the downloaded files in the following `app.config.ts` fields: [`android.googleServicesFile`](https://docs.expo.io/versions/latest/config/app/#googleservicesfile-1) and [`ios.googleServicesFile`](https://docs.expo.io/versions/latest/config/app/#googleservicesfile). This is how an example configuration looks like:

    ```ts title="app.config.ts"
    export default ({ config }: ConfigContext): ExpoConfig => ({
      ...config,
      ios: {
        googleServicesFile: "./GoogleService-Info.plist",
      },
      android: {
        googleServicesFile: "./google-services.json",
      },
      plugins: [
        "@react-native-firebase/app",
        [
          "expo-build-properties",
          {
            ios: {
              useFrameworks: "static",
            },
          },
        ],
      ],
    });
    ```

    <Callout>
      For iOS only, since `firebase-ios-sdk` requires `use_frameworks` you need to configure `expo-build-properties` by adding `"useFrameworks": "static"`.
    </Callout>

    Listing a module in the Config Plugins (the `plugins` array in the config above) is only required for React Native Firebase modules that involve native installation steps - e.g. modifying the Xcode project, `Podfile`, `build.gradle`, `AndroidManifest.xml` etc. React Native Firebase modules without native steps will work out of the box.
  </Step>

  <Step>
    ## Generate native code

    If you are compiling your app locally, you'll need to regenerate the native code for the platforms to pick up the changes:

    ```bash
    npx expo prebuild --clean
    ```

    Then, you could follow the same steps as in the [development environment setup](/docs/mobile/installation/development) guide to run the app locally or [build a production version](/docs/mobile/publishing/checklist#build-your-app) of your app.
  </Step>
</Steps>

Et voilà! You've set up and linked your Firebase project to your mobile app 🎉

You can learn more about the Firebase integration and it's possibilities in the [official documentation](https://rnfirebase.io/).


# Project structure
Source: https://www.turbostarter.dev/docs/mobile/installation/structure

The main directories in the project are:

* `apps` - the location of the main apps
* `packages` - the location of the shared code and the API

### `apps` Directory

This is where the apps live. It includes web app (Next.js), mobile app (React Native - Expo), and the browser extension (WXT - Vite + React). Each app has its own directory.

### `packages` Directory

This is where the shared code and the API for packages live. It includes the following:

* shared libraries (database, mailers, cms, billing, etc.)
* shared features (auth, mails, billing, ai etc.)
* UI components (buttons, forms, modals, etc.)

All apps can use and reuse the API exported from the packages directory. This makes it easy to have one, or many apps in the same codebase, sharing the same code.

## Repository structure

By default the monorepo contains the following apps and packages:

<Files>
  <Folder name="apps" defaultOpen>
    <Folder name="web - Web app (Next.js)" />

    <Folder name="mobile - Mobile app (React Native - Expo)" />

    <Folder name="extension - Browser extension (WXT - Vite + React)" />
  </Folder>

  <Folder name="packages" defaultOpen>
    <Folder name="analytics - Analytics setup" />

    <Folder name="api - API server (including all features logic)" />

    <Folder name="auth - Authentication setup" />

    <Folder name="billing - Billing config and providers" />

    <Folder name="cms - CMS setup and providers" />

    <Folder name="db - Database setup" />

    <Folder name="email - Mail templates and providers" />

    <Folder name="flags - Feature flags" />

    <Folder name="i18n - Internationalization setup" />

    <Folder name="monitoring - Monitoring setup" />

    <Folder name="shared - Shared utilities and helpers" />

    <Folder name="storage - Storage setup" />

    <Folder name="ui - Atomic UI components" />
  </Folder>

  <Folder name="tooling" defaultOpen>
    <Folder name="github - Github actions" />

    <Folder name="oxfmt - Oxfmt config" />

    <Folder name="oxlint - Oxlint config" />

    <Folder name="typescript - TypeScript config" />

    <Folder name="vitest - Vitest config" />
  </Folder>
</Files>

## Mobile application structure

The mobile application is located in the `apps/mobile` folder. It contains the following folders:

<Files>
  <Folder name="public - Static assets" />

  <Folder name="src" defaultOpen>
    <Folder name="app - Main application" />

    <Folder name="assets - Optimized static assets" />

    <Folder name="config - App config" />

    <Folder name="lib - Communication with packages" />

    <Folder name="modules - Application modules" />

    <Folder name="utils - Shared utilities" />
  </Folder>

  <File name=".env.local" />

  <File name="app.config.ts" />

  <File name="eas.json" />

  <File name="package.json" />

  <File name="env.config.ts" />

  <File name="oxlint.config.ts" />

  <File name="metro.config.js" />

  <File name="tsconfig.json" />

  <File name="turbo.json" />
</Files>


# Internationalization
Source: https://www.turbostarter.dev/docs/mobile/internationalization

TurboStarter mobile uses [i18next](https://www.i18next.com/) and [expo-localization](https://docs.expo.dev/versions/latest/sdk/localization/) for internationalization. This powerful combination allows you to leverage both i18next's mature translation framework and Expo's native device locale detection.

<Callout title="Why this combination?">
  While i18next handles the translation management, expo-localization provides
  seamless integration with the device's locale settings. This means your app
  can automatically detect and adapt to the user's preferred language, while
  still maintaining the flexibility to override it when needed.
</Callout>

The mobile app's internationalization is configured to work out of the box with:

* Automatic device language detection
* Right-to-left (RTL) layout support
* Locale-aware date and number formatting
* Fallback language handling

You can read more about the underlying technologies in their documentation:

* [i18next documentation](https://www.i18next.com/overview/getting-started)
* [expo-localization documentation](https://docs.expo.dev/versions/latest/sdk/localization/)

![i18next logo](/images/docs/i18next.jpg)

## Configuration

The global configuration is defined in the `@workspace/i18n` package and shared across all applications. You can read more about it in the [web configuration](/docs/web/internationalization/configuration) documentation.

By default, the locale is automatically detected based on the user's device settings. You can override it and set the default locale of your mobile app in the [app configuration](/docs/mobile/configuration/app) file.

## Translating app

To translate individual components and screens, you can use the `useTranslation` hook.

```tsx
import { useTranslation } from "@workspace/i18n";

export default function MyComponent() {
  const { t } = useTranslation();

  return <Text>{t("hello")}</Text>;
}
```

It's a recommended way to translate your app.

### Store presence

If you plan on shipping your app to different countries or regions or want it to support various languages, you can provide localized strings for things like the display name and system dialogs.

To do so, check the [official Expo documentation](https://docs.expo.dev/guides/localization/) as it requires modifying your app configuration (`app.config.ts`).

You can find the resources below helpful in this process:

<Cards>
  <Card title="Expo Localization" href="https://docs.expo.dev/guides/localization/" description="docs.expo.dev" />

  <Card title="Apple App Store Localization" href="https://developer.apple.com/localization/" description="developer.apple.com" />

  <Card title="Google Play Localization" href="https://support.google.com/googleplay/android-developer/answer/9844778?hl=en" description="support.google.com" />
</Cards>

## Language switcher

TurboStarter ships with a language customizer component that allows you to switch between languages. You can import and use the `LocaleCustomizer` component and drop it anywhere in your application to allow users to change the language seamlessly.

```tsx
import { LocaleCustomizer } from "@workspace/ui-mobile/i18n";

export default function MyComponent() {
  return <LocaleCustomizer />;
}
```

The component automatically displays all languages configured in your i18n settings. When a user switches languages, it will be reflected in the app and saved into persistent storage to keep the language across app restarts.

## Best practices

Here are key best practices for managing translations in your mobile app:

* Use clear, hierarchical translation keys for easy maintenance

  ```ts
  // ✅ Good
  "screen.home.welcome";
  "component.button.submit";

  // ❌ Bad
  "welcomeText";
  ```

* Organize translations by app screens and features

  ```
  translations/
  ├── en/
  │   ├── layout.json
  │   └── common.json
  └── es/
      ├── layout.json
      └── common.json
  ```

* Consider device language settings and regional formats

* Cache translations locally for offline access

* Handle dynamic content for mobile contexts:

  ```ts
  // Device-specific messages
  t("errors.noConnection"); // "Check your internet connection"

  // Dynamic values
  t("storage.space", { gb: 2.5 }); // "2.5 GB available"
  ```

* Keep translations concise - mobile screens have limited space

* Test translations with different screen sizes and orientations


# Marketing
Source: https://www.turbostarter.dev/docs/mobile/marketing

As you saw in the [Extras](/docs/mobile/extras) section, TurboStarter comes with a lot of tips and tricks to make your product better and help you launch your app faster with higher traffic.

The same applies to [submission tips](/docs/mobile/extras#submission-tips) to help you get your app approved by Apple and Google faster.

We'll talk more about the whole process of deploying and publishing your app in the [Publishing](/docs/mobile/publishing/checklist) section, here we'll go through some guidelines that you need to follow to make your store's visibility higher.

## Before you submit

To help your app approval go as smoothly as possible, review the common missteps listed below that can slow down the review process or trigger a rejection. This doesn't replace the official guidelines or guarantee approval, but making sure you can check every item on the list is a good start.

Make sure you:

* Test your app for crashes and bugs
* Ensure that all app information and metadata is complete and accurate
* Update your contact information in case App Review needs to reach you
* Provide App Review with full access to your app. If your app includes account-based features, provide either an active demo account or fully-featured demo mode, plus any other hardware or resources that might be needed to review your app (e.g. login credentials or a sample QR code)
* Enable backend services so that they're live and accessible during review
* Include detailed explanations of non-obvious features and in-app purchases in the App Review notes, including supporting documentation where appropriate

Following these basic steps during development and before submission will help you get your app approved faster.

## App Store (iOS)

Apple reviews are much stricter than Google reviews, so you need to make sure your app is ready for the App Store.

### Guidelines

Apple has a set of [guidelines](https://developer.apple.com/app-store/review/guidelines/) that you need to follow to make sure your app can be accepted in the App Store.

These include:

* **Safety**: Your app must not contain content or behavior that is harmful, abusive, or threatening.
* **Performance**: Your app must be performant and stable, with a smooth user experience.
* **Business**: Your app must not engage in unethical or deceptive practices.
* **Design**: Your app must have a clean and intuitive design.
* **Legal**: Your app must comply with all relevant laws and regulations.

You can read more about each guideline in the [official App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/).

### Search optimization

App store optimization is the process of increasing an app or game's visibility in an app store, with the objective of increasing organic app downloads. Apps are more visible when they rank high on a wide variety of search terms, maintain a high position in the top charts, or get featured on the store.

There are a few actions that you can take to improve your app's visibility in the App Store:

* **Choose accurate keywords**: Use relevant keywords in your app's store listing.
* **Create a compelling app name, subtitle, and description**: Your app's title should be catchy and descriptive, the same applies to the subtitle and description.
* **Assign the right categories**: Make sure your app is categorized in the right category, this will help you reach the right audience.
* **Foster positive ratings**: Ratings and reviews appear on your product page and influence how your app ranks in search results. They can encourage people to engage with your app, so focus on providing a great app experience that motivates users to leave positive reviews.
* **Publish in-app events**: You can publish in-app events to promote your app and encourage users to engage with your app. (e.g. game competitions)
* **Promote in-app purchases**: Your promoted in-app purchases appear in search results on the App Store. Tapping an in-app purchase leads to your product page, which displays your app's description, screenshots, app previews, and in-app events — and lets people initiate an in-app purchase.

Read more about App Store Optimization in the [official documentation](https://developer.apple.com/app-store/search/).

<Cards>
  <Card title="App Review Guidelines" href="https://developer.apple.com/app-store/review/guidelines/" description="Official Apple App Store Review Guidelines" />

  <Card title="Search Optimization" href="https://developer.apple.com/app-store/search/" description="Apple's guide on App Store Search Optimization" />
</Cards>

## Google Play (Android)

Google reviews are less stringent than Apple reviews and usually take less time to review, but you still need to make sure your app is ready for the Play Store.

### Guidelines

Google has its own guidelines that apps must adhere to. Some important aspects to consider include:

* **Spam, functionality, and user experience**: Your app must not be spammy, must work as expected and must provide a good user experience.
* **Restricted content**: Before submitting an app to Google Play, ensure it complies with these content policies and with local laws.
* **Privacy**: Apps that are deceptive, malicious, or intended to abuse or misuse any network, device, or personal data are strictly prohibited
* **Monetization**: Your app must not engage in unethical or deceptive practices.

For more detailed information and an interactive checklist, check the [Google requirements page](https://developers.google.com/workspace/marketplace/about-app-review).

### Search optimization

Ensuring that your app and store listing is thorough and optimized is an important factor in getting discovered by users on Google Play.

Follow these steps to optimize your app's visibility on Google Play:

* **Build a comprehensive store listing**: This includes providing accurate **title**, **description** and **promo text**.
* **Use high-quality graphics and images**: App icons, images, and screenshots help make your app stand out in search results, categories, and featured app lists.
* **Diversify your audience**: Google provides automated machine translations of store listings that you don't explicitly define for your app. However, using a professional translation service for your *Description* can lead to better search results and discoverability for worldwide users.
* **Create a great user experience**: Google Play search factors in the overall experience of your app based on user behavior and feedback. Apps are ranked based on a combination of ratings, reviews, downloads, and other factors.

<Cards>
  <Card title="Prepare your app for review" href="https://support.google.com/googleplay/android-developer/answer/9859455" description="Google Play's guide on preparing your app for review" />

  <Card title="Get discovered on Google Play search" href="https://support.google.com/googleplay/android-developer/answer/4448378" description="Google's guide on improving app discoverability" />
</Cards>

## Common mistakes

There are a few common mistakes that you should avoid to make sure your app can be accepted in the stores. Apple reports that, on average, over **40%** of unresolved issues relate to [guideline 2.1: App Completeness](https://developer.apple.com/app-store/review/guidelines/#2.1), so make sure to avoid these:

* **Crashes and bugs**
* **Broken links**
* **Placeholder content**
* **Incomplete information**
* **Privacy policy issues**
* **Inaccurate screenshots**
* **Repeated submission of similar apps**

Don't worry if your first submission is rejected, improve it, fix all the mentioned issues and try again.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/monitoring/overview

TurboStarter ships with powerful, provider-agnostic monitoring helpers for the mobile app so you can answer the questions that matter in production: **what broke**, **on which screen**, and **which users were impacted**. It's designed for simplicity and extensibility, and works with multiple providers behind a single API.

## Capturing exceptions

On mobile, you'll usually want to report errors from a few key places:

* **UI/runtime crashes**: unexpected JS errors that would otherwise blank the screen or break navigation.
* **Async work**: background tasks, effects, and data fetching where failures are easy to miss.
* **Manual reporting**: wrap critical flows (auth, purchases, sync, deep-links) with `try/catch` so you can attach context when things go wrong.

```tsx
import { Pressable, Text } from "react-native";
import { captureException } from "@workspace/monitoring-mobile";

export default function ExampleComponent() {
  const handleClick = () => {
    try {
      /* some risky operation */
    } catch (error) {
      captureException(error);
    }
  };

  return (
    <Pressable onPress={handleClick}>
      <Text>Trigger Exception</Text>
    </Pressable>
  );
}
```

<Callout type="warn" title="JS exceptions vs native crashes">
  `try/catch` (and most JS error handlers) can only see JavaScript exceptions. Native crashes (for example, a hard crash in a native module) typically require provider-specific native setup to capture crash reports. Use the provider pages below for platform details.
</Callout>

## Identifying users

Error reports become much more actionable once they're tied to a signed-in user. TurboStarter supports identifying the current user after the auth session resolves, so your monitoring provider can associate errors with a stable user profile (without you plumbing this through every capture call).

If you want richer filtering, pass non-sensitive traits (plan, role, locale) depending on what your provider supports.

```tsx title="monitoring.tsx"
import { useEffect } from "react";
import { identify } from "@workspace/monitoring-mobile";
import { authClient } from "~/lib/auth";

export const MonitoringProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    identify(session.data?.user ?? null);
  }, [session]);

  return children;
};
```

<Callout title="Keep user data minimal" type="error">
  Identify users with **stable IDs** and only the traits you need for debugging. Avoid sending PII or secrets (tokens, raw emails, payment details) unless you've explicitly decided it's acceptable for your monitoring provider and compliance requirements.
</Callout>

## Providers

TurboStarter can report through different monitoring providers while keeping your app code consistent. Choose a provider (or swap later) by updating the exports/config in the monitoring package.

<Cards>
  <Card title="Sentry" href="/docs/mobile/monitoring/sentry" />

  <Card title="PostHog" href="/docs/mobile/monitoring/posthog" />
</Cards>

## Recommended practices

<Cards>
  <Card title="Report what you'd actually act on" className="shadow-none">
    Prioritize crashes, failed network calls that break a flow, and unexpected
    states. Skip noisy “expected” errors (validation, user cancellations).
  </Card>

  <Card title="Attach useful context" className="shadow-none">
    Include the screen/route, the action the user took, and relevant IDs
    (request id, order id). Mobile issues are often device- or version-specific,
    so make sure app version/build info is included by your provider.
  </Card>

  <Card title="Guard against loops" className="shadow-none">
    If an effect or retry path can fire repeatedly, debounce or dedupe your
    capture calls so you don't spam reports (or exceed quotas).
  </Card>

  <Card title="Separate dev/staging/prod" className="shadow-none">
    Keep environments isolated so test devices don't pollute production signal.
    Tag builds/releases so you can correlate spikes with deployments.
  </Card>
</Cards>

With solid capture + identification in place, mobile monitoring becomes a feedback loop: you can spot regressions quickly, understand who they affect, and validate fixes by release.


# PostHog
Source: https://www.turbostarter.dev/docs/mobile/monitoring/posthog

[PostHog](https://posthog.com/) is a product analytics platform that can also help with monitoring via error tracking and session replay. On mobile, it's especially useful when you want to connect **what went wrong** with **what the user did** right before it happened.

TurboStarter keeps monitoring provider selection behind a unified API, so you can route captures to PostHog without changing your app code.

<Callout type="warn" title="Prerequisite: PostHog account">
  You'll need a PostHog account ([cloud](https://app.posthog.com/signup) or [self-hosted](https://posthog.com/docs/self-host)) to use it as your monitoring provider.
</Callout>

<Callout type="info" title="You can also use it for analytics!">
  PostHog is one of the preconfigured analytics providers for mobile apps, and also powers [feature flags](/docs/mobile/flags/configuration#posthog). If you want product analytics (events, screens, funnels), see [analytics overview](/docs/mobile/analytics/overview) and the [PostHog configuration](/docs/mobile/analytics/configuration#posthog).
</Callout>

![Posthog banner](/images/docs/web/monitoring/posthog/banner.jpg)

## Configuration

PostHog makes it easy to monitor your mobile app for errors and issues, giving you full visibility into when things go wrong. With TurboStarter, you can enable PostHog-based monitoring in just a few steps, sending errors and related user actions to your PostHog dashboard for debugging and product improvement.

<Steps>
  <Step>
    ### Create a project

    Create a new PostHog [project](https://app.posthog.com/project/settings) for your mobile app. You can do this from the [PostHog dashboard](https://app.posthog.com) using the *New Project* action.
  </Step>

  <Step>
    ### Activate PostHog as your monitoring provider

    TurboStarter chooses the mobile monitoring provider through exports in `packages/monitoring/mobile`. To route monitoring events to PostHog, export the PostHog implementation from the package entrypoint:

    ```ts title="index.ts"
    // [!code word:posthog]
    export * from "./posthog";
    export * from "./posthog/env";
    ```
  </Step>

  <Step>
    ### Set environment variables

    Add your PostHog project key (and host, if you're not using the default cloud region) to your mobile app env. Set these locally and in your build environment (for example, in your [EAS build profile](/docs/mobile/publishing/checklist#environment-variables)):

    ```dotenv title="apps/mobile/.env.local"
    EXPO_PUBLIC_POSTHOG_KEY="your-posthog-project-api-key"
    EXPO_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
    ```
  </Step>
</Steps>

That's it - launch the app, trigger an error, and confirm events are arriving in your PostHog project.

![Posthog error](/images/docs/web/monitoring/posthog/error.png)

If you want to go beyond basic capture (session replay, feature flags, richer device/session context), follow [PostHog's React Native setup guidance](https://posthog.com/docs/error-tracking/installation/react-native).

<Cards>
  <Card title="Error tracking" href="https://posthog.com/docs/error-tracking" description="posthog.com" />

  <Card title="React Native error tracking installation" href="https://posthog.com/docs/error-tracking/installation/react-native" description="posthog.com" />
</Cards>

## Uploading source maps

**Source maps** map the bundled/minified JavaScript running on devices back to your original source code. Without them, mobile stack traces are often hard to read and difficult to action.

<Callout>
  With source maps uploaded to PostHog, error reports can be symbolicated so stack traces point to the real files and line numbers from your project.
</Callout>

PostHog's React Native source maps flow has two main parts:

* **Inject debug IDs** into the bundle during bundling (Metro)
* **Upload source maps** during your iOS/Android build (or via CLI in CI)

<Steps>
  <Step>
    ### Install and authenticate the PostHog CLI

    Install the CLI globally:

    ```bash
    npm install -g @posthog/cli
    ```

    Then authenticate:

    ```bash
    posthog-cli login
    ```

    If you're running in CI, you can authenticate with environment variables instead:

    ```dotenv
    POSTHOG_CLI_HOST="https://us.posthog.com"
    POSTHOG_CLI_ENV_ID="your-posthog-project-id"
    POSTHOG_CLI_TOKEN="your-personal-api-key"
    ```
  </Step>

  <Step>
    ### Inject debug IDs with Metro

    Automatic injection relies on Expo's debug ID support. Update `metro.config.js` to use PostHog's Expo config:

    ```js title="metro.config.js"
    const { getPostHogExpoConfig } = require("posthog-react-native/metro");

    const config = getPostHogExpoConfig(__dirname);

    module.exports = config;
    ```
  </Step>

  <Step>
    ### Upload source maps during builds

    If you can use the Expo plugin (recommended for managed EAS builds), add the plugin to your Expo config:

    ```ts title="app.config.ts"
    export default ({ config }: ConfigContext): ExpoConfig => ({
      ...config,
      plugins: ["posthog-react-native/expo"],
    });
    ```

    If you can't use the Expo plugin, PostHog also supports wiring uploads directly into:

    * **Android**: your Gradle build (`android/app/build.gradle`)
    * **iOS**: your Xcode “Bundle React Native code and images” build phase

    Follow the [official PostHog instructions](https://posthog.com/docs/error-tracking/upload-source-maps/react-native) for the exact snippets for each platform.
  </Step>

  <Step>
    ### Verify uploads in PostHog

    After a release build, confirm your symbol sets are present in [PostHog project error tracking dashboard](https://app.posthog.com/settings/project-error-tracking#error-tracking-symbol-sets) and then trigger a test error to ensure stack traces are resolving as expected.
  </Step>
</Steps>

With debug IDs injected and source maps uploaded, PostHog can symbolicate React Native errors so stack traces point back to your original source files. If traces still look minified, double-check that you're testing a release build and that the latest symbol sets are present in your project settings.

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Upload source maps for React Native" href="https://posthog.com/docs/error-tracking/upload-source-maps/react-native" description="posthog.com" />
</Cards>


# Sentry
Source: https://www.turbostarter.dev/docs/mobile/monitoring/sentry

[Sentry](https://sentry.io/welcome/) is a popular error monitoring platform that captures crashes and exceptions from production devices and helps you debug them with stack traces, breadcrumbs, and user context.

TurboStarter's mobile monitoring layer is provider-agnostic, but Sentry is a great default when you want reliable crash reporting plus readable stack traces in release builds.

<Callout type="warn" title="Prerequisite: Sentry account">
  To use Sentry, create an [account in Sentry](https://sentry.io/signup) first.
</Callout>

![Sentry banner](/images/docs/web/monitoring/sentry/banner.png)

## Configuration

TurboStarter integrates effortlessly with Sentry, so you can capture application errors and analyze performance from development through production. Setting up Sentry as your provider lets you quickly find and fix issues, contributing to a more robust and dependable app.

Follow the steps below to integrate Sentry with your TurboStarter project.

<Steps>
  <Step>
    ### Create a project

    Begin by creating a [project](https://docs.sentry.io/product/projects/) in Sentry. You can set this up from your [dashboard](https://sentry.io/settings/account/projects/) by clicking the *Create Project* button.
  </Step>

  <Step>
    ### Activate Sentry as your monitoring provider

    The monitoring provider to use is determined by the exports in `packages/monitoring/mobile` package. To activate Sentry as your monitoring provider, you need to update the exports in:

    ```ts title="index.ts"
    // [!code word:sentry]
    export * from "./sentry";
    export * from "./sentry/env";
    ```

    If you want to customize the provider, you can find its definition in `packages/monitoring/mobile/src/providers/sentry` directory.
  </Step>

  <Step>
    ### Set environment variables

    Based on your [project settings](https://sentry.io/project/settings), fill the following environment variables in your `.env.local` file in `apps/mobile` directory and your deployment environment (e.g. [EAS build profile](/docs/mobile/publishing/checklist#environment-variables)):

    ```dotenv title="apps/mobile/.env.local"
    EXPO_PUBLIC_SENTRY_DSN="your-sentry-dsn"
    EXPO_PUBLIC_PROJECT_ENVIRONMENT="your-project-environment"
    ```
  </Step>

  <Step>
    ### Wrap your app

    Install the Sentry React Native SDK in the `mobile` workspace.

    ```bash
    pnpm i @sentry/react-native --filter mobile
    ```

    And then wrap the root component of your application with Sentry.wrap:

    ```tsx title="app/_layout.tsx"
    import * as Sentry from "@sentry/react-native";

    export default Sentry.wrap(RootLayout);
    ```

    <Callout>
      TurboStarter initializes the SDK for you based on env + provider exports; you only need to wrap the root component.
    </Callout>
  </Step>
</Steps>

You're all set! Start your app and view any errors or exceptions directly in your [Sentry dashboard](https://sentry.io/settings/account/projects/).

![Sentry error](/images/docs/web/monitoring/sentry/error.jpg)

You can tailor the setup further if needed. For more details, refer to the [official Sentry documentation](https://docs.sentry.io/platforms/react-native/features/).

<Cards>
  <Card title="Quick Start" href="https://docs.sentry.io/platforms/react-native/" description="docs.sentry.io" />

  <Card title="Manual Setup" href="https://docs.sentry.io/platforms/react-native/manual-setup/" description="docs.sentry.io" />
</Cards>

## Uploading source maps

Readable stack traces in Sentry require uploading source maps for release builds. For Expo projects, Sentry recommends enabling **two pieces**:

* the **Sentry Expo config plugin** (uploads during native builds)
* the **Sentry Metro plugin** (adds debug IDs so bundles and source maps match)

### Add the Sentry Expo plugin

Add `@sentry/react-native/expo` plugin to your Expo config (`app.config.ts`):

```ts title="app.config.ts"
export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: [
    [
      "@sentry/react-native/expo",
      {
        url: "https://sentry.io/welcome/",
        project: "your-sentry-project",
        organization: "your-sentry-organization",
      },
    ],
  ],
});
```

Then provide an auth token through environment variables (locally in `.env.local` file in `apps/mobile` directory) and your build environment:

```dotenv title="apps/mobile/.env.local"
SENTRY_AUTH_TOKEN="your-sentry-auth-token"
```

### Add the Sentry Metro plugin

To ensure unique Debug IDs are assigned to the generated bundles and source maps, add the Sentry Metro Plugin to the configuration.

Update `metro.config.js` to use `getSentryExpoConfig`:

```js title="metro.config.js"
const { getSentryExpoConfig } = require("@sentry/react-native/metro");

const config = getSentryExpoConfig(__dirname);

module.exports = config;
```

With the Expo plugin + Metro plugin in place, source maps are uploaded automatically during release native builds and EAS builds (debug builds typically rely on Metro's symbolication).

Take a moment to test your setup by triggering an error in your app, then confirm that source maps are resolving stack traces accurately in your [Sentry dashboard](https://sentry.io/settings/account/projects/). For advanced setup details, troubleshooting, or further customization with React Native and Expo, refer to the [official Sentry documentation](https://docs.sentry.io/platforms/react-native/guides/expo/sourcemaps/).

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Uploading source maps" href="https://docs.sentry.io/platforms/react-native/sourcemaps/uploading/expo/" description="docs.sentry.io" />
</Cards>


# Active organization
Source: https://www.turbostarter.dev/docs/mobile/organizations/active-organization

The active organization on mobile mirrors the behavior used on the [web app](/docs/web/organizations/active-organization) and in the [extension](/docs/extension/organizations). It is tracked in the authenticated session as `activeOrganizationId` and used to scope all organization-bound data and actions.

Below you can find how to read and work with the active organization in your mobile app context.

## Reading the active organization

Use your auth client's helper to read the active organization from the session. This keeps the client in sync with the server and avoids duplicating tenancy logic.

```tsx title="organizations.tsx"
import { authClient } from "~/lib/auth";

export function OrganizationsScreen() {
  const organization = authClient.useActiveOrganization();
  const member = authClient.useActiveMember();

  return (
    <>
      <Text>{organization?.name}</Text>
      <Text>{member?.role}</Text>
    </>
  );
}
```

This mirrors the [extension](/docs/extension/organizations) approach and the [web hook](/docs/web/organizations/active-organization), ensuring the active organization and member role stay consistent with the server session.

## Performing actions

When invoking API routes from the mobile app, prefer passing the `organizationId` explicitly with the payload. This guarantees the correct tenant is targeted even if multiple devices or views are active simultaneously.

```tsx title="create-post.tsx"
import { api } from "~/lib/api";

export function CreatePost() {
  const activeOrganization = authClient.useActiveOrganization();

  const { mutate } = useMutation({
    mutationFn: async (post: PostInput) =>
      api.posts.$post({
        ...post,
        organizationId: activeOrganization?.id,
      }),
  });

  return (
    <Form>
      <Button onPress={onSubmit(mutate)}>Submit</Button>
    </Form>
  );
}
```

This mirrors the recommendation from the [web guide](/docs/web/organizations/active-organization#api-route) and avoids edge cases tied to stale session values.

## Switching organizations

TurboStarter ships an account switcher out of the box for mobile. You can drop it into your app and customize labels and styling as needed.

```tsx title="settings.tsx"
import { AccountSwitcher } from "~/modules/organization/account-switcher";

export function SettingsScreen() {
  return <AccountSwitcher />;
}
```

When a user selects a new organization, it calls your backend to update the session's `activeOrganizationId` and then re-read the session or invalidate related queries.

For deeper background on how the active organization is resolved, see the [web guide](/docs/web/organizations/active-organization).


# Invitations
Source: https://www.turbostarter.dev/docs/mobile/organizations/invitations

Invite teammates by email to join an organization directly from your mobile app. Acceptance is straightforward: we verify the invite, create or reuse the membership with the intended role, and set the user's active organization.

The implementation uses the same APIs and rules as the [web app](/docs/web/organizations/invitations) and is powered by the [Better Auth organization plugin](https://better-auth.com/docs/plugins/organization).

![Mobile invitations list](/images/docs/mobile/organizations/invitations/list.png)

## Capabilities

* Send invitations by email.
* View and filter invitations by status or role, and search by email.
* Resend or revoke an invitation.
* Accept an invitation via a [deep link](https://docs.expo.dev/linking/into-your-app/).

<Callout>
  Permissions are enforced by roles. Typically, only organization admins can
  send or manage invites. See [RBAC (Roles &
  Permissions)](/docs/mobile/organizations/rbac).
</Callout>

## Inviting members

Sending an invitation typically requires the invitee's email and the intended role. You can add multiple recipients in the invitation form to invite several members at once.

![Invite members bottom sheet](/images/docs/mobile/organizations/invitations/invite.png)

After sending, the invitee receives an email with a link to accept. It's a [deep link](https://docs.expo.dev/guides/linking) that opens your app and automatically validates the invite.

## Handling invitations

When a recipient opens an invite link on their device, the app automatically handles the entire flow - reading, parsing, and validating the invite - for you.

![Join organization prompt](/images/docs/mobile/organizations/invitations/join.png)

When the user accepts, we create or reuse their membership and set the active organization in their session. If they reject the invite, we redirect them to their account home.

## Learn more

For underlying details shared across platforms, see the web documentation:

<Cards>
  <Card title="Data model" description="Schema for organizations and invitations" href="/docs/web/organizations/data-model" />

  <Card title="Statuses and flow" description="Invitation status codes and how they update" href="/docs/web/organizations/invitations#status" />

  <Card title="Automatic invalidation" description="How invitations are automatically cleaned up" href="/docs/web/organizations/invitations#automatic-invalidation" />

  <Card title="Admin management" description="Admin tooling for managing invitations" href="/docs/web/organizations/invitations#invitation-management" />
</Cards>

These cover the schema, token lifecycle, and admin tooling shared by the mobile and web apps.


# Overview
Source: https://www.turbostarter.dev/docs/mobile/organizations/overview

Organizations let you build teams and multi-tenant SaaS out of the box in the mobile app.

Users can create organizations, invite teammates, assign roles, and seamlessly switch between workspaces — all from iOS/Android — with the same secure data isolation used on the [web app](/docs/web/organizations/overview).

<Callout title="What is multi-tenancy?">
  [Multi-tenancy](https://www.ibm.com/think/topics/multi-tenant) is a software architecture pattern where a single instance of an application serves multiple tenants, each with its own data and configuration.
</Callout>

The feature is powered by the same [Better Auth organization plugin](https://better-auth.com/docs/plugins/organization) and shares TurboStarter's API, routing, and data layer with the [web app](/docs/web/organizations/overview) and [extension](/docs/extension/organizations). That means your mobile app benefits from the same tenancy rules, RBAC checks, and invitations flow without duplicating backend logic.

<ThemedImage light="/images/docs/web/organizations/multi-tenancy/light.png" dark="/images/docs/web/organizations/multi-tenancy/dark.png" alt="Architecture" width={1375} zoomable height={955} />

## Architecture

On mobile, TurboStarter uses the same pragmatic multi-tenant architecture as the [web app](/docs/web/organizations/overview):

* **Tenant context** lives in the authenticated session as the active organization ID. The mobile client reads this context from the API and includes it when making requests.
* **Data scoping** is performed server-side via `organizationId` on tenant-owned tables and guard clauses in queries. Mobile screens consume scoped endpoints so users only see data for their selected organization.
* **Authorization** combines tenant scoping with role checks. We separate “can access this tenant?” from “can perform this action within the tenant?”.
* **Extensibility**: add new tenant-bound entities by including `organizationId` in your schema and using the provided helpers to read or switch the active organization in the app.

This keeps data isolated per organization while remaining simple to reason about across platforms.

<Callout>
  For deeper details on the shared data model used by the mobile app, see [Data
  model](/docs/web/organizations/data-model).
</Callout>

## Concepts

The same core concepts apply in the mobile app:

| Concept                 | Description                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------------- |
| **Organization**        | A workspace that owns resources and settings, acting as an isolated tenant.                        |
| **Member**              | A user assigned to an organization.                                                                |
| **Role**                | Access level within an organization (see [RBAC](/docs/mobile/organizations/rbac)).                 |
| **Invitation**          | Email request to join an organization (see [Invitations](/docs/mobile/organizations/invitations)). |
| **Active organization** | The currently selected organization in a user's session, used to scope data and permissions.       |

These concepts provide the building blocks for flexible team management and secure, multi-tenant SaaS applications on mobile.

## Development data

In development, TurboStarter automatically [seeds](/docs/mobile/installation/commands#seeding-database) example data when you set up services. The mobile app connects to the same development API, so you can test the full organizations flow end-to-end:

* One organization is created by default.
* All default roles are created and assigned within that organization.
* Sample invitations are generated so you can test the invite flow.

You can safely experiment with these sample organizations, roles, and invitations to understand multi-tenancy features — [reset](/docs/mobile/installation/commands#resetting-database) or [reseed](/docs/mobile/installation/commands#seeding-database) anytime to return to the default state.

The default credentials for demo users can be customized using the `SEED_EMAIL` and `SEED_PASSWORD` environment variables.

<Callout type="error" title="Never run in production">
  The default development data and setup are intended for local development and
  testing only. **Never** use these seeds or configurations in a production
  environment - they are insecure and may expose sensitive functionality.
</Callout>

## Customization

You have flexibility to adapt organizations to fit your mobile experience. For example, you might rename labels (such as Organization to *Team* or *Workspace*), and update the app copy accordingly.

You can adjust the available [roles and permissions](/docs/mobile/organizations/rbac) to suit your access model.

The [invitation flow](/docs/mobile/organizations/invitations) can be customized, including how verification, onboarding, or metadata capture work.

Feel free to check how to configure all of these features inside mobile application in the dedicated sections linked above.


# RBAC (Roles & Permissions)
Source: https://www.turbostarter.dev/docs/mobile/organizations/rbac

Role-based access control (RBAC) lets you define who can do what in an organization.

<Callout title="New to RBAC?">
  If you're new to the RBAC concept, a simple mental model is:

  * Users belong to organizations.
  * Users get roles.
  * Roles map to permissions on resources.
</Callout>

In TurboStarter, we primarily rely on the [Better Auth plugin](https://better-auth.com/docs/plugins/organization) for the heavy lifting—roles, permissions, teams, and member management—while handling critical logic with our own code.

This provides a flexible access control system, letting you control user access based on their role in the organization. You can also define custom permissions per role.

<Callout title="Everything is configured out of the box!">
  TurboStarter ships with the default RBAC system configured out of the box. This setup may be enough if you're not planning a very complex access control system, but you can also easily customize it to your needs.

  On mobile, use conditional UI (disable or hide actions) together with client helpers to match each member's role.
</Callout>

## Roles

Roles are named bundles of permissions. Keep them few and well-defined. By default, we have the following roles:

```ts
const MemberRole = {
  MEMBER: "member",
  ADMIN: "admin",
  OWNER: "owner",
} as const;
```

A user can have multiple roles in an organization. For example, a user can be a member and an admin (if it makes sense for your application).

<Callout type="warn" title="Don't confuse organization admin with super admin">
  The organization's `admin` role is different from the user's global `admin` role.

  The organization `admin` governs permissions only inside the organization, whereas the global `admin` controls access to the [super admin dashboard](/docs/web/admin/overview).
</Callout>

To create additional roles with custom permissions, see the [official documentation](https://better-auth.com/docs/plugins/organization#create-access-control) for more details.

## Permissions

Permissions represent what actions a role can perform on which resources.

To check if the current user has permission to perform an action on mobile, use the client helper and handle the boolean result in your component logic.

```tsx title="create-project.tsx"
import { useQuery, useMutation } from "@tanstack/react-query";
import { authClient } from "~/lib/auth";

export function CreateProject() {
  const { data: canCreate } = useQuery({
    queryKey: ["permission", "project", "create"],
    queryFn: () =>
      authClient.organization.hasPermission({
        permissions: { project: ["create"] },
      }),
  });

  const { mutate, isPending } = useMutation({
    mutationFn: async () => {
      // perform the create action
    },
  });

  return (
    <Button
      disabled={canCreate === false}
      loading={isPending}
      onPress={() => canCreate && mutate()}
    >
      Create
    </Button>
  );
}
```

When you already have the active member's role, prefer the client-side `checkRolePermission` to avoid extra API calls.

```tsx title="update-project.tsx"
import { authClient } from "~/lib/auth";

export function UpdateProject() {
  const activeMember = authClient.useActiveMember();

  const canUpdate = authClient.organization.checkRolePermission({
    permission: {
      project: ["update"],
    },
    role: activeMember.role,
  });

  return <Button disabled={!canUpdate}>Update</Button>;
}
```

We leverage the existing hook to retrieve the active member role within the [active organization](/docs/mobile/organizations/active-organization) context. That way, you can easily check whether a member has permission to perform an action without a server round trip.

<Callout type="warn">
  This does not include any dynamic roles or permissions because everything runs synchronously on the client-side. Use the `hasPermission` APIs to include checks for dynamic roles and permissions.
</Callout>

If you need to add more granular permissions to existing roles, or create new ones, use the [`createAccessControl`](https://better-auth.com/docs/plugins/organization#custom-permissions) API.

For further customization—such as dynamic access control, lifecycle hooks, or team management—see the guidance in the [official documentation](https://better-auth.com/docs/plugins/organization) and the [web guide](/docs/web/organizations/rbac).


# Google Play (Android)
Source: https://www.turbostarter.dev/docs/mobile/publishing/android

[Google Play](https://play.google.com/) is the primary platform for distributing Android apps to billions of users worldwide. It's a powerful marketplace that allows you to reach a large audience and monetize your app.

To submit your app to the Play Store, you'll need to follow a series of steps. We'll walk through those steps here.

<Callout title="Prerequisite" type="warn">
  Before you submit, review the publishing [guidelines](/docs/mobile/marketing) and confirm that your app meets Google's policies to avoid common rejections.
</Callout>

## Developer account

A Google Play Developer account is required to submit your app to the Google Play Store. You can sign up on the [Google Play Console](https://play.google.com/console/) and pay the one-time registration fee.

![Google Play Developer Account](/images/docs/mobile/publishing/android/developer-account.png)

To publish apps to Google Play, you must verify your identity. See the [official guide](https://support.google.com/googleplay/android-developer/answer/14177239) for more information. Next, you'll need to create a new app in the [Google Play Console](https://play.google.com/apps/publish/) by clicking the *Create app* button.

## Submission

After registering your developer account, setting it up, and preparing your app, you're ready to publish it to the Play Store.

There are multiple ways to submit your app:

* **Manual submission:** Upload your app bundle directly to the Play Store via the Play Console.
* **Local submission:** Use [EAS CLI](https://github.com/expo/eas-cli) to submit your app.
* **CI/CD submission:** Use ready-to-use GitHub Actions workflow to automatically submit your app.

**The first submission must be done manually, while subsequent updates can be submitted automatically.** We'll go through each approach in detail below.

### Manual submission

This approach is not recommended, as it is more error-prone and time-consuming due to manual steps. However, it's still the **only way to submit your app for the first time**. You can also use this route if you need to upload a build without EAS Submit (for example, during service maintenance) or if you prefer a fully manual flow.

**Create the app entry in Google Play Console**

1. Visit [Google Play Console](https://play.google.com/console/) and sign in. Accept any pending agreements if prompted.
2. Click *Create app*, then enter your app name, default language, app type, and pricing (free/paid). Confirm policy declarations.
3. Finish initial setup tasks (App access, Ads, Content rating, Target audience, Data safety, Privacy policy URL).

**Upload the `.aab` file to a track (internal/closed/open/production)**

1. The fastest route for a first upload is often *Internal testing*. Go to *Internal testing* → *Releases* (or choose *Closed/Open/Production*), then click *Create new release*.
2. Upload the `.aab` file, add release notes, and review any warnings.
3. Save and continue through the checks until you're ready to submit for review or roll out to [testers](https://play.google.com/console/about/internal-testing/).

**Verify and submit for review**

1. Complete Store listing assets and metadata if not already done.
2. Resolve any policy warnings. When ready, start the rollout to request a [review](/docs/mobile/publishing/android#review).

After your first manual upload is accepted, you can use [Local submission](/docs/mobile/publishing/android#local-submission) or [CI/CD submission](/docs/mobile/publishing/android#cicd-submission-recommended) for subsequent releases.

For more information, please refer to the guides listed below.

<Cards>
  <Card title="First Android submission" url="https://docs.expo.dev/submit/android-manual/" description="docs.expo.dev" />

  <Card title="Create and set up your app" url="https://support.google.com/googleplay/android-developer/answer/9859152" description="google.com" />
</Cards>

### Local submission

<Callout title="First submission must be done manually" type="warn">
  Due to Google Play API limitations, you must upload your app to Google Play **manually at least once** (to any track: internal, closed, open, or production) before automated submissions will work. See the detailed walkthrough in the ["First Android submission" guide](https://docs.expo.dev/submit/android-manual/).
</Callout>

First, you need to **upload and configure a Google Service Account Key with EAS**. This is the required first step to submit your Android app to the Google Play Store. Follow the [guide on uploading a Google Service Account Key for Play Store submissions with EAS](https://expo.fyi/creating-google-service-account) for detailed instructions.

Next, you have to get your app bundle — if you followed the [checklist](/docs/mobile/publishing/checklist), you should have the `.aab` file in your app folder from the [build step](/docs/mobile/publishing/checklist#build-your-app). If you used GitHub Actions to build your app, you can find the results in the `Builds` tab of your [EAS project](https://expo.dev). Download the artifacts and save them on your local machine.

Then, navigate to your app folder and run the following command to submit your app to the Play Store:

```bash
eas submit --platform android
```

The command will guide you through the submission process. You can also configure the steps of the submission process by adding a submission profile in `eas.json`.

<Callout>
  If you upload your Google Service Account key to EAS credentials, you do not need to reference a local file path anywhere.
</Callout>

To speed up the submission process, you can use the `--auto-submit` flag to automatically submit a build after it is built:

```bash
eas build --platform android --auto-submit
```

This will automatically submit the build with all the required credentials to the Play Store right after it is built.

<Cards>
  <Card title="Automate submissions" description="docs.expo.dev" href="https://docs.expo.dev/build/automate-submissions/" />

  <Card title="Creating a Google Service Account" description="expo.fyi" href="https://expo.fyi/creating-google-service-account" />

  <Card title="eas.json reference" description="docs.expo.dev" href="https://docs.expo.dev/eas/json/#android-specific-options-1" />
</Cards>

### CI/CD submission (recommended)

<Callout title="First submission must be done manually" type="warn">
  Due to Google Play API limitations, you must upload your app to Google Play **manually at least once** (to any track: internal, closed, open, or production) before automated submissions will work. See the detailed walkthrough in the ["First Android submission" guide](https://docs.expo.dev/submit/android-manual/).
</Callout>

TurboStarter comes with a pre-configured GitHub Actions workflow to automatically submit your mobile app to the Play Store. You'll find the workflow in the `.github/workflows/publish-mobile.yml` file.

To use this workflow, [upload your Google Play Service Account key to EAS](https://expo.fyi/creating-google-service-account) and check your Android credentials setup by running:

```bash
eas credentials --platform android
```

This way, you avoid storing the JSON key in your repository or CI/CD provider.

<Callout title="Don't forget to set EXPO_TOKEN">
  This workflow also requires a [personal access token](https://docs.expo.dev/accounts/programmatic-access/#personal-access-tokens) for your Expo account. Add it as `EXPO_TOKEN` in your [GitHub repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions), which will allow the `eas submit` command to run.
</Callout>

That's it! After completing these steps, [trigger the workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow) to submit your new build to the Play Store automatically 🎉

<Cards>
  <Card title="Automate submissions" description="docs.expo.dev" href="https://docs.expo.dev/build/automate-submissions/" />

  <Card title="Creating a Google Service Account" description="expo.fyi" href="https://expo.fyi/creating-google-service-account" />

  <Card title="eas.json reference" description="docs.expo.dev" href="https://docs.expo.dev/eas/json/#android-specific-options-1" />
</Cards>

## Review

After filling out the information about your item, you're ready to submit it for review. Click on the *Send for review* button and confirm that you want to proceed with the submission:

![Send for review](/images/docs/mobile/publishing/android/send-for-review.png)

To control **when** your app is released after review, you can configure [Managed publishing](https://support.google.com/googleplay/android-developer/answer/9859654) in the Google Play Console.

After submitting your app for review, it will enter Google's review process. The review time may vary depending on your app, and you'll receive a notification when the status updates. For more details, check out the [Google Play Review Process](https://developers.google.com/workspace/marketplace/about-app-review) documentation.

<Callout title="Your submission might be rejected" type="error">
  If your submission is rejected, you'll receive an email from Google with the rejection reason. You'll need to fix the issues and upload a new version of your app.

  ![Google Play Rejection](/images/docs/mobile/publishing/android/rejection.png)

  Make sure to follow the [guidelines](/docs/mobile/marketing) or check [publishing troubleshooting](/docs/mobile/troubleshooting/publishing) for more info.
</Callout>

When your app is approved by Google, you'll be able to publish it on the Play Store.

![Your update is live email from Google](/images/docs/mobile/publishing/android/update-live.png)

You can learn more about the review process in the official guides listed below.

<Cards>
  <Card title="App review process" description="google.com" href="https://developers.google.com/workspace/marketplace/about-app-review" />

  <Card title="Google Play branding guidelines" description="google.com" href="https://developers.google.com/workspace/marketplace/terms/branding" />
</Cards>


# Checklist
Source: https://www.turbostarter.dev/docs/mobile/publishing/checklist

When you're ready to publish your TurboStarter app to stores, follow this checklist.

This process may take a few hours and some trial and error, so buckle up - you're almost there!

<Steps>
  <Step>
    ## Create database instance

    **Why it's necessary?**

    A production-ready database instance is essential for storing your application's data securely and reliably in the cloud. [PostgreSQL](https://www.postgresql.org/) is the recommended database for TurboStarter due to its robustness, features, and wide support.

    **How to do it?**

    You have several options for hosting your PostgreSQL database:

    * [Supabase](/docs/mobile/recipes/supabase) - Provides a fully managed Postgres database with additional features
    * [Vercel Postgres](https://vercel.com/storage/postgres) - Serverless SQL database optimized for Vercel deployments
    * [Neon](https://neon.com/) - Serverless Postgres with automatic scaling
    * [Turso](https://turso.tech/) - Edge database built on libSQL with global replication
    * [DigitalOcean](https://www.digitalocean.com/products/managed-databases) - Managed database clusters with automated failover

    Choose a provider based on your needs for:

    * Pricing and budget
    * Geographic region availability
    * Scaling requirements
    * Additional features (backups, monitoring, etc.)
  </Step>

  <Step>
    ## Migrate database

    **Why it's necessary?**

    Pushing database migrations ensures that your database schema in the remote database instance is configured to match TurboStarter's requirements. This step is crucial for the application to function correctly.

    **How to do it?**

    You basically have two possibilities of doing a migration:

    <Tabs items={["Using Github Actions (recommended)", "Running locally"]}>
      <Tab value="Using Github Actions (recommended)">
        TurboStarter comes with predefined Github Action to handle database migrations. You can find its definition in the `.github/workflows/publish-db.yml` file.

        What you need to do is to set your `DATABASE_URL` as a [secret for your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions).

        Then, you can run the workflow which will publish the database schema to your remote database instance.

        [Check how to run Github Actions workflow.](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)
      </Tab>

      <Tab value="Running locally">
        You can also run your migrations locally, although this is not recommended for production.

        To do so, set the `DATABASE_URL` environment variable to your database URL (that comes from your database provider) in `.env.local` file and run the following command:

        ```bash
        pnpm with-env pnpm --filter @workspace/db db:migrate
        ```

        This command will run the migrations and apply them to your remote database.

        [Learn more about database migrations.](/docs/web/database/migrations)
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## (Optional) Set up Firebase project

    **Why it's necessary?**

    Setting up a Firebase project is optional, and depends on which features your app is using. For example, if you want to use [Analytics](/docs/mobile/analytics/overview) with [Google Analytics](/docs/mobile/analytics/configuration#google-analytics), or [push notifications](/docs/mobile/push-notifications) on Android, setting up a Firebase project is required.

    **How to do it?**

    Please refer to the [Firebase project](/docs/mobile/installation/firebase) section on how to set up and configure your Firebase project.
  </Step>

  <Step>
    ## Set up web backend API

    **Why it's necessary?**

    Setting up the backend is necessary to have a place to store your data and to have other features work properly (e.g. authentication, billing or storage).

    **How to do it?**

    Please refer to the [web deployment checklist](/docs/web/deployment/checklist) on how to set up and deploy the web app backend to production.
  </Step>

  <Step>
    ## Environment variables

    **Why it's necessary?**

    Setting the correct environment variables is essential for the application to function correctly. These variables include API keys, database URLs, and other configuration details required for your app to connect to various services.

    **How to do it?**

    Use our `.env.example` files to get the correct environment variables for your project. Then add them to your project on the [EAS platform](https://docs.expo.dev/eas/environment-variables/) for correct profile and environment:

    ![EAS environment variables](/images/docs/mobile/eas-environment-variables.png)

    Alternatively, you can add them to your `eas.json` file under correct profile.

    ```json title="eas.json"
    {
      "profiles": {
        "base": {
          "env": {
            "EXPO_PUBLIC_DEFAULT_LOCALE": "en",
            "EXPO_PUBLIC_AUTH_PASSWORD": "true",
            "EXPO_PUBLIC_AUTH_MAGIC_LINK": "false",
            "EXPO_PUBLIC_THEME_MODE": "system",
            "EXPO_PUBLIC_THEME_COLOR": "orange"
          }
        },
        "production": {
          "extends": "base",
          "autoIncrement": true,
          "env": {
            "APP_ENV": "production",
            "EXPO_PUBLIC_SITE_URL": "https://www.turbostarter.dev",
          }
        }
      }
    }
    ```
  </Step>

  <Step>
    ## Build your app

    <Callout title="Prerequisite: EAS account">
      Building your app requires an EAS account and project. If you don't have one, you can create it by following the steps [here](https://expo.dev/eas).
    </Callout>

    **Why it's necessary?**

    Building your app is necessary to create a standalone application bundle that can be published to the stores.

    **How to do it?**

    You basically have two possibilities to build a bundle for your app:

    <Tabs items={["Using Github Actions (recommended)", "Running locally"]}>
      <Tab value="Using Github Actions (recommended)">
        TurboStarter comes with predefined Github Action to handle building your app on EAS. You can find its definition in the `.github/workflows/publish-mobile.yml` file.

        What you need to do is to set your `EXPO_TOKEN` as a [secret for your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). You can obtain it from your EAS account, in the [Access Tokens](https://expo.dev/settings/access-tokens) section.

        Then, you can run the workflow which will build the app on [EAS platform](https://expo.dev/eas).

        [Check how to run Github Actions workflow.](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)
      </Tab>

      <Tab value="Running locally">
        You can also run your build locally, although this is not recommended for production.

        To do it, you'll need to have [EAS CLI](https://github.com/expo/eas-cli) installed on your machine. You can install it by running the following command:

        ```bash
        npm install -g eas-cli
        ```

        Then, run the following command to build your app with the `production` profile:

        ```bash
        eas build --profile production --platform all
        ```

        This will build the app for both platforms (iOS and Android) and output the results in your app folder.
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Submit to stores

    **Why it's necessary?**

    Releasing your app to the stores is essential for making it accessible and discoverable by your users. This allows users to find, install, and trust your application through official channels.

    **How to do it?**

    We've prepared dedicated guides for each store that TurboStarter supports out-of-the-box, please refer to the following pages:

    <Cards>
      <Card title="App Store" href="/docs/mobile/publishing/ios" description="Publish your app to the Apple App Store." />

      <Card title="Google Play" href="/docs/mobile/publishing/android" description="Publish your app to the Google Play Store." />
    </Cards>
  </Step>
</Steps>

That's it! Your app is now live and accessible to your users, good job! 🎉

<Callout title="Other things to consider">
  * Run through the [security checklist](/docs/mobile/security/checklist) (public env, trusted origins, API trust, billing webhooks).
  * Optimize your store listings with compelling descriptions, keywords, screenshots and preview videos
  * Remove placeholder content and replace with your final production content
  * Update all visual branding including favicon, scheme, splash screen and app icons
</Callout>


# App Store (iOS)
Source: https://www.turbostarter.dev/docs/mobile/publishing/ios

[Apple App Store](https://www.apple.com/app-store/) is the primary platform for distributing iOS apps, making them available on iPhones, iPads, and other Apple devices to millions of users worldwide.

To submit your app to the App Store, you'll need to follow a series of steps. We'll walk through those steps here.

<Callout title="Prerequisite" type="warn">
  Before you submit, review the publishing [guidelines](/docs/mobile/marketing) and confirm that your app meets Apple's policies to avoid common rejections.
</Callout>

## Developer account

An Apple Developer account is required to submit your app to the Apple App Store. You can sign up for an Apple Developer account on the [Apple Developer Portal](https://developer.apple.com/account/).

![Apple Developer Account](/images/docs/mobile/publishing/ios/developer-account.png)

To submit apps to the App Store, you must also be a member of the Apple Developer Program. You can join the program by paying the annual fee.

## Submission

There are two primary ways to submit your iOS app to the App Store:

* **Manual:** Uploading the build yourself through Apple's tools, such as [Transporter](https://apps.apple.com/app/transporter/id1450874784) or [Xcode](https://developer.apple.com/xcode/).
* **Automatic (recommended):** Using [EAS Submit](/docs/mobile/publishing/ios#local-submission) or [CI/CD](/docs/mobile/publishing/ios#cicd-submission-recommended), which simplifies the process, ensures consistency, and reduces manual error.

Below, you'll find guidance for both submission methods—choose the one that fits your workflow and project needs.

### Manual submission

This approach is not recommended, as it is more error-prone and time-consuming due to manual steps. Use this route if you need to upload a build without EAS Submit (for example, during service maintenance) or prefer a fully manual flow from macOS.

**Create the app entry in App Store Connect**

1. Visit [App Store Connect](https://appstoreconnect.apple.com/) and sign in. Accept any pending agreements if prompted.
2. From Apps, click the + button and select *New App*.
3. Enter the app name, primary language, bundle identifier, and a unique SKU (for example, your bundle ID, such as `com.company.myapp`).
4. Press Create to finish setting up the app record.

**Upload the IPA with Transporter**

1. Install [Apple's Transporter](https://apps.apple.com/app/transporter/id1450874784) from the Mac App Store.
2. Open Transporter and sign in with your Apple ID.
3. Drag the `.ipa` into Transporter (or click *Add App* to choose the file).
4. Press *Deliver* to upload. Transfer time varies by file size and network.

**Verify processing and select the build**

1. Once uploaded, Apple processes the binary (often 10-20 minutes).
2. Back in [App Store Connect](https://appstoreconnect.apple.com/), open My Apps and select your app.
3. Under the *App Store* tab, select the new build in the *Build* section. If it's missing, wait and refresh.
4. Proceed with the usual App Store steps (screenshots, metadata, compliance, then submit for review).

For more information about the required metadata, refer to the official guides.

<Cards>
  <Card title="Submitting" url="https://developer.apple.com/app-store/submitting/" description="developer.apple.com" />

  <Card title="App Information" url="https://developer.apple.com/help/app-store-connect/reference/app-information/" description="developer.apple.com" />
</Cards>

### Local submission

If you followed the [checklist](/docs/mobile/publishing/checklist), you should have the `.ipa` file in your app folder from the [build step](/docs/mobile/publishing/checklist#build-your-app). If you used GitHub Actions to build your app, you can find the results in the `Builds` tab of your [EAS project](https://expo.dev). Download the artifacts and save them on your local machine.

Then, navigate to your app folder and run the following command to submit your app to the App Store:

```bash
eas submit --platform ios
```

The command will guide you through the submission process. You can configure the submission process by adding a submission profile in `eas.json`:

```json title="eas.json"
{
  "submit": {
    "production": {
      "ios": {
        "ascAppId": "your-app-store-connect-app-id"
      }
    }
  }
}
```

<Accordions>
  <Accordion title="How to find ascAppId?">
    1. Sign in to [App Store Connect](https://appstoreconnect.apple.com/) and choose your team.
    2. Open the [Apps](https://appstoreconnect.apple.com/apps) area.
    3. Select your app from the list.
    4. Switch to the *App Store* tab.
    5. Go to *General* → *App Information*.
    6. In *General Information*, the value labeled *Apple ID* is your `ascAppId`.

    ![App Store Connect App Information](/images/docs/mobile/publishing/ios/asc-app-id.png)
  </Accordion>
</Accordions>

To speed up the submission process, you can use the `--auto-submit` flag to automatically submit a build after it is built:

```bash
eas build --platform ios --auto-submit
```

This will automatically submit the build with all the required credentials to the App Store right after it is built.

<Cards>
  <Card title="eas.json reference" description="docs.expo.dev" href="https://docs.expo.dev/eas/json/#ios-specific-options-1" />

  <Card title="Automate submissions" description="docs.expo.dev" href="https://docs.expo.dev/build/automate-submissions/" />
</Cards>

### CI/CD submission (recommended)

TurboStarter comes with a pre-configured GitHub Actions workflow to submit your mobile app to the App Store automatically. It's located in the `.github/workflows/publish-mobile.yml` file.

To be able to use this workflow, you'd need to fulfill the following prerequisites:

1. **Configure your App Store Connect API Key**

   Run the following command to configure your App Store Connect API Key:

   ```bash
   eas credentials --platform ios
   ```

   The command will prompt you to configure credentials:

   1. Choose the `production` build profile.
   2. Authenticate with your Apple Developer account and proceed through the prompts.
   3. Pick **App Store Connect → Manage your API Key**.
   4. Enable **Use an API Key for EAS Submit** for the project.

2. **Provide a submission profile in `eas.json`**

   Next, add a submission profile in `eas.json` with the following:

   ```json title="eas.json"
   {
     "submit": {
       "production": {
         "ios": {
           "ascAppId": "your-app-store-connect-app-id"
         }
       }
     }
   }
   ```

<Accordions>
  <Accordion title="How to find ascAppId?">
    1) Log into [App Store Connect](https://appstoreconnect.apple.com/) under the correct team.
    2) Go to [Apps](https://appstoreconnect.apple.com/apps) and open your app.
    3) Ensure the *App Store* tab is selected.
    4) Navigate to *General* → *App Information*.
    5) Copy the value shown as *Apple ID* — that is the `ascAppId`.

    ![App Store Connect App Information](/images/docs/mobile/publishing/ios/asc-app-id.png)
  </Accordion>
</Accordions>

<Callout title="Don't forget to set EXPO_TOKEN">
  This workflow also requires a [personal access token](https://docs.expo.dev/accounts/programmatic-access/#personal-access-tokens) for your Expo account. Add it as `EXPO_TOKEN` in your [GitHub repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions), which will allow the `eas submit` command to run.
</Callout>

That's it! After completing these steps, [trigger the workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow) to submit your new build to the App Store automatically 🎉

<Cards>
  <Card title="eas.json reference" description="docs.expo.dev" href="https://docs.expo.dev/eas/json/#ios-specific-options-1" />

  <Card title="Automate submissions" description="docs.expo.dev" href="https://docs.expo.dev/build/automate-submissions/" />
</Cards>

## Review

After completing your app information, you're ready to submit it for review. Click the *Add for review* button and confirm that you want to proceed with the submission:

![Confirm submission](/images/docs/mobile/publishing/ios/confirm-submission.png)

On the *Distribution* tab, you can configure the release process after the review is complete — whether you want to release the app automatically after review, later, or manually.

![App Store Connect Version Release](/images/docs/mobile/publishing/ios/version-release.png)

Once you've submitted your app for review, it will go through Apple's review process. The duration can vary based on the specifics of your app and you'll be notified when the status changes. For more information, refer to the [App Review](https://developer.apple.com/distribute/app-review/) docs.

<Callout title="Your submission might be rejected" type="error">
  If your submission is rejected, you'll receive an email from Apple with the rejection reason. You'll need to fix the issues and upload a new version of your app.

  ![App Store Connect Rejection](/images/docs/mobile/publishing/ios/rejection.png)

  Make sure to follow the [guidelines](/docs/mobile/marketing) or check [publishing troubleshooting](/docs/mobile/troubleshooting/publishing) for more information.

  If you need to clarify anything with Apple, you can reply to the app review request in App Store Connect:

  ![App Store Connect Reply to Review](/images/docs/mobile/publishing/ios/reply-to-review.png)

  This helps you understand the rejection and what you need to change to make your app eligible for distribution.
</Callout>

When your app is approved by Apple (by email or push notification), you'll be able to publish it on the App Store.

![Review notification](/images/docs/mobile/publishing/ios/review-notifications.jpeg)

You can learn more about the review process in the official guides listed below.

<Cards>
  <Card title="App Review Process" url="https://developer.apple.com/distribute/app-review/" description="developer.apple.com" />

  <Card title="App Review Guidelines" url="https://developer.apple.com/app-store/review/guidelines/" description="developer.apple.com" />
</Cards>


# Updates
Source: https://www.turbostarter.dev/docs/mobile/publishing/updates

After you publish your app to the stores, you can release updates to provide your users with new features and bug fixes.

TurboStarter offers two ready-to-use methods for updating your apps; we'll walk through both of them below.

## Over-the-air (OTA) updates

[Over-the-air (OTA) updates](https://en.wikipedia.org/wiki/Over-the-air_update) allow you to push updates to your app without requiring users to download a new version from the app store. This powerful feature enables rapid iteration and quick fixes.

![OTA updates](/images/docs/mobile/ota-updates.png)

TurboStarter integrates with [EAS Update](https://docs.expo.dev/eas-update/introduction/) to provide you with a seamless experience for managing your app updates. We also shipped a native notification that you can use to notify your users about the new updates available.

Then, to push your update straight to your users, you'll just need to run single command:

```bash
eas update --environment [environment] --channel [channel-name] --message "[message]"
```

The app will automatically download the update in the background and install it when your users are ready. You can also configure the update channel and message to be displayed to your users.

Feel free to check the [official documentation](https://docs.expo.dev/eas-update/getting-started/) for more information.

<Callout title="Working only for non-native changes" type="warn">
  OTA updates are **only supported for non-native changes**. If you need to update your app with a new native feature (or add a package that uses native dependencies), you'll need to submit a new version to the stores - see below for more details.
</Callout>

## Submitting a new version

The most traditional way to update your app is to submit a new version to the stores. This is the most reliable approach, but it can take some time for the new version to be approved and made available to users.

To submit a new version, update the version number in both your `package.json` file and your `app.config.ts` file.

```json
{
    ...
    "version": "1.0.0", // [!code --]
    "version": "1.0.1", // [!code ++]
    ...
}
```

Next, follow the exact same steps as [when you initially published your app](/docs/mobile/publishing/checklist). When you submit your app for review, be sure to include release notes for the new version.


# Push notifications
Source: https://www.turbostarter.dev/docs/mobile/push-notifications

TurboStarter ships with [expo-notifications](https://docs.expo.dev/versions/latest/sdk/notifications/) wired into the mobile app so you can request permission, obtain a [push token](https://docs.expo.dev/push-notifications/what-you-need-to-know/), handle foreground delivery, and send remote notifications through the [push notification service](https://docs.expo.dev/push-notifications/sending-notifications/).

Under the hood, delivery goes through [FCM](https://firebase.google.com/docs/cloud-messaging) on Android and [APNs](https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server) on iOS. You work with push tokens on the client and call the HTTP API (or an SDK) from your backend - no need to talk to FCM/APNs directly unless you want finer-grained control later.

![Push notification on a device](/images/docs/mobile/push-notifications/notification.png)

<Callout title="Development build required" type="warn">
  Remote push notifications **do not work in Expo Go** on recent SDKs. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (or a store build) on a physical device, an Android emulator with Google Play services, or an iOS Simulator on Xcode 14+ / macOS 13+ / iOS 16+.
</Callout>

## Architecture

Out of the box, TurboStarter includes:

* The `expo-notifications` config plugin (Android notification icon, default channel, accent color)
* A `NotificationsProvider` that sets the foreground handler and listens for receive / response events
* `useNotificationsPermissions` and `usePushToken` hooks
* A settings screen under **Dashboard → Settings → Notifications** to enable permissions, copy the push token, and fire a local test notification

Token persistence on your backend is intentionally left to you - see [Send from your backend](#send-from-your-backend).

## Configuration

Most of this is already set up. Adjust it when you change branding, channels, or your EAS / Firebase project.

### Config plugin

The config plugin registers native notification behavior. Customize the Android icon (96×96, white on transparent), default channel, and accent color in `app.config.ts`:

```ts title="apps/mobile/app.config.ts"
export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: [
    // ...
    [
      "expo-notifications",
      {
        icon: "./public/images/icon/android/notification.png",
        defaultChannel: "default",
        color: "#0D121C",
        // Optional: custom sounds, background remote notifications, etc.
        // sounds: ["./assets/notification.wav"],
        // enableBackgroundRemoteNotifications: false,
      },
    ],
  ],
});
```

After changing plugin options, rebuild the native app (`eas build` or a local prebuild). Config plugin changes are not applied by OTA updates alone.

<Card title="Notification config plugin" href="https://docs.expo.dev/versions/latest/sdk/notifications/#configurable-properties" description="docs.expo.dev" />

### Project ID

Push tokens are scoped to your EAS project. TurboStarter reads the ID from app config:

```ts
Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
```

Keep `extra.eas.projectId` in sync with your EAS project in `app.config.ts`. If the project ID is missing, fetching a push token will fail.

### Android: FCM credentials

Android delivery requires Firebase Cloud Messaging (FCM V1):

1. Create or reuse a [Firebase project](https://console.firebase.google.com/) and add an Android app with your package name (for example `com.turbostarter.core`).
2. Download `google-services.json` and point `android.googleServicesFile` at it in `app.config.ts` (already wired in TurboStarter).
3. In Firebase → **Project settings → Service accounts**, generate a new private key (JSON).
4. Upload that service account key to EAS as the **FCM V1** credential:

```bash
eas credentials
# Android → your build profile → Google Service Account
# → Manage Google Service Account Key for Push Notifications (FCM V1)
```

You can also upload the key in the [EAS dashboard](https://expo.dev) under **Project → Credentials**.

Do **not** commit the private service account JSON. `google-services.json` itself is public-facing and may be committed.

<Cards>
  <Card title="Add Android FCM V1 credentials" href="https://docs.expo.dev/push-notifications/fcm-credentials/" description="docs.expo.dev" />

  <Card title="Firebase project (TurboStarter)" href="/docs/mobile/installation/firebase" description="turbostarter.dev" />
</Cards>

### iOS: APNs credentials

A paid [Apple Developer](https://developer.apple.com/) account is required.

On the first iOS `eas build`, answer yes when EAS asks to set up push notifications and generate an Apple Push Notifications key. You can also manage credentials anytime:

```bash
eas credentials
# iOS → your build profile → Push Notifications
```

Register the physical device you use for development before building, if you haven't already.

## Permissions and tokens

### Permissions

Use `useNotificationsPermissions` to read and request notification permission. On Android it creates the `default` channel **before** requesting permission (required for the system prompt on Android 8+):

```tsx
import { useNotificationsPermissions } from "~/modules/common/hooks/notifications/use-notifications-permissions";

export const Example = () => {
  const { isGranted, isLoading, request, status } =
    useNotificationsPermissions();

  // Call request() after a contextual prompt, or wire isGranted into a Switch
};
```

Ask for permission in context (after the user understands the value), not on cold start. If the user denies, open system settings with `Linking.openSettings()` - the settings screen already does this when toggling off or when status is `denied`.

### Push token

Once permission is granted, `usePushToken` fetches a push token for the device:

```tsx
import { usePushToken } from "~/modules/common/hooks/notifications/use-push-token";

export const Example = () => {
  const { token, isLoading, getToken } = usePushToken();

  // Persist `token` to your backend when the user is authenticated
};
```

Store the token server-side, keyed by user (and ideally device / install). Refresh it when:

* the user grants permission
* the app comes to the foreground after a reinstall
* your backend reports `DeviceNotRegistered` for that token

### Foreground handling

`NotificationsProvider` sets a global handler so notifications can show a banner / appear in the notification center while the app is open, and registers listeners for receive and tap events:

```tsx title="apps/mobile/src/lib/providers/notifications.tsx"
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldPlaySound: false,
    shouldSetBadge: false,
    shouldShowBanner: true,
    shouldShowList: true,
  }),
});
```

Customize sound, badge, and deep-link behavior in this provider (or in the response listener) to match your product—for example navigate to a screen when `response.notification.request.content.data` contains a route.

## Test notifications

### In the app

Open **Settings → Notifications**, enable permission, then tap **Notify** to schedule a local notification (`trigger: null`). That validates the permission flow and foreground handler without going through a remote push service.

Copy the push token from the same screen when you are ready to test remote delivery.

### Push notifications tool

Paste the token into the [push notifications tool](https://expo.dev/notifications), set a title and body, and send. You should see the notification on the device within a few seconds.

![Push notifications tool](/images/docs/mobile/push-notifications/expo-tool.png)

<Callout type="info" title="Local vs remote">
  **Notify** in settings is a local notification on the device. The push tool (and your backend) send **remote** pushes through the push service → FCM/APNs. Both paths share the same permission and display configuration.
</Callout>

## Send from your backend

There is no built-in API route for storing tokens yet. When you are ready to send remotely:

1. Persist each user's push token(s) from the mobile app (for example after login or when `usePushToken` resolves).
2. Send messages to the push API from your server.

### Quick test with cURL

```bash
curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{
  "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
  "title": "Hello",
  "body": "World",
  "data": { "url": "/dashboard" }
}'
```

### Node SDK (recommended)

Prefer the official [`expo-server-sdk`](https://github.com/expo/expo-server-sdk-node) for batching, gzip, and connection limits:

```ts
import { Expo, type ExpoPushMessage } from "expo-server-sdk";

const expo = new Expo();

const messages: ExpoPushMessage[] = [
  {
    to: "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
    title: "Hello",
    body: "World",
    data: { url: "/dashboard" },
    sound: "default",
  },
];

const chunks = expo.chunkPushNotifications(messages);

for (const chunk of chunks) {
  const tickets = await expo.sendPushNotificationsAsync(chunk);
  // Store ticket IDs, then fetch receipts later
}
```

You can wire this into a [Hono route](/docs/web/api/overview), a [background job](/docs/web/background-tasks/overview), or any server path that already runs in TurboStarter.

<Card title="Send notifications with the push service" href="https://docs.expo.dev/push-notifications/sending-notifications/" description="docs.expo.dev" />

### Message payload tips

Push notifications let you engage users by sending alerts even when your app isn't open. Below are tips for crafting your notification messages and details about the payload fields used in push requests. Understanding these will help you create effective, user-friendly notifications.

| Field             | Notes                                                                        |
| ----------------- | ---------------------------------------------------------------------------- |
| `to`              | One token, or an array of tokens (same project)                              |
| `title` / `body`  | User-visible copy; localize on the server when possible                      |
| `data`            | Opaque JSON for deep links / actions—avoid secrets                           |
| `sound` / `badge` | Platform-dependent; keep defaults conservative                               |
| `channelId`       | Android only; must match a channel you created (TurboStarter uses `default`) |

Batch up to **100** messages per request. Prefer arrays over one HTTP call per user.

## Scale and reliability

The push service is free to use with a rate limit of about **600 notifications per second per project**. Past that, requests fail until you slow down - throttle and retry on your server.

Follow these patterns so delivery stays reliable as you grow:

1. **Limit concurrency** - the Node SDK caps concurrent connections (six by default). Don't open unbounded parallel `fetch` calls.
2. **Retry transient failures** - network errors, HTTP 429, and 5xx should use exponential backoff.
3. **Check push receipts** - a ticket `status: "ok"` only means the service accepted the message. After \~15 minutes, call [`getReceipts`](https://docs.expo.dev/push-notifications/sending-notifications/#push-receipts) with ticket IDs. Receipts expire after 24 hours.
4. **Drop dead tokens** - if a receipt (or ticket) returns `DeviceNotRegistered`, delete that token from your database until the device re-registers.
5. **Gzip large batches** - the Node SDK does this for you; raw HTTP clients can send gzip bodies to reduce upload size.
6. **One project per request** - do not mix push tokens from different EAS projects in the same send call.

For very large fan-out (marketing blasts, digests), queue work in a background job and process chunks over time rather than blocking a request handler.

<Callout type="warn" title="No SLA">
  The push service does not publish an SLA, and FCM/APNs can have outages. Design for eventual delivery and make critical alerts (billing, security) resilient with email or in-app fallbacks when needed.
</Callout>

## Customization

### Android channels

Channels control importance, sound, and whether the user can mute a category. TurboStarter creates a single `default` channel. Add more for transactional vs marketing traffic:

```ts
await Notifications.setNotificationChannelAsync("marketing", {
  name: "Marketing",
  importance: Notifications.AndroidImportance.DEFAULT,
});

await Notifications.setNotificationChannelAsync("account", {
  name: "Account",
  importance: Notifications.AndroidImportance.MAX,
});
```

Pass `channelId` in the push payload so Android routes the message correctly. Keep channel IDs stable—users' mute preferences stick to the ID.

### Deep links and tap handling

Extend the response listener in `NotificationsProvider` (or a dedicated hook) to navigate when the user taps a notification:

```ts
Notifications.addNotificationResponseReceivedListener((response) => {
  const url = response.notification.request.content.data?.url;
  // router.push(url) — validate against an allowlist
});
```

Only put non-sensitive identifiers in `data`; load full content from your API after open. For schemes, Universal Links, and App Links setup, see [Deep linking](/docs/mobile/deep-linking).

### Direct FCM / APNs

If you need to talk to FCM or APNs yourself (custom features, existing notification infra), call `Notifications.getDevicePushTokenAsync()` instead of `getExpoPushTokenAsync()` and send from your own integration. The permission and display APIs stay the same.

<Card title="Send notifications with FCM and APNs" href="https://docs.expo.dev/push-notifications/sending-notifications-custom/" description="docs.expo.dev" />

### Multiple environments

Use separate EAS projects or application IDs for development / preview / production so tokens and credentials never cross environments. See [Multiple environments](/docs/mobile/recipes/multiple-environments).

## Troubleshooting

| Symptom                           | What to check                                                                                    |
| --------------------------------- | ------------------------------------------------------------------------------------------------ |
| No token / `Project ID not found` | `extra.eas.projectId` in `app.config.ts` matches your EAS project                                |
| Android never prompts             | Create the notification channel before `requestPermissionsAsync` (already done in the hook)      |
| Remote push fails on Android      | FCM V1 service account uploaded to EAS; `googleServicesFile` set; package name matches Firebase  |
| Remote push fails on iOS          | APNs key via `eas credentials`; paid Apple team; rebuild after enabling push                     |
| Works in debug, not release       | Production credentials must be your own FCM/APNs keys                                            |
| Token accepted but no banner      | Foreground handler flags; Android channel importance; Do Not Disturb / app notification settings |

<Cards>
  <Card title="Push notifications setup" href="https://docs.expo.dev/push-notifications/push-notifications-setup/" description="docs.expo.dev" />

  <Card title="Push notifications FAQ" href="https://docs.expo.dev/push-notifications/faq/" description="docs.expo.dev" />
</Cards>


# Integrate AI Kit
Source: https://www.turbostarter.dev/docs/mobile/recipes/ai-kit

The mobile app is a client of the same AI backend as web. Integrate the selected AI template on the server first, then port its Expo route and UI into Core Kit.

<Callout type="info" title="Complete the backend first">
  Follow the [web AI Kit recipe](/docs/web/recipes/ai-kit) through API, database, environment, and migration setup before changing the mobile app. Provider SDKs and secret keys must stay on that backend.
</Callout>

AI Kit includes mobile implementations for chat, image generation, RAG, text to speech, and voice. Each one calls the shared `/api/ai/*` routes, but some require native modules that cannot run in Expo Go.

## Pick the native surface

| Template       | Expo source                     | Extra native work                                          |
| -------------- | ------------------------------- | ---------------------------------------------------------- |
| Chat           | `(apps)/chat`, `modules/chat`   | Attachments and an Expo-compatible streaming fetch         |
| Image          | `(apps)/image`, `modules/image` | Image picker, gallery, and sharing                         |
| RAG            | `(apps)/rag`, `modules/rag`     | Document picker, PDF renderer, blob utility, patches       |
| Text to speech | `(apps)/tts`, `modules/tts`     | Audio playback                                             |
| Voice          | `(apps)/voice`, `modules/voice` | LiveKit, WebRTC, microphone permissions, development build |

Start with chat or image if you want the smallest native integration. Add RAG or voice only after a development build works reliably.

<Steps>
  <Step>
    ## Port one route and module

    Copy the selected files from the AI repository into the Core mobile app. For chat:

    ```text
    ../ai/apps/mobile/src/app/(apps)/chat
    ../ai/apps/mobile/src/modules/chat
    ```

    The feature also imports pieces from `../ai/apps/mobile/src/modules/common`. Port only the components and hooks reached by those imports. Adapt the screen to Core's existing authenticated route group, drawer or tab navigation, theme, and `@workspace/ui-mobile` components instead of replacing the application layout.

    A dashboard destination keeps the feature behind Core auth:

    ```text
    apps/mobile/src/app/dashboard/(user)/chat
    apps/mobile/src/modules/ai/chat
    ```

    Core already has `dashboard/(user)/ai.tsx` wired to the flat `/api/ai/chat` demo endpoint. Replace that screen or update its transport when you switch the backend to AI Kit's nested routes.
  </Step>

  <Step>
    ## Keep the Core session on streaming requests

    Core's mobile API client already sends the Better Auth cookie and the `MOBILE` platform header. Keep that client as the source of URLs and session state.

    AI Kit chat uses an Expo-compatible transport because React Native does not use the browser's cookie jar:

    ```tsx title="apps/mobile/src/modules/ai/chat/composer/hooks/use-composer.tsx"
    new DefaultChatTransport({
      fetch: expoFetch as unknown as typeof globalThis.fetch,
      api: api.ai.chat.chats.$url().toString(),
      credentials: "omit",
      headers: () => ({
        cookie: `${config.cookie}=${useI18nConfig.getState().config.locale};${authClient.getCookie()}`,
        "x-client-platform": Platform.MOBILE,
        origin: Linking.createURL(""),
      }),
    });
    ```

    Keep Core's locale cookie, session cookie, platform header, and native origin on the streaming transport. Do not copy AI Kit's anonymous-session bootstrap or create a second auth client.

    If a protected AI route returns `401`, verify the API base URL, cookie header, and Core server's trusted mobile origin before changing the AI code.
  </Step>

  <Step>
    ## Merge dependencies and native configuration

    Use `../ai/apps/mobile/package.json` as the dependency manifest for the selected template. Add only the packages imported by the files you port, using the versions compatible with Core's current Expo SDK.

    For features with native code, also inspect and merge:

    ```text
    ../ai/apps/mobile/app.config.ts
    ../ai/apps/mobile/metro.config.js
    ../ai/patches
    ../ai/pnpm-workspace.yaml
    ```

    Do not replace those Core files. Merge the relevant config plugin, Metro transformer, `allowBuilds` entry, catalog version, or patch.

    RAG needs the PDF and blob configuration used by its preview. Voice needs the LiveKit Expo plugin, WebRTC setup, and microphone permission strings. Rebuild the native app after either change:

    ```bash
    pnpm --filter mobile ios
    # or
    pnpm --filter mobile android
    ```
  </Step>

  <Step>
    ## Connect navigation and shared state

    Register the screen in Core's existing Expo Router layout. Preserve the providers already mounted by the Core app, including authentication, TanStack Query, theme, safe areas, analytics, and monitoring.

    The copied AI UI should read:

    * Session state from Core's `authClient`
    * URLs from Core's typed Hono client
    * Shared response and request types from the ported AI packages
    * Product copy from Core's `@workspace/i18n`
    * Plan access or credits from the same backend policy used by web

    Do not calculate entitlement or remaining usage only in the mobile client. The server must reject unauthorized or over-limit requests before calling a paid provider.
  </Step>

  <Step>
    ## Verify on a device

    ```bash
    pnpm --filter mobile dev
    ```

    Use a simulator or development build and check:

    1. A signed-in Core user can open the AI screen.
    2. Streaming continues after multiple chunks and can be cancelled.
    3. Reloaded history matches the web app for the same account.
    4. Signed-out and over-limit requests fail without invoking a provider.
    5. Backgrounding and resuming the app does not duplicate a generation.
    6. File, camera, audio, and microphone permissions appear only for features that need them.
  </Step>
</Steps>

## Keep the backend shared

Do not create a mobile-only provider route or database. Web and mobile should use the same Hono router, AI package, user ownership checks, storage paths, and usage policy. The platform-specific code ends at transport, navigation, permissions, and native presentation.

<Cards>
  <Card title="Web AI Kit integration" description="Port the AI packages, schemas, Hono routers, and provider configuration first." href="/docs/web/recipes/ai-kit" />

  <Card title="Mobile API client" description="Review Core's cookie, platform header, and base URL setup." href="/docs/mobile/api/client" />

  <Card title="Development builds" description="Configure native modules that are not available in Expo Go." href="/docs/mobile/installation/development" />
</Cards>


# Build a production feature
Source: https://www.turbostarter.dev/docs/mobile/recipes/build-a-feature

Mobile features share the same backend as web: **one Drizzle schema, one Hono router, one set of Zod types.** What changes is how you authenticate requests, present lists, and compose UI with `@workspace/ui-mobile`.

This recipe picks up the **feedback widget** from the [web feature guide](/docs/web/recipes/build-a-feature). Complete the database and API steps there first, then return here for the native layer.

<Callout title="What you'll ship on mobile">
  * `modules/feedback/lib/api.ts` mirroring the web TanStack Query layer
  * A settings-screen entry that opens a **Bottom Sheet** form
  * Cookie-based auth headers so `POST /api/feedback` recognizes the signed-in user
  * Translations via the same `feedback` i18n namespace
</Callout>

## Web vs mobile

| Concern         | Web                        | Mobile                                              |
| --------------- | -------------------------- | --------------------------------------------------- |
| API client      | `credentials: "include"`   | Manual `cookie` header via `authClient.getCookie()` |
| Platform header | `WEB-CLIENT`               | `MOBILE`                                            |
| Overlay UI      | `Modal`                    | `BottomSheet`                                       |
| Lists           | `queryOptions` + DataTable | `infiniteQueryOptions` + `FlatList`                 |
| Navigation      | Next.js `router.replace`   | Expo Router `router.push` / `replace`               |
| UI kit          | `@workspace/ui-web`        | `@workspace/ui-mobile`                              |

Organizations on mobile follow the same split. Compare `apps/mobile/src/modules/organization/` with the web module when you need a bigger reference.

## Mobile architecture

The settings screen opens `FeedbackBottomSheet`, which calls `lib/api.ts`. That module sends `POST /api/feedback` with the session cookie and `MOBILE` platform header.

<Steps>
  <Step>
    ## Confirm the API client

    The mobile client lives in `apps/mobile/src/lib/api/index.tsx`. It forwards locale + session cookies and tags requests as mobile:

    ```tsx title="apps/mobile/src/lib/api/index.tsx"
    export const { api } = hc<AppRouter>(getBaseUrl(), {
      headers: () => ({
        cookie: `${config.cookie}=${useI18nConfig.getState().config.locale};${authClient.getCookie()}`,
        "x-client-platform": Platform.MOBILE,
      }),
      init: {
        credentials: "omit",
      },
    });
    ```

    <Callout type="warn" title="API base URL">
      `getBaseUrl()` must point at your running web API in dev and production. A mismatch here is the most common reason mobile mutations silently fail. See [Using API client](/docs/mobile/api/client).
    </Callout>
  </Step>

  <Step>
    ## Add the feature API module

    Copy the web pattern: `mutationOptions`, `handle()`, and a response schema:

    ```ts title="apps/mobile/src/modules/feedback/lib/api.ts"
    import { mutationOptions } from "@tanstack/react-query";

    import { createFeedbackResponseSchema } from "@workspace/api/schema";
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api";

    const KEY = "feedback";

    export const feedback = {
      mutations: {
        create: mutationOptions({
          mutationKey: [KEY, "create"],
          mutationFn: (json: { message: string; type: string; email?: string }) =>
            handle(api.feedback.$post, {
              schema: createFeedbackResponseSchema,
            })({ json }),
        }),
      },
    };
    ```

    For **read** endpoints (e.g. listing the user's past feedback), prefer `infiniteQueryOptions` and a `FlatList`. See `apps/mobile/src/modules/organization/members/list/members-list.tsx` for the infinite-scroll pattern.
  </Step>

  <Step>
    ## Build the Bottom Sheet form

    Mobile overlays use Bottom Sheets instead of modals. The form wiring matches web: `standardSchemaResolver`, `Controller`, and `Field` components.

    ```tsx title="apps/mobile/src/modules/feedback/feedback-bottom-sheet.tsx"
    import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
    import { useMutation } from "@tanstack/react-query";
    import { Controller, useForm } from "react-hook-form";
    import { Alert, View } from "react-native";

    import { createFeedbackInputSchema } from "@workspace/api/schema";
    import { useTranslation } from "@workspace/i18n";
    import {
      BottomSheet,
      BottomSheetCloseTrigger,
      BottomSheetContent,
      BottomSheetHeader,
      BottomSheetOpenTrigger,
      BottomSheetScrollView,
      BottomSheetTitle,
    } from "@workspace/ui-mobile/bottom-sheet";
    import { Button } from "@workspace/ui-mobile/button";
    import { Field, FieldError, FieldLabel } from "@workspace/ui-mobile/field";
    import { Input } from "@workspace/ui-mobile/input";
    import { Text } from "@workspace/ui-mobile/text";
    import { Textarea } from "@workspace/ui-mobile/textarea";

    import { authClient } from "~/lib/auth";

    import { feedback } from "./lib/api";

    import type { CreateFeedbackInput } from "@workspace/api/schema";

    export const FeedbackBottomSheet = ({
      children,
    }: {
      children: React.ReactNode;
    }) => {
      const { t } = useTranslation(["common", "feedback"]);
      const session = authClient.useSession();
      const user = session.data?.user;

      const create = useMutation({
        ...feedback.mutations.create,
        onSuccess: () => {
          form.reset();
          Alert.alert(t("feedback:success"));
        },
        onError: () => Alert.alert(t("feedback:error")),
      });

      const form = useForm<CreateFeedbackInput>({
        resolver: standardSchemaResolver(createFeedbackInputSchema),
        defaultValues: { message: "", type: "general", email: "" },
      });

      return (
        <BottomSheet>
          <BottomSheetOpenTrigger>{children}</BottomSheetOpenTrigger>
          <BottomSheetContent>
            <BottomSheetHeader>
              <BottomSheetTitle>{t("feedback:title")}</BottomSheetTitle>
            </BottomSheetHeader>
            <BottomSheetScrollView>
              <View className="gap-4 p-4">
                {!user && (
                  <Controller
                    name="email"
                    control={form.control}
                    render={({ field, fieldState }) => (
                      <Field data-invalid={fieldState.invalid}>
                        <FieldLabel>{t("common:email")}</FieldLabel>
                        <Input {...field} keyboardType="email-address" />
                        {fieldState.invalid && (
                          <FieldError errors={[fieldState.error]} />
                        )}
                      </Field>
                    )}
                  />
                )}

                <Controller
                  name="message"
                  control={form.control}
                  render={({ field, fieldState }) => (
                    <Field data-invalid={fieldState.invalid}>
                      <FieldLabel>{t("feedback:message.label")}</FieldLabel>
                      <Textarea {...field} />
                      {fieldState.invalid && (
                        <FieldError errors={[fieldState.error]} />
                      )}
                    </Field>
                  )}
                />

                <Button
                  onPress={form.handleSubmit((data) => create.mutate(data))}
                  disabled={create.isPending}
                >
                  <Text>{t("feedback:submit")}</Text>
                </Button>

                <BottomSheetCloseTrigger />
              </View>
            </BottomSheetScrollView>
          </BottomSheetContent>
        </BottomSheet>
      );
    };
    ```

    Compare with `CreateOrganizationBottomSheet` in `apps/mobile/src/modules/organization/create-organization.tsx`. Same structure, different schema and success navigation.
  </Step>

  <Step>
    ## Add a screen route

    Expose the sheet from an existing settings screen or add a dedicated route under Expo Router.

    ```tsx title="apps/mobile/src/app/dashboard/(user)/settings/feedback.tsx"
    import { useTranslation } from "@workspace/i18n";
    import { Button } from "@workspace/ui-mobile/button";
    import { Text } from "@workspace/ui-mobile/text";

    import { FeedbackBottomSheet } from "~/modules/feedback/feedback-bottom-sheet";

    export default function FeedbackScreen() {
      const { t } = useTranslation("feedback");

      return (
        <FeedbackBottomSheet>
          <Button variant="outline">
            <Text>{t("button")}</Text>
          </Button>
        </FeedbackBottomSheet>
      );
    }
    ```

    Register the screen in your settings stack `_layout.tsx` the same way other settings rows are declared.
  </Step>

  <Step>
    ## Reuse translations

    Mobile pulls from the same `packages/i18n` files as web. If you added `feedback.json` in the web recipe, mobile strings work immediately. Pass the namespace to `useTranslation(["common", "feedback"])`.
  </Step>

  <Step>
    ## Test on device

    **Simulator / device**

    1. Run Metro: `pnpm --filter mobile dev`
    2. Sign in on mobile so the API receives a valid session cookie
    3. Open the feedback screen and submit
    4. Confirm the row in Postgres (same table as web)
    5. Success and error states use `Alert.alert`, same pattern as account settings

    **Maestro E2E (optional)**

    Add a flow under `apps/mobile/e2e/` that taps the feedback entry and asserts the success alert. See [E2E testing](/docs/mobile/tests/e2e).
  </Step>
</Steps>

## File structure

<Files>
  <Folder name="apps/mobile/src - Mobile app" defaultOpen>
    <Folder name="modules/feedback - Feature UI + client API" defaultOpen>
      <File name="feedback-bottom-sheet.tsx - Bottom sheet form" />

      <Folder name="lib - TanStack Query layer" defaultOpen>
        <File name="api.ts - Mutations and query keys" />
      </Folder>
    </Folder>

    <Folder name="app/dashboard/(user)/settings - Expo Router screens" defaultOpen>
      <File name="feedback.tsx - Settings entry point" />
    </Folder>
  </Folder>
</Files>

## Checklist

| Layer          | Done when…                                                  |
| -------------- | ----------------------------------------------------------- |
| **API module** | `feedback.mutations.create` succeeds from a device          |
| **Auth**       | Signed-in submissions set `userId`; guests can pass `email` |
| **UI**         | Bottom sheet opens, validates, shows native alert           |
| **Navigation** | Screen reachable from settings (or your chosen entry point) |
| **i18n**       | No hard-coded strings in the component                      |

## Related guides

<Cards>
  <Card title="Web feature recipe" description="Database schema, Hono router, and full web UI. Start here if you haven't built the API yet." href="/docs/web/recipes/build-a-feature" />

  <Card title="Organizations on mobile" description="Infinite lists, org switching, and Better Auth mutations in one module." href="/docs/mobile/organizations/overview" />

  <Card title="API client" description="Headers, cookies, and TanStack Query on Expo." href="/docs/mobile/api/client" />
</Cards>


# Feature-based access
Source: https://www.turbostarter.dev/docs/mobile/recipes/feature-based-access

Mobile apps sell through the App Store and Google Play, which means entitlements often arrive from **RevenueCat** or **Superwall** before your own API summary catches up. TurboStarter merges both sources so `getActivePlan()` returns the same plan id your web app uses — and you can gate screens, API calls, and paywalls with one mental model.

This recipe walks through feature-based access on **Expo / React Native**: defining features, reading entitlements, enforcing limits, and showing native upgrade paths.

<Callout title="TL;DR">
  1. Share feature keys via `packages/billing/shared/src/config/features.ts` (same as web).
  2. Read store entitlements with `useCustomer()` from `@workspace/billing-mobile`.
  3. Merge entitlements into `getActivePlan()` alongside `billing.queries.summary`.
  4. Gate UI locally; still enforce on the API for anything security-sensitive.
  5. Route upgrades through your paywall provider or the native subscription management sheet.
</Callout>

## Web vs. mobile billing

| Concern                    | Web                                | Mobile                                             |
| -------------------------- | ---------------------------------- | -------------------------------------------------- |
| Checkout                   | Stripe, Lemon Squeezy, Polar, etc. | App Store / Play Store via RevenueCat or Superwall |
| Entitlements               | Subscriptions + orders in Postgres | `useCustomer().entitlements` + API summary         |
| Upgrade UX                 | Pricing page or billing portal     | Paywall, native manage-subscriptions sheet         |
| Source of truth for access | API + billing config               | **API** (client checks are UX only)                |

The billing **config** (`@workspace/billing`) is shared. Feature constants, plan tiers, and `checkPlanLimit()` work identically across platforms.

Read the [web feature-based access recipe](/docs/web/recipes/feature-based-access) first for the shared foundation — `features.ts`, `isFeatureAvailable()`, and billing config. This page focuses on what changes on mobile.

## The mobile entitlement flow

```
App stores (RevenueCat / Superwall)
        │
        ▼
 useCustomer() entitlements ──┐
                              ├──► getActivePlan() ──► isFeatureAvailable()
 billing.queries.summary ─────┘              │
                                              ├──► Paywall or feature UI
                                              │
 billing.queries.summary ──────────────────────┴──► API enforceFeatureAvailable()
        ▲
        └── webhooks sync purchases to database
```

On launch, `BillingProvider` identifies the user with the mobile billing SDK. Purchases update entitlements locally; webhooks sync the same data to your database for API enforcement.

<Steps>
  <Step>
    ## Share feature definitions with web

    Mobile does not get a separate feature list. Edit `packages/billing/shared/src/config/features.ts` and the billing config once — both apps import `@workspace/billing`.

    If you have not added `isFeatureAvailable()` yet, follow the web recipe — add it to `packages/billing/shared/src/utils/plan.ts` and export it through `@workspace/billing`.

    Mobile variants in the config use store product identifiers as variant `id` values. Those ids must match RevenueCat offerings or Superwall products so `findPlanByVariantId()` resolves the correct plan.
  </Step>

  <Step>
    ## Read entitlements from the billing provider

    `useCustomer()` wraps RevenueCat (or Superwall) and normalizes entitlements:

    ```tsx title="packages/billing/mobile/src/providers/revenuecat/hooks/use-customer.tsx"
    const entitlements = useMemo(() => {
      return Object.values(customer.data?.entitlements.all ?? {}).map(
        (entitlement) => ({
          id: entitlement.identifier.toLowerCase(),
          active: entitlement.isActive,
          variantId: entitlement.productIdentifier,
        }),
      );
    }, [customer.data]);
    ```

    Identify users after auth so purchases attach to the right account:

    ```tsx title="apps/mobile/src/lib/providers/billing.tsx"
    const { identify, reset } = useCustomer();

    useEffect(() => {
      if (session.data?.user.id) {
        identify(session.data.user.id, { email: session.data.user.email });
      } else {
        reset();
      }
    }, [session.data?.user.id]);
    ```

    Configure [RevenueCat](/docs/mobile/billing/revenuecat) or [Superwall](/docs/mobile/billing/superwall) so entitlement identifiers align with plan ids or variant ids in your billing config.
  </Step>

  <Step>
    ## Merge entitlements with the API summary

    The account switcher shows the canonical merge pattern — always pass **both** data sources into `getActivePlan()`:

    ```tsx title="apps/mobile/src/modules/organization/account-switcher.tsx"
    import { getActivePlan } from "@workspace/billing";
    import { useCustomer } from "@workspace/billing-mobile";

    const { entitlements } = useCustomer();
    const summary = useQuery(
      billing.queries.summary.get(
        activeOrganization.data?.id ?? session.data?.user.id,
      ),
    );

    const activePlan = getActivePlan(
      summary.data?.map((customer) => ({
        ...customer,
        entitlements,
      })),
    );
    ```

    Why merge?

    * Store purchases may activate before webhooks finish syncing.
    * Web subscriptions (e.g. user bought on desktop) still appear in `summary`.
    * `getActivePlan()` picks the **highest** plan across all sources.

    Extract a hook so every screen uses the same logic:

    ```tsx title="apps/mobile/src/modules/billing/hooks/use-active-plan.ts"
    import { useQuery } from "@tanstack/react-query";
    import { getActivePlan, isFeatureAvailable } from "@workspace/billing";
    import { useCustomer } from "@workspace/billing-mobile";

    import { billing } from "~/modules/billing/lib/api";

    import type { Feature } from "@workspace/billing";

    export const useBillingAccess = (referenceId: string) => {
      const { entitlements } = useCustomer();
      const summary = useQuery({
        ...billing.queries.summary.get(referenceId),
        enabled: !!referenceId,
      });

      const mergedSummary = summary.data?.map((customer) => ({
        ...customer,
        entitlements,
      }));

      const activePlan = getActivePlan(mergedSummary);

      const hasFeature = (feature: Feature) =>
        isFeatureAvailable(mergedSummary ?? [], feature);

      return {
        activePlan,
        hasFeature,
        isLoading: summary.isLoading,
        summary: mergedSummary,
      };
    };
    ```
  </Step>

  <Step>
    ## Gate screens and actions

    ### Full-screen gate

    Show a paywall or upgrade screen when the feature is missing:

    ```tsx title="apps/mobile/src/app/dashboard/teams.tsx"
    import { FEATURES, BillingPlan } from "@workspace/billing";
    import { Text } from "@workspace/ui-mobile/text";
    import { Button } from "@workspace/ui-mobile/button";

    import { useBillingAccess } from "~/modules/billing/hooks/use-active-plan";
    import { openPaywall } from "~/modules/billing/paywall";

    export default function TeamsScreen() {
      const { hasFeature, isLoading } = useBillingAccess(referenceId);

      if (isLoading) {
        return <TeamsSkeleton />;
      }

      if (!hasFeature(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION)) {
        return (
          <View className="flex-1 items-center justify-center gap-4 p-6">
            <Text className="text-center text-lg font-semibold">
              Teams are available on Premium
            </Text>
            <Button onPress={() => openPaywall("teams")}>
              <Text>View plans</Text>
            </Button>
          </View>
        );
      }

      return <TeamsList />;
    }
    ```

    ### Inline gate

    Disable buttons instead of hiding entire tabs when you want discovery:

    ```tsx
    <Button
      disabled={!hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS)}
      onPress={
        hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS)
          ? exportReport
          : () => openPaywall("advanced_reports")
      }
    >
      <Text>Export report</Text>
    </Button>
    ```

    ### Feature list on billing screens

    Reuse `FeaturesList` to show what each plan includes:

    ```tsx title="apps/mobile/src/modules/billing/features-list.tsx"
    import { findPlanById } from "@workspace/billing";

    export const FeaturesList = ({ planId }: { planId: BillingPlan }) => {
      const plan = findPlanById(planId);
      // renders translated feature.* keys
    };
    ```
  </Step>

  <Step>
    ## Show paywalls and manage subscriptions

    ### RevenueCat / Superwall paywall

    Trigger the provider paywall when users tap a locked feature. Configure the paywall in the provider dashboard — no app store review for copy changes when using Superwall.

    Map paywall placements to feature constants in one file so marketing and engineering share vocabulary:

    ```ts title="apps/mobile/src/modules/billing/paywall.ts"
    export const PAYWALL_PLACEMENTS = {
      teams: "teams_upgrade",
      advanced_reports: "reports_upgrade",
    } as const;

    export const openPaywall = async (
      placement: keyof typeof PAYWALL_PLACEMENTS,
    ) => {
      // RevenueCat: Purchases.presentPaywall()
      // Superwall: Superwall.shared.register(placement)
    };
    ```

    ### Native subscription management

    For existing subscribers, `useCustomer().linkToPortal()` opens the App Store or Play Store subscription management UI:

    ```tsx title="apps/mobile/src/modules/billing/portal/native-portal-link.tsx"
    const { linkToPortal } = useCustomer();

    <Button
      onPress={() => linkToPortal({ store: MobileStore.APP_STORE, variantId })}
    >
      <Text>Manage subscription</Text>
    </Button>;
    ```
  </Step>

  <Step>
    ## Enforce limits on mobile mutations

    Limits use the same `checkPlanLimit()` helper as web. Call it before creating resources — ideally in the API mutation the mobile app already uses via tRPC/Hono:

    ```ts
    const { allowed, remaining } = checkPlanLimit({
      id: activePlan,
      key: "projects",
      currentUsage: projectCount,
    });

    if (!allowed) {
      throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
        code: "error.limitReached",
      });
    }
    ```

    On the client, show remaining capacity in settings or billing screens so users upgrade before hitting a hard wall.
  </Step>

  <Step>
    ## Keep API enforcement in sync

    Mobile clients can be patched, jailbroken, or run offline caches. **Always** duplicate feature checks on the server using `enforceFeatureAvailable()` from the [web recipe](/docs/web/recipes/feature-based-access).

    The mobile app should treat `402` / `error.upgradeRequired` responses as a signal to open the paywall:

    ```tsx
    onError: (error) => {
      if (error.code === "error.upgradeRequired") {
        openPaywall("default");
        return;
      }
      toast.error(error.message);
    },
    ```
  </Step>

  <Step>
    ## Test on real devices

    1. **Sandbox purchase** — buy a test subscription in App Store Connect / Play Console sandbox.
    2. **Restore purchases** — verify `useCustomer()` entitlements refresh after `restorePurchases()`.
    3. **Cross-platform** — purchase on web, open mobile, confirm `summary` + entitlements resolve to Premium.
    4. **Expiration** — cancel in sandbox, wait for expiry, confirm gates reappear.
    5. **Organization billing** — switch org in account switcher; `referenceId` should change and plan should follow org purchases.
  </Step>
</Steps>

## Checklist

* Feature keys live in `features.ts` and billing config
* RevenueCat/Superwall entitlement ids match billing variant ids
* `useBillingAccess` merges entitlements + summary everywhere
* Locked features show paywall, not a crash or blank screen
* API routes use `enforceFeatureAvailable()`
* Limits use `checkPlanLimit()` on the server
* Restore purchases tested on iOS and Android

<Cards>
  <Card title="Web feature-based access" href="/docs/web/recipes/feature-based-access" description="Shared features.ts, isFeatureAvailable, and API middleware." />

  <Card title="RevenueCat" href="/docs/mobile/billing/revenuecat" description="Products, entitlements, and webhooks for iOS and Android." />

  <Card title="Superwall" href="/docs/mobile/billing/superwall" description="No-code paywalls and A/B tests without app releases." />

  <Card title="Billing configuration" href="/docs/web/billing/configuration" description="Plans, limits, and variant ids shared across platforms." />
</Cards>


# Multiple environments
Source: https://www.turbostarter.dev/docs/mobile/recipes/multiple-environments

Mobile apps need a little more care than web apps because values can be baked into the native binary or JavaScript update. Keep each environment isolated so test builds never talk to production services by accident.

The safe pattern is:

* use `development`, `preview`, and `production` as your EAS environments
* expose only app-safe values with `EXPO_PUBLIC_`
* keep backend secrets on the web/API side
* use different bundle identifiers for non-production builds

<Callout title="Public means public" type="warn">
  Every `EXPO_PUBLIC_` value can be read from the compiled app. Use it for URLs, feature flags, and public project keys only. Never put secret API keys or private tokens in the mobile app.
</Callout>

<Steps>
  <Step>
    ## Choose your environment values

    Start with the variables the app needs to know at runtime:

    ```dotenv title="apps/mobile/.env.example"
    EXPO_PUBLIC_APP_ENV="development"
    EXPO_PUBLIC_SITE_URL="http://localhost:3000"
    EXPO_PUBLIC_DEFAULT_LOCALE="en"
    EXPO_PUBLIC_AUTH_PASSWORD="true"
    EXPO_PUBLIC_THEME_MODE="system"
    EXPO_PUBLIC_THEME_COLOR="orange"
    ```

    Use the same names for preview and production. Only the values should change.
  </Step>

  <Step>
    ## Use local files for development

    For local development, create `apps/mobile/.env.local`:

    ```dotenv title="apps/mobile/.env.local"
    EXPO_PUBLIC_APP_ENV="development"
    EXPO_PUBLIC_SITE_URL="http://192.168.1.10:3000"
    EXPO_PUBLIC_DEFAULT_LOCALE="en"
    EXPO_PUBLIC_AUTH_PASSWORD="true"
    EXPO_PUBLIC_THEME_MODE="system"
    EXPO_PUBLIC_THEME_COLOR="orange"
    ```

    Use your machine's LAN IP when testing on a physical device, so the app can reach your local web/API server.

    <Callout title="Avoid NODE_ENV switching" type="info">
      Expo recommends not using `NODE_ENV` to switch app environments. For EAS builds and updates, use EAS environments instead.
    </Callout>
  </Step>

  <Step>
    ## Create EAS environments

    Create the same variables in EAS for each environment:

    ```bash
    cd apps/mobile

    eas env:create --environment development --name EXPO_PUBLIC_SITE_URL --value http://192.168.1.10:3000 --visibility plaintext
    eas env:create --environment preview --name EXPO_PUBLIC_SITE_URL --value https://staging.example.com --visibility plaintext
    eas env:create --environment production --name EXPO_PUBLIC_SITE_URL --value https://example.com --visibility plaintext
    ```

    Repeat for each `EXPO_PUBLIC_` value the app needs.

    If you want to test an EAS environment locally, pull it into `.env.local`:

    ```bash
    eas env:pull --environment preview
    ```
  </Step>

  <Step>
    ## Map build profiles to environments

    Make sure each EAS build profile points to the right environment:

    ```json title="apps/mobile/eas.json"
    {
      "build": {
        "development": {
          "developmentClient": true,
          "distribution": "internal",
          "environment": "development",
          "channel": "development"
        },
        "preview": {
          "distribution": "internal",
          "environment": "preview",
          "channel": "preview"
        },
        "production": {
          "environment": "production",
          "channel": "production"
        }
      }
    }
    ```

    Then build with the matching profile:

    ```bash
    pnpm --filter mobile eas build --profile preview
    pnpm --filter mobile eas build --profile production
    ```
  </Step>

  <Step>
    ## Keep app variants separate

    Use a different app name, iOS bundle identifier, and Android package for non-production builds:

    ```ts title="apps/mobile/app.config.ts"
    const appEnv = process.env.EXPO_PUBLIC_APP_ENV ?? "development";
    const isProduction = appEnv === "production";

    export default {
      name: isProduction ? "Acme" : `Acme (${appEnv})`,
      slug: "acme",
      ios: {
        bundleIdentifier: isProduction ? "com.acme.app" : `com.acme.app.${appEnv}`,
      },
      android: {
        package: isProduction ? "com.acme.app" : `com.acme.app.${appEnv}`,
      },
    };
    ```

    This lets you install preview and production builds on the same device, use separate native credentials, and avoid mixing push notifications or purchases.
  </Step>

  <Step>
    ## Match updates to environments

    When publishing an update, pass the environment and channel together:

    ```bash
    pnpm --filter mobile eas update --environment preview --channel preview --message "Preview update"
    pnpm --filter mobile eas update --environment production --channel production --message "Production update"
    ```

    Before release, verify:

    * `EXPO_PUBLIC_SITE_URL` points to the matching web/API environment
    * OAuth redirect URLs are registered for that build variant
    * Sentry/PostHog/RevenueCat/Superwall keys match the environment
    * app store sandbox settings are used outside production
    * no private secrets are present in `EXPO_PUBLIC_` values
  </Step>
</Steps>

## Useful references

* [Environment variables](/docs/mobile/configuration/environment-variables)
* [Expo environment variables](https://docs.expo.dev/guides/environment-variables/)
* [EAS environment variables](https://docs.expo.dev/eas/environment-variables/)


# Onboarding flow
Source: https://www.turbostarter.dev/docs/mobile/recipes/onboarding

TurboStarter ships a **first-run setup** on mobile: welcome carousel → auth → multi-step wizard → optional paywall → dashboard. Progress is stored locally so users can resume after killing the app.

This recipe shows how that flow works, how to add your own steps, and how to turn the skippable paywall into a **hard paywall** when you need payment before the product.

<Callout title="TL;DR">
  1. Landing logic lives in `apps/mobile/src/app/index.tsx` - session + unfinished steps decide the next screen.
  2. Wizard shell + step list live in `apps/mobile/src/app/(setup)/steps/_layout.tsx`.
  3. Soft paywall (default): `usePaywall({ trigger: "onboarding" })` with a Skip button.
  4. Hard paywall: remove Skip, only call `goNext()` after a successful purchase, and re-check on every launch.
  5. For cross-device completion, mirror a server flag (see the [web recipe](/docs/web/recipes/onboarding)).
</Callout>

## What you get out of the box?

TurboStarter mobile includes ready-made onboarding screens - welcome, auth, setup steps, and optional paywall - so users land in the right place on first run. You can customize the flow, reorder or add steps, and progress is saved locally for seamless resumes.

Below, see which screens come standard and how the routing works.

| Screen   | Path              | File                                             |
| -------- | ----------------- | ------------------------------------------------ |
| Welcome  | `/welcome`        | `apps/mobile/src/app/(setup)/welcome.tsx`        |
| Auth     | `/auth/*`         | `apps/mobile/src/app/(setup)/auth/`              |
| Intro    | `/steps/start`    | `apps/mobile/src/app/(setup)/steps/start.tsx`    |
| Consents | `/steps/required` | `apps/mobile/src/app/(setup)/steps/required.tsx` |
| Paywall  | `/steps/paywall`  | `apps/mobile/src/app/(setup)/steps/paywall.tsx`  |

Paths are centralized in `apps/mobile/src/config/paths.ts` under `pathsConfig.setup`.

### Launch routing

`apps/mobile/src/app/index.tsx` picks the next screen in order:

1. **No session** - send the user to `/welcome`, then through `/auth/*`.
2. **Session + unfinished setup** - resume the current step under `/steps/*` (Zustand + AsyncStorage).
3. **Session + setup done** - open the user or organization dashboard.

## Soft vs hard paywall

| Mode               | Behavior                                                            | When to use                              |
| ------------------ | ------------------------------------------------------------------- | ---------------------------------------- |
| **Soft** (default) | Paywall step shows; Skip goes to dashboard on Free                  | Freemium, trials, feature upsells later  |
| **Hard**           | No Skip; purchase (or active entitlement) required before dashboard | Upfront pricing, consumer apps, high CAC |

Feature gating after onboarding is covered in [Feature-based access](/docs/mobile/recipes/feature-based-access). Hard paywall here means **the whole app** is blocked until there is a paid plan.

<Steps>
  <Step>
    ## Landing router

    `apps/mobile/src/app/index.tsx` is the single decision point after launch:

    ```tsx title="apps/mobile/src/app/index.tsx"
    export default function Index() {
      const { data, isPending } = authClient.useSession();
      const { step } = useSetupSteps();

      if (isPending) {
        return <Spinner modal={false} />;
      }

      if (!data) {
        return <Redirect href={pathsConfig.setup.welcome} />;
      }

      if (step) {
        return <Redirect href={step} />;
      }

      if (data.session.activeOrganizationId) {
        return <Redirect href={pathsConfig.dashboard.organization.index} />;
      }

      return <Redirect href={pathsConfig.dashboard.user.index} />;
    }
    ```

    `step` comes from a Zustand store persisted in AsyncStorage (`name: "setup-steps"`). When `current === -1`, there is no active step and the user reaches the dashboard.
  </Step>

  <Step>
    ## Customize the step list

    The wizard shell owns the ordered list of routes:

    ```tsx title="apps/mobile/src/app/(setup)/steps/_layout.tsx"
    const steps = [
      pathsConfig.setup.steps.start,
      pathsConfig.setup.steps.required,
      pathsConfig.setup.steps.paywall,
    ] as const;
    ```

    To add a step (for example “Create workspace”):

    1. Add the path in `apps/mobile/src/config/paths.ts`:

    ```ts title="apps/mobile/src/config/paths.ts"
    steps: {
      start: `${STEPS_PREFIX}/start`,
      required: `${STEPS_PREFIX}/required`,
      workspace: `${STEPS_PREFIX}/workspace`,
      paywall: `${STEPS_PREFIX}/paywall`,
    },
    ```

    2. Insert it into the `steps` array in `_layout.tsx` (order = user journey).
    3. Create `apps/mobile/src/app/(setup)/steps/workspace.tsx` that calls `goNext()` when done.
    4. Add copy under `setup.steps.step.workspace.*` in `packages/i18n/src/translations/en/marketing.json`.

    Progress dots and back/close chrome update automatically from the `steps` array length.

    <Callout type="info" title="Required vs optional steps">
      Put **blocking** steps (legal consents, profile fields you need for the product) before the paywall. Put **nice-to-have** steps after purchase or skip - users abandon less when they feel progress.
    </Callout>
  </Step>

  <Step>
    ## Collect data inside a step

    The consent step is the template for forms: React Hook Form + Zod, CTA disabled until valid, then `goNext()`:

    ```tsx title="apps/mobile/src/app/(setup)/steps/required.tsx"
    const form = useForm({
      resolver: standardSchemaResolver(
        z.object({
          data: z.boolean(),
          privacy: z.boolean(),
        }),
      ),
      defaultValues: { data: false, privacy: false },
    });

    // ...checkboxes...

    <Button
      disabled={Object.values(values).some((value) => !value)}
      onPress={() => goNext()}
    >
      <Text>{t("continue")}</Text>
    </Button>;
    ```

    For data you need later (workspace name, role, goals):

    * **Device-only** - keep it in the same Zustand store as setup progress, or a dedicated persisted store.
    * **Server** - call your Hono API (or Better Auth `updateUser`) before `goNext()`, same pattern as any protected mutation.

    Prefer writing to the server when the answer affects billing, orgs, or other platforms.
  </Step>

  <Step>
    ## Keep the soft paywall (or remove it)

    Default paywall step presents RevenueCat / Superwall with trigger `"onboarding"` and allows Skip:

    ```tsx title="apps/mobile/src/app/(setup)/steps/paywall.tsx"
    const { present, result } = usePaywall({
      onPurchase: () => onEvent("dismissed"),
      onSkip: () => onEvent("skipped"),
      // ...
    });

    <Button
      onPress={() => {
        goNext();
        router.replace(pathsConfig.dashboard.user.index);
      }}
      variant="ghost"
    >
      <Text>{t("skip")}</Text>
    </Button>

    <Button
      onPress={() => {
        void present({ trigger: "onboarding" });
      }}
    >
      <Text>{t("setup.steps.step.paywall.cta")}</Text>
    </Button>
    ```

    Configure the `"onboarding"` placement in the [RevenueCat](/docs/mobile/billing/revenuecat) or [Superwall](/docs/mobile/billing/superwall) dashboard so copy and products can change without an app release (especially with Superwall).

    To drop paywall from onboarding entirely, remove `pathsConfig.setup.steps.paywall` from the `steps` array and delete or ignore the screen. Upsell later from billing settings or feature gates.
  </Step>

  <Step>
    ## Optional: enforce a hard paywall

    Three changes turn the soft step into a gate.

    ### 1. Remove Skip and only advance on purchase

    ```tsx title="apps/mobile/src/app/(setup)/steps/paywall.tsx"
    const { present, result } = usePaywall({
      onPurchase: () => {
        goNext();
        router.replace(pathsConfig.dashboard.user.index);
      },
      onRestore: () => {
        goNext();
        router.replace(pathsConfig.dashboard.user.index);
      },
    });

    // No Skip button - only the CTA that calls present({ trigger: "onboarding" })
    ```

    ### 2. Block the dashboard until a paid plan is active

    Local setup completion (`current === -1`) is not enough if someone restores an old install or clears AsyncStorage. After session is ready, also require a paid plan before leaving setup:

    ```tsx title="apps/mobile/src/app/index.tsx"
    import {
      BillingPlan,
      getActivePlan,
      isSubscriptionActive,
    } from "@workspace/billing";
    import { useCustomer } from "@workspace/billing-mobile";

    // inside Index, after session exists:
    const { entitlements } = useCustomer();
    const summary = useQuery(billing.queries.summary.get(data.user.id));
    const activePlan = getActivePlan(
      summary.data?.map((customer) => ({ ...customer, entitlements })),
    );

    const hasPaidAccess =
      activePlan !== BillingPlan.FREE &&
      /* optionally: */ isSubscriptionActive(/* active subscription */);

    if (!hasPaidAccess) {
      return <Redirect href={pathsConfig.setup.steps.paywall} />;
    }
    ```

    Tune `hasPaidAccess` to your model (any paid plan, specific entitlement id, trial allowed, etc.). Merge store entitlements with the API summary the same way as in [feature-based access](/docs/mobile/recipes/feature-based-access).

    ### 3. Keep API enforcement

    A hard client gate is UX. Anything sensitive still needs `enforceFeatureAvailable()` / plan checks on the API so a patched client cannot skip payment.
  </Step>

  <Step>
    ## Reset and re-test the flow

    During development you will run the wizard many times:

    | Action          | How                                                                                                |
    | --------------- | -------------------------------------------------------------------------------------------------- |
    | Restart steps   | Tap **X** in the steps header (`reset()` → welcome) or clear the `setup-steps` key in AsyncStorage |
    | Fresh install   | Delete the app / clear Expo data                                                                   |
    | Paywall sandbox | Use App Store / Play sandbox accounts; restore purchases after reinstall                           |
    | E2E             | Flows under `apps/mobile/e2e/` already cover welcome → auth → steps                                |

    After changing the `steps` array order, bump or clear the persisted store so old indices do not point at the wrong screen.
  </Step>
</Steps>

## Checklist

* [ ] Step paths registered in `pathsConfig.setup.steps` and listed in `_layout.tsx`
* [ ] Each step advances with `goNext()` (or `setCurrent(-1)` on the last intentional exit)
* [ ] Marketing i18n keys exist for every step
* [ ] Soft paywall: Skip works; hard paywall: Skip removed + launch re-check for paid plan
* [ ] RevenueCat / Superwall `"onboarding"` placement configured
* [ ] API still enforces paid capabilities ([feature-based access](/docs/mobile/recipes/feature-based-access))

## Other platforms

Web and extension do not ship this wizard by default - they use empty slots and thinner first-run UX. Build them with the same mental model (landing guard → steps → optional hard paywall):

<Cards>
  <Card title="Web onboarding" href="/docs/web/recipes/onboarding" description="Fill dashboard onboarding routes, persist completion on the server, optional choose-plan gate." />

  <Card title="Extension onboarding" href="/docs/extension/recipes/onboarding" description="First-run popup, web auth, and plan-aware empty states." />

  <Card title="Mobile paywalls" href="/docs/mobile/billing/overview" description="RevenueCat, Superwall, and how triggers map to placements." />

  <Card title="Feature-based access" href="/docs/mobile/recipes/feature-based-access" description="Gate individual features after the user is inside the app." />
</Cards>


# Versioning
Source: https://www.turbostarter.dev/docs/mobile/recipes/versioning

Mobile users do not pull every commit. They install a binary, keep it for weeks or months, and only move forward when the store (or an over-the-air update) gives them something new. Versioning is how you tell stores, devices, and support which contract that binary is on.

Get it wrong and uploads are rejected, OTA updates land on incompatible native code, or you cannot answer "which build crashed?"

## Two numbers, two jobs

Almost every mobile release talks about **two** identifiers. Mixing them up is the usual failure mode.

|              | Marketing / user-facing version                  | Build number (iOS) / version code (Android)             |
| ------------ | ------------------------------------------------ | ------------------------------------------------------- |
| Looks like   | `1.6.2`                                          | `47`                                                    |
| Job          | What humans and store listings call this release | Strictly increasing integer so each upload is unique    |
| Changes when | You intend a new "Version X" for users           | Every store upload, even for the same marketing version |

You can ship build `48`, `49`, `50` all labeled `1.6.2` while you iterate on a release candidate. You cannot upload a lower build number than one the store already accepted for that app.

## Why it is stricter than web

* **Store gates** - Apple and Google reject duplicate or decreasing identifiers. There is no "just redeploy".
* **Long-lived binaries** - old installs stick around. Features, API assumptions, and force-upgrade policies all key off version.
* **Native vs JavaScript** - a JS-only fix can often ship over the air. A new native module, SDK bump, permission, or icon usually needs a full store binary.
* **Support and crashes** - device + OS + **app version** is the minimum useful bug report.

So versioning is part of your product contract with the OS stores and with users who will not all upgrade on day one.

## OTA updates vs a new store version

Think in release *types*, not only in numbers:

| Kind of change                                                         | Typical path                                                                                                |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| UI, copy, pure JS fixes                                                | Over-the-air update on the **same** marketing version / runtime                                             |
| Native code, new native dependency, permissions, splash/icon, Expo SDK | New store binary. Bump the marketing version when you want users (and OTA runtime) to move to that contract |
| Unsure                                                                 | Assume store release                                                                                        |

OTA is fast and does not need review, but it only stays safe if the update is tied to a **runtime** that matches the native binary. When you bump the marketing version and ship a new binary, older OTA channels should not silently apply to that new install (and the reverse). That split is intentional.

## Habits that pay off

* Decide the marketing version **before** you cut the store build you will submit.
* Write store "What's New" for humans. Reviewers and users both read it.
* Plan for stragglers: what is the oldest version you still support, and when do you force upgrade?
* Keep mobile versions on their own cadence. They will not line up with every web deploy, and that is fine.

## In TurboStarter

Expo config holds the user-facing `version`. Production EAS builds use a remote version source with `autoIncrement` so iOS build numbers and Android version codes advance without hand editing. `runtimeVersion` uses the `appVersion` policy, so OTA bundles stay scoped to that marketing version.

When you need a new store binary, bump `version` in `apps/mobile/app.config.ts` and keep `apps/mobile/package.json` aligned, then follow [publishing](/docs/mobile/publishing/checklist). For JS-only fixes on an existing binary, use [OTA updates](/docs/mobile/publishing/updates) without bumping.

If you are importing an app that was already live, seed EAS with `eas build:version:set` once so the next auto-increment does not collide with store history.

Upstream: [App versions on EAS](https://docs.expo.dev/build-reference/app-versions/).


# API trust
Source: https://www.turbostarter.dev/docs/mobile/security/api-trust

Access control has two jobs:

1. Prove **who** the caller is (authentication)
2. Prove **what** they are allowed to do (authorization)

On mobile, (1) starts with the Better Auth session on the device. (2) must still happen on the **API**. Hiding a menu item in React Native is not enough.

## Call protected routes

The Expo app uses the shared Hono client with the user session. Sensitive reads and all mutations should hit routes guarded by `enforceAuth` (and permission middleware when needed).

```ts
// After the user is signed in on device
const { data } = await handle(api.billing.summary.$get)({
  query: { referenceId: user.id },
});
```

If a screen is “private”, still assume a modified client can call the endpoint directly. The server decides.

See [Protected routes](/docs/web/api/protected-routes) and [Web access control](/docs/web/security/access-control) for middleware details.

## Client RBAC is UX only

Organization helpers like `hasPermission` / `checkRolePermission` on the auth client are useful to disable buttons and hide screens.

They are **NOT** a security boundary. Repeat the same checks in the API with `enforceOrganizationPermission` or `enforceMembership`.

<Card title="Organizations RBAC" href="/docs/mobile/organizations/rbac" description="Roles and client-side permission helpers." />

## Multi-tenant isolation

**Never authorize from a client-provided `organizationId` alone.** Send the id if the API needs it, but the server must verify membership for the authenticated user before scoping queries.

When you add features (projects, documents, …):

1. Authenticate the request (`enforceAuth`)
2. Confirm membership / permission for that organization
3. Filter every query by the **verified** tenant id

## Feature and plan gates

Plan-based UI (paywalls, locked screens) should match server enforcement - feature middleware or entitlement checks on the API. See the [feature-based access recipe](/docs/mobile/recipes/feature-based-access).

## Practical rules

* Do not embed admin-only logic that only runs on the device
* Do not trust boolean flags stored only in AsyncStorage / SecureStore as proof of entitlement
* Do not pass secret API keys into the mobile client “to make a shortcut”
* Do log auth failures and 401/403 spikes in monitoring so abuse is visible

<Cards>
  <Card title="Mobile API" href="/docs/mobile/api/overview" description="Session-aware Hono client from Expo." />

  <Card title="Web access control" href="/docs/web/security/access-control" description="enforceAuth, RBAC, and tenancy on the server." />
</Cards>


# Auth & deep links
Source: https://www.turbostarter.dev/docs/mobile/security/auth

Mobile auth uses the same Better Auth server as web, with Expo-specific clients and deep links for magic links and OAuth callbacks.

## Trusted origins

Auth redirects and deep links must only succeed for **your** app scheme. Add it to Better Auth `trustedOrigins` on the server:

```ts title="packages/auth/src/server.ts"
export const auth = betterAuth({
  trustedOrigins: [
    "turbostarter://**",
    // ...
  ],
});
```

The scheme comes from `apps/mobile/app.config.ts`. If you change it, update `trustedOrigins` in the same change.

<Callout type="warn" title="CSRF protection">
  Without a tight trusted-origin allowlist, auth flows are open to CSRF-style abuse and malicious open redirects. Only schemes you control should be listed.
</Callout>

[Read more in mobile auth configuration](/docs/mobile/auth/configuration) and [Better Auth security](https://better-auth.com/docs/reference/security).

## Sessions on device

* Use the shared Better Auth Expo client - do not roll your own token storage format
* Prefer the library’s secure defaults for persisting session data
* Sign-out should clear local session state and invalidate server-side where the API supports it
* Never log access tokens, refresh tokens, or full session cookies in analytics or crash reports

## OAuth and native providers

Native [Sign in with Apple](/docs/mobile/auth/oauth/apple) and [Google](/docs/mobile/auth/oauth/google) reduce phishing risk versus generic webviews, but they still depend on correct server config:

1. Production redirect / callback URLs match the deployed API
2. App scheme is in `trustedOrigins`
3. Provider client IDs match the iOS/Android app ids you ship
4. Bundle / package identifiers in the provider console match store builds

## Deep links as entry points

Treat every deep link like an unauthenticated HTTP request:

* Validate path and params before navigating to a sensitive screen
* Do not grant access or apply a purchase solely because a link opened the app
* After magic-link / OAuth return, confirm the session with the auth client / API before showing private UI

Full setup for schemes, Universal Links, and App Links: [Deep linking](/docs/mobile/deep-linking).

## Hardening checklist

* Keep email verification enabled for password sign-up (server config)
* Offer stronger factors where it fits your product (OTP, 2FA on web account settings)
* Use HTTPS for `EXPO_PUBLIC_SITE_URL` in production
* Review `trustedOrigins` whenever you add a new scheme or universal link domain

<Cards>
  <Card title="Authentication overview" href="/docs/mobile/auth/overview" description="Supported methods and native OAuth." />

  <Card title="Auth configuration" href="/docs/mobile/auth/configuration" description="Providers, UI flags, and trusted origins." />
</Cards>


# Billing
Source: https://www.turbostarter.dev/docs/mobile/security/billing

Mobile billing SDKs run on the device. That is fine for presenting paywalls and starting purchases - it is **not** enough to unlock paid features by itself.

## Client vs server

| On device                                                | On the API                                     |
| -------------------------------------------------------- | ---------------------------------------------- |
| RevenueCat / Superwall public SDK keys (`EXPO_PUBLIC_*`) | Webhook signing secrets                        |
| Paywall UI and purchase sheets                           | Signature verification                         |
| Optimistic UI (“thanks for subscribing”)                 | Customer / entitlement updates in the database |

Webhook handlers live with the shared API (often next to web billing routes) and use `@workspace/billing-mobile/server`:

```ts title="packages/api - mobile billing webhook"
import { webhookHandler, provider } from "@workspace/billing-mobile/server";

export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
  webhookHandler(c.req.raw),
);
```

Rules when you extend handlers:

1. Verify the provider signature before mutating data
2. Use environment-specific secrets (sandbox vs production)
3. Never grant a plan solely from a client callback or deep link

<Callout type="error" title="API deployment required">
  Production purchases need a deployed API that receives provider webhooks. See [Mobile billing webhooks](/docs/mobile/billing/webhooks) and the [web deployment checklist](/docs/web/deployment/checklist).
</Callout>

## Public SDK keys

Client SDK keys for Superwall / RevenueCat are often required in the app. Treat them like `EXPO_PUBLIC_` values: anyone can extract them.

Mitigations:

* Restrict keys in the provider dashboard (bundle id / package name)
* Prefer server-side entitlement as the source of truth
* Rotate keys if they leak into a public repo or wrong environment

## Entitlements in the UI

Gate screens with the billing summary / entitlements returned by your **authenticated API**, not only with the native SDK’s local cache.

Local SDK state is useful for snappy UX; the server must still refuse unpaid access on protected routes.

## Related guides

<Cards>
  <Card title="Billing overview" href="/docs/mobile/billing/overview" description="RevenueCat, Superwall, and configuration." />

  <Card title="Billing webhooks" href="/docs/mobile/billing/webhooks" description="Verify events and sync customer access." />

  <Card title="Web integrations" href="/docs/web/security/integrations" description="Signature verification patterns on the API." />
</Cards>


# Checklist
Source: https://www.turbostarter.dev/docs/mobile/security/checklist

Use this checklist before submitting to the App Store or Google Play, and again after major auth or billing changes. Pair it with the [publishing checklist](/docs/mobile/publishing/checklist).

## Secrets & configuration

* [ ] No secrets in the mobile app or git (only placeholders in examples)
* [ ] No sensitive values use the `EXPO_PUBLIC_` prefix
* [ ] `EXPO_PUBLIC_SITE_URL` points at the production API / web origin
* [ ] EAS preview and production profiles use the correct public env
* [ ] Billing webhook secrets exist only on the server

## Authentication

* [ ] App scheme from `app.config.ts` is listed in Better Auth `trustedOrigins`
* [ ] OAuth / native Sign in with Apple & Google use production callbacks and app ids
* [ ] Deep links do not unlock paid or admin features by themselves
* [ ] Session tokens are not logged to analytics or crash reporting

## API trust

* [ ] Sensitive actions call protected Hono routes
* [ ] Client RBAC only drives UI; server repeats the checks
* [ ] Organization-scoped features verify membership on the API
* [ ] Feature / plan gates match server entitlement checks

## Billing

* [ ] Purchase unlocks wait for webhook-verified state (or a trusted server summary)
* [ ] Provider dashboard restricts client keys to your bundle / package ids
* [ ] Sandbox and production webhook secrets are not mixed

## Process

* [ ] Dependencies updated for known React Native / Expo advisories
* [ ] Monitoring scrubbers hide tokens and cookies
* [ ] Auth and billing failures are visible in logs / crash tools

<Callout title="Ship with confidence">
  You do not need every optional hardening step on day one, but you **do** need public-only mobile env, correct trusted origins, server-side authz, and webhook-backed entitlements.
</Callout>


# Overview
Source: https://www.turbostarter.dev/docs/mobile/security/overview

The mobile app is a **client**. It talks to the same [Hono API](/docs/web/api/overview) and [Better Auth](/docs/mobile/auth/overview) stack as web - it should never become a second source of truth for secrets, entitlements, or tenant access.

This section is a **security playbook** - what must stay off-device, how auth deep links stay safe, and what to double-check before you ship to the stores.

<Callout title="Be mindful">
  Security is not a one-time setup. Revisit these practices whenever you add screens, deep links, billing providers, or native modules that touch sensitive data.
</Callout>

## Security model

Defense on mobile is client-focused. The API still owns authorization:

| Layer                | What it protects                                               | Where it lives                    |
| -------------------- | -------------------------------------------------------------- | --------------------------------- |
| No secrets on device | API keys and webhook secrets never ship in the binary          | Server env / web deployment       |
| Public env only      | `EXPO_PUBLIC_*` values are readable by anyone with the app     | `apps/mobile` + EAS               |
| Trusted deep links   | Auth redirects only from your app scheme                       | Better Auth `trustedOrigins`      |
| Session + API        | Mutations require a valid session on protected routes          | Auth client → Hono `enforceAuth`  |
| Entitlements         | Purchases are confirmed by provider webhooks, not UI callbacks | `@workspace/billing-mobile` + API |

Server-side rules (middleware, Zod validation, webhook signatures, package `./server` entry points) are documented in [Web security](/docs/web/security/overview). Use that section whenever you change the API.

Each of these topics is covered in more detail in the following guides:


# Secrets & environment
Source: https://www.turbostarter.dev/docs/mobile/security/secrets

Anything in the mobile binary can be extracted. TurboStarter keeps privileged secrets on the **web/API** side and only exposes public configuration to Expo.

For the full file layout, see [Environment variables](/docs/mobile/configuration/environment-variables). This page focuses on the security rules.

## Public vs private

| Kind                      | Prefix / location             | Use for                                                                         |
| ------------------------- | ----------------------------- | ------------------------------------------------------------------------------- |
| Public (in the app)       | `EXPO_PUBLIC_*`               | Site URL, locale, theme, feature flags safe to expose                           |
| Build-time                | EAS env / `eas.json` profiles | Same public values for preview vs production builds                             |
| Private (never on mobile) | Web / API host secrets        | Database URL, Better Auth secret, billing webhook secrets, provider secret keys |

```dotenv title="Safe ✅"
# apps/mobile - public only
EXPO_PUBLIC_SITE_URL="https://app.example.com"
EXPO_PUBLIC_DEFAULT_LOCALE="en"
EXPO_PUBLIC_THEME_MODE="system"

# Optional public SDK keys that providers document as client-safe
EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY="appl_..."
```

```dotenv title="Unsafe - never do this ❌"
EXPO_PUBLIC_DATABASE_URL="postgresql://..."
EXPO_PUBLIC_BETTER_AUTH_SECRET="..."
EXPO_PUBLIC_STRIPE_SECRET_KEY="sk_live_..."
EXPO_PUBLIC_REVENUECAT_WEBHOOK_SECRET="..."
```

<Callout type="warn" title="Public means public">
  If a value has the `EXPO_PUBLIC_` prefix, assume every user (and anyone who unpacks your IPA/APK) can read it. That includes “obscure” identifiers that still grant privileged access to a third-party API.
</Callout>

## Where secrets live

| Environment       | Store secrets in                                   | On the device?       |
| ----------------- | -------------------------------------------------- | -------------------- |
| Local development | Root / web `.env.local` for the API                | No                   |
| Production API    | Host secrets (Vercel, Railway, …)                  | No                   |
| EAS builds        | EAS secrets / `eas.json` for **public** app config | Only `EXPO_PUBLIC_*` |

Mobile never needs `DATABASE_URL`, `BETTER_AUTH_SECRET`, or billing **signing** secrets. Those stay with the API the app calls.

<Callout title="What does this mean?">
  Add secret keys to the **web app where the API is deployed**. The Expo app should only talk to that API for sensitive operations.

  [See web environment variables](/docs/web/configuration/environment-variables#secret-keys) for the server-side layout.
</Callout>

## Production checklist for env

1. Point `EXPO_PUBLIC_SITE_URL` at your real production API / web origin
2. Confirm no secret keys use the `EXPO_PUBLIC_` prefix
3. Use separate EAS profiles (or EAS env) for preview vs production
4. Keep billing webhook secrets on the server only
5. Rotate any key that was ever committed or shipped in a public build

<Cards>
  <Card title="Environment variables" href="/docs/mobile/configuration/environment-variables" description="Shared vs app-specific files and EAS injection." />

  <Card title="Web secrets" href="/docs/web/security/secrets" description="NEXT_PUBLIC_ rules and server env presets." />
</Cards>


# Tech Stack
Source: https://www.turbostarter.dev/docs/mobile/stack

## Turborepo

[Turborepo](https://turborepo.dev/) is a monorepo tool that helps you manage your project's dependencies and scripts. We chose a monorepo setup to make it easier to manage the structure of different features and enable code sharing between different packages.

<Card href="https://turborepo.dev/" title="Turborepo - Make Ship Happen" description="turbo.build" icon={<Turborepo />} />

## React Native + Expo

[React Native](https://reactnative.dev/) is an open-source mobile application development framework created by Facebook. It is used to develop applications for Android and iOS by enabling developers to use [React](https://react.dev) along with native platform capabilities.

> It's like Next.js for mobile development.

[Expo](https://expo.dev/) is a framework and a platform built around React Native. It provides a set of tools and services that help you develop, build, deploy, and quickly iterate on iOS, Android, and web apps from the same JavaScript/TypeScript codebase. It's like Next.js for mobile development.

<Cards className="grid-cols-2">
  <Card href="https://reactnative.dev/" title="React Native" description="reactnative.dev" icon={<React />} />

  <Card href="https://expo.dev/" title="Expo" description="expo.dev" icon={<Expo />} />
</Cards>

## Tailwind CSS

[Uniwind](https://uniwind.dev/) uses Tailwind CSS as scripting language to create a universal style system for React Native. It allows you to use Tailwind CSS classes in your React Native components, providing a familiar styling experience for web developers. We also use [React Native Reusables](https://github.com/mrzachnugent/react-native-reusables) for our headless components library with support of CLI to generate pre-designed components with a single command.

<Cards className="grid-cols-2">
  <Card href="https://uniwind.dev/" title="Uniwind" description="uniwind.dev" icon={<Uniwind />} />

  <Card href="https://github.com/mrzachnugent/react-native-reusables" title="react-native-reusables" description="github.com" icon={<ReactNativeReusables />} />
</Cards>

## Hono & React Query

[Hono](https://hono.dev) is a small, simple, and ultrafast web framework for the edge. It provides tools to help you build APIs and web applications faster. It includes an RPC client for making type-safe function calls from the frontend. We use Hono to build our serverless API endpoints.

To make data fetching and caching from our API easy and reliable, we pair Hono with [React Query](https://tanstack.com/query/latest). It helps manage asynchronous data, caching, and state synchronization between the client and backend, delivering a fast and seamless UX.

<Cards>
  <Card href="https://hono.dev" title="Hono" description="hono.dev" icon={<Hono />} />

  <Card href="https://tanstack.com/query/latest" title="React Query" description="tanstack.com" icon={<Tanstack />} />
</Cards>

## Better Auth

[Better Auth](https://better-auth.com) is a modern authentication library for fullstack applications. It provides ready-to-use snippets for features like email/password login, magic links, OAuth providers, and more. We use Better Auth to handle all authentication flows in our application.

<Card href="https://better-auth.com" title="Better Auth" description="better-auth.com" icon={<BetterAuth />} />

## Drizzle

[Drizzle](https://orm.drizzle.team/) is a super fast [ORM](https://orm.drizzle.team/docs/overview) (Object-Relational Mapping) tool for databases. It helps manage databases, generate TypeScript types from your schema, and run queries in a fully type-safe way.

We use [PostgreSQL](https://www.postgresql.org) as our default database, but thanks to Drizzle's flexibility, you can easily switch to MySQL, SQLite or any [other supported database](https://orm.drizzle.team/docs/connect-overview) by updating a few configuration lines.

<Cards>
  <Card href="https://orm.drizzle.team/" title="Drizzle" description="orm.drizzle.team" icon={<Drizzle />} />

  <Card href="https://www.postgresql.org" title="PostgreSQL" description="postgresql.org" icon={<Postgres />} />
</Cards>

## EAS (Expo Application Services)

[EAS](https://expo.dev/eas) is a set of cloud services provided by Expo for React Native app development. It includes tools for building, submitting, and updating your app, as well as over-the-air updates and analytics.

<Card href="https://expo.dev/eas" title="EAS (Expo Application Services)" description="expo.dev/eas" icon={<Expo />} />


# E2E tests
Source: https://www.turbostarter.dev/docs/mobile/tests/e2e

Mobile E2E tests verify real user flows on iOS and Android: welcome screens, sign-in, onboarding, and sign-out. Tests use [Maestro](https://maestro.mobile.dev) against a native app build and a production API server, so you catch issues that only appear on device. See the [Maestro docs](https://docs.maestro.dev) for the full reference.

<Callout title="Why Maestro?">
  Maestro uses YAML flows that are easy to read and maintain. It handles waits, retries, and platform quirks (like iOS keychain prompts) without boilerplate. Flows run on real emulators and simulators, giving you confidence that gestures, navigation, and native UI behave correctly.
</Callout>

![Maestro test run](/images/docs/mobile/tests/e2e/maestro-run.png)

## Prerequisites

1. **Start services**: Postgres must be running for the API server:

```bash
pnpm services:setup
```

2. **Environment files**:

```bash
cp .env.example .env
cp apps/web/.env.example apps/web/.env.local
cp apps/mobile/.env.example apps/mobile/.env
```

3. **Install Maestro** (first time only):

```bash
curl -Ls "https://get.maestro.mobile.dev" | bash
```

4. **Build the app**: E2E tests need a native build. Use the [EAS](https://docs.expo.dev/build/introduction/) `e2e` profile:

```bash
cd apps/mobile
eas build --platform android --profile e2e --local
# or
eas build --platform ios --profile e2e --local
```

The `e2e` profile in `eas.json` produces an APK (Android) or simulator build (iOS) with the correct `EXPO_PUBLIC_SITE_URL` for reaching your local API:

| Platform         | `EXPO_PUBLIC_SITE_URL`  | Why                                      |
| ---------------- | ----------------------- | ---------------------------------------- |
| Android emulator | `http://10.0.2.2:3000`  | Emulator loopback alias for host machine |
| iOS simulator    | `http://127.0.0.1:3000` | Localhost on the Mac host                |

5. **Start the API server**: the mobile app needs a running backend:

```bash
pnpm with-env pnpm turbo build --filter=web
pnpm with-env pnpm --filter web start -H 0.0.0.0 -p 3000
```

## Test structure

E2E tests live in `apps/mobile/e2e/`:

```
apps/mobile/e2e/
├── config.yaml              # Flow discovery and execution order
├── flows/
│   ├── welcome.yaml         # Welcome screen smoke test
│   └── auth/
│       ├── sign-in-password.yaml
│       └── sign-out.yaml
├── subflows/
│   └── complete-setup.yaml  # Reusable onboarding steps
└── test-results/            # JUnit reports and debug output
```

### Flow execution order

`config.yaml` defines which flows run and in what [execution order](https://docs.maestro.dev/maestro-flows/workspace-management/sequential-execution):

```yaml title="apps/mobile/e2e/config.yaml"
flows:
  - "flows/**"
executionOrder:
  continueOnFailure: false
  flowsOrder:
    - welcome
    - sign-in-password
    - sign-out
```

Flows run sequentially: welcome → sign-in → sign-out. The sign-out flow depends on the sign-in flow leaving the user on the dashboard.

## Example flows

<Callout title="Test IDs">
  Mobile screens expose [`testID`](https://reactnative.dev/docs/view#testid) props for Maestro to target. This keeps flows stable when copy or styling changes.

  Add `testID` to any new interactive element you want to cover in Maestro flows.
</Callout>

### Welcome screen

```yaml title="apps/mobile/e2e/flows/welcome.yaml"
appId: com.turbostarter.core
---
- launchApp:
    clearState: true
    clearKeychain: true
- extendedWaitUntil:
    visible:
      id: welcome-get-started
    timeout: 120000
- assertVisible:
    id: welcome-get-started
- assertVisible:
    id: welcome-login
```

`clearState` and `clearKeychain` ensure each run starts fresh, with no leftover sessions from previous tests.

### Sign in with password

The sign-in flow navigates from welcome → login → onboarding → dashboard:

```yaml title="apps/mobile/e2e/flows/auth/sign-in-password.yaml"
appId: com.turbostarter.core
env:
  E2E_USER_EMAIL: me+user@turbostarter.dev
  E2E_USER_PASSWORD: "Pa$$w0rd"
---
- launchApp:
    clearState: true
    clearKeychain: true
- tapOn:
    id: welcome-login
- tapOn:
    id: login-email-input
- inputText: ${E2E_USER_EMAIL}
- tapOn:
    id: login-password-input
- inputText: ${E2E_USER_PASSWORD}
- pressKey: Enter
- runFlow: ../../subflows/complete-setup.yaml
- assertVisible:
    id: dashboard-home
```

[Subflows](https://docs.maestro.dev/maestro-flows/flow-control-and-logic/nested-flows) like `complete-setup.yaml` handle reusable steps (checkboxes, skip paywall) so you do not duplicate onboarding logic across flows.

## Running tests

### All flows

```bash
pnpm --filter mobile test:e2e
```

### Single flow

```bash
pnpm --filter mobile exec maestro test e2e/flows/welcome.yaml
```

### With JUnit output

```bash
pnpm --filter mobile exec maestro test e2e \
  --format junit \
  --output e2e/test-results/report.xml
```

### Maestro Studio (interactive)

```bash
maestro studio
```

![Maestro Studio](/images/docs/mobile/tests/e2e/maestro-studio.gif)

[Maestro Studio](https://docs.maestro.dev/getting-started/maestro-studio-desktop) lets you record, edit, and replay flows visually, which is useful when building new tests.

## CI

The `CI / E2E / Mobile` workflow runs on pull requests labeled `e2e` or `e2e-mobile`. It has two jobs:

* **Android**: builds the app via EAS, starts an API server, boots a Pixel 6 emulator, and runs Maestro flows
* **iOS**: builds via EAS, starts PostgreSQL natively, boots an iPhone simulator, and runs the same flows

Both jobs upload JUnit reports and debug artifacts. Mobile CI requires an `EXPO_TOKEN` secret for EAS builds.

<Callout title="Resource requirements">
  Mobile E2E CI is the most resource-intensive pipeline. Android runs need KVM-enabled runners with a 4 GB emulator. iOS runs need `macos-latest`. Use the `e2e-mobile` label only when mobile changes need verification.
</Callout>

## Writing new flows

1. **Add testIDs** to the components you want to interact with
2. **Create a YAML flow** in `apps/mobile/e2e/flows/`
3. **Register it** in `config.yaml` if it should run as part of the suite
4. **Use subflows** for steps shared across multiple flows (onboarding, dismiss dialogs)
5. **Handle platform dialogs**: iOS "Save Password?" prompts are dismissed with `repeat`/`while` blocks in existing flows

### Tips for reliable flows

* Use [`extendedWaitUntil`](https://docs.maestro.dev/api-reference/commands/extendedwaituntil) instead of fixed `sleep` values
* Set `clearState: true` and `clearKeychain: true` on `launchApp` for auth flows
* Use `pressKey: Enter` to submit forms instead of hunting for submit buttons
* Mark optional taps with `optional: true` when an element may not appear (e.g. tab already selected)

## Next steps

* [Mobile development setup](/docs/mobile/installation/development): emulator and simulator configuration
* [Unit tests](/docs/mobile/tests/unit): fast Vitest tests for mobile packages
* [Authentication](/docs/mobile/auth/overview): auth methods available on mobile


# Billing
Source: https://www.turbostarter.dev/docs/mobile/troubleshooting/billing

## Products/offerings not visible on the paywall

If your paywall loads but shows **no products** (empty packages/offerings), it's almost always a **store configuration** issue (App Store Connect / Google Play) or an **app-to-store mismatch**, not a UI bug in the paywall.

### Quick checks

First, verify the **product identifiers** in your provider match the store **exactly** (case-sensitive), you're testing on a **real device** (not simulator/emulator), your app's **Bundle ID / package name** matches what the store knows, and you're using the correct provider **platform key** for the build/environment you're running.

### iOS

On iOS, confirm your IAPs are in **Ready to Submit** or **Approved** (and allow **24h+** after approval for store propagation). If you see an error like the one below, it usually means App Store Connect has pending **Agreements/Tax/Banking** requirements (e.g. Paid Applications Agreement not signed, banking not cleared, tax forms incomplete):

```
[StoreKit] Error enumerating unfinished transactions: Error Domain=ASDErrorDomain Code=509 "No active account"
```

Also double-check you're not accidentally using a **StoreKit Configuration file** when you expect live store products, and if you recently changed product metadata and things got flaky, try creating a **new product identifier** and testing again.

### Android

On Android, make sure the product is **Active** in Play Console and that you're testing with an app build distributed via a **testing track** (internal/closed) with your account added as a **tester**. If products are region/compatibility-limited, confirm they're available for your tester's country/device settings.

### Still empty?

Sign into the App Store / Play Store on the device with the intended test account, confirm you're running the expected build type (local debug vs TestFlight can differ), and add logs around the provider's product fetch plus any underlying store error—those messages typically point directly to what's misconfigured.


# Installation
Source: https://www.turbostarter.dev/docs/mobile/troubleshooting/installation

## Cannot clone the repository

Issues related to cloning the repository are usually related to a Git misconfiguration in your local machine. The commands displayed in this guide using SSH: these will work only if you have setup your SSH keys in Github.

If you run into issues, [please make sure you follow this guide to set up your SSH key in Github.](https://docs.github.com/en/authentication/connecting-to-github-with-ssh)

If this also fails, please use HTTPS instead. You will be able to see the commands in the repository's Github page under the "Clone" dropdown.

Please also make sure that the account that accepted the invite to TurboStarter, and the locally connected account are the same.

## Local database doesn't start

If you cannot run the local database container, it's likely you have not started [Docker](https://docs.docker.com/get-docker/) locally. Our local database requires Docker to be installed and running.

Please make sure you have installed Docker (or compatible software such as [Colima](https://github.com/abiosoft/colima), [Orbstack](https://github.com/orbstack/orbstack)) and that is running on your local machine.

Also, make sure that you have enough [memory and CPU allocated](https://docs.docker.com/engine/containers/resource_constraints/) to your Docker instance.

## I don't see my translations

If you don't see your translations appearing in the application, there are a few common causes:

1. Check that your translation `.json` files are properly formatted and located in the correct directory
2. Verify that the language codes in your configuration match your translation files
3. Enable debug mode (`debug: true`) in your i18next configuration to see detailed logs

[Read more about configuration for translations](/docs/mobile/internationalization#configuration)

## Expo cannot detect XCode

If you get the following error:

```bash
Expo cannot detect Xcode Xcode must be fully installed before you can continue
```

This is usually related to the Xcode CLI not being installed. You can fix this by running the following command:

```bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
```

If you still face the issue, please make sure you have the latest version of Xcode installed.

## "Module not found" error

This issue is mostly related to either dependency installed in the wrong package or issues with the file system.

The most common cause is incorrect dependency installation. Here's how to fix it:

1. Clean the workspace:

   ```bash
   pnpm clean
   ```

2. Reinstall the dependencies:
   ```bash
   pnpm i
   ```

If you're adding new dependencies, make sure to install them in the correct package:

```bash
# For main app dependencies
pnpm install --filter mobile my-package

# For a specific package
pnpm install --filter @workspace/ui my-package
```

If the issue persists, please check the file system for any issues.

### Windows OneDrive

OneDrive can cause file system issues with Node.js projects due to its file syncing behavior. If you're using Windows with OneDrive, you have two options to resolve this:

1. Move your project to a location outside of OneDrive-synced folders (recommended)
2. Disable OneDrive sync specifically for your development folder

This prevents file watching and symlink issues that can occur when OneDrive tries to sync Node.js project files.


# Publishing
Source: https://www.turbostarter.dev/docs/mobile/troubleshooting/publishing

## My app submission was rejected

If your app submission was rejected, you probably got an email with the reason. You'll need to fix the issues and upload a new build of your app to the store and send it for review again.

Make sure to follow the [guidelines](/docs/mobile/marketing) when submitting your app to ensure that everything is setup correctly.

## App Store screenshots don't match requirements

If your app submission was rejected due to screenshot issues, make sure:

1. Screenshots match the required dimensions for each device
2. Screenshots accurately represent your app's functionality
3. You have provided screenshots for all required device sizes
4. Screenshots don't contain device frames unless they match Apple's requirements

[See Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/)

## Version number conflicts

If you get version number conflicts when submitting:

1. Ensure your `app.json` version matches what's in the store
2. Increment the version number appropriately:
   ```bash
   "version": "1.0.1",
   "android.versionCode": 2,
   "ios.buildNumber": "2"
   ```
3. Make sure both stores have unique version numbers

## Missing or incorrect environment variables

If your build succeeds but the binary is misconfigured (e.g., API URL shows as `undefined`, Sentry auth fails, or `app.config.*` settings don’t apply), verify your EAS environment variables:

1. Define variables on EAS and assign them to the correct environment (`development`, `preview`, `production`).
2. For values used in app code, prefix with `EXPO_PUBLIC_` and read via `process.env.EXPO_PUBLIC_...`.
3. For config-time values (bundle identifiers, file paths), read `process.env.VARNAME` from your `app.config.*`.
4. Explicitly set `environment` in `eas.json` build profiles, or pass `--environment` to `eas update` so updates use the same variables as builds.
5. For local development, pull variables into a `.env` file:
   ```bash
   eas env:pull --environment development
   ```
6. Use secret file variables (e.g., `GOOGLE_SERVICES_JSON`) and reference them in `app.config.*`.
7. Keep `.env` out of git; cloud builds don’t rely on your local `.env`.

See: [Environment variables in EAS](https://docs.expo.dev/eas/environment-variables/).

## My app crashes on production build

If the app works in development but crashes in a production build, check these common causes:

1. **Missing or incorrect environment variables at build time**. EAS cloud jobs don’t use your local `.env` by default. Ensure variables exist on EAS, are assigned to the correct environment, and use `EXPO_PUBLIC_` for values read in app code. See: [Environment variables in EAS](https://docs.expo.dev/eas/environment-variables/).
2. **Missing native config files**. Provide `google-services.json` / `GoogleService-Info.plist` via secret file variables (e.g., `GOOGLE_SERVICES_JSON`) and reference them in `app.config.*`.
3. **Production-only code paths**. Guard dev-only code with `__DEV__`, avoid importing dev tools in production, and ensure feature flags don’t access undefined values.
4. **Misconfigured native modules or plugins**. Verify required plugins/babel config are present and rebuild after cache clears.

Try this:

1. Run the app with a production JS bundle locally to surface minification issues:
   ```bash
   npx expo start --no-dev --minify
   ```
2. Inspect device logs when the crash occurs (Android: `adb logcat`, iOS: Console.app or Xcode Devices).
3. Rebuild with a clean cache if needed:
   ```bash
   eas build --clear-cache
   ```


# Overview
Source: https://www.turbostarter.dev/docs/web/admin/overview

TurboStarter ships with a fully functional admin dashboard - it's a comprehensive tool for managing your application and users from one central place.

The panel is designed to be intuitive and easy to use, while being customizable and scalable at the same time. You can access it at [/admin](http://localhost:3000/admin).

![Admin Dashboard](/images/docs/web/admin/home.png)

## Roles and permissions

With the initial configuration, your app has two roles available to users: `user` and `admin`. By default, all users are created with the `user` role.

To access the admin dashboard, a user must have the `admin` permission.

```ts
const UserRole = {
  USER: "user",
  ADMIN: "admin",
} as const;
```

You can, of course, define more roles and assign granular permissions, but we recommend keeping the number of roles to a minimum.

## Making a user an admin

To promote a user to the admin role, use your database provider's UI or leverage our built-in [Studio](/docs/web/database/overview#studio). After you find the user you want to promote, change their role from `user` to `admin`.

**Ensure the user you are promoting truly requires admin privileges, as they will gain access to all resources and permissions.**

<Callout title="Recommendations">
  To determine whether a user is eligible for the `admin` role, review the following recommendations before promoting the user:

  * The user's email is verified
  * Two-factor authentication (2FA) is enabled
  * The user is **not** banned or reported
</Callout>

<Callout title="Testing locally">
  By default, when you [run services](/docs/web/installation/commands#setting-up-services) for the first time, your database is [seeded](/docs/web/installation/commands#seeding-database) with example data. This includes an admin user with test credentials that you can use to test admin functionality locally.

  ```json
  {
    "email": "me+admin@turbostarter.dev",
    "password": "Pa$$w0rd"
  }
  ```

  You can modify these by setting the `SEED_EMAIL` and `SEED_PASSWORD` environment variables in the `.env.local` file and running the seed process again.

  **This flow is for local testing purposes only. Do not use it in production.**
</Callout>

## Dashboard

The admin dashboard is your **central place** to manage your application. It includes management tools for each resource you have defined.

Users with the `admin` permission will see an additional dropdown item in the navigation menu, allowing them to access the admin dashboard.

![Admin access through the navigation menu](/images/docs/web/admin/user-navigation.png)

Explore each section of the page below to familiarize yourself with the available tools and options.


# Super Admin UI
Source: https://www.turbostarter.dev/docs/web/admin/ui

When you open [/admin](http://localhost:3000/admin), you will see the homepage of the admin dashboard. It includes some quick actions and a summary of the resources you have in your application. Feel free to customize it to your needs.

To simplify navigation, we also shipped a sidebar that you can use to navigate between different sections and access all admin capabilities.

![Super Admin UI](/images/docs/web/admin/home.png)

Check below for more details about each section.

## Users

Central place to manage your users. You can see the list of users, search and filter them e.g. by role, 2FA, banned state, and created date.

Use it to quickly find users that you need to manage or to see how your SaaS is performing.

![Users](/images/docs/web/admin/users.png)

When you click on a user, you will see the user details. You can edit the user's name and role, view the user's 2FA status, and see the user's created/updated timestamps.

You can also see and manage the resources related to this specific user like user's connected accounts/providers, subscriptions, memberships, etc.

![User](/images/docs/web/admin/user.png)

Beyond simply viewing user information, the admin dashboard enables you to perform a variety of essential user management actions, including:

* **Impersonate the user**: Temporarily log in as the selected user to troubleshoot their experience, verify permissions, or offer assistance directly from their perspective.
* **Ban or unban the user**: Restrict access to your application by banning users who violate terms of service, or lift restrictions when appropriate by unbanning them.
* **Delete the user**: Permanently remove a user and any associated data from your system when necessary, such as for GDPR compliance or at user request.

These administrative actions help you maintain a secure, compliant, and user-friendly environment for your SaaS platform.

## Organizations

See how your multi-tenancy is performing in an elegant way presented as a data table. You can search and filter organizations by name, slug, member count and many more.

![Organizations](/images/docs/web/admin/organizations.png)

In the single organization view, you can get an overview of the specified organization, e.g see its members or invitations that are associated with it.

![Organization](/images/docs/web/admin/organization.png)

Here are some example actions you can perform when managing an organization:

* **Edit organization details**: Change the organization name, slug, or other profile information.
* **Invite or remove members**: Add new users to an organization or revoke access from existing members.
* **Change member roles**: Promote a member to an admin or downgrade their access.
* **View and manage invitations**: See pending invites and revoke them if needed.
* **Delete organization**: Remove an organization and all its related data (action usually restricted to super admins).
* **Impersonate organization admin**: Temporarily assume the perspective of an organization's admin for troubleshooting.
* **Audit activity**: View a history of actions taken within the organization for security and compliance.

These actions help you maintain control over multi-tenant environments and ensure that your SaaS remains secure and organized.

## Customers

Manage your customers and their subscriptions. Use search, filters, and sorting to quickly find the right record and understand billing state at a glance.

![Customers](/images/docs/web/admin/customers.png)

A few example actions you can perform when managing a customer:

* **Open a customer** to view subscription details and billing history.
* **Change subscription plan** or move a customer to a different tier.
* **Start or extend a trial**, or **cancel a subscription** when needed.
* **Update billing details** like billing email and tax information.
* **Delete customer** to remove them and their billing profile (restricted action).

## Add your own resources

It’s your admin panel at the end of the day - extend it with any domain‑specific resources your product needs. The UI ships with reusable table, filter, form, and layout primitives so you can compose new sections quickly.

To make CRUD panels fast to build, we also provide dedicated hooks, UI components, and API helpers that handle the boring plumbing - data fetching, pagination, sorting, filters, and mutations — so you can focus on your domain logic instead of boilerplate.

<Steps>
  <Step>
    ### Start from an example

    Duplicate an existing resource (like `Users` or `Organizations`) as a baseline and adjust the schema/columns to your needs.
  </Step>

  <Step>
    ### Build the list view

    Compose a data table with columns, sorting, full‑text search, and filters using the shipped primitives.

    Leverage the dedicated hooks, UI components, and API helpers to handle fetching, pagination, sorting, filters, and mutations with minimal boilerplate.
  </Step>

  <Step>
    ### Add a details view

    Create a single‑resource page and, if helpful, add tabs for related entities (e.g., memberships, invoices) using the same building blocks.
  </Step>

  <Step>
    ### Wire up navigation

    Register your route in the admin sidebar so the new resource appears alongside the built‑ins.
  </Step>

  <Step>
    ### Secure with permissions

    Protect access using your RBAC rules and feature flags to control who can view or manage the resource.
  </Step>
</Steps>

Et voilà! You now have a new resource in your admin panel 🥳


# Configuration
Source: https://www.turbostarter.dev/docs/web/ai/configuration

To ensure scalability and avoid security vulnerabilities, AI requests are proxied by our [Hono backend](/docs/web/api/overview). This means you need to set up AI integration on both the client and server side.

<Callout title="Why proxy requests?">
  We want to avoid exposing API keys directly to the browser, as this could lead to abuse of your key and generate unnecessary costs.
</Callout>

In this section, we'll explore the configuration for both sides to give you a smooth start.

## Server-side

On the backend, you need to set up two things: environment variables to configure the provider and the procedure to pass responses to the client. Let's go through it!

### Environment variables

You need to set the environment variables that correspond to the AI provider you want to use.

For example, for the OpenAI provider, you would need to set the following environment variables:

```dotenv
OPENAI_API_KEY=<your-openai-api-key>
```

However, if you want to use the Anthropic provider, you would need to set these environment variables:

```dotenv
ANTHROPIC_API_KEY=<your-anthropic-api-key>
```

You can find the list of all available providers in the [official documentation](https://sdk.vercel.ai/providers/ai-sdk-providers), along with the required variables that need to be set to ensure the integration works correctly.

### API endpoint

As we're proxying the requests, we need to register an [API endpoint](/docs/web/api/new-endpoint) that will be used to pass the responses to the client.

The steps will be the same as we described in the [API](/docs/web/api/new-endpoint) section. An example implementation could look like this:

```ts title="ai/router.ts"
export const aiRouter = new Hono().post("/chat", async (c) =>
  streamText({
    model: openai.responses("gpt-5"),
    messages: convertToModelMessages((await c.req.json()).messages),
  }).toUIMessageStreamResponse(),
);
```

As you can see, we're defining which provider and specific model we want to use here.

We're also using [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts), which allows us to pass the result to the user as soon as the model starts generating it, without needing to wait for the full response to be completed. This gives the user a sense of immediacy and makes the conversation more interactive.

## Client-side

To consume the server response, we can leverage the ready-to-use hooks provided by the [Vercel AI SDK](https://sdk.vercel.ai/docs/ai-sdk-ui/chatbot), such as the `useChat` hook:

```tsx title="page.tsx"
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";

const AI = () => {
  const { messages } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/ai/chat",
    }),
  });

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            switch (part.type) {
              case "text":
                return <div key={`${message.id}-${i}`}>{part.text}</div>;
            }
          })}
        </div>
      ))}
    </div>
  );
};

export default AI;
```

By leveraging this integration, we can easily manage the state of the AI request and update the UI as soon as the response is ready.

TurboStarter ships with a ready-to-use implementation of AI chat, allowing you to see this solution in action. Feel free to reuse or modify it according to your needs.


# Overview
Source: https://www.turbostarter.dev/docs/web/ai/overview

<Callout title="Looking for AI-assisted development?">
  TurboStarter includes a set of AI rules, skills, subagents, and commands for popular AI editors and tools - so the AI follows this repo's conventions and produces more consistent changes.

  See [AI-assisted development](/docs/web/installation/ai-development) to set it up.
</Callout>

For AI integration, TurboStarter uses the [Vercel AI SDK](https://sdk.vercel.ai/docs/introduction), which provides a unified toolkit for building AI features across providers.

<Callout title="Why Vercel AI SDK?">
  It's a simple yet powerful library that exposes a unified API for all major AI providers.

  This lets you build AI features without worrying about the quirks of each underlying provider API.
</Callout>

You can learn more about the `ai` package in the [official documentation](https://sdk.vercel.ai/docs/introduction).

## Features

The starter includes common AI features out of the box, such as:

* **Chat**: Build chat applications with ease.
* **Streaming responses**: Stream responses from your AI provider in real time.
* **Image generation**: Generate images using AI technology.
* **Embeddings**: Generate embeddings for your data.
* **Vector stores**: Store and query your embeddings efficiently.

You can easily compose your application using these building blocks or extend them to suit your specific needs.

For complete persisted templates such as multi-model chat, RAG, image generation, text to speech, and voice, follow the [AI Kit integration recipe](/docs/web/recipes/ai-kit). It keeps Core authentication, billing, and shared infrastructure as the foundation.

## Providers

TurboStarter relies on the AI SDK to support multiple AI providers. This means you can switch providers without changing your code, as long as they are supported by the `ai` package.

You can find the list of supported providers in the [official documentation](https://sdk.vercel.ai/providers/ai-sdk-providers).

<Callout title="Custom providers">
  You can also add your own custom provider. It just needs to implement the common interface and provide the required methods.

  Read more about this in the [official guide](https://sdk.vercel.ai/providers/community-providers/custom-providers).
</Callout>

Provider configuration is straightforward. We'll explore it in more detail in the [Configuration](/docs/web/ai/configuration) section.


# Configuration
Source: https://www.turbostarter.dev/docs/web/analytics/configuration

The `@workspace/analytics-web` package offers a streamlined and flexible approach to tracking events in your TurboStarter web app using various analytics providers. It abstracts the complexities of different analytics services and provides a consistent interface for event tracking.

In this section, we'll guide you through the configuration process for each supported provider.

Note that the configuration is validated against a schema, so you'll see error messages in the console if anything is misconfigured.

## Providers

TurboStarter supports multiple analytics providers, each with its own unique configuration. Below, you'll find detailed information on how to set up and use each supported provider. Choose the one that best suits your needs and follow the instructions in the respective accordion section.

<Accordions>
  <Accordion title="Vercel Analytics" id="vercel">
    To use Vercel Analytics as your provider, you need to [create a Vercel account](https://vercel.com/) and [set up a project](https://vercel.com/docs/projects/overview).

    Next, enable analytics in your Vercel project settings:

    1. Navigate to the [Vercel dashboard](https://vercel.com/dashboard).
    2. Select your project.
    3. Go to the *Analytics* section.
    4. Click *Enable* in the dialog.

    <Callout>
      Enabling Web Analytics will add new routes (scoped at `/_vercel/insights/*`) after your next deployment.
    </Callout>

    Also, make sure to activate the Vercel provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:vercel]
        export * from "./vercel";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:vercel]
        export * from "./vercel/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:vercel]
        export * from "./vercel/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/vercel` directory.

    For more information, please refer to the [Vercel Analytics documentation](https://vercel.com/docs/analytics/overview).

    ![Vercel Analytics dashboard](/images/docs/web/analytics/vercel.avif)
  </Accordion>

  <Accordion title="Google Analytics" id="google-analytics">
    To use Google Analytics as your analytics provider, you need to [create a Google Analytics account](https://analytics.google.com/) and [set up a property](https://support.google.com/analytics/answer/9304153).

    Next, add a data stream in your Google Analytics account settings:

    1. Navigate to [Google Analytics](https://analytics.google.com/).
    2. In the *Admin* section, under *Data collection and modification*, click on *Data Streams*.
    3. Click *Add stream*.
    4. Select *Web* as the platform.
    5. Enter the required details for the stream (at minimum, provide a name and website URL).
    6. Click *Create stream*.

    After creating the stream, you'll need two pieces of information:

    1. Your [Measurement ID](https://support.google.com/analytics/answer/12270356) (it should look like `G-XXXXXXXXXX`):

    ![Google Analytics Measurement ID](/images/docs/web/analytics/google/id.png)

    2. Your [Measurement Protocol API secret](https://support.google.com/analytics/answer/9814495):

    ![Google Analytics Measurement Protocol API secret](/images/docs/web/analytics/google/api-secret.png)

    Set these values in your `.env.local` file in the `apps/web` directory and in your deployment environment:

    ```dotenv
    NEXT_PUBLIC_ANALYTICS_GOOGLE_MEASUREMENT_ID="your-measurement-id"
    GOOGLE_ANALYTICS_SECRET="your-measurement-protocol-api-secret"
    ```

    Also, make sure to activate the Google Analytics provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:google-analytics]
        export * from "./google-analytics";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:google-analytics]
        export * from "./google-analytics/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:google-analytics]
        export * from "./google-analytics/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/google-analytics` directory.

    For more information, please refer to the [Google Analytics documentation](https://developers.google.com/analytics).

    ![Google Analytics dashboard](/images/docs/web/analytics/google/dashboard.jpg)
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout title="You can also use it for monitoring!">
      PostHog is also one of pre-configured providers for [monitoring](/docs/web/monitoring/posthog) and [feature flags](/docs/web/flags/configuration#posthog) in TurboStarter.
    </Callout>

    To use PostHog as your analytics provider, you need to configure a PostHog instance. You can obtain the [Cloud](https://app.posthog.com/signup) instance by [creating an account](https://app.posthog.com/signup) or [self-host](https://posthog.com/docs/self-host) it.

    Then, create a project and, based on your [project settings](https://app.posthog.com/project/settings), fill the following environment variables in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_POSTHOG_KEY="your-posthog-api-key"
    NEXT_PUBLIC_POSTHOG_HOST="your-posthog-instance-host"
    ```

    Also, make sure to activate the PostHog provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:posthog]
        export * from "./posthog";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:posthog]
        export * from "./posthog/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:posthog]
        export * from "./posthog/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/posthog` directory.

    For more information, please refer to the [PostHog documentation](https://posthog.com/docs).

    ![PostHog dashboard](/images/docs/web/analytics/posthog.png)
  </Accordion>

  <Accordion title="Mixpanel" id="mixpanel">
    To use Mixpanel as your analytics provider, you need to [create an account](https://mixpanel.com/home/) and [obtain your project token](https://help.mixpanel.com/hc/en-us/articles/115004502806-Find-Project-Token).

    Then, set it as an environment variable in your `.env.local` file in the `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_MIXPANEL_TOKEN="your-project-token"
    ```

    Also, make sure to activate the Mixpanel provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:mixpanel]
        export * from "./mixpanel";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:mixpanel]
        export * from "./mixpanel/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:mixpanel]
        export * from "./mixpanel/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/mixpanel` directory.

    For more information, please refer to the [Mixpanel documentation](https://docs.mixpanel.com/).

    ![Mixpanel dashboard](/images/docs/web/analytics/mixpanel.png)
  </Accordion>

  <Accordion title="Plausible" id="plausible">
    To use Plausible as your analytics provider, you need to [create an account](https://plausible.io/) and [set up a website](https://plausible.io/docs/add-website).

    Then, set your domain and host in your `.env.local` file in the `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_PLAUSIBLE_DOMAIN="your-website-domain.com"
    NEXT_PUBLIC_PLAUSIBLE_HOST="https://plausible.io"
    ```

    Also, make sure to activate the Plausible provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:plausible]
        export * from "./plausible";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:plausible]
        export * from "./plausible/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:plausible]
        export * from "./plausible/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/plausible` directory.

    For more information, please refer to the [Plausible documentation](https://plausible.io/docs).

    ![Plausible dashboard](/images/docs/web/analytics/plausible.png)
  </Accordion>

  <Accordion title="Umami" id="umami">
    To use Umami as your analytics provider, you need to [set up Umami](https://umami.is/docs/getting-started) either by using their [cloud service](https://cloud.umami.is/) or [self-hosting](https://umami.is/docs/install).

    Then, set your website ID and host in your `.env.local` file in the `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-website-id"
    NEXT_PUBLIC_UMAMI_HOST="https://your-umami-instance.com"
    UMAMI_API_HOST="https://your-umami-instance.com"
    UMAMI_API_KEY="your-api-key"
    ```

    Also, make sure to activate the Umami provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:umami]
        export * from "./umami";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:umami]
        export * from "./umami/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:umami]
        export * from "./umami/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/umami` directory.

    For more information, please refer to the [Umami documentation](https://umami.is/docs).

    ![Umami dashboard](/images/docs/web/analytics/umami.jpg)
  </Accordion>

  <Accordion title="Open Panel" id="open-panel">
    To use Open Panel as your analytics provider, you need to [create an account](https://openpanel.dev/) and [set up a client for your project](https://docs.openpanel.dev/docs).

    Then, you would need to set your client ID and secret in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_OPEN_PANEL_CLIENT_ID="your-client-id"
    OPEN_PANEL_CLIENT_SECRET="your-client-secret"
    ```

    Also, make sure to activate the Open Panel provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:open-panel]
        export * from "./open-panel";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:open-panel]
        export * from "./open-panel/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:open-panel]
        export * from "./open-panel/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/open-panel` directory.

    For more information, please refer to the [Open Panel documentation](https://docs.openpanel.dev/).

    ![Open Panel dashboard](/images/docs/web/analytics/open-panel.webp)
  </Accordion>

  <Accordion title="Vemetric" id="vemetric">
    To use Vemetric as your analytics provider, you need to [create an account](https://vemetric.com/) and [obtain your project token](https://vemetric.com/docs/).

    Then, set it as an environment variable in your `.env.local` file in the `apps/web` directory and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_VEMETRIC_PROJECT_TOKEN="your-project-token"
    ```

    Also, make sure to activate the Vemetric provider as your analytics provider by updating the exports in:

    <Tabs items={["index.tsx", "server.ts", "env.ts"]}>
      <Tab value="index.tsx">
        ```ts
        // [!code word:vemetric]
        export * from "./vemetric";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:vemetric]
        export * from "./vemetric/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:vemetric]
        export * from "./vemetric/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/analytics/web/src/providers/vemetric` directory.

    For more information, please refer to the [Vemetric documentation](https://vemetric.com/docs/).

    ![Vemetric dashboard](/images/docs/web/analytics/vemetric.webp)
  </Accordion>
</Accordions>

## Client-side context

To enable tracking events, capturing page views and other analytics features **on the client-side**, you need to wrap your app with the `Provider` component that's implemented by every provider and available through the `@workspace/analytics-web` package.

In the kit, `ConsentProvider` wraps analytics and passes `enabled` only when the user has granted **measurement** consent. See [Cookie consent](/docs/web/recipes/cookie-consent) for the full flow.

```tsx title="apps/web/src/lib/providers/analytics.tsx"
import { useConsentManager } from "@c15t/nextjs";

import { Provider } from "@workspace/analytics-web";

export const AnalyticsProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const { has } = useConsentManager();
  const enabled = has("measurement");

  return <Provider enabled={enabled}>{children}</Provider>;
};
```

By implementing this setup, you ensure that all analytics events are properly tracked from your client-side code only after consent. This configuration allows you to safely utilize the [Analytics API](/docs/web/analytics/tracking) within your client components, enabling comprehensive event tracking and data collection.


# Overview
Source: https://www.turbostarter.dev/docs/web/analytics/overview

TurboStarter comes with built-in analytics support for multiple providers as well as a unified API for tracking events. This API enables you to easily and consistently track user behavior and app usage across your SaaS application.

## Providers

The starter implements multiple providers for managing analytics. To learn more about each provider and how to configure them, see their respective sections:

<Cards>
  <Card title="Vercel Analytics" href="/docs/web/analytics/configuration#vercel" />

  <Card title="Google Analytics" href="/docs/web/analytics/configuration#google-analytics" />

  <Card title="PostHog" href="/docs/web/analytics/configuration#posthog" />

  <Card title="Mixpanel" href="/docs/web/analytics/configuration#mixpanel" />

  <Card title="Plausible" href="/docs/web/analytics/configuration#plausible" />

  <Card title="Umami" href="/docs/web/analytics/configuration#umami" />

  <Card title="Open Panel" href="/docs/web/analytics/configuration#open-panel" />

  <Card title="Vemetric" href="/docs/web/analytics/configuration#vemetric" />
</Cards>

All configuration and setup is built-in with a unified API, allowing you to switch between providers by simply changing the exports. You can even introduce your own provider without breaking any tracking-related logic.

In the following sections, we'll cover how to set up each provider and how to track events in your application.


# Tracking events
Source: https://www.turbostarter.dev/docs/web/analytics/tracking

The implementation strategy for each analytics provider varies depending on whether it's designed for client-side or server-side use. We'll explore both approaches, as they are crucial for ensuring accurate and comprehensive analytics data in your web SaaS application.

## Client-side tracking

The client strategy for tracking events, which every provider must implement, is straightforward:

```ts
export type AllowedPropertyValues = string | number | boolean;

type TrackFunction = (
  event: string,
  data?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderClientStrategy {
  Provider: ({
    children,
    enabled,
  }: {
    children: React.ReactNode;
    enabled?: boolean;
  }) => React.ReactNode;
  track: TrackFunction;
}
```

<Callout>
  You don't need to worry much about this implementation, as all the providers are already configured for you. However, it's useful to be aware of this structure if you plan to add your own custom provider — honor `enabled` so [cookie consent](/docs/web/recipes/cookie-consent) can turn tracking off when measurement is rejected.
</Callout>

As shown above, each provider must supply two key elements:

1. `Provider` - a component that [wraps your app](/docs/web/analytics/configuration#client-side-context). Pass `enabled={false}` to skip init and page views when consent is missing.
2. `track` - a function responsible for sending event data to the provider.

To track an event, you simply need to invoke the `track` method, passing the event name and an optional data object:

```tsx
import { track } from "@workspace/analytics-web";

export const MyComponent = () => {
  return (
    <button onClick={() => track("button.click", { country: "US" })}>
      Track event
    </button>
  );
};
```

## Identifying users

Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.

For identification purposes, the client strategy can also expose `identify` and `reset` methods. They are optional and only needed if you want to identify users in your app and associate their actions with a specific user ID.

Not all analytics providers support user identification (for example, [Vercel Analytics](/docs/web/analytics/configuration#vercel) and [Plausible](/docs/web/analytics/configuration#plausible)), so make sure your chosen provider exposes these methods before using them.

```ts
type IdentifyFunction = (
  userId: string,
  traits?: Record<string, AllowedPropertyValues>,
) => void;

export interface AnalyticsProviderClientStrategy {
  identify: IdentifyFunction;
  reset: () => void;
}
```

To identify users on the client, call the `identify` function, passing the user's ID and an optional traits object:

```tsx
import { identify } from "@workspace/analytics-web";

identify("user-123", { name: "John Doe" });
```

This will associate all future events with the user's ID, allowing you to track user behavior and gain valuable insights into your application's usage patterns.

<Callout title="Configured by default!">
  The `identify` method is configured out-of-the-box to react to changes in the user's authentication state.

  When the user is authenticated, the `identify` method will be called with the user's ID and traits. When the user is logged out, the `reset` method will be called to clear the existing user identification.
</Callout>

## Server-side tracking

The server strategy for tracking events that every provider has to implement is even simpler:

```ts
export interface AnalyticsProviderServerStrategy {
  track: TrackFunction;
}
```

<Callout>
  You don't need to worry much about this implementation, as all the providers are already configured for you. However, it's useful to be aware of this structure if you plan to add your own custom provider.
</Callout>

This server-side strategy allows you to track events outside of the browser environment, which is particularly useful for scenarios involving server actions or React Server Components.

To track an event on the server side, simply call the `track` method, providing the event name and an optional data object:

```tsx
// [!code word:server]
import { track } from "@workspace/analytics-web/server";

track("button.click", {
  country: "US",
  region: "California",
});
```

<Callout type="error" title="Ensure correct import!">
  Make sure to use the correct import for the `track` function. We're using the same name for both client and server tracking, but they are different functions. For server-side, just add `/server` to the import path (`@workspace/analytics-web/server`).

  <Tabs items={["Client-side", "Server-side"]}>
    <Tab value="Client-side">
      ```tsx
      import { track } from "@workspace/analytics-web";
      ```
    </Tab>

    <Tab value="Server-side">
      ```tsx
      // [!code word:server]
      import { track } from "@workspace/analytics-web/server";
      ```
    </Tab>
  </Tabs>
</Callout>

<Callout title="Identifying users on the server" type="warn">
  On the server, there are no dedicated identification helpers like `identify` or `reset`. Most providers that support user-level tracking expect you to pass an identifier or traits directly within the `track` call (for example, as a `userId` or similar property), so make sure to check your specific provider's documentation for the recommended way to include user information.
</Callout>

Congratulations! You've now mastered event tracking in your TurboStarter web app. With this knowledge, you're well-equipped to analyze user behaviors and gain valuable insights into your application's usage patterns. Happy analyzing! 📊


# Using API client
Source: https://www.turbostarter.dev/docs/web/api/client

In Next.js, you can access the API client in two ways:

* **server-side**: in server components and API routes
* **client-side**: in client components

When you create a new page and want to fetch data, you have flexibility in where to make the API calls. Server Components are great for initial data loading since the fetching happens during server-side rendering, eliminating an extra client-server round trip. The data is then efficiently streamed to the client.

By default in Next.js, every component is a Server Component. You can opt into client-side rendering by adding the `use client` directive at the top of a component file. Client Components are useful when you need interactive features or want to fetch data based on user interactions. While they're initially server-rendered, they're also hydrated and rendered on the client, allowing you to make API calls directly from the browser.

Let's explore both approaches to understand their differences and use cases.

## Server-side

We're creating a server-side API client inside `apps/web/src/lib/api/server.ts` file. The client automatically handles passing authentication headers from the user's session to secure API endpoints.

It's pre-configured with all the necessary setup, so you can start using it right away without any additional configuration.

Then, there is nothing simpler than calling the API from your server component:

```tsx title="page.tsx"
import { api } from "~/lib/api/server";

export default async function MyServerComponent() {
  const response = await api.posts.$get();
  const posts = await response.json();

  /* do something with the data... */
  return <div>{JSON.stringify(posts)}</div>;
}
```

<Card title="Next.js - Server components" description="nextjs.org" href="https://nextjs.org/docs/app/building-your-application/rendering/server-components" />

## Client-side

We're creating a separate client-side API client in `apps/web/src/lib/api/client.tsx` file. It's a simple wrapper around the [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) that fetches or mutates data from the API.

It also requires wrapping your app in a `QueryClientProvider` component to provide the query client to the rest of the app:

```tsx title="layout.tsx"
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <QueryClientProvider>{children}</QueryClientProvider>
      </body>
    </html>
  );
}
```

Of course, it's all already configured for you, so you just need to start using `api` in your client components:

```tsx title="page.tsx"
"use client";

import { api } from "~/lib/api/client";

export default function MyClientComponent() {
  const { data: posts, isLoading } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await api.posts.$get();

      if (!response.ok) {
        throw new Error("Failed to fetch posts!");
      }

      return response.json();
    },
  });

  if (isLoading) {
    return <div>Loading...</div>;
  }

  /* do something with the data... */
  return <div>{JSON.stringify(posts)}</div>;
}
```

<Card title="Next.js - Client components" description="nextjs.org" href="https://nextjs.org/docs/app/building-your-application/rendering/client-components" />

<Callout type="warn" title="Ensure correct API url">
  Inside the `apps/web/src/lib/api/utils.ts` we're calling a function to get base url of your api, so make sure it's set correctly (especially on production) and your API endpoint is corresponding with the name there.

  ```tsx title="utils.ts"
  export const getBaseUrl = () => {
    if (typeof window !== "undefined") return window.location.origin;
    if (env.NEXT_PUBLIC_URL) return env.NEXT_PUBLIC_URL;
    if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
    return `http://localhost:${process.env.PORT ?? 3000}`;
  };
  ```

  As you can see we're mostly relying on the [environment variables](/docs/web/configuration/environment-variables) to get it, so there shouldn't be any issues with it, but in case, please be aware where to find it 😉
</Callout>

## Handling responses

As you can see in the examples above, the [Hono RPC](https://hono.dev/docs/guides/rpc) client returns a plain `Response` object, which you can use to get the data or handle errors. However, implementing this handling in every query or mutation can be tedious and will introduce unnecessary boilerplate in your codebase.

That's why we've developed the `handle` function that unwraps the response for you, handles errors, and returns the data in a consistent format. You can safely use it with any procedure from the API client:

<Tabs items={["Server-side", "Client-side"]}>
  <Tab value="Server-side">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/server";

    export default async function MyServerComponent() {
      const posts = await handle(api.posts.$get)();

      /* do something with the data... */
      return <div>{JSON.stringify(posts)}</div>;
    }
    ```
  </Tab>

  <Tab value="Client-side">
    ```tsx
    // [!code word:handle]

    "use client";

    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/client";

    export default function MyClientComponent() {
      const { data: posts, isLoading } = useQuery({
        queryKey: ["posts"],
        queryFn: handle(api.posts.$get),
      });

      if (isLoading) {
        return <div>Loading...</div>;
      }

      /* do something with the data... */
      return <div>{JSON.stringify(posts)}</div>;
    }
    ```
  </Tab>
</Tabs>

With this approach, you can focus on the business logic instead of repeatedly writing code to handle API responses in your browser extension components, making your extension's codebase more readable and maintainable.

The same error handling and response unwrapping benefits apply whether you're building web, mobile, or extension interfaces - allowing you to keep your data fetching logic consistent across all platforms.


# Internationalization
Source: https://www.turbostarter.dev/docs/web/api/internationalization

Since TurboStarter provides fully featured [internationalization](/docs/web/internationalization/overview) out of the box, you can easily localize not only the frontend but also the API layer. This can be useful when you need to fetch localized data from the database or send emails in different languages.

Let's explore possibilities of this feature.

## Request-based localization

To get the locale for the current request, you can leverage the `localize` middleware:

```ts title="email/router.ts"
const emailRouter = new Hono().get("/", localize, (c) => {
  const locale = c.var.locale;

  // do something with the locale
});
```

Inside it, we're setting the `locale` variable in the current request context, making it available to the procedure.

## Error handling

When handling errors in an internationalized API, you'll want to ensure error messages are properly translated for your users. TurboStarter provides built-in support for localizing error messages using error codes and a special `onError` hook.

That's why it's recommended to use error codes instead of direct messages in your throw statements:

```ts
throw new HttpException(HttpStatusCode.UNAUTHORIZED, {
  code: "auth:error.unauthorized",
  /* 👇 optional */
  message: "You are not authorized to access this resource.",
});
```

The error code will then be used to retrieve the localized message, and the returned response from your API will look like this:

```json
{
  "code": "auth:error.unauthorized",
  /* 👇 localized based on request's locale */
  "message": "You are not authorized to access this resource.",
  "path": "/api/auth/login",
  "status": 401,
  "timestamp": "2024-01-01T00:00:00.000Z"
}
```

Then, you can either use the returned code to get the localized message in your frontend, or simply use the returned message as is.


# Mutations
Source: https://www.turbostarter.dev/docs/web/api/mutations

As we saw in [adding new endpoint](/docs/web/api/new-endpoint#maybe-mutation), mutations allow us to modify data on the server, like creating, updating, or deleting resources. They can be defined similarly to queries using our API client.

Just like queries, mutations can be executed either server-side or client-side depending on your needs. Let's explore both approaches.

## Server actions

Next.js provides [server actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) as a powerful way to handle mutations directly on the server. They're particularly well-suited for form submissions and other data modifications.

Using our `api` client with server actions is straightforward - you simply call the API function on the server.

Here's an example of how you can define an action to create a new post:

<Tabs items={["With helper", "Without helper"]}>
  <Tab value="With helper">
    ```tsx
    // [!code word:handle]
    "use server";

    import { revalidatePath } from "next/cache";

    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/server";

    export async function createPost(post: PostInput) {
      try {
        await handle(api.posts.$post)(post);
      } catch (error) {
        onError(error);
      }

      revalidatePath("/posts");
    }
    ```
  </Tab>

  <Tab value="Without helper">
    ```tsx
    "use server";

    import { revalidatePath } from "next/cache";

    import { api } from "~/lib/api/server";

    export async function createPost(post: PostInput) {
      const response = await api.posts.$post(post);

      if (!response.ok) {
        return { error: "Failed to create post" };
      }

      revalidatePath("/posts");
    }
    ```
  </Tab>
</Tabs>

In the above example we're also using `revalidatePath` to revalidate the path `/posts` to fetch the updated list of posts.

<Cards>
  <Card title="Server actions and mutation" description="nextjs.org" href="https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations" />

  <Card title="revalidatePath" description="nextjs.org" href="https://nextjs.org/docs/app/api-reference/functions/revalidatePath" />
</Cards>

## useMutation hook

On the other hand, if you want to perform a mutation on the client-side, you can use the `useMutation` hook that comes straight from the integration with [React Query](https://tanstack.com/query).

<Tabs items={["With helper", "Without helper"]}>
  <Tab value="With helper">
    ```tsx
    // [!code word:handle]
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/react";

    export function CreatePost() {
      const queryClient = useQueryClient();
      const { mutate } = useMutation({
        mutationFn: handle(api.posts.$post),
        onSuccess: () => {
          toast.success("Post created successfully!");
          queryClient.invalidateQueries({ queryKey: ["posts"] });
        },
      });

      return <form onSubmit={...} />;
    }
    ```
  </Tab>

  <Tab value="Without helper">
    ```tsx
    import { api } from "~/lib/api/react";

    export function CreatePost() {
      const queryClient = useQueryClient();
      const { mutate } = useMutation({
        mutationFn: async (post: PostInput) => {
          const response = await api.posts.$post(post);

          if (!response.ok) {
            throw new Error("Failed to create post!");
          }
        },
        onSuccess: () => {
          toast.success("Post created successfully!");
          queryClient.invalidateQueries({ queryKey: ["posts"] });
        },
      });

      return <form onSubmit={...} />;
    }
    ```
  </Tab>
</Tabs>

<Cards>
  <Card title="useMutation hook" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/reference/useMutation" />

  <Card title="Query invalidation" description="tanstack.com" href="https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation" />
</Cards>


# Adding new endpoint
Source: https://www.turbostarter.dev/docs/web/api/new-endpoint

To define a new API endpoint, you can either extend an existing entity (e.g. add new customer route) or create a new, separate module.

<Steps>
  <Step>
    ## Create new module

    To create a new module you can create a new folder in the `modules` folder. For example `modules/posts`.

    Then you would need to create a router declaration for this module. We're following a convention with the filename describing its purpose, so you would need to create a file named `router.ts` in the `modules/posts` folder.

    ```typescript title="modules/posts/router.ts"
    import { Hono } from "hono";

    import { validate } from "../../middleware";

    export const postsRouter = new Hono().get(
      "/",
      validate("query", filtersSchema),
      (c) => getAllPosts(c.req.valid("query")),
    );
    ```

    As you can see we're implementing a `.get` method without any additional middlewares for the router. This is a simple way to define a new GET endpoint.

    Also, we're using a [zod](https://zod.dev/) validator to ensure that input passed to the endpoint is correct.
  </Step>

  <Step>
    ### Maybe mutation?

    The same way you can define a mutation for the new entity, just by changing the `get` to `post`:

    ```ts title="modules/posts/router.ts"
    // [!code word:.post]
    export const postsRouter = new Hono().post(
      "/",
      enforceAuth,
      validate("json", postSchema),
      (c) => createPost(c.req.valid("json")),
    );
    ```

    Hono supports all [HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods), so you can define a new endpoint for any method you need (e.g. `put`, `delete`, etc.).

    The `enforceAuth` middleware ensures that only authenticated users can access the endpoint, while the zod validator checks if the input data matches the expected schema. This combination provides both authentication and data validation in a single, clean setup.

    [Read more about protected routes](/docs/web/api/protected-routes).
  </Step>

  <Step>
    ## Implement logic

    Then you would need to create a controller for this module. There is a place, where the logic happens, e.g. for the `GET /` endpoint we would need to create a `getAllPosts` function which will fetch posts from the database.

    ```typescript title="modules/posts/queries.ts"
    import { db } from "@workspace/db/server";
    import { posts } from "@workspace/db/schema";

    export const getAllPosts = (filters: Filters) => {
      return db.select().from(posts).all().where(/* your filter logic here */);
    };
    ```
  </Step>

  <Step>
    ## Register router

    To make the module and its endpoints available in the API you need to register a router for this module in the `index.ts` file:

    ```ts title="index.ts"
    import { postsRouter } from "./modules/posts/router";

    const appRouter = new Hono()
      .basePath("/api")
      .route("/posts", postsRouter)
      /* other routers from your app logic */
      .onError(onError);

    type AppRouter = typeof appRouter;

    export type { AppRouter };
    export { appRouter };
    ```

    The `basePath` method sets a prefix for all routes in this router. While optional, using it helps organize API endpoints. This modular approach makes the API structure clearer and easier to maintain.
  </Step>
</Steps>

That's it! You've just created a new API endpoint - it's now available at `/api/posts` 🎉

<Callout title="It's fully type-safe!">
  By exporting the `AppRouter` type you get fully type-safe RPC calls in the
  client. It's important because without producing a huge amount of code, we're
  fully type-safe from the frontend code. It helps avoid passing incorrect data
  to the procedure and streamline consuming returned types without a need to
  define these types by hand.
</Callout>


# OpenAPI
Source: https://www.turbostarter.dev/docs/web/api/openapi

[OpenAPI](https://www.openapis.org/) is the standard way to describe HTTP APIs. In TurboStarter, it sits next to the existing [Hono RPC client](/docs/web/api/client) - you keep end-to-end TypeScript types for first-party apps, and you also get a machine-readable contract that tools, partners, and AI agents can consume.

That second audience matters more than ever. Agents, MCP servers, codegen tools, and API gateways all speak OpenAPI. When your routes are documented with Zod schemas, you can hand an agent a single URL and let it call your API safely - without teaching it your TypeScript types by hand.

<Callout title="TL;DR">
  Annotate Hono routes with a thin `document()` helper (`summary` + response Zod schema), serve an OpenAPI document at `/api/openapi`, and browse everything in Scalar at `/api/docs`. Better Auth contributes a second schema source for auth endpoints. Your existing RPC clients stay unchanged.
</Callout>

![Scalar API reference for TurboStarter](/images/docs/web/api/openapi/docs.png)

## Why OpenAPI in TurboStarter?

Hono RPC already gives you type-safe calls inside the monorepo. OpenAPI covers everything *outside* that closed loop:

| Audience                   | What OpenAPI unlocks                                                                                        |
| -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **AI agents & MCP**        | Feed `/api/openapi` to an agent so it can discover operations, validate payloads, and call your product API |
| **External clients**       | Generate typed SDKs for Python, Go, Swift, or any language your customers use                               |
| **Partners & integrators** | Share an interactive Scalar portal instead of a Notion dump of endpoints                                    |
| **QA & contract tests**    | Diff the OpenAPI document in CI, or generate smoke tests from the schema                                    |
| **API gateways**           | Import the document into Kong, Cloudflare, or similar for routing and auth policies                         |

You document once in Zod. Runtime validation, RPC inference, and the OpenAPI document all stay aligned.

## What's included?

TurboStarter wires three pieces together:

<Cards>
  <Card title="OpenAPI document" description="Generated from annotated Hono routes at /api/openapi." />

  <Card title="Scalar UI" description="Interactive reference at /api/docs with Try it support." />

  <Card title="Auth schema" description="Better Auth OpenAPI plugin as a second Scalar source." />
</Cards>

Covered out of the box (once annotated):

* **Organizations** - members, invitations, slug helpers
* **Billing** - summary, usage, checkout, portal (webhooks stay undocumented on purpose)
* **Storage** - upload, signed, public, and delete URLs
* **Admin** - users, organizations, customers, summary
* **AI** - streaming chat endpoint
* **Auth** - Better Auth routes via the dedicated OpenAPI plugin

Intentionally **excluded** from the API document: billing webhooks, the auth catch-all proxy, the status monitor, and the `/openapi` + `/docs` routes themselves. Those are infrastructure, not product surface.

## Architecture

```txt
packages/api
├── src/lib/openapi.ts          # document() + shared error responses
├── src/schema/openapi.ts       # ApiError envelope (Zod)
├── src/modules/*/router.ts     # doc({ summary, response }) per route
└── src/index.ts                # /openapi + /docs handlers

packages/auth
└── src/server.ts               # openAPI() Better Auth plugin
```

Flow:

1. Routes keep using Zod via `validate()` for request bodies and query params.
2. `document()` attaches OpenAPI metadata (tags, summary, security, response schemas).
3. `openAPIRouteHandler` walks the Hono app and emits OpenAPI JSON.
4. Scalar loads that JSON (plus Better Auth's schema) and renders `/api/docs`.

First-party apps keep calling through the [typed RPC client](/docs/web/api/client). OpenAPI is an additional export, not a replacement.

## Setup

If your TurboStarter already includes OpenAPI, skip to [Documenting a route](#documenting-a-route). Otherwise, add the pieces below.

<Steps>
  <Step>
    ## Install packages

    Add the OpenAPI stack to `@workspace/api`. Prefer whatever versions `hono-openapi` and `@scalar/hono-api-reference` recommend for your current Hono release - peer dependencies can change between majors:

    ```bash
    pnpm --filter @workspace/api add hono-openapi @scalar/hono-api-reference
    ```

    Install any additional packages those libraries list as peers (for example standard-schema / OpenAPI helpers). Then switch request validation to `hono-openapi`'s `validator` so Zod schemas feed both runtime checks and the document. You can remove `@hono/zod-validator` once that migration is done.
  </Step>

  <Step>
    ## Enable Better Auth OpenAPI

    Register the official plugin so auth endpoints get their own schema:

    ```ts title="packages/auth/src/server.ts"
    import { openAPI } from "better-auth/plugins";

    export const auth = betterAuth({
      // ...existing config
      plugins: [
        // ...existing plugins
        openAPI(),
      ],
    });
    ```

    Better Auth then exposes an OpenAPI schema endpoint (check the [Better Auth OpenAPI plugin docs](https://www.better-auth.com/docs/plugins/open-api) for the exact path in your version), which Scalar will load as a second source next to your Hono API document.
  </Step>

  <Step>
    ## Add shared OpenAPI helpers

    Create a small error envelope and a `document()` factory. Every module reuses the same 401 / 403 / 422 responses and cookie security scheme:

    ```ts title="packages/api/src/schema/openapi.ts"
    import * as z from "zod";

    export const apiErrorSchema = z.object({
      code: z.string().optional(),
      message: z.string(),
      status: z.number(),
      timestamp: z.string(),
      path: z.string(),
    });

    export type ApiError = z.infer<typeof apiErrorSchema>;
    ```

    ```ts title="packages/api/src/lib/openapi.ts"
    import { describeRoute, resolver } from "hono-openapi";

    import { apiErrorSchema } from "../schema/openapi";

    import type { DescribeRouteOptions } from "hono-openapi";
    import type * as z from "zod";

    export const jsonResponse = <T extends z.ZodType>(
      schema: T,
      description = "OK",
    ) => ({
      description,
      content: {
        "application/json": {
          schema: resolver(schema),
        },
      },
    });

    export const errorResponses: NonNullable<DescribeRouteOptions["responses"]> = {
      401: jsonResponse(apiErrorSchema, "Unauthorized"),
      403: jsonResponse(apiErrorSchema, "Forbidden"),
      422: jsonResponse(apiErrorSchema, "Validation error"),
    };

    export const document =
      (tag: string) =>
      <T extends z.ZodType>({
        summary,
        response,
        responses,
        security = [{ cookieAuth: [] }],
        ...options
      }: {
        summary: string;
        response?: T;
      } & DescribeRouteOptions) =>
        describeRoute({
          ...options,
          tags: [tag],
          summary,
          security,
          responses: {
            ...errorResponses,
            ...(response ? { 200: jsonResponse(response) } : undefined),
            ...responses,
          },
        });
    ```

    <Callout type="info" title="One helper, every module">
      `document("Organizations")` (or `"Billing"`, `"Admin"`, …) returns a middleware factory. Keep call sites boring: `doc({ summary, response })` and move on.
    </Callout>
  </Step>

  <Step>
    ## Switch validation to hono-openapi

    Update `validate` so request schemas are part of the OpenAPI document:

    ```ts title="packages/api/src/middleware.ts"
    import { validator } from "hono-openapi";

    export const validate = <
      T extends z.ZodType,
      Target extends keyof ValidationTargets,
    >(
      target: Target,
      schema: T,
    ) =>
      validator(target, schema, async (result, c) => {
        if (result.success) {
          return;
        }

        // ...existing i18n error mapping
        throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY, {
          code,
          message,
        });
      });
    ```

    Behavior for clients stays the same - invalid input still returns `422` with your localized error envelope.
  </Step>

  <Step>
    ## Mount `/openapi` and `/docs`

    Register the document handler and Scalar UI on the main app router (after your feature routes, before `onError`):

    ```ts title="packages/api/src/index.ts"
    import { Scalar } from "@scalar/hono-api-reference";
    import { openAPIRouteHandler } from "hono-openapi";

    const appRouter = app
      .get(
        "/openapi",
        openAPIRouteHandler(app, {
          documentation: {
            info: {
              title: "TurboStarter API",
              version: "1.0.0", // bump when you ship breaking API changes
            },
            servers: [{ url: "/" }],
            tags: [
              { name: "Admin" },
              { name: "AI" },
              { name: "Billing" },
              { name: "Organizations" },
              { name: "Storage" },
            ],
            components: {
              securitySchemes: {
                cookieAuth: {
                  type: "apiKey",
                  in: "cookie",
                  // match your Better Auth session cookie name
                  name: "turbostarter.session_token",
                },
              },
            },
          },
          exclude: [/^\/openapi$/, /^\/docs$/],
        }),
      )
      .get(
        "/docs",
        Scalar({
          pageTitle: "TurboStarter API",
          sources: [
            { url: "/api/openapi", title: "API" },
            // Better Auth OpenAPI schema URL for your setup
            { url: "/api/auth/open-api/generate-schema", title: "Auth" },
          ],
        }),
      )
      .onError(onError);
    ```

    With the web app running, open:

    * [http://localhost:3000/api/docs](http://localhost:3000/api/docs) - Scalar UI

    ![Scalar API reference for TurboStarter](/images/docs/web/api/openapi/docs.png)

    * [http://localhost:3000/api/openapi](http://localhost:3000/api/openapi) - raw OpenAPI JSON

    ![OpenAPI JSON document in the browser](/images/docs/web/api/openapi/json.png)
  </Step>
</Steps>

## Documenting a route

Annotating a route is a three-line habit: response Zod schema, `doc({ summary, response })`, keep `validate` for inputs.

<Steps>
  <Step>
    ### Define response schemas

    Put response shapes next to your input schemas so the contract lives with the module:

    ```ts title="packages/api/src/schema/organization.ts"
    export const generateSlugInputSchema = z.object({
      name: z.string(),
    });

    export const generateSlugResponseSchema = z.object({
      slug: z.string(),
    });

    export const getMembersResponseSchema = z.object({
      data: z.array(
        z.object({
          id: z.string(),
          organizationId: z.string(),
          role: z.enum(MemberRole),
          createdAt: z.coerce.date(),
          userId: z.string(),
          user: z.object({
            id: z.string(),
            name: z.string(),
            email: z.string(),
            image: z
              .string()
              .nullish()
              .transform((val) => (val === null ? undefined : val)),
          }),
        }),
      ),
      total: z.number(),
    });
    ```
  </Step>

  <Step>
    ### Attach `document()` to the handler

    ```ts title="packages/api/src/modules/organization/router.ts"
    import { document } from "../../lib/openapi";
    import { enforceAuth, validate } from "../../middleware";
    import {
      generateSlugInputSchema,
      generateSlugResponseSchema,
      getMembersInputSchema,
      getMembersResponseSchema,
    } from "../../schema";

    const doc = document("Organizations");

    export const organizationRouter = new Hono()
      .use(enforceAuth)
      .get(
        "/slug",
        doc({
          summary: "Generate organization slug",
          response: generateSlugResponseSchema,
        }),
        validate("query", generateSlugInputSchema),
        async (c) => c.json(await generateSlug(c.req.valid("query").name)),
      )
      .get(
        "/:id/members",
        doc({
          summary: "List organization members",
          response: getMembersResponseSchema,
        }),
        validate("query", getMembersInputSchema),
        (c, next) =>
          enforceMembership({ organizationId: c.req.param("id") })(c, next),
        async (c) =>
          c.json(
            await getMembers({
              organizationId: c.req.param("id"),
              ...c.req.valid("query"),
            }),
          ),
      );
    ```
  </Step>

  <Step>
    ### Override security or content type when needed

    Public routes should clear the default cookie security. Streaming or non-JSON responses use a custom `responses` map:

    ```ts title="packages/api/src/modules/storage/router.ts"
    .get(
      "/public",
      doc({
        summary: "Get public URL",
        response: storageUrlResponseSchema,
        security: [], // public - no session cookie required
      }),
      validate("query", getObjectUrlSchema),
      async (c) => c.json(await getPublicUrl(c.req.valid("query"))),
    );
    ```

    ```ts title="packages/api/src/modules/ai/router.ts"
    import { resolver } from "hono-openapi";

    .post(
      "/chat",
      doc({
        summary: "Stream AI chat",
        responses: {
          200: {
            description: "UI message stream",
            content: {
              "text/plain": { schema: resolver(z.string()) },
            },
          },
        },
      }),
      enforceAuth,
      async (c) => {
        /* ... */
      },
    );
    ```
  </Step>
</Steps>

<Callout type="warn" title="Don't document everything">
  Skip webhooks, health checks, and internal proxies. If a route is not meant for humans or agents to call, leave `document()` off - it won't appear in `/api/openapi`.
</Callout>

## Using the docs day to day

1. Start the web app (`pnpm --filter web dev` or `pnpm dev`).
2. Open `/api/docs` and switch between the **API** and **Auth** sources.
3. Sign in to the app in another tab so Scalar's "Try it" requests carry the session cookie.
4. Spot-check a few operations (organizations, billing, storage) and confirm request/response schemas match reality.
5. Hit `/api/openapi` when you need the raw document for codegen or an MCP tool.

![Trying an authenticated Organizations request in Scalar](/images/docs/web/api/openapi/try-it.png)

## AI agents and MCP

OpenAPI is the cheapest way to make your product API agent-ready.

* **Point an agent at `/api/openapi`** - many agent frameworks can load an OpenAPI document and turn each operation into a tool.
* **Build an MCP server from the document** - generate or hand-write MCP tools that wrap authenticated Hono routes. Agents in Cursor, Claude, or ChatGPT then talk to *your* SaaS the same way they talk to third-party APIs.
* **Keep schemas strict** - Zod on input *and* output is what makes tool calling reliable. Vague `z.any()` responses produce vague agent behavior.
* **Separate product API from admin API** - publish a public subset for customer agents; keep Admin tagged and gated behind stronger auth.

Example agent prompt once docs are live:

```txt
Load https://your-app.com/api/openapi and list organization members for org_123.
Use the session cookie from my browser / the provided API token.
```

Pair this with TurboStarter's [docs MCP server](/docs/web/installation/mcp) for documentation, and your own OpenAPI-backed MCP for live product actions - docs for knowledge, OpenAPI for execution.

## Scaling the setup

The base integration is intentionally thin. Grow it as your API surface grows.

### Codegen SDKs

Export `/api/openapi` in CI and generate clients for languages outside the monorepo:

```bash
# example - generate a typed JS/TS client from the live document
npx openapi-typescript https://your-app.com/api/openapi -o src/generated/api.ts
```

The same document works with OpenAPI Generator and other SDK pipelines - pick whatever fits your stack.

### Versioning and environments

* Bump `info.version` when you ship breaking response changes.
* Add multiple `servers` entries (local, staging, production) so Scalar and codegen pick the right base URL.
* For multi-tenant or white-label APIs, generate the document per deployment with environment-specific `servers` and `info.title`.

### Split public vs private documents

When you expose partner APIs, mount a second handler that only includes tagged public routes:

```ts
openAPIRouteHandler(app, {
  documentation: {
    info: {
      title: "TurboStarter Public API",
      version: "1.0.0", // your public API version
    },
    // ...
  },
  exclude: [
    /^\/openapi$/,
    /^\/docs$/,
    /^\/admin(\/|$)/, // hide admin from the public document
  ],
});
```

Serve `/api/openapi` privately and `/api/public/openapi` on a partner portal.

### Contract testing

Treat the OpenAPI JSON as an artifact:

* Snapshot `/api/openapi` in CI and fail on unexpected diffs
* Validate responses against response Zod schemas in integration tests (you already have the schemas)
* Generate smoke tests for every `200` operation before a release

### Gateways and edge

Import the document into your API gateway for rate limits, auth policies, and request validation at the edge - without re-describing routes a third time.

### Error envelope consistency

Align runtime errors with `apiErrorSchema` (including `timestamp`) so documented error responses match what clients actually receive. Agents and generated SDKs rely on that consistency.

## Checklist

When you add a new Hono module:

* [ ] Add input **and** response Zod schemas under `packages/api/src/schema`
* [ ] Create `const doc = document("YourTag")` in the router
* [ ] Call `doc({ summary, response })` on every product-facing route
* [ ] Use `security: []` for public routes
* [ ] Leave webhooks / health / internal proxies undocumented
* [ ] Confirm the route appears under the right tag in `/api/docs`
* [ ] Smoke the operation with Scalar "Try it" while authenticated

<Cards>
  <Card title="API overview" description="Hono app structure and status monitor" href="/docs/web/api/overview" />

  <Card title="Adding a new endpoint" description="Module + router conventions" href="/docs/web/api/new-endpoint" />

  <Card title="Protected routes" description="Auth and permission middleware" href="/docs/web/api/protected-routes" />

  <Card title="Using the API client" description="Typed RPC for first-party apps" href="/docs/web/api/client" />

  <Card title="MCP server" description="Docs access for AI assistants" href="/docs/web/installation/mcp" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/web/api/overview

TurboStarter is designed to be a scalable and production-ready full-stack starter kit. One of its core features is a dedicated and extensible API layer. To enable this in a type-safe way, we chose [Hono](https://hono.dev) as the API server and client library.

<Callout title="Why Hono?">
  Hono is a small, simple, and ultrafast web framework that gives you a way to
  define your API endpoints with full type safety. It provides built-in
  middleware for common needs like validation, caching, and CORS.

  It also includes a [RPC client](https://hono.dev/docs/guides/rpc) for making
  type-safe function calls from the frontend. Being edge-first, it's optimized
  for serverless environments and offers excellent performance.
</Callout>

All API endpoints and their resolvers live in the `packages/api` package. Inside, the `modules` folder contains the API's feature modules. Each module has its own directory and exports its resolvers.

For each module, we create a separate Hono router and then aggregate all sub-routers into one main router in the `index.ts` file.

By default, the API is integrated with the web app and exposed as a [Next.js route handler](https://nextjs.org/docs/app/getting-started/route-handlers):

```ts title="apps/web/src/app/api/[...route]/route.ts"
import { handle } from "hono/vercel";

import { appRouter } from "@workspace/api";

const handler = handle(appRouter);
export {
  handler as GET,
  handler as POST,
  handler as OPTIONS,
  handler as PUT,
  handler as PATCH,
  handler as DELETE,
  handler as HEAD,
};
```

<Callout type="warn" title="API availability">
  As the API is a separate service, it **must** be deployed to use it in other apps (e.g. [mobile app](/docs/mobile/api/overview) or [browser extension](/docs/extension/api/overview)), even if you don't need the web app itself.

  By default, it's hosted together with the web app, so you don't need to worry about it separately. However, you can also [deploy it as a standalone service](/docs/web/deployment/api).
</Callout>

## Observability

To give you some visibility into how the API is performing in production, and to track its usage and performance metrics, we integrated a basic status monitor, which is available at the [/api/status](http://localhost:3000/api/status) route.

![API status monitor](/images/docs/web/api/status.png)

You can use it to check if the API is running and to get basic metrics like:

* uptime
* response time
* recent errors
* CPU and memory usage
* event loop lag
* server info
* route analytics
* p50, p95, and p99 latency

Feel free to extend it with your own metrics and monitoring tools.

To learn more about the API, check the following sections:


# Protected routes
Source: https://www.turbostarter.dev/docs/web/api/protected-routes

Hono has built-in support for [middlewares](https://hono.dev/docs/guides/middleware), which are functions that can be used to modify the context or execute code before or after a route handler is executed.

That's how we can secure our API endpoints from unauthorized access. Below are some examples of you can leverage middlewares to protect your API routes.

## Authenticated access

After validating the user's authentication status, we store their data in the context using [Hono's built-in context](https://hono.dev/docs/api/context). This allows us to access the user's information in subsequent middleware and procedures without having to re-validate the session.

Here's an example of middleware that validates whether the user is currently logged in and stores their data in the context:

```ts title="middleware.ts"
export const enforceAuth = createMiddleware<{
  Variables: {
    user: User;
  };
}>(async (c, next) => {
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
  const user = session?.user ?? null;

  if (!user) {
    throw new HTTPException(HttpStatusCode.UNAUTHORIZED, {
      message: "You need to be logged in to access this feature!",
    });
  }

  c.set("user", user);
  await next();
});
```

Then we can use our defined middleware to protect endpoints by adding it before the route handler:

```ts title="billing/router.ts"
export const billingRouter = new Hono().get(
  "/customer",
  enforceAuth,
  async (c) => c.json(await getCustomerByUserId(c.var.user.id)),
);
```

## Role-based access

In most cases, you will want to restrict access to certain endpoints based on the user's role.

You can achieve this by creating a middleware that will check if the user has the required role and then pass the execution to the next middleware or procedure.

E.g. for admin endpoints we want to ensure that the user has the `admin` role:

```ts title="middleware.ts"
export const enforceAdmin = createMiddleware<{
  Variables: {
    user: User;
  };
}>(async (c, next) => {
  const user = c.var.user;

  if (!hasAdminPermission(user)) {
    throw new HttpException(HttpStatusCode.FORBIDDEN, {
      message: "You need to be an admin to access this feature!",
    });
  }

  await next();
});
```

Then we can use our defined middleware to protect endpoints by adding it before the route handler:

```ts title="admin/router.ts"
export const adminRouter = new Hono().get(
  "/users",
  enforceAuth,
  enforceAdmin,
  (c) => c.json(...),
);
```

## Feature-based access

When developing your API you may want to restrict access to certain features based on the user's current subscription plan. (e.g. only users with "Pro" plan can access teams).

For a full walkthrough — defining features in billing config, gating UI, usage limits, and platform-specific patterns — see the [feature-based access recipe](/docs/web/recipes/feature-based-access). The middleware below is the API enforcement piece of that flow.

You can achieve this by creating a middleware that will check if the user has access to the feature and then pass the execution to the next middleware or procedure:

```ts title="middleware.ts"
export const enforceFeatureAvailable = (feature: Feature) =>
  createMiddleware<{
    Variables: {
      user: User;
    };
  }>(async (c, next) => {
    const { data: customer } = await getCustomerById(c.var.user.id);

    const hasFeature = isFeatureAvailable(customer, feature);

    if (!hasFeature) {
      throw new HTTPException(HttpStatusCode.PAYMENT_REQUIRED, {
        message: "Upgrade your plan to access this feature!",
      });
    }

    await next();
  });
```

Use it within your procedure the same way as we did with `enforceAuth` middleware:

```ts title="teams/router.ts"
export const teamsRouter = new Hono().get(
  "/",
  enforceAuth,
  enforceFeatureAvailable(FEATURES.PRO.TEAMS),
  (c) => c.json(...),
);
```

<Callout title="Go further">
  The recipe also covers `checkPlanLimit()` for quota-style restrictions, upgrade prompts in React, and mobile or extension gating:

  <Cards>
    <Card title="Feature-based access (web)" href="/docs/web/recipes/feature-based-access" description="End-to-end plan gating: features.ts, middleware, UI, and limits." />

    <Card title="Feature-based access (mobile)" href="/docs/mobile/recipes/feature-based-access" description="RevenueCat and Superwall entitlements merged with API summary." />

    <Card title="Feature-based access (extension)" href="/docs/extension/recipes/feature-based-access" description="Gate extension UI and link users to web checkout." />
  </Cards>
</Callout>

These are just examples of what you can achieve with Hono middlewares. You can use them to add any kind of logic to your API (e.g. [logging](https://hono.dev/docs/middleware/builtin/logger), [caching](https://hono.dev/docs/middleware/builtin/cache), etc.)

For the broader security model - server/client boundaries, secrets, tenancy, and webhooks - see the [Security](/docs/web/security/overview) section.


# Two-Factor Authentication (2FA)
Source: https://www.turbostarter.dev/docs/web/auth/2fa

TurboStarter uses [Better Auth's 2FA plugin](https://better-auth.com/docs/plugins/2fa) to provide multi-factor authentication (MFA) capabilities. Two-factor authentication adds an extra layer of security by requiring users to provide a second form of verification alongside their password.

## Available methods

TurboStarter supports multiple 2FA verification methods through Better Auth:

* **TOTP (Time-based One-Time Password)** - codes generated by authenticator apps
* **OTP (One-Time Password)** - codes sent via email or SMS
* **Backup codes** - single-use recovery codes for account recovery

You can use any TOTP-compatible authenticator app, such as:

* [Google Authenticator](https://support.google.com/accounts/answer/1066447)
* [Authy](https://authy.com/)
* [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app)
* [1Password](https://1password.com/features/authenticator/)
* [Bitwarden](https://bitwarden.com/help/authenticator-keys/)

## Enabling 2FA

<Steps>
  <Step>
    ### Enable in settings

    Users enable two-factor authentication in their account security settings.

    ![Enable 2FA](/images/docs/web/auth/two-factor/enable.png)
  </Step>

  <Step>
    ### Setup authenticator

    A QR code is displayed for users to scan with their authenticator app.

    ![Setup authenticator](/images/docs/web/auth/two-factor/authenticator-app.png)
  </Step>

  <Step>
    ### Verify setup

    Users enter a verification code from their authenticator to confirm setup.
  </Step>

  <Step>
    ### Backup codes

    Users receive single-use backup codes for account recovery.

    ![Backup codes](/images/docs/web/auth/two-factor/backup-codes.png)
  </Step>
</Steps>

<Callout type="info">
  Recovery codes are essential for account recovery if users lose access to
  their authenticator device. Make sure to educate users about safely storing
  their backup codes.
</Callout>

## Using 2FA

<Steps>
  <Step>
    ### Sign in normally

    Users enter their email and password or other methods as usual.
  </Step>

  <Step>
    ### 2FA prompt

    After successful password verification, users are prompted for their 2FA code.

    ![2FA prompt](/images/docs/web/auth/two-factor/sign-in-prompt.png)
  </Step>

  <Step>
    ### Enter verification code

    Users input the 6-digit code from their authenticator app.
  </Step>

  <Step>
    ### Access granted

    Upon successful verification, users gain access to their account.
  </Step>
</Steps>

### Trusted devices

Users can mark devices as trusted during 2FA verification. Trusted devices won't require 2FA verification for 60 days, providing a balance between security and convenience.

## Configuration

2FA is configured through Better Auth's plugin system. The plugin handles:

* Secure secret generation and storage
* QR code generation for authenticator setup
* TOTP code validation
* Backup code generation and management
* Trusted device management

For detailed implementation instructions, refer to the [Better Auth 2FA documentation](https://better-auth.com/docs/plugins/2fa).


# Configuration
Source: https://www.turbostarter.dev/docs/web/auth/configuration

TurboStarter supports multiple different authentication methods:

* **Password** - the traditional email/password method
* **Magic Link** - passwordless email link authentication
* **OTP** - one-time passwords sent to email or phone number
* **Passkey** - passkeys as an alternative to passwords
* **Anonymous** - guest mode for unauthenticated users
* **OAuth** - OAuth providers; [Apple](https://better-auth.com/docs/authentication/apple), [Google](https://better-auth.com/docs/authentication/google), and [GitHub](https://better-auth.com/docs/authentication/github) are set up by default
* [Google One Tap](https://developers.google.com/identity/gsi/web/guides) - native, one-click prompt for Google authentication

All authentication methods are enabled by default, but you can easily customize them to your needs. You can enable or disable any method, or configure it separately according to your requirements.

<Callout>
  Remember that you can mix and match these methods or add new ones - for
  example, you can have both password and magic link/OTP authentication enabled
  at the same time, giving your users more flexibility in how they authenticate.
</Callout>

Authentication configuration can be customized through a simple configuration file. The following sections explain the available options and how to configure each authentication method based on your requirements.

## API

The **server-side** authentication configuration lives in `packages/auth/src/server.ts`. It configures the [Better Auth](https://better-auth.com) package with the desired providers and settings:

```ts title="server.ts"
export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: () => {},
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: () => {},
  },
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),
  plugins: [
    magicLink({
      sendMagicLink: () => {},
    }),
    passkey(),
    anonymous(),
    expo(),
    nextCookies(),
  ],
  socialProviders: {
    [SocialProvider.APPLE]: {
      clientId: env.APPLE_CLIENT_ID,
      clientSecret: env.APPLE_CLIENT_SECRET,
      appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER,
    },
    [SocialProvider.GOOGLE]: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
    [SocialProvider.GITHUB]: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
  },

  /* other configuration options */
});
```

The configuration is validated against Better Auth's schema at runtime, providing immediate feedback if any settings are incorrect or insecure. This validation ensures your authentication setup remains robust and properly configured.

All authentication routes and handlers are centralized within the [Hono API](/docs/web/api/overview), giving you a single source of truth and complete control over the authentication flow. This centralization makes it easier to maintain, debug, and customize the authentication process as needed.

[Read more about it in the official documentation](https://better-auth.com/docs/basic-usage).

## UI

We have separate configuration that determines what is displayed to your users in the **UI**. It's set at `apps/web/config/auth.ts`.

```ts title="apps/web/config/auth.ts"
import { authConfigSchema, type AuthConfig } from "@workspace/auth";

export const authConfig = authConfigSchema.parse({
  providers: {
    password: true,
    magicLink: true,
    emailOtp: false,
    passkey: false,
    anonymous: true,
    oAuth: ["apple", "google"],
  },
}) satisfies AuthConfig;
```

The configuration is also validated using the Zod schema, so if something is off, you'll see the errors.

<Callout title="Use environment variables instead of inline configuration">
  **Avoid editing the config file directly.** Prefer environment variables to override the defaults.

  For example, if you want to switch from password to magic link, you'd change the following environment variables:

  ```dotenv title=".env.local"
  NEXT_PUBLIC_AUTH_PASSWORD=false
  NEXT_PUBLIC_AUTH_MAGIC_LINK=true
  ```
</Callout>

To display third-party providers in the UI, you need to set the `oAuth` array to include the provider you want to display. The default is Apple, Google and Github:

```tsx title="apps/web/config/auth.ts"
providers: {
    ...
    oAuth: ["apple", "google", "github"],
    ...
},
```

## Third-party providers

To enable third-party authentication providers, you'll need to:

1. Create an OAuth application in the provider’s developer console ([Apple](https://developer.apple.com/account/), [Google Cloud Console](https://console.cloud.google.com/), [GitHub](https://github.com/settings/developers), or another supported provider).
2. Set the matching environment variables in your TurboStarter app.

Each provider needs its own credentials and env vars. See the [Better Auth OAuth docs](https://better-auth.com/docs/concepts/oauth) for step-by-step setup per provider.

<Callout title="Multiple environments">
  Make sure to set both development and production environment variables
  appropriately. Your OAuth provider may require different callback URLs for
  each environment.
</Callout>


# User flow
Source: https://www.turbostarter.dev/docs/web/auth/flow

TurboStarter ships with a fully functional authentication system. Most views and components are preconfigured and easy to customize.

Here you will find a quick walkthrough of the authentication flow.

## Sign up

The sign-up page is where users can create an account. They need to provide their email address and password.

![Sign up](/images/docs/web/auth/sign-up.png)

Once successful, users are asked to confirm their email address. This is enabled by default - and due to security reasons, it's not possible to disable it.

<Callout type="warn" title="Sending authentication emails">
  Make sure to configure the [email provider](/docs/web/emails/configuration) together with the [auth hooks](/docs/web/emails/sending#authentication-emails) to be able to send emails from your app.
</Callout>

![Confirm email](/images/docs/web/auth/confirm-email.png)

## Sign in

The sign-in page lets users log in with email and password, magic link (if enabled), one-time password (if enabled), or third-party providers.

![Sign in](/images/docs/web/auth/sign-in.png)

## Sign out

The sign out button is located in the user menu.

![Sign out](/images/docs/web/auth/sign-out.png)

## Forgot password

The forgot-password page lets users request a reset. They enter their email and follow the instructions sent to them.

![Forgot password](/images/docs/web/auth/forgot-password.png)

The reset-password page is where users land from the password-reset email. They set a new password and confirm it.

![Reset password](/images/docs/web/auth/update-password.png)

## Two-factor authentication

Two-factor authentication is a security feature that requires users to provide a code sent to their email or phone number in addition to their password when logging in.

![Two-factor authentication](/images/docs/web/auth/two-factor/sign-in-prompt.png)


# OAuth
Source: https://www.turbostarter.dev/docs/web/auth/oauth

Better Auth supports over **30** (!) different [OAuth providers](https://better-auth.com/docs/concepts/oauth). They can be easily configured and enabled in the kit without any additional configuration needed.

<Callout title="Everything configured out of the box!">
  TurboStarter provides you with all the configuration required to handle OAuth providers responses from your app:

  * redirects
  * middleware
  * confirmation API routes

  You just need to configure one of the below providers on their side and set correct credentials as environment variables in your TurboStarter app.
</Callout>

![OAuth providers](/images/docs/web/auth/social-providers.png)

Third Party providers need to be configured, managed and enabled fully on the provider's side. TurboStarter just needs the correct credentials to be set as environment variables in your app and passed to the [authentication API configuration](/docs/web/auth/configuration#api).

To enable OAuth providers in your TurboStarter app, you need to:

1. Set up an OAuth application in the provider's developer console (like [Apple Developer Portal](https://developer.apple.com/account/), [Google Cloud Console](https://console.cloud.google.com/), [Github Developer Settings](https://github.com/settings/developers) or any other provider you want to use)
2. Configure the provider's credentials as environment variables in your app. For example, for Google OAuth:

```dotenv title="apps/web/.env.local"
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```

Then, pass it to the authentication configuration in `packages/auth/src/server.ts`:

```ts title="server.ts"
export const auth = betterAuth({
  ...

  socialProviders: {
    [SocialProvider.GOOGLE]: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },

  ...
});
```

<Callout title="Missing provider?">
  Better Auth provides a [generic OAuth plugin](https://better-auth.com/docs/plugins/generic-oauth) that allows you to add any OAuth provider to your app.

  It supports both OAuth 2.0 and OpenID Connect (OIDC) flows, allowing you to easily add social login or custom OAuth authentication to your application.
</Callout>


# Overview
Source: https://www.turbostarter.dev/docs/web/auth/overview

TurboStarter uses [Better Auth](https://better-auth.com) to handle authentication. It's a secure, production-ready authentication solution that integrates seamlessly with many frameworks and provides enterprise-grade security out of the box.

<Callout title="Why Better Auth?">
  One of the core principles of TurboStarter is to do things **as simple as possible**, and to make everything **as performant as possible**.

  Better Auth provides an excellent developer experience with minimal configuration while keeping enterprise-grade security. Its framework-agnostic approach and focus on performance make it the perfect choice for TurboStarter.

  Recently, Better Auth [announced](https://better-auth.com/blog/authjs-joins-better-auth) an incorporation of [Auth.js (28k+ stars on GitHub)](https://authjs.dev/), making it even more powerful and flexible.
</Callout>

![Better Auth](/images/docs/better-auth.png)

You can read more about Better Auth in the [official documentation](https://better-auth.com/docs).

TurboStarter supports multiple authentication methods:

* **Password** - the traditional email/password method
* **Magic Link** - magic links
* **OTP** - one-time passwords with automatic expiration
* **Passkey** - passkeys ([WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API))
* **Anonymous** - allowing users to proceed anonymously
* **OAuth** - social providers ([Apple](https://better-auth.com/docs/authentication/apple), [Google](https://better-auth.com/docs/authentication/google), and [GitHub](https://better-auth.com/docs/authentication/github) preconfigured)
* [Google One Tap](https://developers.google.com/identity/gsi/web/guides) - native, one-click prompt for Google authentication

As well as common applications flows, with ready-to-use views and components:

* **Sign in** - sign in with email/password, magic link, one-time password, or OAuth providers
* **Sign up** - sign up with email/password or OAuth providers
* **Sign out** - end session by signing out
* **2FA** - two-factor authentication with TOTP, OTP, or recovery codes
* **Password recovery** - forgot and reset password
* **Email verification** - verify email address

You can **build your auth flow like LEGO bricks** - plug in only the parts you need and customize them as you wish.


# Inngest
Source: https://www.turbostarter.dev/docs/web/background-tasks/inngest

[Inngest](https://www.inngest.com) is an event-driven background jobs platform. You define durable functions in TypeScript, publish events from your app, and Inngest runs each step with automatic retries, observability, and scheduling, without operating your own queue workers.

<Callout title="What Inngest is good for">
  Use Inngest when work should start from **events** (user signed up, invoice paid, export requested) or **cron**, and each job may need multiple durable steps, sleeps, or fan-out. Functions live in your repo; Inngest handles delivery, retries, and the execution dashboard.
</Callout>

## When to choose Inngest

| Need                                                                   | Prefer                                                          |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- |
| Event-driven workflows, multi-step fan-out, long sleeps between steps  | **Inngest**                                                     |
| Dedicated workers, long CPU-heavy jobs, first-class DX in the monorepo | [Trigger.dev](/docs/web/background-tasks/trigger)               |
| Simple HTTP callbacks, delay/cron without a second control plane       | [Upstash QStash](/docs/web/background-tasks/qstash)             |
| Durable orchestration that stays on Vercel’s runtime                   | [Vercel Workflows](/docs/web/background-tasks/vercel-workflows) |

**Pick Inngest if** you think in events (“when X happens, run Y”), want typed event contracts, and need step-level retries without managing Redis or a queue. **Skip it if** you only need a one-shot HTTP callback (QStash is lighter), or you already standardized on Trigger.dev and do not want a second platform.

<Steps>
  <Step>
    ## Create an Inngest app and keys

    1. Sign up at [Inngest](https://www.inngest.com) and create an app.
    2. Copy the **Event key** (publish events) and **Signing key** (authenticate the serve endpoint).

    Add them to the root env file (never commit real values):

    ```dotenv title=".env.local"
    INNGEST_EVENT_KEY=your_event_key_here
    INNGEST_SIGNING_KEY=your_signing_key_here
    ```

    Register both as **server-only** variables when you enable the recipe. Use `.optional()` until you rely on Inngest in every environment, then require them in production validation.

    ```ts title="apps/web/env.config.ts"
    server: {
      // Existing variables...
      INNGEST_EVENT_KEY: z.string().min(1).optional(),
      INNGEST_SIGNING_KEY: z.string().min(1).optional(),
    },
    ```

    ```ts title="packages/api/src/env/index.ts"
    server: {
      // Existing variables...
      INNGEST_EVENT_KEY: z.string().min(1).optional(),
      INNGEST_SIGNING_KEY: z.string().min(1).optional(),
    },
    ```

    The serve route runs in the web app; event sends usually run from the API package. Validate the keys in both places you read them. Use separate Inngest environments and keys for local, preview, and production. See [Secrets & environment](/docs/web/security/secrets).
  </Step>

  <Step>
    ## Install the SDK

    This guide targets the current Inngest TypeScript SDK **v4** API (`triggers`, `eventType`). Install the latest major and pin it in the lockfile so upgrades stay intentional:

    ```bash
    pnpm add --filter @workspace/api inngest
    pnpm add --filter web inngest
    ```

    The web app needs the package for `inngest/next` serve. The API package needs it to create functions and call `inngest.send()`.

    Expose the Inngest modules from the API package so the web app can import them:

    ```json title="packages/api/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./schema/*": "./src/schema/*.ts",
        "./constants/*": "./src/constants/*.ts",
        "./inngest": "./src/lib/inngest/index.ts",
        "./inngest/*": "./src/lib/inngest/*.ts"
      }
    }
    ```
  </Step>

  <Step>
    ## Create the Inngest client and event types

    Prefer decentralized `eventType()` definitions (v4) over a single global schema map. That keeps send sites, triggers, and `event.data` on one contract with optional Zod runtime validation.

    ```ts title="packages/api/src/lib/inngest/client.ts"
    import { eventType, Inngest } from "inngest";
    import * as z from "zod";

    export const inngest = new Inngest({
      id: "turbostarter",
    });

    export const userDataProcess = eventType("user/data.process", {
      schema: z.object({
        userId: z.string(),
        operation: z.enum(["export", "analyze", "cleanup"]),
      }),
    });
    ```

    The SDK reads `INNGEST_EVENT_KEY` from the environment when sending. The Next.js serve handler uses `INNGEST_SIGNING_KEY` to verify Inngest requests.
  </Step>

  <Step>
    ## Define durable functions

    Put functions next to the client so the API and the serve route share one module graph.

    <Tabs items={["Process user data", "Daily cleanup"]}>
      <Tab value="Process user data">
        ```ts title="packages/api/src/lib/inngest/functions/process-user-data.ts"
        import { inngest, userDataProcess } from "../client";

        export const processUserData = inngest.createFunction(
          {
            id: "process-user-data",
            triggers: [userDataProcess],
          },
          async ({ event, step }) => {
            const { userId, operation } = event.data;

            return await step.run("process-data", async () => {
              switch (operation) {
                case "export":
                  // Load the user from the DB using userId, then export.
                  await new Promise((resolve) => setTimeout(resolve, 2000));
                  return { success: true, result: "Data exported to CSV" };

                case "analyze":
                  await new Promise((resolve) => setTimeout(resolve, 5000));
                  return {
                    success: true,
                    result: { totalActions: 156, avgSessionTime: "4m 32s" },
                  };

                case "cleanup":
                  await new Promise((resolve) => setTimeout(resolve, 3000));
                  return { success: true, result: "Removed 23 obsolete records" };

                default: {
                  const _exhaustive: never = operation;
                  throw new Error(`Unknown operation: ${_exhaustive}`);
                }
              }
            });
          },
        );
        ```
      </Tab>

      <Tab value="Daily cleanup">
        ```ts title="packages/api/src/lib/inngest/functions/daily-cleanup.ts"
        import { cron } from "inngest";

        import { inngest } from "../client";

        export const dailyCleanup = inngest.createFunction(
          {
            id: "daily-cleanup",
            triggers: [cron("0 2 * * *")], // Daily at 02:00 UTC
          },
          async ({ step }) => {
            await step.run("cleanup-logs", async () => {
              // Delete or archive old log rows
              return { logsCleaned: true };
            });

            await step.run("cleanup-temp-files", async () => {
              // Remove expired uploads from storage
              return { tempFilesCleaned: true };
            });

            await step.run("generate-reports", async () => {
              // Build daily ops report
              return { reportsGenerated: true };
            });
          },
        );
        ```
      </Tab>
    </Tabs>

    Export a barrel for the serve route and web imports:

    ```ts title="packages/api/src/lib/inngest/index.ts"
    export { inngest, userDataProcess } from "./client";
    export { dailyCleanup } from "./functions/daily-cleanup";
    export { processUserData } from "./functions/process-user-data";
    ```

    Each `step.run()` is retried independently. Prefer small steps over one long handler so a failed email send does not redo an expensive export.
  </Step>

  <Step>
    ## Serve functions from Next.js

    Inngest invokes your app over HTTP. Add an App Router handler at `/api/inngest`:

    ```ts title="apps/web/src/app/api/inngest/route.ts"
    import { serve } from "inngest/next";

    import { dailyCleanup, inngest, processUserData } from "@workspace/api/inngest";

    export const { GET, POST, PUT } = serve({
      client: inngest,
      functions: [processUserData, dailyCleanup],
    });
    ```

    Keep this route **unauthenticated by your session middleware**. Inngest authenticates with the signing key instead.
  </Step>

  <Step>
    ## Develop locally with the Dev Server

    Run the web app, then point the Inngest Dev Server at your serve URL:

    ```bash
    pnpm --filter web dev
    npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
    ```

    The Dev Server syncs function definitions, lets you send test events, and shows step timelines without deploying. Use it before you wire production keys.
  </Step>

  <Step>
    ## Trigger work from TurboStarter (tRPC)

    Never accept a `userId` from the browser as authorization. Take the authenticated session from `protectedProcedure`, then publish only the identifiers the worker needs.

    ```ts title="packages/api/src/modules/tasks/tasks.router.ts"
    import * as z from "zod";

    import { inngest, userDataProcess } from "../../lib/inngest/client";
    import { createTRPCRouter, protectedProcedure } from "../../trpc";

    export const tasksRouter = createTRPCRouter({
      processUserData: protectedProcedure
        .input(
          z.object({
            operation: z.enum(["export", "analyze", "cleanup"]),
          }),
        )
        .mutation(async ({ ctx, input }) => {
          await inngest.send(
            userDataProcess.create({
              userId: ctx.session.user.id,
              operation: input.operation,
            }),
          );

          return {
            success: true,
            message: "Background task queued",
          };
        }),
    });
    ```

    Register the router:

    ```ts title="packages/api/src/router.ts"
    import { tasksRouter } from "./modules/tasks/tasks.router";

    export const appRouter = createTRPCRouter({
      // ...existing routers
      tasks: tasksRouter,
    });
    ```

    ### From a client component

    ```tsx title="apps/web/src/modules/tasks/process-data-button.tsx"
    "use client";

    import { useMutation } from "@tanstack/react-query";

    import { useTRPC } from "~/trpc/react";

    export function ProcessDataButton() {
      const trpc = useTRPC();
      const { mutate, isPending } = useMutation(
        trpc.tasks.processUserData.mutationOptions({
          onSuccess: () => {
            console.log("Task queued");
          },
        }),
      );

      return (
        <button
          type="button"
          disabled={isPending}
          onClick={() => mutate({ operation: "analyze" })}
        >
          {isPending ? "Queueing..." : "Analyze my data"}
        </button>
      );
    }
    ```

    ### From a server action

    ```ts title="apps/web/src/app/actions/user-actions.ts"
    "use server";

    import { api } from "~/trpc/server";

    export async function processUserData(
      operation: "export" | "analyze" | "cleanup",
    ) {
      return api.tasks.processUserData({ operation });
    }
    ```

    For organization-scoped jobs, verify membership (or a stored job row the user owns) **before** `inngest.send()`, and pass a job ID (not a full customer record) into the event payload.
  </Step>
</Steps>

## Security checklist

* Derive `userId` / `organizationId` from the session or a DB row you already authorized, never from an untrusted client field alone.
* Keep `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` server-only (no `NEXT_PUBLIC_` prefix).
* Make steps **idempotent**: retries are expected. Claim work with a status column (`pending` → `processing` → `done`) or an idempotency key.
* Log stable IDs (user, job, run), not secrets or full PII payloads.
* Re-check permissions inside delayed steps if access can change after the event was sent.

## Monitoring and debugging

Use the [Inngest Dashboard](https://app.inngest.com) to:

* Inspect function runs, step timelines, and failure reasons
* Compare success vs failure rates and duration
* Replay failed runs after you ship a fix
* Configure alerts on failure spikes

Locally, prefer the Dev Server over guessing from application logs alone.

## Best practices

<Accordions>
  <Accordion title="Break work into steps">
    ```ts
    await step.run("fetch-source", async () => fetchSource(jobId));
    await step.run("transform", async () => transform(jobId));
    await step.run("notify", async () => notify(jobId));
    ```

    A failed notify step should not re-download the source.
  </Accordion>

  <Accordion title="Use descriptive function IDs">
    ```ts
    // Good
    {
      id: "user-data-export-csv";
    }

    // Hard to operate
    {
      id: "task1";
    }
    ```
  </Accordion>

  <Accordion title="Fail loud so Inngest can retry">
    ```ts
    await step.run("charge", async () => {
      try {
        return await chargeCustomer(invoiceId);
      } catch (error) {
        console.error("Charge failed", { invoiceId, error });
        throw error; // Re-throw for retry
      }
    });
    ```

    Use non-retryable errors only when the input is permanently invalid.
  </Accordion>

  <Accordion title="Keep event payloads small">
    Send IDs and enums. Load large blobs inside the step from your database or storage. Smaller events are safer to log and easier to version.
  </Accordion>
</Accordions>

## FAQ

<Accordions>
  <Accordion title="Inngest vs Trigger.dev: which should I use?">
    Use **Trigger.dev** for dedicated background workers and long-running jobs that feel like “tasks” in a tasks package. Use **Inngest** when the product model is “react to events” with multi-step, sleep, and fan-out. Many teams pick one platform and stick to it to reduce ops overhead.
  </Accordion>

  <Accordion title="Do I need Redis or a queue broker?">
    No. Inngest hosts the queue and orchestration. Your Next.js app exposes `/api/inngest` and runs function steps when Inngest calls it.
  </Accordion>

  <Accordion title="Will serverless timeouts still kill my job?">
    Each **step** should finish within your platform’s function limit. Long workflows are fine if you split them into steps (and use `step.sleep` for delays). Avoid one giant step that runs for minutes on a short-timeout plan.
  </Accordion>

  <Accordion title="How do I schedule a cron in Inngest?">
    Pass `cron("0 2 * * *")` (or an array of triggers) in the function options. Cron runs are visible in the same dashboard as event-driven runs.
  </Accordion>
</Accordions>

## Next steps

With Inngest wired into TurboStarter you can:

* Queue reliable background jobs from authenticated tRPC mutations
* Schedule maintenance with cron triggers
* Compose multi-step workflows with independent retries
* Observe and replay failures from the Inngest dashboard

Compare alternatives in the [background tasks overview](/docs/web/background-tasks/overview), or go deeper in the official docs.

<Cards>
  <Card title="Inngest documentation" description="inngest.com" href="https://www.inngest.com/docs" />

  <Card title="Inngest dashboard" description="app.inngest.com" href="https://app.inngest.com" />

  <Card title="trigger.dev guide" description="TurboStarter docs" href="/docs/web/background-tasks/trigger" />

  <Card title="Vercel Workflows" description="TurboStarter docs" href="/docs/web/background-tasks/vercel-workflows" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/web/background-tasks/overview

Background tasks and cron jobs are long-running processes that execute outside of your main application flow, allowing you to handle time-intensive operations and scheduled workflows without blocking user interactions or hitting serverless function timeouts.

<Callout title="Perfect for time-intensive & scheduled operations">
  Background tasks are ideal for operations that take longer than typical serverless function timeouts (10-60 seconds), such as processing large files, sending batch emails, or making multiple API calls.

  Cron jobs are perfect for recurring operations like daily reports, cleanup tasks, or periodic data synchronization.
</Callout>

## What are background tasks?

**Background tasks** are asynchronous processes that run separately from your main application thread. Instead of forcing users to wait for lengthy operations to complete, you can offload these tasks to run in the background while your application remains responsive.

**Cron jobs** are scheduled background tasks that run automatically at specific times or intervals. They're perfect for maintenance operations, reports, and recurring workflows that need to happen without user intervention.

Think of background tasks as your application's *worker threads* - they handle the heavy lifting while your main application stays fast and responsive for users.

<ThemedImage alt="Background tasks architecture diagram" light="/images/docs/web/background-tasks/light.png" dark="/images/docs/web/background-tasks/dark.png" width={1335} height={285} zoomable />

## Why use background tasks?

<Cards className="grid-cols-1">
  <Card title="Avoid timeouts">
    Most serverless platforms have strict execution limits:

    * **[Vercel (Hobby)](https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration)**: 300 seconds
    * **[Vercel (Pro)](https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration)**: 800 seconds
    * **[Vercel (Enterprise)](https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration)**: 800 seconds
    * **[AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html)**: 900 seconds
    * **[Netlify Functions](https://docs.netlify.com/functions/overview/#default-deployment-options)**: 30 seconds

    Background tasks let you bypass these limitations entirely.
  </Card>

  <Card title="Better user experience">
    Users don't have to wait for long-running processes. They can continue using
    your application while tasks complete in the background.
  </Card>

  <Card title="Automated workflows">
    Cron jobs enable hands-off automation of recurring tasks like daily backups,
    weekly reports, or monthly user engagement analysis - all running reliably
    without manual intervention.
  </Card>

  <Card title="Improved reliability">
    Background tasks can be automatically retried if they fail, ensuring your
    critical processes eventually complete successfully.
  </Card>

  <Card title="Resource optimization">
    Your main application servers stay available to handle user requests instead of being tied up with heavy processing tasks.
  </Card>
</Cards>

## Common use cases

Here are some typical scenarios where background tasks shine:

<Accordions>
  <Accordion title="File processing">
    * **Video transcoding**: Converting uploaded videos to different formats or resolutions
    * **Image optimization**: Batch processing user-uploaded images
    * **Document parsing**: Extracting text from PDFs or generating thumbnails
  </Accordion>

  <Accordion title="Data operations">
    * **Database migrations**: Moving or transforming large datasets
    * **Report generation**: Creating complex analytics reports
    * **Data synchronization**: Syncing data between different systems
  </Accordion>

  <Accordion title="Communication">
    * **Email campaigns**: Sending personalized emails to large user lists
    * **Notification processing**: Delivering push notifications across multiple platforms
    * **SMS campaigns**: Bulk SMS sending with rate limiting
  </Accordion>

  <Accordion title="AI and ML tasks">
    * **Content generation**: Using AI models to generate text, images, or videos
    * **Data analysis**: Running machine learning models on large datasets
    * **Natural language processing**: Analyzing text content for insights
  </Accordion>

  <Accordion title="Third-party integrations">
    * **API synchronization**: Syncing data with external services
    * **Webhook processing**: Handling incoming webhooks that trigger complex workflows
    * **Social media automation**: Posting content across multiple platforms
  </Accordion>

  <Accordion title="Scheduled operations (Cron jobs)">
    * **Daily reports**: Generating and emailing daily analytics or performance reports
    * **Database maintenance**: Cleaning up old records, optimizing indexes, or running backups
    * **User engagement**: Sending weekly newsletters or monthly account summaries
    * **System monitoring**: Health checks, performance monitoring, and alert notifications
    * **Content management**: Auto-publishing scheduled content or archiving old posts
  </Accordion>
</Accordions>

## When not to use background tasks?

Background tasks and cron jobs aren't always the right solution. Consider alternatives for:

* **Real-time operations**: Tasks that users need immediate results from
* **Simple, fast operations**: Tasks that complete in under 5-10 seconds
* **Database queries**: Standard CRUD operations that should remain synchronous
* **User authentication**: Login/logout processes should be immediate

<Callout type="warn" title="Keep it simple">
  Start with synchronous processing for simple tasks and manual processes for infrequent operations. Only move to background tasks when you hit timeout limitations or user experience issues, and only use cron jobs when you need reliable automation.
</Callout>

## Which tool should I use?

TurboStarter documents four approaches. Pick one primary platform so ops stay simple; add a second only when the workload model is truly different.

| Tool                                                            | Best for                                                         |
| --------------------------------------------------------------- | ---------------------------------------------------------------- |
| [Trigger.dev](/docs/web/background-tasks/trigger)               | Dedicated workers, long jobs, cron, first-class monorepo package |
| [Upstash QStash](/docs/web/background-tasks/qstash)             | Serverless HTTP callbacks, delays, light cron                    |
| [Inngest](/docs/web/background-tasks/inngest)                   | Event-driven multi-step workflows and fan-out                    |
| [Vercel Workflows](/docs/web/background-tasks/vercel-workflows) | Durable `"use workflow"` / `"use step"` on Vercel (beta)         |

**Rule of thumb:** start with Trigger.dev for most SaaS background work. Choose QStash when you only need reliable HTTP delivery. Choose Inngest when the product is “react to events.” Choose Vercel Workflows when you want Vercel-native durable orchestration and accept beta constraints.

## Getting started

Ready to add background tasks to your TurboStarter application?


# Upstash QStash
Source: https://www.turbostarter.dev/docs/web/background-tasks/qstash

[Upstash QStash](https://upstash.com/docs/qstash) is a serverless message queue and task scheduler designed specifically for serverless and edge environments. It uses HTTP endpoints instead of persistent connections, making it perfect for modern web applications.

<Callout title="Why QStash?">
  QStash is built for the serverless world - no infrastructure to manage, automatic scaling, and pay-per-use pricing. It delivers messages to your HTTP endpoints with built-in retries, delays, and scheduling capabilities.
</Callout>

<Steps>
  <Step>
    ## Setup

    Visit [Upstash Console](https://console.upstash.com) and create a free account. Create a new QStash project and note down your credentials.

    Add your QStash credentials to your root environment variables:

    ```dotenv title=".env.local"
    QSTASH_URL=https://qstash.upstash.io
    QSTASH_TOKEN=your_qstash_token_here
    QSTASH_CURRENT_SIGNING_KEY=your_current_signing_key_here
    QSTASH_NEXT_SIGNING_KEY=your_next_signing_key_here
    ```

    You can find these values in your Upstash Console under the QStash project settings.

    For production, make sure to add these environment variables to your deployment platform.
  </Step>

  <Step>
    ## Install dependencies

    Add the QStash SDK to your API package:

    ```bash
    pnpm add --filter api @upstash/qstash
    ```
  </Step>

  <Step>
    ## Create the QStash client

    Create a utility file to initialize the QStash client in your API package:

    ```ts title="packages/api/src/lib/qstash.ts"
    import { Client } from "@upstash/qstash";

    import { env } from "~/env";

    export const qstashClient = new Client({
      baseUrl: env.QSTASH_URL,
      token: env.QSTASH_TOKEN,
    });
    ```
  </Step>

  <Step>
    ## Create task handlers

    QStash delivers messages to HTTP endpoints, so you'll create API routes to handle your background tasks.

    Let's create task handlers for common operations:

    <Tabs items={["Task router", "Process user data", "Daily cleanup", "Verification middleware"]}>
      <Tab value="Task router">
        ```ts title="packages/api/src/modules/tasks/router.ts"
        import { Hono } from "hono";
        import * as z from "zod";

        import { qstashVerifyMiddleware } from "../../middleware/qstash-verify";
        import { dailyCleanupHandler } from "./handlers/daily-cleanup";
        import { processUserDataHandler } from "./handlers/process-user-data";

        const processUserDataSchema = z.object({
          userId: z.string(),
          operation: z.enum(["export", "analyze", "cleanup"]),
        });

        export const tasksRouter = new Hono()
          .basePath("/tasks")
          // Apply QStash signature verification to all task routes
          .use(qstashVerifyMiddleware)
          .post("/process-user-data", processUserDataHandler)
          .post("/daily-cleanup", dailyCleanupHandler);
        ```
      </Tab>

      <Tab value="Process user data">
        ```ts title="packages/api/src/modules/tasks/handlers/process-user-data.ts"
        import type { Context } from "hono";
        import * as z from "zod";

        const ProcessUserDataSchema = z.object({
          userId: z.string(),
          operation: z.enum(["export", "analyze", "cleanup"]),
        });

        export async function processUserDataHandler(c: Context) {
          try {
            const payload = ProcessUserDataSchema.parse(await c.req.json());
            const { userId, operation } = payload;

            console.log("Starting user data processing", { userId, operation });

            switch (operation) {
              case "export":
                // Simulate data export
                await new Promise((resolve) => setTimeout(resolve, 2000));
                console.log("User data exported successfully");
                return c.json({
                  success: true,
                  result: "Data exported to CSV",
                });

              case "analyze":
                // Simulate data analysis
                await new Promise((resolve) => setTimeout(resolve, 5000));
                console.log("User data analysis completed");
                return c.json({
                  success: true,
                  result: { totalActions: 156, avgSessionTime: "4m 32s" },
                });

              case "cleanup":
                // Simulate data cleanup
                await new Promise((resolve) => setTimeout(resolve, 3000));
                console.log("User data cleanup completed");
                return c.json({
                  success: true,
                  result: "Removed 23 obsolete records",
                });

              default:
                throw new Error(`Unknown operation: ${operation}`);
            }
          } catch (error) {
            console.error("Task failed:", error);
            return c.json({ error: "Task failed" }, 500);
          }
        }
        ```
      </Tab>

      <Tab value="Daily cleanup">
        ```ts title="packages/api/src/modules/tasks/handlers/daily-cleanup.ts"
        import type { Context } from "hono";

        export async function dailyCleanupHandler(c: Context) {
          try {
            console.log("Starting daily cleanup");

            // Cleanup old logs
            await new Promise((resolve) => setTimeout(resolve, 5000));
            console.log("Logs cleaned up");

            // Cleanup temporary files
            await new Promise((resolve) => setTimeout(resolve, 3000));
            console.log("Temp files cleaned up");

            // Generate daily reports
            await new Promise((resolve) => setTimeout(resolve, 8000));
            console.log("Reports generated");

            return c.json({
              success: true,
              cleanupTime: new Date().toISOString(),
              itemsProcessed: 1247,
            });
          } catch (error) {
            console.error("Daily cleanup failed:", error);
            return c.json({ error: "Daily cleanup failed" }, 500);
          }
        }
        ```
      </Tab>

      <Tab value="Verification middleware">
        ```ts title="packages/api/src/middleware/qstash-verify.ts"
        import { Receiver } from "@upstash/qstash";
        import { createMiddleware } from "hono/factory";

        export const qstashVerifyMiddleware = createMiddleware(async (c, next) => {
          const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
          const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;

          if (!currentSigningKey || !nextSigningKey) {
            return c.json({ error: "QStash signing keys not configured" }, 500);
          }

          const signature = c.req.header("upstash-signature");

          if (!signature) {
            return c.json({ error: "Missing QStash signature" }, 401);
          }

          try {
            const body = await c.req.text();

            const receiver = new Receiver({
              currentSigningKey,
              nextSigningKey,
            });

            const isValid = receiver.verify({
              body,
              signature,
            });

            if (!isValid) {
              return c.json({ error: "Invalid QStash signature" }, 401);
            }

            // Re-create the request with the body for the next handler
            const newRequest = new Request(c.req.url, {
              method: c.req.method,
              headers: c.req.headers,
              body,
            });

            c.req = newRequest;
            await next();
          } catch (error) {
            console.error("QStash signature verification failed:", error);
            return c.json({ error: "Invalid signature" }, 401);
          }
        });
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Register task routes

    Add the tasks router to your main API:

    ```ts title="packages/api/src/index.ts"
    import { tasksRouter } from "./modules/tasks/router";

    const appRouter = new Hono()
      .basePath("/api")
      .route("/tasks", tasksRouter)
      // ... other existing routers
      .onError(onError);

    export { appRouter };
    ```
  </Step>

  <Step>
    ## Triggering tasks

    You can trigger tasks from your TurboStarter application by publishing messages to QStash, which will then deliver them to your task endpoints.

    Create a service to handle task triggering:

    ```ts title="packages/api/src/modules/tasks/service.ts"
    import { qstashClient } from "../../lib/qstash";

    function getTaskUrl(taskName: string): string {
      const baseUrl = process.env.NEXT_PUBLIC_URL || "http://localhost:3000";
      return `${baseUrl}/api/tasks/${taskName}`;
    }

    export class TaskService {
      static async processUserData(
        userId: string,
        operation: "export" | "analyze" | "cleanup",
      ) {
        return await qstashClient.publishJSON({
          url: getTaskUrl("process-user-data"),
          body: { userId, operation },
        });
      }

      static async scheduleUserDataProcessing(
        userId: string,
        operation: "export" | "analyze" | "cleanup",
        delaySeconds: number,
      ) {
        return await qstashClient.publishJSON({
          url: getTaskUrl("process-user-data"),
          body: { userId, operation },
          delay: `${delaySeconds}s`,
        });
      }

      static async scheduleDailyCleanup() {
        return await qstashClient.schedules.create({
          destination: getTaskUrl("daily-cleanup"),
          cron: "0 2 * * *", // Daily at 2 AM
        });
      }
    }
    ```
  </Step>

  <Step>
    ## Create API endpoints for triggering

    Create endpoints to trigger tasks from your application:

    ```ts title="packages/api/src/modules/tasks/trigger/router.ts"
    import { Hono } from "hono";
    import * as z from "zod";

    import { enforceAuth, validate } from "../../middleware";
    import { TaskService } from "./service";

    const triggerUserDataSchema = z.object({
      userId: z.string(),
      operation: z.enum(["export", "analyze", "cleanup"]),
      delaySeconds: z.number().optional(),
    });

    export const taskTriggerRouter = new Hono()
      .post(
        "/trigger/process-user-data",
        enforceAuth,
        validate("json", triggerUserDataSchema),
        async (c) => {
          const { userId, operation, delaySeconds } = c.req.valid("json");

          const result = delaySeconds
            ? await TaskService.scheduleUserDataProcessing(
                userId,
                operation,
                delaySeconds,
              )
            : await TaskService.processUserData(userId, operation);

          return c.json({
            success: true,
            messageId: result.messageId,
            message: delaySeconds
              ? `Task scheduled to run in ${delaySeconds} seconds`
              : "Task queued for immediate processing",
          });
        },
      )
      .post("/trigger/daily-cleanup", enforceAuth, async (c) => {
        const result = await TaskService.scheduleDailyCleanup();

        return c.json({
          success: true,
          scheduleId: result.scheduleId,
          message: "Daily cleanup scheduled",
        });
      });
    ```

    Add it to your main router:

    ```ts title="packages/api/src/index.ts"
    import { taskTriggerRouter } from "./modules/tasks/trigger/router";

    const appRouter = new Hono()
      .basePath("/api")
      .route("/tasks", tasksRouter)
      .route("/", taskTriggerRouter) // Trigger routes at root level
      // ... other existing routers
      .onError(onError);

    export { appRouter };
    ```
  </Step>

  <Step>
    ## Using tasks in your application

    ### From the client

    ```tsx title="apps/web/src/modules/tasks/process-data-button.tsx"
    "use client";

    import { handle } from "@workspace/api/utils";
    import { useMutation } from "@tanstack/react-query";

    import { api } from "~/lib/api/client";

    export function ProcessDataButton({ userId }: { userId: string }) {
      const { mutate: processData, isPending } = useMutation({
        mutationFn: handle(api.trigger["process-user-data"].$post),
        onSuccess: (data) => {
          console.log("Task queued:", data.messageId);
        },
      });

      return (
        <button
          onClick={() =>
            processData({
              json: {
                userId,
                operation: "analyze",
                delaySeconds: 30, // Optional delay
              },
            })
          }
          disabled={isPending}
        >
          {isPending ? "Queueing..." : "Analyze User Data"}
        </button>
      );
    }
    ```

    ### From a server action

    ```ts title="apps/web/src/app/actions/user-actions.ts"
    "use server";

    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/server";

    export async function processUserData(
      userId: string,
      operation: "export" | "analyze" | "cleanup",
    ) {
      try {
        const result = await handle(api.trigger["process-user-data"].$post)({
          json: { userId, operation },
        });

        return {
          success: true,
          messageId: result.messageId,
        };
      } catch (error) {
        console.error("Failed to queue background task:", error);
        throw new Error("Failed to queue background task");
      }
    }
    ```
  </Step>
</Steps>

## Advanced features

### Cron jobs & scheduling

QStash makes it easy to schedule recurring tasks:

```ts
// Schedule a task to run every day at 2 AM
await qstashClient.schedules.create({
  destination: `${baseUrl}/api/tasks/daily-cleanup`,
  cron: "0 2 * * *",
});

// Schedule a task to run every Monday at 9 AM
await qstashClient.schedules.create({
  destination: `${baseUrl}/api/tasks/weekly-report`,
  cron: "0 9 * * 1",
});

// One-time delayed task
await qstashClient.publishJSON({
  url: `${baseUrl}/api/tasks/reminder`,
  body: { userId: "123", type: "follow-up" },
  delay: "3d", // 3 days from now
});
```

### Topics (Fanout pattern)

Create topics to send messages to multiple endpoints:

```ts
// Create a topic
await qstashClient.topics.upsert({
  name: "user-events",
  endpoints: [
    { url: `${baseUrl}/api/tasks/update-analytics` },
    { url: `${baseUrl}/api/tasks/send-notification` },
    { url: `${baseUrl}/api/tasks/update-crm` },
  ],
});

// Publish to topic - all endpoints will receive the message
await qstashClient.publishJSON({
  topic: "user-events",
  body: {
    userId: "123",
    event: "user-registered",
    timestamp: new Date().toISOString(),
  },
});
```

### Queues (Sequential processing)

Create queues for ordered task processing:

```ts
// Create a queue
const queue = qstashClient.queue({ queueName: "user-onboarding" });

// Add tasks to queue (they'll run in order)
await queue.enqueueJSON({
  url: `${baseUrl}/api/tasks/send-welcome-email`,
  body: { userId: "123" },
});

await queue.enqueueJSON({
  url: `${baseUrl}/api/tasks/setup-user-profile`,
  body: { userId: "123" },
});

await queue.enqueueJSON({
  url: `${baseUrl}/api/tasks/trigger-onboarding-sequence`,
  body: { userId: "123" },
});
```

## Monitoring and debugging

### QStash Dashboard

Visit the [Upstash Console](https://console.upstash.com) to monitor your tasks:

* **Message tracking**: See all messages, their status, and delivery attempts
* **Logs**: View detailed logs for each message delivery
* **Analytics**: Monitor throughput, success rates, and error patterns
* **Schedules**: Manage and monitor your cron jobs
* **Dead letter queue**: Handle messages that failed after all retries

### Local development

During development, you can:

1. **Use ngrok** for local testing:

   ```bash
   # Install ngrok
   npm install -g ngrok

   # Expose your local server
   ngrok http 3000

   # Use the ngrok URL in your QStash configuration
   ```

2. **Check message delivery** in the Upstash Console

3. **Use console.log** in your task handlers for debugging

## Best practices

<Accordions>
  <Accordion title="Always verify signatures">
    Use the QStash signature verification middleware to ensure messages are authentic:

    ```ts
    // ✅ Good - Always verify QStash signatures
    .use(qstashVerifyMiddleware)

    // ❌ Not secure - Accepting unverified requests
    .post("/tasks/sensitive-operation", handler)
    ```
  </Accordion>

  <Accordion title="Handle errors gracefully">
    Return appropriate HTTP status codes so QStash knows whether to retry:

    ```ts
    // ✅ Good - Clear error handling
    try {
      await processTask(payload);
      return c.json({ success: true });
    } catch (error) {
      console.error("Task failed:", error);
      // 5xx = QStash will retry, 4xx = won't retry
      return c.json({ error: "Task failed" }, 500);
    }
    ```
  </Accordion>

  <Accordion title="Use idempotent operations">
    Make your tasks safe to run multiple times in case of retries:

    ```ts
    // ✅ Good - Check if work already done
    const existingResult = await db.findProcessedResult(payload.id);
    if (existingResult) {
      return c.json({ success: true, result: existingResult });
    }

    // Proceed with processing...
    ```
  </Accordion>

  <Accordion title="Set appropriate timeouts">
    Configure timeouts based on your expected processing time:

    ```ts
    // For quick tasks
    await qstashClient.publishJSON({
      url: taskUrl,
      body: payload,
      timeout: "30s",
    });

    // For longer tasks
    await qstashClient.publishJSON({
      url: taskUrl,
      body: payload,
      timeout: "300s", // 5 minutes
    });
    ```
  </Accordion>

  <Accordion title="Use structured logging">
    Include relevant context in your logs:

    ```ts
    console.log("Task started", {
      taskType: "process-user-data",
      userId: payload.userId,
      operation: payload.operation,
      timestamp: new Date().toISOString(),
    });
    ```
  </Accordion>
</Accordions>

## Next steps

With QStash integrated into your TurboStarter application, you can now:

* **Process background tasks** without worrying about serverless timeouts
* **Schedule recurring operations** with reliable cron job functionality
* **Handle high-volume messaging** with automatic retries and scaling
* **Build complex workflows** using topics, queues, and delays

Ready to explore more advanced features? Check out the official documentation for webhooks, batch operations, and advanced routing patterns.

<Cards>
  <Card title="Documentation" description="upstash.com" href="https://upstash.com/docs/qstash" />

  <Card title="Dashboard" description="console.upstash.com" href="https://console.upstash.com" />
</Cards>


# trigger.dev
Source: https://www.turbostarter.dev/docs/web/background-tasks/trigger

[trigger.dev](https://trigger.dev) is an open-source background jobs framework that lets you write reliable workflows in plain async code.

<Callout title="Why trigger.dev?">
  trigger.dev provides automatic retries, real-time monitoring, and seamless scaling - all while letting you write background tasks in familiar JavaScript/TypeScript code directly in your TurboStarter project.
</Callout>

<Steps>
  <Step>
    ## Setup

    Visit [trigger.dev](https://trigger.dev) and create a free account. Create a new project and note down your API key.

    Add your trigger.dev API key to your root environment variables:

    ```dotenv title=".env.local"
    TRIGGER_SECRET_KEY=your_secret_key_here
    ```

    For production, make sure to add the production API key to your deployment environment.
  </Step>

  <Step>
    ## Create a new package in your repository

    You can use the [Turbo generator](/docs/web/customization/add-package) to quickly scaffold the package structure:

    ```bash
    turbo gen package
    ```

    When prompted, name your package `tasks`. This will create the basic structure for you.

    Alternatively, create a new folder `tasks` in the `/packages` directory and add the following files:

    <Tabs items={["package.json", "tsconfig.json", "trigger.config.ts"]}>
      <Tab value="package.json">
        ```json
        {
          "name": "@workspace/tasks",
          "private": true,
          "version": "0.1.0",
          "type": "module",
          "exports": {
            ".": "./src/index.ts"
          },
          "scripts": {
            "clean": "git clean -xdf .cache .turbo dist node_modules",
            "dev": "pnpm dlx trigger.dev@latest dev",
            "deploy": "pnpm dlx trigger.dev@latest deploy"
          },
          "dependencies": {
            "@trigger.dev/sdk": "4.3.3"
          },
          "devDependencies": {
            "@trigger.dev/build": "4.3.3",
            "@workspace/tsconfig": "workspace:*",
            "typescript": "catalog:"
          }
        }
        ```
      </Tab>

      <Tab value="tsconfig.json">
        ```json
        {
          "extends": "@workspace/tsconfig/base.json",
          "include": ["**/*.ts"],
          "exclude": ["dist", "build", "node_modules"]
        }
        ```
      </Tab>

      <Tab value="trigger.config.ts">
        ```ts
        import { defineConfig } from "@trigger.dev/sdk";

        export default defineConfig({
          project: "your_project_id", // Replace with your actual project ID
          runtime: "node",
          logLevel: "log",
          maxDuration: 300,
          dirs: ["./src/trigger"],
        });
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Create your first task

    Now create your first task in the `packages/tasks/src/trigger` directory:

    <Tabs items={["process-user-data.ts", "daily-cleanup.ts", "src/index.ts"]}>
      <Tab value="process-user-data.ts">
        ```ts title="packages/tasks/src/trigger/process-user-data.ts"
        import { task, logger, wait } from "@trigger.dev/sdk";
        import * as z from "zod";

        const ProcessUserDataSchema = z.object({
          userId: z.string(),
          operation: z.enum(["export", "analyze", "cleanup"]),
        });

        export const processUserDataTask = task({
          id: "process-user-data",
          run: async (payload: z.infer<typeof ProcessUserDataSchema>) => {
            const { userId, operation } = payload;

            logger.info("Starting user data processing", { userId, operation });

            switch (operation) {
              case "export":
                await wait.for({ seconds: 2 });
                logger.info("User data exported successfully");
                return { success: true, result: "Data exported to CSV" };

              case "analyze":
                await wait.for({ seconds: 5 });
                logger.info("User data analysis completed");
                return {
                  success: true,
                  result: { totalActions: 156, avgSessionTime: "4m 32s" },
                };

              case "cleanup":
                await wait.for({ seconds: 3 });
                logger.info("User data cleanup completed");
                return { success: true, result: "Removed 23 obsolete records" };

              default:
                throw new Error(`Unknown operation: ${operation}`);
            }
          },
        });
        ```
      </Tab>

      <Tab value="daily-cleanup.ts">
        ```ts title="packages/tasks/src/trigger/daily-cleanup.ts"
        import { schedules, task, logger, wait } from "@trigger.dev/sdk";

        export const dailyCleanupTask = task({
          id: "daily-cleanup",
          run: async () => {
            logger.info("Starting daily cleanup");

            // Cleanup old logs
            await wait.for({ seconds: 5 });
            logger.info("Logs cleaned up");

            // Cleanup temporary files
            await wait.for({ seconds: 3 });
            logger.info("Temp files cleaned up");

            // Generate daily reports
            await wait.for({ seconds: 8 });
            logger.info("Reports generated");

            return {
              success: true,
              cleanupTime: new Date().toISOString(),
              itemsProcessed: 1247,
            };
          },
        });

        // Schedule the task to run daily at 2 AM
        schedules.create({
          task: "daily-cleanup",
          cron: "0 2 * * *",
        });
        ```
      </Tab>

      <Tab value="src/index.ts">
        ```ts title="packages/tasks/src/index.ts"
        export * from "./trigger/process-user-data";
        export * from "./trigger/daily-cleanup";
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Test your task

    You can test your tasks locally by running:

    ```bash
    # Start the development server
    pnpm --filter @workspace/tasks dev
    ```

    This will deploy your tasks to trigger.dev in the development environment, allowing you to trigger them from the dashboard or programmatically.
  </Step>

  <Step>
    ## Deploy your tasks

    To deploy your tasks to production on trigger.dev, run:

    ```bash
    pnpm --filter @workspace/tasks deploy
    ```

    You can also add this command as an automated deployment step in your CI/CD pipeline by creating a new GitHub action.

    Add the `TRIGGER_ACCESS_TOKEN` secret to your repository secrets, which you can create in the trigger.dev dashboard.

    ```yml title=".github/workflows/deploy-tasks.yml"
    name: Deploy to trigger.dev (prod)

    on:
      push:
        branches:
          - main

    jobs:
      deploy:
        runs-on: ubuntu-latest

        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: "24"
          - uses: pnpm/action-setup@v4
          - name: Install dependencies
            run: pnpm install
          - name: Deploy trigger tasks
            env:
              TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
            run: |
              pnpm --filter @workspace/tasks deploy
    ```
  </Step>

  <Step>
    ## Triggering tasks

    You can trigger tasks from your TurboStarter application using the API layer.

    <Callout type="warning" title="Direct task triggering not recommended">
      While you can trigger tasks directly from your frontend or server components using the trigger.dev SDK, it's recommended to use the API layer approach shown below.

      This provides better security, validation, and separation of concerns.
    </Callout>

    First, add the `@workspace/tasks` package as a dependency to your API package:

    ```json title="packages/api/package.json"
    {
      "dependencies": {
        "@workspace/tasks": "workspace:*"
      }
    }
    ```

    ### From an API endpoint

    Create a new API module to handle task triggering:

    ```ts title="packages/api/src/modules/tasks/tasks.router.ts"
    import { tasks } from "@trigger.dev/sdk";
    import { Hono } from "hono";
    import * as z from "zod";
    import type { processUserDataTask } from "@workspace/tasks";

    import { enforceAuth, validate } from "../../middleware";

    const processUserDataSchema = z.object({
      userId: z.string(),
      operation: z.enum(["export", "analyze", "cleanup"]),
    });

    export const tasksRouter = new Hono().post(
      "/process-user-data",
      enforceAuth,
      validate("json", processUserDataSchema),
      async (c) => {
        const { userId, operation } = c.req.valid("json");

        const handle = await tasks.trigger<typeof processUserDataTask>(
          "process-user-data",
          { userId, operation },
        );

        return c.json({
          success: true,
          taskId: handle.id,
          message: "Background task started successfully",
        });
      },
    );
    ```

    Then register it in your main API router:

    ```ts title="packages/api/src/index.ts"
    import { tasksRouter } from "./modules/tasks/tasks.router";

    const appRouter = new Hono()
      .basePath("/api")
      .route("/tasks", tasksRouter)
      // ... other existing routers
      .onError(onError);

    export { appRouter };
    ```

    ### From the client

    You can call the task endpoint from your web app using TurboStarter's API client:

    ```tsx title="apps/web/src/modules/tasks/process-data-button.tsx"
    "use client";

    import { handle } from "@workspace/api/utils";
    import { useMutation } from "@tanstack/react-query";

    import { api } from "~/lib/api/client";

    export function ProcessDataButton({ userId }: { userId: string }) {
      const { mutate: processData, isPending } = useMutation({
        mutationFn: handle(api.tasks["process-user-data"].$post),
        onSuccess: (data) => {
          console.log("Task started:", data.taskId);
        },
      });

      return (
        <button
          onClick={() =>
            processData({
              json: { userId, operation: "analyze" },
            })
          }
          disabled={isPending}
        >
          {isPending ? "Processing..." : "Analyze User Data"}
        </button>
      );
    }
    ```

    ### From a server action

    ```ts title="apps/web/src/app/actions/user-actions.ts"
    "use server";

    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/server";

    export async function processUserData(userId: string, operation: string) {
      try {
        const result = await handle(api.tasks["process-user-data"].$post)({
          json: { userId, operation },
        });

        return {
          success: true,
          taskId: result.taskId,
        };
      } catch (error) {
        console.error("Failed to trigger background task:", error);
        throw new Error("Failed to start background task");
      }
    }
    ```
  </Step>
</Steps>

## Monitoring and debugging

### Dashboard access

Visit the [trigger.dev dashboard](https://trigger.dev) to monitor your tasks:

* View task execution logs and performance metrics
* Track success and failure rates across all your tasks
* Monitor task duration and resource usage
* Replay failed tasks with a single click
* Set up alerts for task failures or performance issues

### Local development

During development, run your tasks locally while connected to trigger.dev:

```bash
# Start everything in the workspace
pnpm dev

# or start the tasks package only
pnpm --filter @workspace/tasks dev
```

This allows you to:

* Test tasks locally with real data
* Debug with breakpoints and console logs
* See immediate feedback as you develop

## Best practices

<Accordions>
  <Accordion title="Use descriptive task IDs">
    ```ts
    // ✅ Good - Clear and descriptive
    id: "user-data-export-csv";
    id: "weekly-newsletter-campaign";
    id: "cleanup-temp-files";

    // ❌ Not so good - Generic and unclear
    id: "task1";
    id: "job";
    id: "process";
    ```
  </Accordion>

  <Accordion title="Include proper error handling">
    ```ts
    run: async (payload) => {
      try {
        const result = await processData(payload);
        logger.info("Task completed successfully", { result });
        return result;
      } catch (error) {
        logger.error("Task failed:", error.message);
        throw error; // Re-throw to trigger retry logic
      }
    },
    ```
  </Accordion>

  <Accordion title="Use structured logging">
    ```ts
    logger.info("Processing started", {
      userId: payload.userId,
      operation: payload.operation,
      timestamp: new Date().toISOString(),
    });
    ```
  </Accordion>

  <Accordion title="Keep tasks focused">
    Instead of one massive task, create focused, single-purpose tasks that can be composed together for complex workflows.
  </Accordion>

  <Accordion title="Configure appropriate retries">
    Set retry policies based on your task's requirements:

    ```ts
    // For critical operations
    retry: {
      maxAttempts: 5,
      minTimeoutInMs: 2000,
      maxTimeoutInMs: 30000,
      factor: 2,
    }

    // For less critical operations
    retry: {
      maxAttempts: 2,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 5000,
      factor: 1.5,
    }
    ```
  </Accordion>
</Accordions>

## Next steps

With trigger.dev integrated into your TurboStarter application, you can now:

* **Handle long-running operations** that would timeout in serverless functions
* **Schedule recurring tasks** like reports, cleanups, and maintenance
* **Process background jobs** reliably with automatic retries
* **Scale your application** without worrying about task execution infrastructure

Ready to explore more advanced features? Check out the official documentation for additional capabilities like webhooks, batching, and custom integrations.

<Cards>
  <Card title="Documentation" description="trigger.dev" href="https://trigger.dev/docs" />

  <Card title="Examples" description="trigger.dev" href="https://trigger.dev/docs/guides/introduction" />
</Cards>


# Vercel Workflows
Source: https://www.turbostarter.dev/docs/web/background-tasks/vercel-workflows

[Vercel Workflows](https://vercel.com/docs/workflows) run durable TypeScript functions that can pause, retry, and resume after a process crash or redeploy. The open-source [Workflow SDK](https://workflow-sdk.dev) turns ordinary async functions into orchestrators (`"use workflow"`) and retried units of work (`"use step"`).

<Callout title="What Vercel Workflows are good for">
  Use Workflows when a business process must survive minutes to days (onboarding sequences, approval waits, multi-step billing repairs) while staying close to your Next.js deploy on Vercel. You write async/await; the runtime persists progress between steps.
</Callout>

<Callout type="warn" title="Public beta">
  Review current [pricing, limits, and release status](https://vercel.com/docs/workflows) before putting Workflows on a critical path. Prefer it for genuine durable orchestration, not for a short route handler that finishes in a few seconds.
</Callout>

## When to choose Vercel Workflows

| Need                                                             | Prefer                                              |
| ---------------------------------------------------------------- | --------------------------------------------------- |
| Durable steps that pause/resume on Vercel with minimal new infra | **Vercel Workflows**                                |
| Event bus, fan-out, and a mature multi-step dashboard            | [Inngest](/docs/web/background-tasks/inngest)       |
| Dedicated workers and long CPU jobs outside the request path     | [Trigger.dev](/docs/web/background-tasks/trigger)   |
| Fire-and-forget HTTP jobs with delay/cron                        | [Upstash QStash](/docs/web/background-tasks/qstash) |

**Pick Workflows if** you deploy primarily to Vercel, want durable orchestration in-repo with `"use workflow"` / `"use step"`, and accept beta-era limits. **Skip it if** you need a battle-tested multi-cloud queue today. Trigger.dev or Inngest are safer defaults for production-critical jobs.

<Steps>
  <Step>
    ## Install and configure the Workflow SDK

    From the monorepo root (or the web app), run the current setup:

    ```bash
    pnpm --filter web exec npx workflow@latest
    ```

    Or install manually and wrap Next.js config yourself:

    ```bash
    pnpm add --filter web workflow
    ```

    ### Compose `withWorkflow` carefully

    TurboStarter already wraps `next.config.ts` (Content Collections, PostHog, and other plugins). Add `withWorkflow` **once** around the final config. Do not nest duplicate wrappers if you re-run the CLI.

    ```ts title="apps/web/next.config.ts"
    import { withContentCollections } from "@content-collections/next";
    import { withPostHogConfig } from "@posthog/next";
    import { withWorkflow } from "workflow/next";

    // ... build `config` as today ...

    export default withWorkflow(
      withPostHogConfig(withContentCollections(config), {
        // existing PostHog options
      }),
    );
    ```

    Preserve every existing wrapper. Running setup twice should refresh the integration, not stack another `withWorkflow`.

    ### Optional TypeScript plugin

    ```json title="apps/web/tsconfig.json"
    {
      "compilerOptions": {
        "plugins": [{ "name": "workflow" }]
      }
    }
    ```

    ### Pin a reviewed version

    Use a current `workflow` release. Older beta builds had security issues around webhook tokens. After install, run `pnpm audit` and follow the Workflow SDK security guidance before exposing hooks or webhooks.
  </Step>

  <Step>
    ## Keep Turborepo cache correct

    The SDK generates routes under `src/app/.well-known/workflow/` during build. If Turborepo caches `.next` but not those files, cache hits can miss workflow registration.

    Update the web app’s build outputs:

    ```json title="apps/web/turbo.json"
    {
      "tasks": {
        "build": {
          "outputs": [
            ".next/**",
            "!.next/cache/**",
            "src/app/.well-known/workflow/**"
          ]
        }
      }
    }
    ```

    Without this, workflows may work on a cold build and fail intermittently on cache hits.
  </Step>

  <Step>
    ## Proxy matcher (only if you broaden it)

    TurboStarter’s `src/proxy.ts` currently matches docs paths only, so Workflow internals are unaffected by default. If you later expand the matcher to cover the whole app, **exclude** Workflow’s well-known paths or local queue operations can fail with cryptic `ArrayBuffer` errors:

    ```ts title="apps/web/src/proxy.ts"
    export const config = {
      matcher: [
        {
          source:
            "/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)",
        },
      ],
    };
    ```
  </Step>

  <Step>
    ## Create a durable workflow

    Keep **orchestration** in the workflow function and **I/O** (database, email, third-party APIs) inside step functions. Pass a stored job ID, not a browser-supplied user object.

    ```ts title="apps/web/src/workflows/process-stored-job.ts"
    import { FatalError } from "workflow";

    import { processStoredJob } from "~/lib/tasks/process-stored-job";

    export async function processStoredJobWorkflow(jobId: string): Promise<void> {
      "use workflow";

      await processJobStep(jobId);
    }

    async function processJobStep(jobId: string): Promise<void> {
      "use step";

      try {
        await processStoredJob(jobId);
      } catch (error) {
        // Permanent validation failures should not spin forever
        if (error instanceof Error && error.message.startsWith("Invalid job")) {
          throw new FatalError(error.message);
        }
        throw error; // Retryable by default
      }
    }
    ```

    `processStoredJob` (for example in `apps/web/src/lib/tasks/process-stored-job.ts`) is your product code. It should:

    1. Load the pending job (and organization) from the database
    2. Claim it atomically (`pending` → `processing`)
    3. Perform the work idempotently
    4. Record success or failure

    Steps may retry, so duplicate side effects are bugs.

    ### Sleeps and waits

    Use Workflow primitives when the orchestrator must pause without burning compute:

    ```ts
    import { sleep } from "workflow";

    export async function onboardingWorkflow(userId: string) {
      "use workflow";

      await sendWelcomeStep(userId);
      await sleep("2d");
      await sendTipsStep(userId);
    }
    ```

    Put `fetch`, DB, and Node APIs inside `"use step"` functions. The workflow body should stay deterministic orchestration.
  </Step>

  <Step>
    ## Start workflows after authorization

    Start runs with `start` from `workflow/api`. Do **not** call the workflow function as a normal async function from a route if you want durable execution.

    ### From a tRPC mutation (recommended)

    ```ts title="packages/api/src/modules/tasks/tasks.router.ts"
    import { TRPCError } from "@trpc/server";
    import * as z from "zod";

    import { createTRPCRouter, protectedProcedure } from "../../trpc";
    import { claimJobForUser } from "./tasks.service";

    export const tasksRouter = createTRPCRouter({
      startProcessJob: protectedProcedure
        .input(z.object({ jobId: z.string().uuid() }))
        .mutation(async ({ ctx, input }) => {
          // Product-specific: verify this session may operate the job
          const job = await claimJobForUser({
            jobId: input.jobId,
            userId: ctx.session.user.id,
          });

          if (!job) {
            throw new TRPCError({ code: "FORBIDDEN" });
          }

          // Import dynamically if the API package should not depend on workflow at build time,
          // or call a thin web-app route that owns `start()`.
          return { jobId: job.id, status: "authorized" as const };
        }),
    });
    ```

    Because `start()` belongs with the Next.js Workflow runtime, a clean split is: **authorize in tRPC**, then start from a Route Handler in the web app (or a server action that only runs in `apps/web`).

    ### From a Route Handler

    ```ts title="apps/web/src/app/api/tasks/process/route.ts"
    import { NextResponse } from "next/server";
    import * as z from "zod";
    import { start } from "workflow/api";

    import { auth } from "@workspace/auth/server";

    import { processStoredJobWorkflow } from "~/workflows/process-stored-job";

    const requestSchema = z.object({
      jobId: z.string().uuid(),
    });

    export async function POST(request: Request): Promise<Response> {
      const session = await auth.api.getSession({ headers: request.headers });

      if (!session) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
      }

      const { jobId } = requestSchema.parse(await request.json());

      // Verify session.user can operate this stored job before continuing.
      // Re-check access inside delayed steps if permissions can change.

      const run = await start(processStoredJobWorkflow, [jobId]);

      return NextResponse.json({ runId: run.runId }, { status: 202 });
    }
    ```

    Authorization is required, not sample fluff. Skipping it is an IDOR waiting to happen. Prefer “job ID + membership check” over shipping a full organization DTO into the workflow.
  </Step>

  <Step>
    ## Inspect and test the lifecycle

    ```bash
    npx workflow health
    npx workflow web
    npx workflow inspect runs
    ```

    On Vercel, open **Observability → Workflows**. Still keep **application job status in your database** so support and customers are not tied to provider-specific run UIs.

    ### Test more than the happy path

    1. An authenticated member can start a job they own
    2. Another user cannot start that job
    3. A transient step failure retries without duplicating an external side effect
    4. A permanent failure (`FatalError`) becomes visible and does not stay `pending` forever
    5. Redeploying while a workflow is paused does not lose the run
    6. Starting the same stored job twice does not process it twice
  </Step>
</Steps>

## Production checklist

* Pin a reviewed Workflow SDK version; upgrade deliberately after reading changelogs
* Keep step inputs small: IDs, not secrets or full customer records
* Log stable job and organization identifiers
* Define which errors retry vs fail permanently (`FatalError`)
* Alert on failed runs and jobs stuck in `processing` too long
* Document how support can safely replay or cancel a job
* Confirm Workflow [pricing and limits](https://vercel.com/docs/workflows) before launch
* Include `src/app/.well-known/workflow/**` in Turborepo build outputs

## Best practices

<Accordions>
  <Accordion title="Orchestration vs steps">
    Workflow functions coordinate. Step functions talk to the world. If you put a DB write in the workflow body, you risk non-deterministic replay and missing retries.
  </Accordion>

  <Accordion title="Idempotent claims">
    ```ts
    // Pseudocode: claim once
    const updated = await db
      .update(jobs)
      .set({ status: "processing" })
      .where(and(eq(jobs.id, jobId), eq(jobs.status, "pending")));

    if (!updated.rowCount) {
      return; // Already claimed or finished
    }
    ```
  </Accordion>

  <Accordion title="Do not call the workflow function directly">
    ```ts
    // Wrong for durable execution
    await processStoredJobWorkflow(jobId);

    // Right
    await start(processStoredJobWorkflow, [jobId]);
    ```
  </Accordion>
</Accordions>

## FAQ

<Accordions>
  <Accordion title="Workflows vs Inngest: what’s the difference?">
    **Workflows** emphasize durable async functions on Vercel with `"use workflow"` / `"use step"`. **Inngest** emphasizes an event-driven control plane (events, cron, fan-out) that invokes your serve endpoint. Choose based on whether you want Vercel-native durability or a dedicated event platform.
  </Accordion>

  <Accordion title="Can I use Workflows outside Vercel?">
    The SDK is open source, but production story and ops tooling are strongest on Vercel today. Check the Workflow docs for other deploy targets before committing.
  </Accordion>

  <Accordion title="Why did `start()` say it received an invalid workflow function?">
    Almost always: missing `"use workflow"` on the function, or `next.config` not wrapped with `withWorkflow()`. Fix those before debugging further.
  </Accordion>

  <Accordion title="Should every API call become a workflow?">
    No. Use workflows for multi-step processes that must survive failure and time. Ordinary CRUD and sub-10s work should stay in tRPC procedures or short route handlers.
  </Accordion>
</Accordions>

## Next steps

With Workflows integrated you can:

* Run durable multi-step jobs that resume after deploys
* Pause with `sleep` without holding a serverless invocation open
* Keep authorization in TurboStarter’s session layer while orchestration stays in-repo
* Inspect runs via the Workflow CLI and Vercel Observability

Continue with the [Workflow SDK documentation](https://workflow-sdk.dev) for hooks, streaming, and advanced control flow, or compare options in the [background tasks overview](/docs/web/background-tasks/overview).

<Cards>
  <Card title="Workflow SDK docs" description="workflow-sdk.dev" href="https://workflow-sdk.dev" />

  <Card title="Vercel Workflows" description="vercel.com" href="https://vercel.com/docs/workflows" />

  <Card title="Inngest guide" description="TurboStarter docs" href="/docs/web/background-tasks/inngest" />

  <Card title="trigger.dev guide" description="TurboStarter docs" href="/docs/web/background-tasks/trigger" />
</Cards>


# Configuration
Source: https://www.turbostarter.dev/docs/web/billing/configuration

The billing configuration schema mirrors the billing data your app needs, so that:

* we can display the data in the UI (pricing table, billing section, etc.)
* we can create the correct checkout session
* some features can work correctly (e.g. [feature-based access](/docs/web/recipes/feature-based-access))

It is shared across all web billing providers and lives in `packages/billing/shared/src/config/schema.ts`, with the default config exported from `packages/billing/shared/src/config/index.ts`. Some billing providers differ in what you can and cannot do. In those cases, the schema validates the supported shape, but it is still up to you to make sure your provider setup matches the data in your config.

The schema is based on a few entities:

* **Plans:** The main products you are selling (e.g. "Free", "Premium", etc.)
* **Variants:** The purchasable pricing options for a plan (one-time or recurring)
* **Features:** The list of features included in a plan (used for the UI and [access control](/docs/web/recipes/feature-based-access))
* **Discounts:** Optional discounts that apply to specific variants

```ts title="index.ts"
type BillingConfig = {
  plans: BillingConfigPlan[];
  discounts?: BillingConfigDiscount[];
};
```

<Callout title="Getting the schema right is important!" type="error">
  Getting the IDs of your plans and variants is **extremely important**, as these are used to:

  * create the correct checkout
  * manage your customers billing data

  Please take it easy while you configure this, do one step at a time, and test it thoroughly.
</Callout>

## Billing provider

To set the active web billing provider, modify the exports in the `packages/billing/web/src/providers` directory. It defaults to [Stripe](/docs/web/billing/stripe).

<Tabs items={["index.ts", "env.ts"]}>
  <Tab value="index.ts">
    ```ts
    // [!code word:stripe]
    export * from "./stripe";
    ```
  </Tab>

  <Tab value="env.ts">
    ```ts
    // [!code word:stripe]
    export * from "./stripe/env";
    ```
  </Tab>
</Tabs>

It is important to set this correctly, because it determines which provider strategy and environment variables are used by `@workspace/billing-web/server`.

## Plans

Plans are the main products you are selling. They are defined by the following fields:

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      features: [
        "Unlimited projects",
        "Priority support",
        "Advanced integrations",
        "Team collaboration",
        "Analytics dashboard"
      ],
      limits: {
        projects: 10,
        members: 5,
        storage: null,
      },
      variants: [],
    },
  ],
  ...
}) satisfies BillingConfig;
```

Let's break down the fields:

* `id`: The internal identifier for the plan. In the default setup this uses the built-in `BillingPlan` enum (`free`, `premium`, `enterprise`). It does not need to match anything in the billing provider, but it should stay stable because it is used throughout the app for plan logic and access control.
* `name`: The name of the plan
* `description`: The description of the plan
* `badge`: A badge to display on the product (e.g. "Bestseller", "Popular", etc.). Can be `null`.
* `features`: The list of included features for the plan.
* `limits`: Optional usage caps for this plan.
* `variants`: The list of purchasable variants for this plan (see below).

Most of these fields populate the pricing table UI.

### Limits

You can define optional `limits` directly on a plan. This is useful for quota-style restrictions such as:

* number of projects
* number of members
* storage allowance
* any other usage key your app wants to enforce

`limits` is a record of keys to values:

* a number means the plan has a hard cap for that key
* `null` means unlimited
* a missing key means no limit is configured for that capability

```ts title="index.ts"
{
  id: BillingPlan.FREE,
  name: "Free",
  description: "Get started for free",
  badge: null,
  features: ["FEATURE_1", "FEATURE_2"],
  limits: {
    projects: 3,
    members: 1,
    storage: null,
  },
  variants: [],
}
```

<Callout title="Checking limits">
  We ship a `checkPlanLimit()` helper for evaluating limits in application code.

  ```ts title="limits.ts"
  import { checkPlanLimit } from "@workspace/billing/shared/utils/plan";

  const { allowed, limit, current, remaining } = checkPlanLimit({
    id: BillingPlan.PREMIUM,
    key: "projects",
    currentUsage: 9,
  });
  ```

  It returns:

  * `allowed`: whether the action should be allowed
  * `limit`: the configured limit, or `null` if unlimited / not configured
  * `current`: the current usage value you passed in
  * `remaining`: remaining capacity, or `null` if unlimited / not configured

  By default, `checkPlanLimit()` assumes you are checking whether the user can add **one more** unit, because `increment` defaults to `1`.

  ```ts title="limits.ts"
  const { allowed, limit, current, remaining } = checkPlanLimit({
    id: BillingPlan.PREMIUM,
    key: "members",
    currentUsage: 3,
    increment: 2,
  });
  ```

  This is useful when you want to validate bulk actions, for example inviting multiple members at once.

  See the [feature-based access recipe](/docs/web/recipes/feature-based-access) for how limits fit alongside boolean feature gates in API routes and UI.
</Callout>

### Variants

Variants are the purchasable options for a plan. They can be one-time or recurring, and they can represent flat, per-seat, or metered billing depending on their `type`.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      variants: [
        {
          /* 👇 This is the `priceId` from the provider (e.g. Stripe), `variantId` (e.g. Lemon Squeezy) or `productId` (e.g. Polar) */
          id: "price_1PpZAAFQH4McJDTlig6Fxsyy",
          cost: 1900,
          currency: "usd",
          type: BillingType.FLAT,
          model: BillingModel.RECURRING,
          interval: RecurringInterval.MONTH,
          trialDays: 7,
          hidden: false,
        },
      ],
    },
  ],
  ...
}) satisfies BillingConfig;
```

Let's break down the fields:

* `id`: The unique identifier for the variant. **This must match the corresponding identifier in the billing provider.**
* `cost`: The price amount in the smallest currency unit (e.g. cents). Displayed values are typically divided by 100.
* `currency`: The currency code for the price (defaults to `usd`)
* `type`: The billing type for this variant. If omitted on a custom variant it defaults to `flat`.

<Callout title="Set the correct currency on your billing provider">
  Make sure you have the same currency set on your billing provider (e.g. as a [store currency](https://docs.lemonsqueezy.com/help/payments/currencies) on Lemon Squeezy).
</Callout>

* `model`: The billing model for this variant (`one-time` or `recurring`)
* `interval`: The interval for recurring variants (e.g. `month`, `year`)
* `trialDays`: Trial length in days for recurring variants (optional)
* `hidden`: Whether this variant should be hidden from the pricing table (defaults to `false`). This is useful for grandfathered prices, mobile-only variants, or internal migration paths.

The cost is used **only** for UI purposes. The billing provider will handle the actual billing - therefore, please make sure the cost is correctly set in the billing provider.

<Callout title="Set the correct variant ID!" type="error">
  Make sure the `id` matches the correct identifier in the billing provider. This is critical, as it’s used to identify the correct variant when creating a checkout session.
</Callout>

### Custom variants

Sometimes - you want to display a variant in the pricing table - but not actually have it in the billing provider. This is common for custom plans, free plans that don't require the billing provider subscription, or plans that are not yet available.

To do so, let's add the `custom` flag to the variant:

```ts title="index.ts"
{
  id: "enterprise-monthly",
  label: "Contact us!",
  href: "/contact",
  model: BillingModel.RECURRING,
  interval: RecurringInterval.MONTH,
  custom: true, //[!code highlight]
}
```

Here's the full example:

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      features: [
        "Unlimited projects",
        "Priority support",
        "Advanced integrations",
        "Team collaboration",
        "Analytics dashboard"
      ],
      variants: [
        {
          id: "premium-monthly",
          label: "Contact us!",
          href: "/contact",
          type: BillingType.FLAT,
          model: BillingModel.RECURRING,
          interval: RecurringInterval.MONTH,
          custom: true, // [!code highlight]
        },
      ],
    },
  ],
  ...
}) satisfies BillingConfig;
```

As you can see, the plan now has a custom variant. The UI will display it in the pricing table, but it won't be available for purchase.

We do this by using the following fields:

* `custom`: A flag to indicate that the plan is custom. This will prevent the plan from being available for purchase. It's set to `false` by default.
* `label`: Displayed in the pricing table instead of a numeric amount.
* `href`: The link to the page where the user goes when they click on the variant. This is used in the pricing table.

<Callout title="Translations supported!">
  All labels and descriptions can be translated using the [internationalization](/docs/web/internationalization/overview) feature. The UI will display the correct translation based on the user's locale.

  ```ts title="index.ts"
  label: "common:contactUs",
  ```

  To make strings translatable, make sure to provide the translation key in the config.
</Callout>

### Discounts

Sometimes, you want to offer a discount to your users. This is done by adding a discount to the `discounts` array and pointing it at specific variant IDs via `appliesTo`.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  discounts: [
    {
      code: "50OFF",
      type: BillingDiscountType.PERCENT,
      off: 50,
      appliesTo: [
        "price_1PpUagFQH4McJDTlHwsCzOmyT6",
      ],
    },
  ],
  ...
}) satisfies BillingConfig;
```

Let's break down the fields:

* `code`: The discount/promo code (e.g. "50OFF"). **This must match the code configured in the billing provider.**
* `type`: The type of the discount (e.g. `percent`, `amount`, etc.)
* `off`: The discount value (e.g. 50 for 50% off when `type` is `percent`)
* `appliesTo`: The list of variant IDs this discount applies to

This data allows you to display the correct banner in the UI (e.g. “10% off for the first 100 customers!”) and apply the discount to the correct variant at checkout.

## Adding more products, plans and discounts

Simply add more plans, variants, discounts, and limits to the config. The UI **should** handle most traditional cases; if you have a more complex billing setup, you may need to adjust the UI accordingly.


# Credits-based billing
Source: https://www.turbostarter.dev/docs/web/billing/credits

Credits-based billing is a great fit when you want customers to spend a balance over time instead of paying only for seats or raw metered usage.

<Cards>
  <Card title="AI tokens or generations" description="Customers pay for each token or generation they use." />

  <Card title="Image processing jobs" description="Customers pay for each image processing job they perform." />

  <Card title="Document conversions" description="Customers pay for each document they convert." />

  <Card title="Scraping runs" description="Customers pay for each scraping run they perform." />
</Cards>

TurboStarter does **not** ship a built-in credits ledger, top-up flow, or renewal policy. Instead, it gives you the billing building blocks you need:

* provider-agnostic checkout
* synced `customer`, `subscription`, and `order` records
* webhook callbacks you can extend
* [one-time](/docs/web/billing/one-time) and [recurring](/docs/web/billing/subscriptions) billing variants

This guide shows one practical way to build a credits system on top of that foundation.

## Recommended architecture

The simplest mental model is:

1. **Billing sells access or packages** through [recurring subscriptions](/docs/web/billing/subscriptions) and [one-time purchases](/docs/web/billing/one-time).
2. **Your database stores the actual credit balance**.
3. **Your backend grants, deducts, and resets credits** based on billing events and product usage.

In practice, most apps end up with:

* a table for the current credit balance
* a table for credit transactions
* a mapping from billing `variantId` to how many credits should be granted
* webhook logic for initial grants, renewals, and top-ups
* application logic that deducts credits when users perform billable actions

## Credits model

Before writing code, decide which model you want.

<Cards>
  <Card title="Subscription credits" description="A fresh credit allowance is granted every billing period.">
    Works best with recurring billing variants such as `1,000` credits every
    month or `10,000` credits every year.
  </Card>

  <Card title="Top-up credits" description="Customers buy extra credits separately whenever they need them.">
    Works best with one-time billing variants such as `500` or `10,000` extra
    credits.
  </Card>

  <Card title="Hybrid model" description="Recurring credits plus one-time top-ups.">
    This is the most common model for AI and API products.
  </Card>
</Cards>

<Steps>
  <Step>
    ## Define the billing variants

    Credits-based billing usually combines:

    * [recurring subscription](/docs/web/billing/subscriptions) variants for the base allowance
    * [one-time](/docs/web/billing/one-time) variants for top-ups

    For example:

    ```ts title="index.ts"
    export const config = billingConfigSchema.parse({
      plans: [
        {
          id: BillingPlan.PREMIUM,
          name: "Premium",
          description: "For teams with recurring credit usage",
          badge: "Popular",
          features: ["Monthly credit allowance", "Priority support"],
          variants: [
            {
              id: "price_premium_monthly",
              cost: 2_900,
              currency: "usd",
              model: BillingModel.RECURRING,
              interval: RecurringInterval.MONTH,
              trialDays: 7,
            },
            {
              id: "price_credits_500",
              cost: 900,
              currency: "usd",
              model: BillingModel.ONE_TIME,
            },
          ],
        },
      ],
    }) satisfies BillingConfig;
    ```

    TurboStarter handles checkout and syncs the purchase into the billing tables. Your app decides what each variant means in terms of credits.
  </Step>

  <Step>
    ## Map variants to credit grants

    You need a place to define how many credits each billing variant should grant.

    There are two reasonable approaches:

    * keep the mapping in application code
    * store the mapping in a database table

    For most apps, a code-based mapping is the easiest place to start.

    ```ts title="credit-grants.ts"
    export const CREDIT_GRANTS = {
      price_premium_monthly: {
        kind: "subscription",
        credits: 1_000,
      },
      price_premium_yearly: {
        kind: "subscription",
        credits: 15_000,
      },
      price_credits_500: {
        kind: "topup",
        credits: 500,
      },
      price_credits_5000: {
        kind: "topup",
        credits: 5_000,
      },
    } as const;
    ```

    This keeps the relationship between billing configuration and credit allocation very explicit.

    <Callout title="Variant IDs must stay in sync" type="warn">
      Your credit grant mapping should use the same `variant.id` values that you configured in billing. If those IDs drift, credits will not be granted correctly.
    </Callout>
  </Step>

  <Step>
    ## Create a credits ledger in your database

    TurboStarter syncs purchases for you, but it does not create a credit balance table. You will need one.

    The most useful setup is:

    * a **balance table** with the current available credits
    * a **transaction table** with every credit change

    Here is a Drizzle-friendly example:

    ```ts title="credits.ts"
    import {
      integer,
      pgEnum,
      pgTable,
      text,
      timestamp,
      unique,
    } from "drizzle-orm/pg-core";

    import { generateId } from "@workspace/shared/utils";

    export const creditReferenceTypeEnum = pgEnum("credit_reference_type", [
      "user",
      "organization",
    ]);

    export const creditTransactionTypeEnum = pgEnum("credit_transaction_type", [
      "grant",
      "topup",
      "consume",
      "refund",
      "adjustment",
      "expiration",
    ]);

    export const creditBalance = pgTable(
      "credit_balance",
      {
        id: text("id").primaryKey().$defaultFn(generateId),
        referenceId: text("reference_id").notNull(),
        referenceType: creditReferenceTypeEnum("reference_type").notNull(),
        balance: integer("balance").notNull().default(0),
        createdAt: timestamp("created_at").defaultNow().notNull(),
        updatedAt: timestamp("updated_at")
          .defaultNow()
          .$onUpdate(() => new Date())
          .notNull(),
      },
      (t) => [unique().on(t.referenceId, t.referenceType)],
    );

    export const creditTransaction = pgTable("credit_transaction", {
      id: text("id").primaryKey().$defaultFn(generateId),
      referenceId: text("reference_id").notNull(),
      referenceType: creditReferenceTypeEnum("reference_type").notNull(),
      type: creditTransactionTypeEnum("type").notNull(),
      amount: integer("amount").notNull(),
      balanceAfter: integer("balance_after").notNull(),
      variantId: text("variant_id"),
      subscriptionExternalId: text("subscription_external_id"),
      orderExternalId: text("order_external_id"),
      idempotencyKey: text("idempotency_key").notNull().unique(),
      description: text("description"),
      createdAt: timestamp("created_at").defaultNow().notNull(),
    });
    ```

    You can simplify this further if you only support user billing or only support organization billing.
  </Step>

  <Step>
    ## Add atomic credit helpers

    Credit deduction and credit grants should be atomic. If two requests hit at the same time, you do not want users overspending their balance.

    At minimum, you will usually want helpers for:

    * reading the current balance
    * adding credits
    * resetting credits
    * consuming credits only if enough balance exists

    ```ts title="credits.service.ts"
    import { and, eq } from "@workspace/db";
    import { db } from "@workspace/db/server";

    import { creditBalance, creditTransaction } from "./schema/credits";

    export const addCredits = async ({
      referenceId,
      referenceType,
      amount,
      type,
      idempotencyKey,
      variantId,
      subscriptionExternalId,
      orderExternalId,
      description,
    }: {
      referenceId: string;
      referenceType: "user" | "organization";
      amount: number;
      type: "grant" | "topup" | "adjustment" | "refund";
      idempotencyKey: string;
      variantId?: string;
      subscriptionExternalId?: string;
      orderExternalId?: string;
      description?: string;
    }) => {
      return db.transaction(async (tx) => {
        const [existing] = await tx
          .select()
          .from(creditTransaction)
          .where(eq(creditTransaction.idempotencyKey, idempotencyKey));

        if (existing) {
          return existing;
        }

        const [balanceRow] = await tx
          .insert(creditBalance)
          .values({
            referenceId,
            referenceType,
            balance: 0,
          })
          .onConflictDoNothing()
          .returning();

        const [current] = await tx
          .select()
          .from(creditBalance)
          .where(
            and(
              eq(creditBalance.referenceId, referenceId),
              eq(creditBalance.referenceType, referenceType),
            ),
          );

        const nextBalance = (current?.balance ?? balanceRow?.balance ?? 0) + amount;

        await tx
          .update(creditBalance)
          .set({ balance: nextBalance })
          .where(
            and(
              eq(creditBalance.referenceId, referenceId),
              eq(creditBalance.referenceType, referenceType),
            ),
          );

        const [transaction] = await tx
          .insert(creditTransaction)
          .values({
            referenceId,
            referenceType,
            type,
            amount,
            balanceAfter: nextBalance,
            idempotencyKey,
            variantId,
            subscriptionExternalId,
            orderExternalId,
            description,
          })
          .returning();

        return transaction;
      });
    };
    ```

    For consumption, use a transaction that checks the current balance and only deducts when enough credits remain.
  </Step>

  <Step>
    ## Deduct credits when work is completed

    Use credits when your product performs a billable action.

    Good examples:

    * an AI generation succeeds
    * a file export finishes
    * a crawl completes
    * an image is rendered

    The safest approach is:

    1. determine how many credits the action costs
    2. verify the user or organization is allowed to spend them
    3. perform the action
    4. deduct credits in a transaction

    ```ts title="consume-credits.ts"
    export const consumeCredits = async ({
      referenceId,
      referenceType,
      amount,
      idempotencyKey,
      description,
    }: {
      referenceId: string;
      referenceType: "user" | "organization";
      amount: number;
      idempotencyKey: string;
      description?: string;
    }) => {
      return db.transaction(async (tx) => {
        const [existing] = await tx
          .select()
          .from(creditTransaction)
          .where(eq(creditTransaction.idempotencyKey, idempotencyKey));

        if (existing) {
          return existing;
        }

        const [current] = await tx
          .select()
          .from(creditBalance)
          .where(
            and(
              eq(creditBalance.referenceId, referenceId),
              eq(creditBalance.referenceType, referenceType),
            ),
          );

        const balance = current?.balance ?? 0;

        if (balance <Steps amount) {
          throw new Error("Insufficient credits");
        }

        const nextBalance = balance - amount;

        await tx
          .update(creditBalance)
          .set({ balance: nextBalance })
          .where(
            and(
              eq(creditBalance.referenceId, referenceId),
              eq(creditBalance.referenceType, referenceType),
            ),
          );

        const [transaction] = await tx
          .insert(creditTransaction)
          .values({
            referenceId,
            referenceType,
            type: "consume",
            amount: -amount,
            balanceAfter: nextBalance,
            idempotencyKey,
            description,
          })
          .returning();

        return transaction;
      });
    };
    ```

    <Callout title="Deduct after successful work whenever possible">
      For many products, it is safer to consume credits after the billable action succeeds. If you deduct before work starts, you also need a refund path for failures.
    </Callout>
  </Step>

  <Step>
    ## Grant credits from billing events

    This is where TurboStarter’s billing sync becomes useful.

    After checkout and webhook processing, you already have synced:

    * `customer`
    * `subscription`
    * `order`

    You can use webhook callbacks to translate billing events into credit grants.

    <Cards>
      <Card title="Initial subscription grant" description="When a customer first subscribes, grant the recurring credit allowance once." />

      <Card title="Renewal grant" description="When the subscription enters a new billing period, either reset the balance to the plan allowance or add more credits on top of the remaining balance." />

      <Card title="Top-up grant" description="When a one-time credit package is purchased, add the purchased credits to the current balance." />
    </Cards>

    ### Webhook extension

    TurboStarter lets you extend the billing webhook handler with callbacks.

    ```ts title="router.ts"
    import { Hono } from "hono";

    import { provider, webhookHandler } from "@workspace/billing-web/server";

    export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
      webhookHandler(c.req.raw, {
        onCheckoutSessionCompleted: async (sessionId) => {
          // grant initial subscription credits or top-up credits
        },
        onSubscriptionUpdated: async (subscriptionId) => {
          // detect a new billing period and grant renewal credits
        },
        onEvent: async (event) => {
          // optional: handle provider-specific events if needed
        },
      }),
    );
    ```

    The easiest rule is:

    * if the purchase came from a [recurring](/docs/web/billing/subscriptions) variant, grant the subscription allowance
    * if the purchase came from a [one-time](/docs/web/billing/one-time) variant, add the top-up amount

    Because webhook payload shapes differ across providers, many teams use the synced billing tables as the stable source of truth after the webhook runs.
  </Step>

  ### Renewal strategy

  There is no single correct renewal strategy. Pick the one that matches your product.

  <Accordions>
    <Accordion title="Reset each period">
      At every renewal, the balance becomes the new plan allowance.

      Good for:

      * “1000 credits per month”
      * use-it-or-lose-it plans
    </Accordion>

    <Accordion title="Rollover unused credits">
      At every renewal, add the new allowance on top of the remaining balance.

      Good for:

      * annual contracts
      * friendlier customer policies
    </Accordion>

    <Accordion title="Separate buckets">
      Keep recurring credits and top-up credits separate.

      Good for:

      * products where top-ups should never expire
      * products where subscription credits reset but purchased credits do not
    </Accordion>
  </Accordions>

  <Callout title="idempotency pattern">
    Credit grants and deductions should always be idempotent.

    A good pattern is to create deterministic keys such as:

    * `subscription:{externalId}:period_end:{timestamp}`
    * `order:{externalId}`
    * `usage:{jobId}`

    Store those keys in your `credit_transaction` table and reject duplicate processing.

    This protects you from:

    * webhook retries
    * double form submissions
    * job retries
    * provider-side event duplication
  </Callout>

  <Step>
    ## Show credits in the UI

    Once you have a balance table, the UI is straightforward.

    At minimum, most apps show:

    * current balance
    * recent credit transactions
    * the active plan
    * a button to buy more credits

    The “buy more credits” action usually points to:

    * a one-time top-up variant in your pricing page
    * a dedicated billing/settings page
    * a custom modal that triggers checkout
  </Step>
</Steps>

## Top-ups

Top-ups are usually the easiest part of a credits system because they map naturally to [one-time](/docs/web/billing/one-time) billing variants.

Recommended flow:

1. Create a one-time billing variant for each top-up package.
2. Map each variant ID to a credit amount.
3. When checkout completes successfully, add the matching credits.
4. Record the top-up in `credit_transaction` using the order’s external ID as the idempotency key.

This gives you a simple “buy more credits” feature without changing your subscription model.

## Common variations

<Cards>
  <Card title="Expiring credits" description="Add an expiration date to the balance or transaction records and ignore expired credits during consumption." />

  <Card title="Bonus credits" description="Use the same credit ledger for admin grants, referral rewards, and promotional campaigns." />

  <Card title="Different costs for different actions" description="You do not need a separate billing variant for every action. In many apps, one subscription funds a shared credit wallet, and your backend decides the cost of each action." />

  <Card title="Organization billing" description="If you support organizations, store balances by organization `referenceId` and consume credits on behalf of the organization instead of the user." />
</Cards>

## Testing

Before shipping, test the full lifecycle:

1. Start a subscription checkout and verify the initial credits are granted once.
2. Purchase a one-time top-up and verify credits are added once.
3. Trigger a renewal event and verify your reset or rollover logic behaves correctly.
4. Consume credits from a real billable action and verify the deduction is atomic.
5. Retry the same webhook or job and verify idempotency prevents double processing.

If something looks off, the most common causes are:

* the `variant.id` values do not match your grant mapping
* webhook logic is not idempotent
* credits are deducted before failed work and never refunded
* renewals and top-ups are writing to the same balance without a clear policy

## Recommended setup

If you want the fastest path to production, start with this:

1. Use recurring billing variants for monthly or yearly credit allowances.
2. Use one-time billing variants for top-ups.
3. Keep a single `credit_balance` table plus a `credit_transaction` audit table.
4. Store grant rules in code keyed by `variant.id`.
5. Use webhook callbacks to grant credits and your backend services to consume them.

That gives you a simple, understandable credits system that fits naturally into TurboStarter’s existing billing flow while still leaving room for more advanced policies later.


# Dodo Payments
Source: https://www.turbostarter.dev/docs/web/billing/dodo-payments

[Dodo Payments](https://dodopayments.com/) is another billing provider available within TurboStarter. Here we'll go through the configuration and how to set it up as a provider for your app.

To switch to Dodo Payments, update the exports in `packages/billing/web/src/providers`:

<Tabs items={["index.ts", "env.ts"]}>
  <Tab value="index.ts">
    ```ts
    // [!code word:dodo-payments]
    export * from "./dodo-payments";
    ```
  </Tab>

  <Tab value="env.ts">
    ```ts
    // [!code word:dodo-payments]
    export * from "./dodo-payments/env";
    ```
  </Tab>
</Tabs>

Then, let's configure the integration:

<Steps>
  <Step>
    ## Get API key

    After you create your [Dodo Payments](https://dodopayments.com/) account, go to `Developer > API Keys` in the dashboard and generate an API key for your app.

    TurboStarter uses the server-side Dodo SDK, so you only need the secret API key.

    ![Dodo Payments API Key](/images/docs/web/billing/dodo-payments/api-key.png)

    For local development, use [Test Mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode) so you can safely test checkouts and webhooks without affecting live transactions.

    <Card title="Dodo Payments API Introduction" description="docs.dodopayments.com" href="https://docs.dodopayments.com/api-reference/introduction" />
  </Step>

  <Step>
    ## Set environment variables

    You need to set the following environment variables:

    ```dotenv title="apps/web/.env.local"
    DODO_PAYMENTS_API_KEY="" # Your Dodo Payments API key
    DODO_PAYMENTS_WEBHOOK_KEY="" # Your Dodo Payments webhook secret key
    DODO_PAYMENTS_ENVIRONMENT="test_mode" # "test_mode" or "live_mode"
    ```

    **Please do not add the secret keys to the `.env` file in production.** During development, you can place them in `.env.local` as it's not committed to the repository. In production, set them in the environment variables of your hosting provider.
  </Step>

  <Step>
    ## Create products

    For your users to choose from the available plans, you need to create those products first in [Dodo Payments](https://docs.dodopayments.com/api-reference/products/post-products).

    ![Dodo Payments Products](/images/docs/web/billing/dodo-payments/products.png)

    Create one product per purchasable billing variant you want to offer in TurboStarter.

    This is important because the current Dodo Payments provider implementation uses the billing variant `id` as the Dodo `product_id` during checkout and subscription sync.

    ![Dodo Payments Product Variants](/images/docs/web/billing/dodo-payments/variants.png)

    Unlike [Stripe](/docs/web/billing/stripe), Dodo Payments does not map naturally to a single product with multiple prices in the current TurboStarter implementation. Instead, each variant in your billing config should point at the exact Dodo product you want to sell.

    <Callout type="warn" title="Match the product id with configuration">
      You need to make sure that the variant ID you set in the billing configuration matches the Dodo Payments `product_id`.

      [See configuration](/docs/web/billing/configuration#variants) for more information.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync subscription status, successful payments, failed payments, and one-time orders back into your database, you need to set up a webhook.

    The webhook handling code comes ready to use with TurboStarter. You only need to create the endpoint in Dodo Payments and point it to your app.

    To configure a new webhook, go to `Developer > Webhooks` in the Dodo Payments dashboard and click **Add Webhook**.

    ![Dodo Payments Webhook](/images/docs/web/billing/dodo-payments/webhook.png)

    Select the following events:

    * For subscriptions:
      * `subscription.active`
      * `subscription.updated`
      * `subscription.on_hold`
      * `subscription.renewed`
      * `subscription.plan_changed`
      * `subscription.failed`
      * `subscription.cancelled`
      * `subscription.expired`
    * For one-off payments and checkout completion:
      * `payment.succeeded`
      * `payment.failed`
      * `payment.cancelled`
      * `payment.processing`

    After creating the webhook, copy its **Signing Secret** and store it in:

    ```dotenv title="apps/web/.env.local"
    DODO_PAYMENTS_WEBHOOK_KEY=<your-webhook-secret>
    ```

    To get the callback URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    You have two good options for local webhook testing:

    <Tabs items={["Dodo CLI", "Tunnel"]}>
      <Tab value="Dodo CLI">
        Dodo Payments provides a [CLI](https://docs.dodopayments.com/developer-resources/sdks/cli) for forwarding real test-mode webhooks to your local app.

        First, log in and make sure you're using **Test Mode**:

        ```bash
        dodo login
        ```

        Then start listening and forward events to your local endpoint:

        ```bash
        dodo wh listen --forward-to http://localhost:3000/api/billing/webhook/dodo-payments
        ```

        You can also trigger mock events locally while testing:

        ```bash
        dodo wh trigger
        ```

        <Card title="Dodo Payments Webhooks" description="docs.dodopayments.com" href="https://docs.dodopayments.com/developer-resources/webhooks" />
      </Tab>

      <Tab value="Tunnel">
        If you prefer testing with a public callback URL, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine.

        To do so, install ngrok and run it with the following command while your TurboStarter web development server is running:

        ```bash
        ngrok http 3000
        ```

        ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

        This will give you a public URL that you can use when creating the webhook in Dodo Payments. Just append `/api/billing/webhook/dodo-payments` to it.
      </Tab>
    </Tabs>

    ### Production deployment

    When going to production, configure the webhook URL in Dodo Payments using the same endpoint path:

    `/api/billing/webhook/dodo-payments`

    If your app is hosted at `https://myapp.com`, then the webhook URL should be:

    `https://myapp.com/api/billing/webhook/dodo-payments`

    All the relevant events above are already handled by TurboStarter. If you want to handle more events, check [Webhooks](/docs/web/billing/webhooks) for more information.
  </Step>

  <Step>
    ## Configure the customer portal

    TurboStarter uses the [Dodo Payments Customer Portal API](https://docs.dodopayments.com/features/customer-portal) to create a billing portal session for the current customer and redirect them to Dodo's hosted portal.

    That means your users can manage subscriptions, billing history, invoices, and payment methods without you building a custom management UI.

    ![Dodo Payments Customer Portal](/images/docs/web/billing/dodo-payments/customer-portal.avif)

    The current implementation creates a dynamic portal session server-side using the Dodo customer id, so there is no extra app-side setup required beyond:

    1. making sure the customer exists in Dodo Payments
    2. keeping your API key and webhook key configured correctly
    3. using the correct Dodo environment (`test_mode` or `live_mode`)

    If you want to explore the hosted portal outside the app flow, Dodo Payments also provides static environment-specific customer portal links based on your `business_id`.

    <Card title="Dodo Payments Customer Portal" description="docs.dodopayments.com" href="https://docs.dodopayments.com/features/customer-portal" />
  </Step>
</Steps>

## Add discount

You can add a discount for your customers that will apply during checkout.

Create the discount code in Dodo Payments and then reference that same code in your TurboStarter billing configuration.

The current Dodo checkout integration passes the configured `discount.code` to Dodo as `discount_code`, so the value must match exactly.

![Dodo Payments Discount](/images/docs/web/billing/dodo-payments/discount.png)

You also need to add the discount code and details to TurboStarter billing configuration to enable displaying it in the UI, creating checkout sessions with it, and calculating prices.

[See discounts configuration](/docs/web/billing/configuration#discounts) for more details.

<Card title="Dodo Payments Checkout Sessions" description="docs.dodopayments.com" href="https://docs.dodopayments.com/developer-resources/checkout-session" />

That's it! You have now set up Dodo Payments as a billing provider for your app.

Feel free to add more products, discounts, and subscription plans, then test the full checkout and portal flow in `test_mode` before switching to `live_mode`.

<Callout type="warn" title="Ensure configuration matches">
  Make sure the data you set in TurboStarter matches the products, discounts, environment, and webhook settings you created in Dodo Payments.

  [See configuration](/docs/web/billing/configuration) for more information.
</Callout>


# Lemon Squeezy
Source: https://www.turbostarter.dev/docs/web/billing/lemon-squeezy

[Lemon Squeezy](https://lemonsqueezy.com/) is another billing provider available within TurboStarter. Here we'll go through the configuration and how to set it up as a provider for your app.

To switch to Lemon Squeezy, update the exports in `packages/billing/web/src/providers`:

<Tabs items={["index.ts", "env.ts"]}>
  <Tab value="index.ts">
    ```ts
    // [!code word:lemon-squeezy]
    export * from "./lemon-squeezy";
    ```
  </Tab>

  <Tab value="env.ts">
    ```ts
    // [!code word:lemon-squeezy]
    export * from "./lemon-squeezy/env";
    ```
  </Tab>
</Tabs>

Then, let's configure the integration:

<Steps>
  <Step>
    ## Get API keys

    Once you've signed up and created a store on [Lemon Squeezy](https://lemonsqueezy.com/), generate a new API key by navigating to the [API page](https://app.lemonsqueezy.com/settings/api) in your account settings. Click the plus button, enter a name for the API key, and select *Create*. Make sure to copy and save the API key, as you'll need it for configuring the integration.

    For development, be sure to enable [Test Mode](https://docs.lemonsqueezy.com/help/getting-started/test-mode) so you don't affect live transactions.
  </Step>

  <Step>
    ## Set environment variables

    You need to set the following environment variables:

    ```dotenv title="apps/web/.env.local"
    LEMON_SQUEEZY_API_KEY="" # Your Lemon Squeezy API key
    LEMON_SQUEEZY_SIGNING_SECRET="" # Your Lemon Squeezy webhook signing secret
    LEMON_SQUEEZY_STORE_ID="" # Your Lemon Squeezy store ID (can be found under Settings > Stores next to your store url, e.g. #12345)
    ```

    **Please do not add the secret keys to the .env file in production.** During development, you can place them in `.env.local` as it's not committed to the repository. In production, you can set them in the environment variables of your hosting provider.
  </Step>

  <Step>
    ## Create products

    For your users to choose from the available subscription plans, you need to create those Products first on the [Products page](https://app.lemonsqueezy.com/products). You can create as many products as you want.

    Create one product per plan you want to offer. You can add multiple variants within the product to offer multiple models or different billing intervals.

    ![Lemon Squeezy Products](/images/docs/web/billing/lemon-squeezy/products.webp)

    To offer multiple intervals for each plan, you can use the [Variant](https://docs.lemonsqueezy.com/help/products/variants) feature of Lemon Squeezy. Just create one variant for each interval/model you want to offer.

    ![Lemon Squeezy Variants](/images/docs/web/billing/lemon-squeezy/variants.png)

    <Callout type="warn" title="Match the variant id with configuration">
      You need to make sure that the variant ID you set in the configuration matches the ID of the variant you created in Lemon Squeezy.

      [See configuration](/docs/web/billing/configuration#variants) for more information.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync the current subscription status or checkout conclusion and other information to your database, you need to set up a webhook.

    The webhook handling code comes ready to use with TurboStarter, you just have to create the webhook in the Lemon Squeezy dashboard and insert the URL for your project.

    To configure a new webhook, go to the [Webhooks page](https://app.lemonsqueezy.com/settings/webhooks) in the Lemon Squeezy settings and click the *Plus* button.

    ![Lemon Squeezy Webhook](/images/docs/web/billing/lemon-squeezy/webhook.png)

    Select the following events:

    * For subscriptions:
      * `subscription_created`
      * `subscription_updated`
      * `subscription_cancelled`
    * For one-off payments:
      * `order_created`

    You will also have to enter a *Signing secret* which you can get by running the following command in your terminal:

    ```bash
    openssl rand -base64 32
    ```

    Copy the generated string and paste it into the *Signing secret* field.

    You also need to add this secret to your environment variables:

    ```dotenv title="apps/web/.env.local"
    LEMON_SQUEEZY_SIGNING_SECRET=<your-generated-secret>
    ```

    To get the callback URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    If you want to test the webhook locally, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine. Ngrok will then give you a URL that you can use to test the webhook locally.

    To do so, install ngrok and run it with the following command (while your TurboStarter web development server is running):

    ```bash
    ngrok http 3000
    ```

    ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

    This will give you a URL (see the *Forwarding* output) that you can use to create a webhook in Lemon Squeezy. Just use that url and add `/api/billing/webhook/lemon-squeezy` to it.

    <Card title="Lemon Squeezy Webhooks" description="docs.lemonsqueezy.com" href="https://docs.lemonsqueezy.com/api/webhooks" />

    ### Production deployment

    When going to production, you will need to set the webhook URL and the events you want to listen to in Lemon Squeezy.

    The webhook path is `/api/billing/webhook/lemon-squeezy`. If your app is hosted at `https://myapp.com` then you need to enter `https://myapp.com/api/billing/webhook/lemon-squeezy` as the URL.

    All the relevant events are automatically handled by TurboStarter, so you don't need to do anything else. If you want to handle more events please check [Webhooks](/docs/web/billing/webhooks) for more information.
  </Step>
</Steps>

## Add discount

You can add a discount for your customers that will apply on a specific price.

You can create the discount on [Discounts page](https://app.lemonsqueezy.com/discounts).

![Lemon Squeezy Discounts](/images/docs/web/billing/lemon-squeezy/discount.png)

You can set there a details of discount such as products that it should apply to, amount off, duration, max redemptions and more.

<Card title="Lemon Squeezy Discounts" description="lemonsqueezy.com" href="https://www.lemonsqueezy.com/marketing/discount-codes" />

You need to add also the discount code and details to TurboStarter billing configuration to enable displaying it in the UI, creating checkout sessions with it and calculate prices.

[See discounts configuration](/docs/web/billing/configuration#discounts) for more details.

That's it! 🎉 You have now set up Lemon Squeezy as a billing provider for your app.

Feel free to add more products, prices, discounts and manage your customers data and subscriptions using Lemon Squeezy.

<Callout type="warn" title="Ensure configuration matches">
  Make sure that the data you set in the configuration matches the details of things you created in Lemon Squeezy.

  [See configuration](/docs/web/billing/configuration) for more information.
</Callout>


# Metered usage
Source: https://www.turbostarter.dev/docs/web/billing/metered-usage

Metered usage billing lets you charge customers for what they actually use, such as API calls, AI tokens, storage, generated images, or processed jobs.

TurboStarter supports metered billing for [recurring subscriptions](/docs/web/billing/subscriptions):

* define a billing variant as `BillingType.METERED`
* let customers subscribe through the normal checkout flow
* report billable usage from trusted server-side code
* query usage later for billing screens or internal checks

## How it works

Metered billing follows a simple pattern:

<Steps>
  <Step>
    A customer subscribes to a metered recurring plan.
  </Step>

  <Step>
    Your app tracks billable usage on the backend.
  </Step>

  <Step>
    Your server reports that usage to the billing provider.
  </Step>

  <Step>
    The provider aggregates usage and bills the customer for the billing period.
  </Step>
</Steps>

This works especially well when your product usage changes over time and a flat subscription would be too rigid.

## Configuration

Metered usage is configured like any other billing variant, but with `type: BillingType.METERED`.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Best for usage-based products",
      badge: "Popular",
      features: [
        "Unlimited projects",
        "Usage-based billing",
        "Priority support",
      ],
      variants: [
        {
          id: "price_monthly_metered",
          meterId: "mtr_your_meter_id",
          type: BillingType.METERED, // [!code highlight]
          unit: "credit",
          model: BillingModel.RECURRING,
          interval: RecurringInterval.MONTH,
          trialDays: 7,
          tiers: [
            { cost: 8, upTo: 25_000 },
            { cost: 6, upTo: 125_000 },
            { cost: 4 },
          ],
        },
      ],
    },
  ],
}) satisfies BillingConfig;
```

Let's break down the fields:

* `id`: The *provider-specific* price, variant, or product ID. This must match your billing provider exactly.
* `type`: Must be `BillingType.METERED`.
* `meterId`: The provider meter identifier used when querying usage.
* `unit`: The unit shown in your UI, such as `credit`, `request`, or `token`.
* `model`: Must be `BillingModel.RECURRING`.
* `interval`: Required for metered billing.
* `trialDays`: Optional trial length.
* `cost`: Use this for a simple flat usage rate.
* `tiers`: Use this for tiered usage pricing.

<Callout title="Metered billing is recurring-only" type="warn">
  Metered variants cannot use `BillingModel.ONE_TIME`. The billing schema validates this for you.
</Callout>

### Fixed usage pricing

Use `cost` when every unit should cost the same amount.

```ts title="index.ts"
{
  id: "price_monthly_metered",
  meterId: "mtr_your_meter_id",
  type: BillingType.METERED,
  unit: "request",
  model: BillingModel.RECURRING,
  interval: RecurringInterval.MONTH,
  cost: 5,
}
```

### Tiered usage pricing

Use `tiers` when you want the unit price to change as usage grows.

```ts title="index.ts"
{
  id: "price_monthly_metered",
  meterId: "mtr_your_meter_id",
  type: BillingType.METERED,
  unit: "credit",
  model: BillingModel.RECURRING,
  interval: RecurringInterval.MONTH,
  tiers: [
    { cost: 8, upTo: 25_000 },
    { cost: 6, upTo: 125_000 },
    { cost: 4 },
  ],
}
```

This works well for patterns like:

* charging the same amount for every API call or token
* cheaper unit pricing at higher usage volumes
* including an initial amount of usage at a lower or zero rate

## Checkout

Metered variants use the normal recurring checkout flow. Unlike [per-seat billing](/docs/web/billing/per-seat), TurboStarter does not send a quantity during checkout for metered plans.

That is because metered billing is based on usage reported later, not on an upfront seat count.

In practice, this means:

1. Checkout creates the subscription
2. Your app reports usage after billable work happens
3. The provider calculates the final bill from reported usage

## Reporting usage

Usage reporting should happen in trusted server-side code only.

That can be:

* an API route
* a server action
* a background job
* a queue worker

<Callout type="warn" title="Do not report usage directly from the browser">
  Usage affects invoices, so the backend should be the source of truth.
</Callout>

### Example flow

<Steps>
  <Step>
    Identify the billing reference for the user or organization.
  </Step>

  <Step>
    Resolve the provider customer connected to that reference.
  </Step>

  <Step>
    Perform the billable work.
  </Step>

  <Step>
    Report the usage amount to the billing provider.
  </Step>
</Steps>

```ts title="report-usage.ts"
import { getCustomersByReferenceId } from "@workspace/billing/server";
import { recordUsage } from "@workspace/billing-web/server";

export const reportCreditsUsage = async ({
  referenceId,
  quantity,
}: {
  referenceId: string;
  quantity: number;
}) => {
  const [customer] = await getCustomersByReferenceId(referenceId);

  if (!customer) {
    return { recorded: false };
  }

  return recordUsage({
    externalId: customer.externalId,
    quantity,
    event: "credits_used",
  });
};
```

In this example:

* `referenceId` is the user or organization being billed
* `externalId` is the provider customer ID stored by TurboStarter
* `event` is used by providers that track usage through meter events

<Callout title="Keep usage reporting idempotent when possible">
  If the same billable action can be retried, make sure your own backend logic avoids double-reporting usage.
</Callout>

## Querying usage

TurboStarter also supports querying aggregated usage. This is useful when you want to:

* show current usage in billing settings
* show usage during a trial or billing period
* validate internal dashboards or support workflows

```ts title="get-usage.ts"
import { getCustomersByReferenceId } from "@workspace/billing/server";
import { getUsage } from "@workspace/billing-web/server";

export const getCurrentUsage = async ({
  referenceId,
  meterId,
  start,
  end,
}: {
  referenceId: string;
  meterId: string;
  start: Date;
  end: Date;
}) => {
  const [customer] = await getCustomersByReferenceId(referenceId);

  if (!customer) {
    return { usage: 0, start, end };
  }

  return getUsage({
    externalId: customer.externalId,
    meterId,
    start,
    end,
  });
};
```

The billing UI can use this to show usage for the active subscription period.

## Provider notes

Metered billing works across the supported web billing providers, but the way usage is reported differs slightly.

* **Stripe**: reports billing meter events for a customer and queries usage through the configured `meterId`
* **Lemon Squeezy**: records usage against the active subscription item and returns the current period usage from that item
* **Polar**: ingests meter events for a customer and queries aggregated totals through the configured `meterId`

The two IDs that matter most are:

* `variant.id`: the provider price, variant, or product ID used for checkout
* `meterId`: the provider meter identifier used for querying usage

**Make sure both match the correct objects in your billing provider.**

## Discounts

Metered variants support `cost` and `tiers`, so TurboStarter can still describe the pricing model in the UI.

One important difference from [flat](/docs/web/billing/subscriptions) and [per-seat](/docs/web/billing/per-seat) recurring plans is that automatic recurring discount comparison is not applied to metered variants in the same way. In most cases, your metered plan pricing should be explained directly through the variant pricing itself.

## Testing

Before shipping, test the full flow:

1. Subscribe to a metered plan.
2. Trigger a billable action from your app.
3. Verify that usage is reported successfully from server-side code.
4. Query usage for the current period and confirm it matches what you expect.
5. Check the billing provider dashboard to make sure usage and invoicing look correct.

If something looks off, the most common causes are:

* the variant is missing `type: BillingType.METERED`
* the `meterId` is missing or incorrect
* usage is being reported from the wrong billing reference
* the provider customer does not exist yet for that reference
* your backend is reporting usage twice for the same billable action

## Recommended setup

For most usage-based SaaS products, the simplest setup is:

1. Create a recurring metered variant with a clear `unit`.
2. Configure the matching metered price in your billing provider.
3. Add a `meterId` to your billing config.
4. Report usage only from trusted server-side code.
5. Query usage for the current billing period anywhere you want to show progress or billing context.

This gives you a clean model: TurboStarter handles subscription checkout and billing state, while your application decides what counts as billable usage and when to report it.


# One-time payments
Source: https://www.turbostarter.dev/docs/web/billing/one-time

While not a typical SaaS billing model, TurboStarter supports one-time (one-off) payments.

One-time payments are useful when you want to sell products that aren't subscription-based, such as:

* **Lifetime access**: products sold once, granting access forever.
* **Multiple purchases**: one-off items/add-ons that can be bought multiple times.

Some of this will require custom code (e.g. fulfillment), but TurboStarter provides a solid foundation for handling checkout and syncing successful purchases into your app.

## Configuration

One-time payments are represented as **variants** with `model: BillingModel.ONE_TIME` in your billing configuration.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  ...
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      features: [
        "Unlimited projects",
        "Priority support",
        "Advanced integrations",
        "Team collaboration",
        "Analytics dashboard"
      ],
      variants: [
        {
          /* 👇 This is the `priceId` from the provider (e.g. Stripe), `variantId` (e.g. Lemon Squeezy) or `productId` (e.g. Polar) */
          id: "price_1PpUagFQH4McJDTlHCzOmyT6",
          cost: 29900,
          currency: "usd",
          type: BillingType.FLAT,
          model: BillingModel.ONE_TIME,  // [!code highlight]
        },
      ],
    },
  ],
  ...
}) satisfies BillingConfig;
```

Let's break down the fields:

* `id`: The unique identifier for the variant. **This must match the identifier in the billing provider.**
* `cost`: The price amount in the smallest currency unit (e.g. cents). Displayed values are typically divided by 100.
* `currency`: The currency code (defaults to `usd`).
* `type`: Usually `BillingType.FLAT` for a standard one-time purchase.
* `model`: The billing model for the variant. For one-time payments, it must be `BillingModel.ONE_TIME`.

Please remember that the `cost` is set for UI purposes. **The billing provider handles the actual billing**, so make sure the amount is correct in the provider.

## Provider notes

* **Stripe**: one-time purchases typically complete on `checkout.session.completed`. Your `variant.id` should match the Stripe **Price ID** (e.g. `price_...`). See [Stripe setup](/docs/web/billing/stripe).
* **Lemon Squeezy**: one-time purchases typically emit `order_created`. Your `variant.id` should match the Lemon Squeezy **Variant ID**. See [Lemon Squeezy setup](/docs/web/billing/lemon-squeezy).
* **Polar**: one-time purchases typically emit `order.created`. Your `variant.id` should match the Polar **Product ID** (Polar models each “variant” as a separate product). See [Polar setup](/docs/web/billing/polar).

When a product is purchased, TurboStarter will create an order in the provider-agnostic `order` table - you can use this data to fulfill the order and grant access to the product.


# Overview
Source: https://www.turbostarter.dev/docs/web/billing/overview

The `@workspace/billing` and `@workspace/billing-web` packages are used to manage all the billing-related logic and features for your web SaaS application.

Inside, we're making an abstraction layer that allows us to use different billing providers without breaking our code nor changing the internal API calls.

![Billing Providers](/images/docs/web/billing/providers.png)

## Providers

TurboStarter implements multiple providers for managing billing:

<Cards>
  <Card title="Stripe" href="/docs/web/billing/stripe" description="The most popular and customizable billing provider." />

  <Card title="Lemon Squeezy" href="/docs/web/billing/lemon-squeezy" description="A Stripe-owned Merchant of Record (MoR) platform for modern subscription billing." />

  <Card title="Polar" href="/docs/web/billing/polar" description="Built for developers, Polar offers flexible, SaaS-focused billing solutions." />

  <Card title="Dodo Payments" href="/docs/web/billing/dodo-payments" description="Streamlined recurring and usage-based billing for SaaS companies." />
</Cards>

All configuration and setup is built-in with a unified API, so you can switch between providers by simply changing the exports and even introduce your own provider without breaking any billing-related logic.

Depending on the service you use, you will need to set the environment variables accordingly. By default - the billing package uses [Stripe](/docs/web/billing/stripe). Alternatively, you can use [Lemon Squeezy](/docs/web/billing/lemon-squeezy), [Polar](/docs/web/billing/polar) or [Dodo Payments](/docs/web/billing/dodo-payments). We may introduce more providers in the future.

## Configuration

The shared billing configuration is maintained in the `@workspace/billing` package, while provider-specific checkout, portal, webhook, and usage logic lives in `@workspace/billing-web`.

To better understand all billing features and customization options provided by TurboStarter, explore the following dedicated guides:

<Cards>
  <Card title="Configuration" href="/docs/web/billing/configuration" description="Learn how to structure billing settings for your app, plan details and features." />

  <Card title="Subscriptions" href="/docs/web/billing/subscriptions" description="Set up and manage recurring billing cycles, trial periods, and upgrade/downgrade logic." />

  <Card title="One-time" href="/docs/web/billing/one-time" description="Handle single, non-recurring payments, such as purchases or one-off charges." />

  <Card title="Credits" href="/docs/web/billing/credits" description="Implement a system for pre-paid credits, allowing users to consume features based on available balance." />

  <Card title="Metered usage" href="/docs/web/billing/metered-usage" description="Track and invoice customers based on actual usage, ideal for pay-as-you-go models." />

  <Card title="Per-seat" href="/docs/web/billing/per-seat" description="Charge customers based on user count, perfect for teams or organizations with varying members." />

  <Card title="Webhooks" href="/docs/web/billing/webhooks" description="Handle webhooks from your billing provider and manage payment statuses." />

  <Card title="Feature-based access" href="/docs/web/recipes/feature-based-access" description="Gate features and API routes by subscription plan — full recipe." />
</Cards>

## B2C vs B2B

TurboStarter supports B2B billing by treating either a **user** or an **organization** as the billing reference.

In practice, this means:

* a personal account can be the customer for self-serve B2C billing
* an [organization](/docs/web/organizations/overview) can be the customer for B2B team billing
* checkout, billing portal access, summaries, and usage queries all work against the selected `referenceId`

When a user starts checkout on behalf of an organization, the kit creates or reuses a provider customer linked to that organization reference. Billing data is then stored and queried through that organization rather than the individual user.

This works especially well for SaaS products where:

* teams share one workspace
* the organization pays the invoice
* owners and admins manage upgrades and billing settings
* usage, seats, or credits belong to the organization

<Callout title="B2B billing is not limited to per-seat pricing">
  Organization billing works for flat subscriptions, one-time purchases, metered billing, credits-based systems, and per-seat pricing. Per-seat is just one pricing model that builds on the same organization billing foundation.
</Callout>

Organization billing is also permission-aware. In the current setup, billing actions performed for an organization are checked against organization [billing permissions](/docs/web/organizations/rbac), so members can view billing while admins and owners can manage it according to their role.

## Fetching customer status

After your user completes checkout, you'll often want to fetch their current billing summary (subscription status, orders, current plan) to:

* gate features in your UI — see the [feature-based access recipe](/docs/web/recipes/feature-based-access)
* show “Current plan” / “Manage subscription” states
* keep the app in sync after upgrades/downgrades

You can do this via the billing `summary` endpoint (`/api/billing/summary`) using the web [API client](/docs/web/api/client).

### Server-side

```tsx title="page.tsx"
import { handle } from "@workspace/api/utils";
import { getActivePlan } from "@workspace/billing";

import { api } from "~/lib/api/server";

export default async function BillingStatus() {
  const summary = await handle(api.billing.summary.$get)();
  const plan = getActivePlan(summary);

  return <p>Current plan: {plan}</p>;
}
```

### Client-side

```tsx title="billing-status.tsx"
"use client";

import { useQuery } from "@tanstack/react-query";
import { handle } from "@workspace/api/utils";
import { getActivePlan } from "@workspace/billing";

import { api } from "~/lib/api/client";

export function BillingStatus() {
  const summary = useQuery({
    queryKey: ["billing", "summary"],
    queryFn: () => handle(api.billing.summary.$get)(),
  });

  if (!summary.data) {
    return null;
  }

  const plan = getActivePlan(summary.data);

  return <p>Current plan: {plan}</p>;
}
```

In summary, TurboStarter offers a flexible and unified billing framework, allowing you to mix and match billing models and providers as needed. Each section above provides focused documentation to help you configure the approach that best suits your SaaS application's needs.


# Per-seat billing
Source: https://www.turbostarter.dev/docs/web/billing/per-seat

Per-seat billing is a great fit for team plans. Instead of charging a flat subscription price for the whole organization, you charge based on how many members are in it.

TurboStarter supports this out of the box for **organization billing**:

* define a billing variant as `BillingType.PER_SEAT`
* start checkout for an organization
* let the kit calculate the seat quantity automatically
* sync subscription quantity when members are added or removed

<Callout title="Per-seat builds on organization billing">
  Per-seat pricing is one way to do B2B billing in TurboStarter, but it is not the only one. Organizations can also be billed through [flat subscriptions](/docs/web/billing/subscriptions), [one-time purchases](/docs/web/billing/one-time), [metered usage](/docs/web/billing/metered-usage), or [credits-based models](/docs/web/billing/credits) by starting checkout on behalf of the organization.
</Callout>

## How it works

Per-seat billing follows a simple flow:

<Steps>
  <Step>
    Create a per-seat billing variant in your billing config.
  </Step>

  <Step>
    A user starts checkout on behalf of an organization.
  </Step>

  <Step>
    TurboStarter counts the organization's billable seats and sends that quantity to the billing provider.
  </Step>

  <Step>
    If the organization membership changes later, the subscription quantity can be updated to match.
  </Step>
</Steps>

This keeps billing aligned with the actual team size without making you manually pass seat counts around in your UI.

<Callout title="Per-seat plans are organization-only" type="warn">
  Per-seat variants are meant for organization purchases. They are not shown for personal billing.
</Callout>

## Configuration

Per-seat billing is configured the same way as other billing variants, but with `type: BillingType.PER_SEAT`.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Best for growing teams",
      badge: "Popular",
      features: [
        "Unlimited projects",
        "Priority support",
        "Team collaboration",
      ],
      variants: [
        {
          id: "price_monthly_per_seat",
          type: BillingType.PER_SEAT, // [!code highlight]
          model: BillingModel.RECURRING,
          interval: RecurringInterval.MONTH,
          trialDays: 7,
          tiers: [
            { cost: 2_000, upTo: 5 },
            { cost: 1_700, upTo: 25 },
            { cost: 1_300 },
          ],
        },
      ],
    },
  ],
}) satisfies BillingConfig;
```

Let's break down the fields:

* `id`: The *provider-specific* price, variant, or product ID. This must match your billing provider exactly.
* `type`: Must be `BillingType.PER_SEAT`.
* `model`: Can be `BillingModel.ONE_TIME` or `BillingModel.RECURRING`.
* `interval`: Required for recurring variants.
* `trialDays`: Optional for recurring variants.
* `cost`: Use this for a simple fixed price per seat.
* `tiers`: Use this when the price per seat changes as the team grows.

### Fixed per-seat pricing

Use `cost` when every seat should cost the same amount.

```ts title="index.ts"
{
  id: "price_yearly_per_seat",
  type: BillingType.PER_SEAT,
  model: BillingModel.RECURRING,
  interval: RecurringInterval.YEAR,
  cost: 19_900,
}
```

### Tiered per-seat pricing

Use `tiers` when you want volume pricing, such as cheaper seats for larger teams.

```ts title="index.ts"
{
  id: "price_monthly_per_seat",
  type: BillingType.PER_SEAT,
  model: BillingModel.RECURRING,
  interval: RecurringInterval.MONTH,
  tiers: [
    { cost: 2_000, upTo: 5 },
    { cost: 1_700, upTo: 25 },
    { cost: 1_300 },
  ],
}
```

This works well for patterns like:

* a simple flat price per seat
* discounted pricing for larger teams
* free or cheaper seats up to a threshold

<Callout title="Tiered one-time seat pricing is not supported" type="warn">
  One-time per-seat variants can use `cost`, but not `tiers`.
</Callout>

## Checkout

When a user starts checkout for an organization, TurboStarter automatically determines the seat quantity from that organization's member count.

That means:

* you do not pass seat quantity manually from the frontend
* the selected `referenceId` decides which organization is being billed
* the initial subscription quantity matches the team's current size

The current implementation uses the organization member count as the billable seat count, with a minimum of `1`.

## Syncing subscription quantity

For recurring per-seat billing, the subscription quantity should stay aligned with the organization as it changes over time.

TurboStarter includes hooks you can use to sync seats after events such as:

* adding a member
* accepting an invitation
* removing a member

This gives you automatic quantity updates while letting the provider handle proration according to its own rules and settings.

## Seat sync vs seat enforcement

These are related, but they solve different problems:

* **Per-seat billing** keeps subscription quantity in sync with team size
* **Seat enforcement** blocks actions when an organization should not be allowed to add more members

One simple way to handle enforcement is to store a `members` [limit](/docs/web/billing/configuration#limits) on the plan and check it before creating an invite:

```ts title="invite.ts"
import { checkPlanLimit } from "@workspace/billing/shared/utils/plan";

const { allowed } = checkPlanLimit({
  id: BillingPlan.PREMIUM,
  key: "members",
  currentUsage: organization.members.length,
});

if (!allowed) {
  throw new Error("This plan has reached its member limit.");
}
```

Combine seat limits with boolean feature gates using the [feature-based access recipe](/docs/web/recipes/feature-based-access).

This works especially well for organization plans like:

```ts title="billing.ts"
limits: {
  members: 5,
},
```

If you want hard limits, add that logic before inviting or adding members. For example, you might:

* block invitations when no paid seats remain
* allow invites for owners and admins only after upgrading
* show a warning when removing a member would free up a paid seat

This is usually the best place to put product-specific business rules, because every SaaS handles seats a little differently.

## Permissions

Organization billing is [permission-aware](/docs/web/organizations/rbac), when checkout or portal actions are performed on behalf of an organization, TurboStarter checks the member's billing permissions for that organization.

By default:

* members can read billing data
* admins can read billing data and create billing actions
* owners can fully manage billing

This helps keep team billing safe while still allowing the right people to upgrade plans or manage subscriptions.

## Provider notes

Per-seat billing works across the supported web billing providers, but the provider IDs still need to match your configuration exactly.

* **Stripe**: quantity is sent in checkout line items and can later be updated on the subscription item
* **Lemon Squeezy**: quantity is sent at checkout and later updated through subscription items
* **Polar**: quantity is sent as seats and recurring subscriptions can be updated later

As always, the configured `variant.id` must match the billing provider's identifier exactly.

## Testing

Before shipping, test the full flow:

1. Start a checkout for an organization and confirm the initial quantity matches the number of members.
2. Add a member and verify that the subscription quantity increases.
3. Remove a member and verify that the quantity decreases.
4. Check your billing provider dashboard to confirm proration behaves the way you expect.

If something looks off, the most common causes are:

* the variant is not configured with `type: BillingType.PER_SEAT`
* the provider ID in `variant.id` does not match
* the checkout is being created for the wrong billing reference
* seat sync hooks are missing or not firing

## Recommended setup

For most team-based SaaS products, the simplest setup is:

1. Create a recurring organization variant with `type: BillingType.PER_SEAT`.
2. Configure the matching price in your billing provider.
3. Use organization checkout so quantity is derived automatically.
4. Keep membership-driven seat sync enabled.
5. Add custom seat enforcement only if your product needs hard invite or membership limits.

This gives you a clean default: teams pay for the seats they actually use, and your billing stays in sync as the organization grows.


# Polar
Source: https://www.turbostarter.dev/docs/web/billing/polar

[Polar](https://polar.sh/) is another billing provider available within TurboStarter. Here we'll go through the configuration and how to set it up as a provider for your app.

To switch to Polar, update the exports in `packages/billing/web/src/providers`:

<Tabs items={["index.ts", "env.ts"]}>
  <Tab value="index.ts">
    ```ts
    // [!code word:polar]
    export * from "./polar";
    ```
  </Tab>

  <Tab value="env.ts">
    ```ts
    // [!code word:polar]
    export * from "./polar/env";
    ```
  </Tab>
</Tabs>

Then, let's configure the integration:

<Steps>
  <Step>
    ## Get the access token

    After you have created your account for [Polar](https://polar.sh/) and created your organization, you will need to get the access token.

    Under the *Settings*, scroll to *Developers* and click "New token". Enter a name for the token, set the expiration duration and select the scopes you want the token to have.

    To keep it simple, you can select all scopes.

    ![Polar Access Token](/images/docs/web/billing/polar/access-token.png)

    For local development, make sure to use [Sandbox Mode](https://docs.polar.sh/integrate/sandbox) to not mess with the real transactions.
  </Step>

  <Step>
    ## Set environment variables

    You need to set the following environment variables:

    ```dotenv title="apps/web/.env.local"
    POLAR_ACCESS_TOKEN="" # Your Polar access token
    POLAR_WEBHOOK_SECRET="" # Your Polar webhook secret
    POLAR_ORGANIZATION_SLUG="" # Your Polar organization slug (can be found under Settings > Organization)
    ```

    **Please do not add the secret keys to the .env file in production.** During development, you can place them in `.env.local` as it's not committed to the repository. In production, you can set them in the environment variables of your hosting provider.
  </Step>

  <Step>
    ## Create products

    For your users to choose from the available subscription plans, you need to create those Products first on the [Products page](https://docs.polar.sh/features/products). You can create as many products as you want.

    ![Polar Products](/images/docs/web/billing/polar/products.png)

    Polar takes a different approach to product variants. Instead of having one product with multiple pricing options, Polar treats each pricing option as a separate product. This simplifies the user experience and API while giving you full flexibility.

    At checkout, customers can choose between different products (like monthly or yearly plans), each with its own pricing and benefits.

    ![Polar Product Variants](/images/docs/web/billing/polar/variants.png)

    <Callout type="warn" title="Match the product id with configuration">
      You need to make sure that the product ID you set in the configuration matches the ID of the product you created in Polar.

      [See configuration](/docs/web/billing/configuration#variants) for more information.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync the current subscription status or checkout conclusion and other information to your database, you need to set up a webhook.

    The webhook handling code comes ready to use with TurboStarter, you just have to create the webhook in the Polar dashboard and insert the URL for your project.

    To configure a new webhook, go to the [Webhooks page](https://docs.polar.sh/integrate/webhooks/endpoints) in the Polar settings and click the *Add endpoint* button.

    ![Polar Webhook](/images/docs/web/billing/polar/webhook.png)

    Select the following events:

    * For subscriptions:
      * `subscription.created`
      * `subscription.updated`
    * For one-off payments:
      * `order.created`
      * `order.updated`

    You will also have to enter a *Secret* which you can get by running the following command in your terminal:

    ```bash
    openssl rand -base64 32
    ```

    Copy the generated string and paste it into the *Secret* field.

    You also need to add this secret to your environment variables:

    ```dotenv title="apps/web/.env.local"
    POLAR_WEBHOOK_SECRET=<your-generated-secret>
    ```

    To get the URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    If you want to test the webhook locally, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine. Ngrok will then give you a URL that you can use to test the webhook locally.

    To do so, install ngrok and run it with the following command (while your TurboStarter web development server is running):

    ```bash
    ngrok http 3000
    ```

    ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

    This will give you a URL (see the *Forwarding* output) that you can use to create a webhook in Polar. Just use that url and add `/api/billing/webhook/polar` to it.

    <Card title="Polar Webhooks" description="docs.polar.sh" href="https://docs.polar.sh/integrate/webhooks/delivery" />

    ### Production deployment

    When going to production, you will need to set the webhook URL and the events you want to listen to in Polar.

    The webhook path is `/api/billing/webhook/polar`. If your app is hosted at `https://myapp.com` then you need to enter `https://myapp.com/api/billing/webhook/polar` as the URL.

    All the relevant events are automatically handled by TurboStarter, so you don't need to do anything else. If you want to handle more events please check [Webhooks](/docs/web/billing/webhooks) for more information.
  </Step>
</Steps>

## Add discount

You can add a discount for your customers that will apply on a specific product.

You can create the discount under the *Products* page on *Discounts* tab in the Polar dashboard.

![Polar Discount](/images/docs/web/billing/polar/discount.png)

You can set there a details of discount such as products that it should apply to, amount off, duration, max redemptions and more.

<Card title="Polar Discounts" description="docs.polar.sh" href="https://docs.polar.sh/features/discounts" />

You need to add also the discount code and details to TurboStarter billing configuration to enable displaying it in the UI, creating checkout sessions with it and calculate prices.

[See discounts configuration](/docs/web/billing/configuration#discounts) for more details.

That's it! 🎉 You have now set up Polar as a billing provider for your app.

Feel free to add more products, prices, discounts and manage your customers data and subscriptions using Polar.

<Callout type="warn" title="Ensure configuration matches">
  Make sure that the data you set in the configuration matches the details of things you created in Polar.

  [See configuration](/docs/web/billing/configuration) for more information.
</Callout>


# Stripe
Source: https://www.turbostarter.dev/docs/web/billing/stripe

[Stripe](https://stripe.com) is the default billing provider for TurboStarter. Here we'll go through the configuration and how to set it up as a provider for your app.

<Steps>
  <Step>
    ## Get API keys

    After you have created your account for [Stripe](https://stripe.com), you will need to get the API key. You can do this by going to the [API page](https://dashboard.stripe.com/apikeys) in the dashboard. Here you will find the *Secret key* and the *Publishable key*. You will need the *Secret key* for the integration to work.

    For local development, make sure to create and use a dedicated [Sandbox](https://docs.stripe.com/sandboxes) to not mess with the real transactions.
  </Step>

  <Step>
    ## Set environment variables

    You need to set the following environment variables:

    ```dotenv title="apps/web/.env.local"
    STRIPE_SECRET_KEY="" # Your Stripe secret key
    STRIPE_WEBHOOK_SECRET="" # The secret key of the webhook you created (see below)
    ```

    **Please do not add the secret keys to the .env file in production.** During development, you can place them in `.env.local` as it's not committed to the repository. In production, you can set them in the environment variables of your hosting provider.
  </Step>

  <Step>
    ## Create products

    For your users to choose from the available subscription plans, you need to create those Products first on the [Products page](https://dashboard.stripe.com/products). You can create as many products as you want.

    Create one product per plan you want to offer. You can add multiple prices within this product to offer multiple models or different billing intervals.

    ![Stripe Products](/images/docs/web/billing/stripe/products.webp)

    <Callout type="warn" title="Match the price id with configuration">
      You need to make sure that the variant ID you set in the configuration matches the ID of the price you created in Stripe.

      [See configuration](/docs/web/billing/configuration#variants) for more information.
    </Callout>
  </Step>

  <Step>
    ## Create a webhook

    To sync the current subscription status or checkout conclusion and other information to your database, you need to set up a webhook.

    The webhook code comes ready to use with TurboStarter, you just have to create the webhook in the Stripe dashboard and insert the URL for your project.

    To configure a new webhook, go to the [Webhooks page](https://dashboard.stripe.com/webhooks) in the Stripe settings and click the Add endpoint button.

    ![Stripe Webhook](/images/docs/web/billing/stripe/webhook.png)

    Select the following events:

    * For subscriptions:
      * `customer.subscription.created`
      * `customer.subscription.updated`
      * `customer.subscription.deleted`
    * For one-off payments:
      * `checkout.session.completed`

    To get the URL for the webhook, you can either use a local development URL or the URL of your deployed app:

    ### Local development

    There are two ways to test the webhook during local development:

    <Tabs items={["Stripe CLI", "Tunnel"]}>
      <Tab value="Stripe CLI">
        The Stripe CLI which allows you to listen to Stripe events straight to your own localhost. You can install and use the CLI using a variety of methods, but we recommend using official way to do it.

        [Install the Stripe CLI](https://docs.stripe.com/stripe-cli)

        Then - login to your Stripe account using the project you want to run:

        ```bash
        stripe login
        ```

        Copy the webhook secret displayed in the terminal and set it as the `STRIPE_WEBHOOK_SECRET` environment variable in your `apps/web/.env.local` file:

        ```dotenv title="apps/web/.env.local"
        STRIPE_WEBHOOK_SECRET=<your-secret-key>
        ```

        Now, you can listen to Stripe events running the following command:

        ```bash
        stripe listen --forward-to localhost:3000/api/billing/webhook/stripe
        ```

        This will forward all the Stripe events to your local endpoint.

        <Callout type="warn" title="Not receiving events?">
          **If you have not logged in** - the first time you set it up, you are required to sign in. This is a one-time process. Once you sign in, you can use the CLI to listen to Stripe events.

          **Please sign in and then re-run the command.** Now, you can listen to Stripe events.

          If you're not receiving events, please make sure that:

          * the webhook secret is correct
          * the account you signed in is the same as the one you're using in your app
        </Callout>

        You can even trigger the event manually for testing purposes:

        ```bash
        stripe trigger customer.subscription.created
        ```

        <Card title="Stripe CLI" description="docs.stripe.com" href="https://docs.stripe.com/stripe-cli" />
      </Tab>

      <Tab value="Tunnel">
        If you want to test the webhook locally, you can use [ngrok](https://ngrok.com) to create a tunnel to your local machine. Ngrok will then give you a URL that you can use to test the webhook locally.

        To do so, install ngrok and run it with the following command (while your TurboStarter web development server is running):

        ```bash
        ngrok http 3000
        ```

        ![Ngrok](/images/docs/web/billing/stripe/ngrok.png)

        This will give you a URL (see the *Forwarding* output) that you can use to create a webhook in Stripe. Just use that url and add `/api/billing/webhook/stripe` to it.

        <Card title="Stripe Webhooks" description="docs.stripe.com" href="https://docs.stripe.com/webhooks" />
      </Tab>
    </Tabs>

    ### Production deployment

    When going to production, you will need to set the webhook URL and the events you want to listen to in Stripe.

    The webhook path is `/api/billing/webhook/stripe`. If your app is hosted at `https://myapp.com` then you need to enter `https://myapp.com/api/billing/webhook/stripe` as the URL.

    All the relevant events are automatically handled by TurboStarter, so you don't need to do anything else. If you want to handle more events please check [Webhooks](/docs/web/billing/webhooks) for more information.
  </Step>

  <Step>
    ## Configure Stripe Customer Portal

    Stripe requires you to set up the Customer Portal so that users can manage their billing information, invoices and plan settings from there.

    You can do it [under the following link.](https://dashboard.stripe.com/settings/billing/portal)

    ![Stripe Customer Portal](/images/docs/web/billing/stripe/customer-portal.png)

    Remember to:

    1. Ensure that users have the ability to change or upgrade their subscription plans by enabling the relevant option in the Customer Portal settings.
    2. Adjust the cancellation settings to suit your application's requirements, such as whether users can cancel immediately or at the end of the billing period.
  </Step>
</Steps>

## Add discount

You can add a discount for your customers that will apply on a specific price.

<Steps>
  <Step>
    ### Create coupon

    First, you'd need to create a coupon on the [Coupons page](https://dashboard.stripe.com/coupons).

    ![Stripe Coupons](/images/docs/web/billing/stripe/coupon.png)

    You can set there a details of discount such as prices that it should apply to, amount off, duration, max redemptions and more.
  </Step>

  <Step>
    ### Add promotion code

    To enable using code during checkout you need to get a promotion code. You can define it on the same page as the coupon and give some user-friendly name to it.

    ![Stripe Promotion Code](/images/docs/web/billing/stripe/promotion-code.png)

    This code can then be applied at new checkout sessions by passing it as one of the parameters in the checkout session creation.

    <Card title="Stripe Discounts" description="docs.stripe.com" href="https://docs.stripe.com/checkout/custom-checkout/add-discounts" />
  </Step>

  <Step>
    ### Configure discount

    You need to add also the discount code and details to TurboStarter billing configuration to enable displaying it in the UI, creating checkout sessions with it and calculate prices.

    [See discounts configuration](/docs/web/billing/configuration#discounts) for more details.
  </Step>
</Steps>

That's it! 🎉 You have now set up Stripe as a billing provider for your app.

Feel free to add more products, prices, discounts and manage your customers data and subscriptions using Stripe.

<Callout type="warn" title="Ensure configuration matches">
  Make sure that the data you set in the configuration matches the details of things you created in Stripe.

  [See configuration](/docs/web/billing/configuration) for more information.
</Callout>


# Subscriptions
Source: https://www.turbostarter.dev/docs/web/billing/subscriptions

TurboStarter supports subscription billing (recurring payments) on the web across providers like [Stripe](/docs/web/billing/stripe), [Lemon Squeezy](/docs/web/billing/lemon-squeezy), [Polar](/docs/web/billing/polar), and [Dodo Payments](/docs/web/billing/dodo-payments).

Subscriptions are configured in your **billing config** using:

* **plans**: what you sell (Free, Premium, Enterprise, etc.)
* **variants**: how you sell it (monthly, yearly, trials, etc.)

## Configuration

Subscriptions are represented as **variants** with `model: BillingModel.RECURRING`.

The example below shows a standard flat recurring subscription. Per-seat and metered subscriptions use the same recurring model, but add extra fields such as `type`, `meterId`, or tier configuration. See [Per-seat](/docs/web/billing/per-seat) and [Metered usage](/docs/web/billing/metered-usage) for those setups.

```ts title="index.ts"
export const config = billingConfigSchema.parse({
  plans: [
    {
      id: BillingPlan.PREMIUM,
      name: "Premium",
      description: "Become a power user and gain benefits",
      badge: "Bestseller",
      features: [
        "Unlimited projects",
        "Priority support",
        "Advanced integrations",
        "Team collaboration",
        "Analytics dashboard",
      ],
      variants: [
        // Monthly
        {
          id: "price_monthly_or_variant_id",
          cost: 1900,
          currency: "usd",
          type: BillingType.FLAT,
          model: BillingModel.RECURRING, // [!code highlight]
          interval: RecurringInterval.MONTH,
          trialDays: 7,
        },
        // Yearly
        {
          id: "price_yearly_or_variant_id",
          cost: 8900,
          currency: "usd",
          type: BillingType.FLAT,
          model: BillingModel.RECURRING, // [!code highlight]
          interval: RecurringInterval.YEAR,
          trialDays: 7,
        },
      ],
    },
  ],
}) satisfies BillingConfig;
```

Breaking down the fields:

* `id`: **Provider identifier** for this recurring price/variant/product.
* `cost`: Amount in the smallest currency unit (e.g. cents). Used for UI; provider charges the real amount.
* `currency`: Currency code (defaults to `usd`).
* `type`: Usually `BillingType.FLAT` for a standard subscription. Other recurring billing types are available.
* `model`: Must be `BillingModel.RECURRING`.
* `interval`: Required for recurring variants (`RecurringInterval.MONTH`, `RecurringInterval.YEAR`, etc.).
* `trialDays`: Optional trial length in days.

<Callout type="warn" title="Match IDs exactly">
  The `variant.id` value must match what your billing provider expects (Stripe price ID, Lemon Squeezy variant ID, Polar product ID, etc.). A mismatch is the **#1 reason** why a checkout can't be created.
</Callout>

## Provider notes

* **Stripe**: `variant.id` should match a Stripe **Price ID** (`price_...`). Webhook events used for subscriptions include `customer.subscription.*`. See [Stripe setup](/docs/web/billing/stripe).
* **Lemon Squeezy**: `variant.id` should match a Lemon Squeezy **Variant ID**. See [Lemon Squeezy setup](/docs/web/billing/lemon-squeezy).
* **Polar**: `variant.id` should match a Polar **Product ID** (Polar treats each “variant” as a separate product). Subscription events include `subscription.created` / `subscription.updated`. See [Polar setup](/docs/web/billing/polar).


# Webhooks
Source: https://www.turbostarter.dev/docs/web/billing/webhooks

TurboStarter handles billing webhooks to update customer data based on events received from the billing provider.

Occasionally, you may need to set up additional webhooks or perform custom actions with webhooks.

In such cases, you can customize the billing webhook handler in the billing router at `packages/api/src/modules/billing/router.ts`.

By default, the webhook handler is configured to be **as straightforward as possible**:

```ts title="router.ts"
import { webhookHandler, provider } from "@workspace/billing-web/server";

export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
  webhookHandler(c.req.raw),
);
```

However, you can extend it using the callbacks provided from `@workspace/billing-web` package:

```ts title="router.ts"
import { webhookHandler, provider } from "@workspace/billing-web/server";

export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
  webhookHandler(c.req.raw, {
    onCheckoutSessionCompleted: (sessionId) => {},
    onSubscriptionCreated: (subscriptionId) => {},
    onSubscriptionUpdated: (subscriptionId) => {},
    onSubscriptionDeleted: (subscriptionId) => {},
    onEvent: (rawEvent) => {},
  }),
);
```

You can provide one or more of the callbacks to handle the events you are interested in.

<Callout title="Don't mix up web and mobile billing" type="warn">
  Web billing webhooks are set up using the same method as [in the mobile app](/docs/mobile/billing/webhooks). Make sure to keep your configurations organized and confirm that events are handled properly for each provider on both platforms.
</Callout>


# CLI
Source: https://www.turbostarter.dev/docs/web/cli

<CliDemo />

To help you get started with TurboStarter **as quickly as possible**, we've developed a [CLI](https://www.npmjs.com/package/@turbostarter/cli) that enables you to create a new project (with all the configuration) in seconds.

The CLI is a set of commands that will help you create a new project, generate code, and manage your project efficiently.

Currently, the following actions are available:

* **Starting a new project** - Generate starter code for your project with all necessary configurations in place (billing, database, emails, etc.)
* **Updating existing project** - Pull the latest upstream changes into your TurboStarter repository

**The CLI is in beta**, and we're actively working on adding more commands and actions.

## Installation

You can run commands without installing globally:

<Tabs items={['npm', 'pnpm', 'yarn', 'bun']}>
  <Tab value="npm">
    ```bash
    npx @turbostarter/cli@latest <command>
    ```
  </Tab>

  <Tab value="pnpm">
    ```bash
    pnpm dlx @turbostarter/cli@latest <command>
    ```
  </Tab>

  <Tab value="yarn">
    ```bash
    yarn dlx @turbostarter/cli@latest <command>
    ```
  </Tab>

  <Tab value="bun">
    ```bash
    bunx @turbostarter/cli@latest <command>
    ```
  </Tab>
</Tabs>

Or install globally and run:

<Tabs items={['npm', 'pnpm', 'yarn', 'bun']}>
  <Tab value="npm">
    ```bash
    npm install -g @turbostarter/cli

    turbostarter <command>
    ```
  </Tab>

  <Tab value="pnpm">
    ```bash
    pnpm add -g @turbostarter/cli

    turbostarter <command>
    ```
  </Tab>

  <Tab value="yarn">
    ```bash
    yarn global add @turbostarter/cli

    turbostarter <command>
    ```
  </Tab>

  <Tab value="bun">
    ```bash
    bun add -g @turbostarter/cli

    turbostarter <command>
    ```
  </Tab>
</Tabs>

You can also display help for it or check the actual version using `--help` or `-v` flags.

### Starting a new project

Use the `new` command to initialize configuration and dependencies for a new project.

```bash
turbostarter new
```

You will be asked a few questions to configure your project:

```bash
✔ All prerequisites satisfied, let's start! 🚀

? What do you want to ship? ›
    ◉   Web app
    ◉   Mobile app
    ◯   Browser extension
? Enter your project name. ›
? Configure all providers now? ›
    Yes, configure now (recommended)
    No, just let me ship, now!

Creating a new TurboStarter project in ...

✔ Repository successfully pulled!
✔ Git successfully configured!
✔ Dependencies successfully installed!
✔ Services successfully started!

🎉 You can now get started. Open the project and just ship it! 🎉

Problems? https://turbostarter.dev/docs
```

It will create a new project, configure providers, install dependencies and start required services in development mode.

### Updating existing project

Use the `project update` command to pull the latest upstream changes into your TurboStarter repository.

```bash
turbostarter project update
```

Before updating, the CLI validates that:

* You are running the command from a TurboStarter project root
* Your git working tree is clean
* Your `upstream` remote points to `turbostarter/core`

Then it fetches upstream changes and merges `upstream/main` into your current branch. If conflicts occur, it prints the conflicting files with next steps.


# Blog
Source: https://www.turbostarter.dev/docs/web/cms/blog

TurboStarter comes with a pre-configured blog implementation that allows you to manage your blog content.

## Creating a new blog post

To create a new blog post, you need to create a new directory (its name will be used as the slug of the blog post) with `.mdx` files in the `packages/cms/src/collections/blog/content` directory. Each file in this directory should be named after the locale it belongs to (e.g `en.mdx`, `es.mdx`, etc.).

The file will start with a [frontmatter](https://mdxjs.com/guides/frontmatter/) block, which is a yaml-like block that contains metadata about the post. The frontmatter block should be surrounded by three dashes (`---`).

```mdx title="packages/cms/src/collections/blog/content/my-first-blog-post/en.mdx"
---
title: Quick Tips to Improve Your Skills Right Away
description: Whether you're learning a new technical skill or working on personal development, these quick tips can help you improve right away. Learn how to break down your goals, practice consistently, and track your progress using Markdown.
publishedAt: 2023-04-19
tags: [learning, skills, progress]
thumbnail: https://images.unsplash.com/photo-1483639130939-150975af84e5?q=80&w=2370&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
status: published
---
```

Let's break down the frontmatter fields:

* `title`: The title of the blog post (it will be also used to generate a slug for the blog post)
* `description`: The description of the blog post
* `publishedAt`: The date when the blog post was published
* `tags`: The tags of the blog post
* `thumbnail`: The thumbnail of the blog post
* `status`: The status of the blog post (could be `published` or `draft`)

After the frontmatter block, you can add the content of the blog post:

```mdx title="packages/cms/src/collections/blog/content/my-first-blog-post/en.mdx"
# Quick Tips to Improve Your Skills Right Away

Awesome paragraph!

[Link](https://www.turbostarter.dev)

<Callout>This is a callout component.</Callout>

...
```

You can consume the content the same as it's described in [Content Collections](/docs/web/cms/content-collections).

## BONUS: Using custom components

As you're using MDX, you can use **any React component** in your blog posts. Just define it as a normal React component and pass it to `<MdxContent />` in `components` prop:

```tsx title="apps/web/src/app/content/page.tsx"
import { MyComponent } from "~/modules/common/my-component";

export default function Page() {
  return (
    <MDXContent
      code={data.body}
      components={{ ...defaultMdxComponents, MyComponent }}
    />
  );
}
```

Then, you would be able to use it in your document content and it will rendered on the page as a result:

```mdx title="packages/cms/src/collections/blog/content/my-first-blog-post/en.mdx"
...

# Heading

Excellent paragraph!

<MyComponent />

1. First item
2. Second item
3. Third item
```

TurboStarter ships with a set of default components that you can use in your blog posts, e.g. `<Callout />`, `<Card />` etc. Use them or define your own to make your blog posts more engaging.


# Content Collections
Source: https://www.turbostarter.dev/docs/web/cms/content-collections

By default, TurboStarter uses [Content Collections](https://www.content-collections.dev/) to store and retrieve content from the MDX files.

Content from there is used to populate data in the following places:

* **Blog**
* **Legal pages**
* **Documentation**

<Callout title="Why content-collections?">
  It is a great alternative to headless CMS like Contentful or Prismic based on MDX (a more powerful version of markdown). It is free, open source and the content is located right in your repository.
</Callout>

Of course, you can add more collections and views, as it's very flexible.

## Defining new collection

To define a new collection, you need to create a new file in the `packages/cms/src/collections` directory:

```ts title="packages/cms/src/collections/legal/index.ts"
import { defineCollection } from "@content-collections/core";

export const legal = defineCollection({
  name: "legal",
  directory: "src/collections/legal/content",
  include: "**/*.mdx",
  schema: (z) => ({
    title: z.string(),
    description: z.string(),
  }),
  transform: async (doc, context) => {
    const mdx = await transformMDX(doc, context);

    return {
      ...mdx,
      slug: doc._meta.directory,
      locale: doc._meta.fileName.split(".")[0],
    };
  },
});
```

Then it's passed to the config in `packages/cms/content-collections.ts` file which is used to generate types and parse content from MDX files.

```tsx title="packages/cms/content-collections.ts"
import { defineConfig } from "@content-collections/core";

import { legal } from "./src/collections/legal";

export default defineConfig({
  collections: [legal],
});
```

When you run a development server, content collections will be automatically rebuilt (in `.content-collections` directory) and you will be able to import the content and metadata of each file in your application.

<Callout title="It's fully type-safe!">
  By exporting the generated content you get fully type-safe API to interact
  with the content. We can have type safety on the data that we're receiving
  from the MDX files.
</Callout>

## Using content collections

To get some content from `@workspace/cms` package, you need to use the exposed API that we described in the [Overview section](/docs/web/cms/overview#api):

```tsx title="apps/web/src/app/[locale](marketing)/legal/[slug]/page.tsx"
import { content } from "@workspace/cms";

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string; locale: string }>;
}) {
  const item = getContentItemBySlug({
    collection: CollectionType.LEGAL,
    slug: (await params).slug,
    locale: (await params).locale,
  });

  return <h1>{title}</h1>;
}
```

Voila! You can now access the content from the MDX files.

<Cards>
  <Card title="Content Collections" description="content-collections.dev" href="https://www.content-collections.dev/" />

  <Card title="MDX" description="mdxjs.com" href="https://mdxjs.com/" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/web/cms/overview

TurboStarter implements a CMS interface that abstracts the implementation from where you store your data. It provides a simple API to interact with your data, and it's easy to extend and customize.

By default, the starter kit ships with these implementations in place:

1. [Content Collections](https://www.content-collections.dev/) - a headless CMS that uses [MDX](https://mdxjs.com/) files to store your content.

The implementation is available under `@workspace/cms` package, here we'll go over how to use it.

## API

The CMS package provides a simple, unified API to interact with the content. It's the same for all the providers, so you can easily use it with any of the implementations without changing the code.

### Fetching content items

To fetch items from your colletions, you can use the `getContentItems` function.

```ts
import { getContentItems } from "@workspace/cms";

const { items, count } = getContentItems({
  collection: CollectionType.BLOG,
  tags: [ContentTag.SKILLS],
  sortBy: "publishedAt",
  sortOrder: SortOrder.DESCENDING,
  status: ContentStatus.PUBLISHED,
  locale: "en",
});
```

It accepts an object with the following properties:

* `collection`: The collection to fetch the items from.
* `tags`: The tags to filter the items by.
* `sortBy`: The field to sort the items by.
* `sortOrder`: The order to sort the items in.
* `status`: The status of the items to fetch. It can be `published` or `draft`. By default, only `published` items are fetched.
* `locale`: The locale to fetch the items in. By default, all locales are fetched.

### Fetching a single content item

To fetch a single content item, you can use the `getContentItemBySlug` function.

```ts
import { getContentItemBySlug } from "@workspace/cms";

const item = getContentItemBySlug({
  collection: CollectionType.BLOG,
  slug: "my-first-blog-post",
  status: ContentStatus.PUBLISHED,
  locale: "en",
});
```

It accepts an object with the following properties:

* `collection`: The collection to fetch the item from.
* `slug`: The slug of the item to fetch.
* `status`: The status of the item to fetch. It can be `published` or `draft`. By default, only `published` items are fetched.
* `locale`: The locale to fetch the item in. By default, all locales are fetched.


# App configuration
Source: https://www.turbostarter.dev/docs/web/configuration/app

The application configuration is set at `apps/web/src/config/app.ts`. This configuration stores some overall variables for your application.

This allows you to host multiple apps in the same monorepo, as every application defines its own configuration.

The recommendation is to **not update this directly** - instead, please define the environment variables and override the default behavior. The configuration is strongly typed so you can use it safely accross your codebase - it'll be validated at build time.

```ts title="apps/web/src/config/app.ts"
import env from "env.config";

export const appConfig = {
  name: env.NEXT_PUBLIC_PRODUCT_NAME,
  url: env.NEXT_PUBLIC_URL,
  locale: env.NEXT_PUBLIC_DEFAULT_LOCALE,
  theme: {
    mode: env.NEXT_PUBLIC_THEME_MODE,
    color: env.NEXT_PUBLIC_THEME_COLOR,
  },
} as const;
```

For example, to set the product name and default locale, you'd update the following variables:

```dotenv title=".env.local"
NEXT_PUBLIC_PRODUCT_NAME="TurboStarter"
NEXT_PUBLIC_DEFAULT_LOCALE="en"
```

<Callout type="warn" title="Do NOT use process.env!">
  Do NOT use `process.env` to get the values of the variables. Variables
  accessed this way are not validated at build time, and thus the wrong variable
  can be used in production.
</Callout>


# Environment variables
Source: https://www.turbostarter.dev/docs/web/configuration/environment-variables

Environment variables are defined in the `.env` file in the root of the repository and in the root of the `apps/web` package.

* **Shared environment variables**: Defined in the **root** `.env` file. These are shared between environments (e.g., development, staging, production) and apps (e.g., web, mobile).
* **Environment-specific variables**: Defined in `.env.development` and `.env.production` files. These are specific to the development and production environments.
* **App-specific variables**: Defined in the app-specific directory (e.g., `apps/web`). These are specific to the app and are not shared between apps.
* **Secret keys**: Not stored in the `.env` file. Instead, they are stored in the environment variables of the CI/CD system.
* **Local secret keys**: If you need to use secret keys locally, you can store them in the `.env.local` file. This file is not committed to Git, making it safe for sensitive information.

## Shared variables

Here you can add all the environment variables that are shared across all the apps. This file should be located in the **root** of the project.

To override these variables in a specific environment, please add them to the specific environment file (e.g. `.env.development`, `.env.production`).

```dotenv title=".env.local"
# Shared environment variables

# The database URL is used to connect to your database.
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"

# The name of the product. This is used in various places across the apps.
PRODUCT_NAME="TurboStarter"

# The url of the web app. Used mostly to link between apps.
URL="http://localhost:3000"

...
```

If you're using Supabase for your database, the [Supabase recipe](/docs/web/recipes/supabase#configure-environment-variables) shows the exact `DATABASE_URL` format and how to set it in your `.env.local`.

## App-specific variables

Here you can add all the environment variables that are specific to the app (e.g. `apps/web`).

You can also override the shared variables defined in the root `.env` file.

```dotenv title="apps/web/.env.local"
# App-specific environment variables

# Env variables extracted from shared to be exposed to the client in Next.js app
NEXT_PUBLIC_PRODUCT_NAME="${PRODUCT_NAME}"
NEXT_PUBLIC_URL="${URL}"
NEXT_PUBLIC_DEFAULT_LOCALE="${DEFAULT_LOCALE}"

# Theme mode and color
NEXT_PUBLIC_THEME_MODE="system"
NEXT_PUBLIC_THEME_COLOR="orange"

...
```

For example, server-only app-specific variables in `apps/web/.env.local` often include third-party integration keys that should never be exposed with `NEXT_PUBLIC_`. In the AI starter, that can include provider keys such as:

```dotenv title="apps/web/.env.local"
OPENAI_API_KEY=""
ANTHROPIC_API_KEY=""
BRAVE_SEARCH_API_KEY=""
EXA_API_KEY=""
FIRECRAWL_API_KEY=""
TAVILY_API_KEY=""
```

<Callout title="NEXT_PUBLIC_ prefix">
  To make environment variables available in the Next.js **client-side** app code, you need to prefix them with `NEXT_PUBLIC_`. They will be injected to the code during the build process.

  Only environment variables prefixed with `NEXT_PUBLIC_` will be injected, so don't use this prefix for environment variables that should be used only in the server-side code.

  [Read more about Next.js environment variables.](https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables)
</Callout>

## Secret keys

Secret keys and sensitive information are to be never stored in the `.env` file. Instead, **they are stored in the environment variables of the CI/CD system.**

<Callout title="What does this mean?">
  It means that you will need to add the secret keys to the environment
  variables of your CI/CD system (e.g., GitHub Actions, Vercel, Cloudflare, your
  VPS, Netlify, etc.). This is not a TurboStarter-specific requirement, but a
  best practice for security for any application. Ultimately, it's your choice.
</Callout>

Below is some examples of "what is a secret key?" in practice.

```dotenv title=".env.local"
# Secret keys

# The database URL is used to connect to your database.
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"

# Stripe server config - required only if you use Stripe as a billing provider
STRIPE_WEBHOOK_SECRET=""
STRIPE_SECRET_KEY=""

# Lemon Squeezy server config - required only if you use Lemon Squeezy as a billing provider
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_SIGNING_SECRET=""
LEMON_SQUEEZY_STORE_ID=""

...
```

<Callout title="Secrets used locally">
  If you need to use secret keys locally, you can store them in the `.env.local`
  file. This file is not committed to Git, therefore it is safe to store
  sensitive information in it.
</Callout>

For security-focused rules around `NEXT_PUBLIC_`, production secret storage, and `BETTER_AUTH_SECRET`, see [Secrets & environment](/docs/web/security/secrets).


# Paths configuration
Source: https://www.turbostarter.dev/docs/web/configuration/paths

The paths configuration is set at `apps/web/config/paths.ts`. This configuration stores all the paths that you'll be using in your application. It is a convenient way to store them in a central place rather than scatter them in the codebase using magic strings.

It is **unlikely you'll need to change** this unless you're heavily editing the codebase.

```ts title="apps/web/config/paths.ts"
const pathsConfig = {
  index: "/",
  marketing: {
    pricing: "/pricing",
    contact: "/contact",
    blog: {
      index: BLOG_PREFIX,
      post: (slug: string) => `${BLOG_PREFIX}/${slug}`,
    },
    legal: (slug: string) => `${LEGAL_PREFIX}/${slug}`,
  },
  auth: {
    login: `${AUTH_PREFIX}/login`,
    register: `${AUTH_PREFIX}/register`,
    join: `${AUTH_PREFIX}/join`,
    forgotPassword: `${AUTH_PREFIX}/password/forgot`,
    updatePassword: `${AUTH_PREFIX}/password/update`,
    error: `${AUTH_PREFIX}/error`,
  },
  dashboard: {
    user: {
      index: DASHBOARD_PREFIX,
      ai: `${DASHBOARD_PREFIX}/ai`,
      settings: {
        index: `${DASHBOARD_PREFIX}/settings`,
        security: `${DASHBOARD_PREFIX}/settings/security`,
        billing: `${DASHBOARD_PREFIX}/settings/billing`,
      },
    },
    ...
  },
  ...,
} as const;
```

<Callout title="Fully type-safe">
  By declaring the paths as constants, we can use them safely throughout the
  codebase. There is no risk of misspelling or using magic strings.
</Callout>


# Adding apps
Source: https://www.turbostarter.dev/docs/web/customization/add-app

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new app to your TurboStarter project within your monorepo and want to keep pulling updates from the TurboStarter repository.
</Callout>

In some ways - creating a new repository may be the easiest way to manage your application. However, if you want to keep your application within the monorepo and pull updates from the TurboStarter repository, you can follow these instructions.

To pull updates into a separate application outside of `web` - we can use [git subtree](https://www.atlassian.com/git/tutorials/git-subtree).

Basically, we will create a subtree at `apps/web` and create a new remote branch for the subtree. When we create a new application, we will pull the subtree into the new application. This allows us to keep it in sync with the `apps/web` folder.

To add a new app to your TurboStarter project, you need to follow these steps:

<Steps>
  <Step>
    ## Create a subtree

    First, we need to create a subtree for the `apps/web` folder. We will create a branch named `web-branch` and create a subtree for the `apps/web` folder.

    ```bash
    git subtree split --prefix=apps/web --branch web-branch
    ```
  </Step>

  <Step>
    ## Create a new app

    Now, we can create a new application in the `apps` folder.

    Let's say we want to create a new app `ai-chat` at `apps/ai-chat` with the same structure as the `apps/web` folder (which acts as the template for all new apps).

    ```bash
    git subtree add --prefix=apps/ai-chat origin web-branch --squash
    ```

    You should now be able to see the `apps/ai-chat` folder with the contents of the `apps/web` folder.
  </Step>

  <Step>
    ## Update the app

    When you want to update the new application, follow these steps:

    ### Pull the latest updates from the TurboStarter repository

    The command below will update all the changes from the TurboStarter repository:

    ```bash
    git pull upstream main
    ```

    ### Push the `web-branch` updates

    After you have pulled the updates from the TurboStarter repository, you can split the branch again and push the updates to the web-branch:

    ```bash
    git subtree split --prefix=apps/web --branch web-branch
    ```

    Now, you can push the updates to the `web-branch`:

    ```bash
    git push origin web-branch
    ```

    ### Pull the updates to the new application

    Now, you can pull the updates to the new application:

    ```bash
    git subtree pull --prefix=apps/ai-chat origin web-branch --squash
    ```
  </Step>
</Steps>

That's it! You now have a new application in the monorepo 🎉


# Adding packages
Source: https://www.turbostarter.dev/docs/web/customization/add-package

<Callout title="Advanced topic" type="warn">
  This is an **advanced topic** - you should only follow these instructions if you are sure you want to add a new package to your TurboStarter application instead of adding a folder to your application in `apps/web` or modify existing packages under `packages`. You don't need to do this to add a new page or component to your application.
</Callout>

To add a new package to your TurboStarter application, you need to follow these steps:

<Steps>
  <Step>
    ## Generate a new package

    First, enter the command below to create a new package in your TurboStarter application:

    ```bash
    turbo gen package
    ```

    Turborepo will ask you to enter the name of the package you want to create. Enter the name of the package you want to create and press enter.

    If you don't want to add dependencies to your package, you can skip this step by pressing enter.

    The command will have generated a new package under packages named `@workspace/<package-name>`. If you named it `example`, the package will be named `@workspace/example`.

    Finally, to make fast refresh work when you make changes to the package, you need to add the package to the `next.config.ts` file in the root of your TurboStarter application `apps/web`.

    ```ts title="next.config.ts"
    const INTERNAL_PACKAGES = [
      // all internal packages,
      "@workspace/example",
    ];
    ```
  </Step>

  <Step>
    ## Export a module from your package

    By default, the package exports a single module using the `index.ts` file. You can add more exports by creating new files in the package directory and exporting them from the `index.ts` file or creating export files in the package directory and adding them to the `exports` field in the `package.json` file.

    ### From `index.ts` file

    The easiest way to export a module from a package is to create a new file in the package directory and export it from the `index.ts` file.

    ```ts title="packages/example/src/module.ts"
    export function example() {
      return "example";
    }
    ```

    Then, export the module from the `index.ts` file.

    ```ts title="packages/example/src/index.ts"
    export * from "./module";
    ```

    ### From `exports` field in `package.json`

    **This can be very useful for tree-shaking.** Assuming you have a file named `module.ts` in the package directory, you can export it by adding it to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./module": "./src/module.ts"
      }
    }
    ```

    **When to do this?**

    1. when exporting two modules that don't share dependencies to ensure better tree-shaking. For example, if your exports contains both client and server modules.
    2. for better organization of your package

    For example, create two exports `client` and `server` in the package directory and add them to the `exports` field in the `package.json` file.

    ```json title="packages/example/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./client": "./src/client.ts",
        "./server": "./src/server.ts"
      }
    }
    ```

    1. The `client` module can be imported using `import { client } from '@workspace/example/client'`
    2. The `server` module can be imported using `import { server } from '@workspace/example/server'`
  </Step>

  <Step>
    ## Use the package in your application

    You can now use the package in your application by importing it using the package name:

    ```ts title="apps/web/src/app/page.tsx"
    import { example } from "@workspace/example";

    console.log(example());
    ```
  </Step>
</Steps>

Et voilà! You have successfully added a new package to your TurboStarter application. 🎉


# Components
Source: https://www.turbostarter.dev/docs/web/customization/components

For the components part, we're using [shadcn/ui](https://ui.shadcn.com) for atomic, accessible and highly customizable components.

<Callout type="info" title="Why shadcn/ui?">
  shadcn/ui is a powerful tool that allows you to generate pre-designed
  components with a single command. It's built with Tailwind CSS and Base UI,
  and it's highly customizable.
</Callout>

TurboStarter defines two packages that are responsible for the UI part of your app:

* `@workspace/ui` - shared styles, [themes](/docs/web/customization/styling#themes) and assets (e.g. icons)
* `@workspace/ui-web` - pre-built UI web components, ready to use in your app

## Adding a new component

There are basically two ways to add a new component:

<Tabs items={["Using the CLI", "Copy-pasting"]}>
  <Tab value="Using the CLI">
    TurboStarter is fully compatible with [shadcn CLI](https://ui.shadcn.com/docs/cli), so you can generate new components with single command.

    Run the following command from the **root** of your project:

    ```bash
    pnpm --filter @workspace/ui-web ui:add
    ```

    This will launch an interactive command-line interface to guide you through the process of adding a new component where you can pick which component you want to add.

    ```bash
    Which components would you like to add? > Space to select. A to toggle all.
    Enter to submit.

    ◯  accordion
    ◯  alert
    ◯  alert-dialog
    ◯  aspect-ratio
    ◯  avatar
    ◯  badge
    ◯  button
    ◯  calendar
    ◯  card
    ◯  checkbox
    ```

    Newly created components will appear in the `packages/ui/web/src` directory.
  </Tab>

  <Tab value="Copy-pasting">
    You can always copy-paste a component from the [shadcn/ui](https://ui.shadcn.com/docs/components) website and modify it to your needs.

    This is possible, because the components are headless and don't need (in most cases) any additional dependencies.

    Copy code from the website, create a new file in the `packages/ui/web/src` directory and paste the code into the file.
  </Tab>
</Tabs>

<Callout title="Keep it atomic" type="warn">
  Keep in mind that you should always try to keep shared components as atomic as possible. This will make it easier to reuse them and to build specific views by composition.

  E.g. include components like `Button`, `Input`, `Card`, `Dialog` in shared package, but keep specific components like `LoginForm` in your app directory.
</Callout>

## Using components

Each component is a standalone entity which has a separate export from the package. It helps to keep things modular, avoid unnecessary dependencies and make tree-shaking possible.

To import a component from the UI package, use the following syntax:

```tsx title="components/my-component.tsx"
// [!code word:card]
import {
  Card,
  CardContent,
  CardHeader,
  CardFooter,
  CardTitle,
  CardDescription,
} from "@workspace/ui-web/card";
```

Then you can use it to build a component specific to your app:

```tsx title="components/my-component.tsx"
export function MyComponent() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>My Component</CardTitle>
      </CardHeader>
      <CardContent>
        <p>My Component Content</p>
      </CardContent>
      <CardFooter>
        <Button>Click me</Button>
      </CardFooter>
    </Card>
  );
}
```

<Callout title="Recommendation: use v0 to generate layouts">
  We recommend using [v0](https://v0.dev) to generate layouts for your app. It's a powerful tool that allows you to generate layouts from the natural language instructions.

  Of course, **it won't replace a designer**, but it can be a good starting point for your layout.
</Callout>

<Cards>
  <Card href="https://ui.shadcn.com/" title="shadcn/ui" description="ui.shadcn.com" />

  <Card href="https://v0.dev/chat" title="v0 by Vercel" description="v0.dev" />
</Cards>


# Styling
Source: https://www.turbostarter.dev/docs/web/customization/styling

To build the web user interface, TurboStarter comes with [Tailwind CSS](https://tailwindcss.com/) and [Base UI](https://base-ui.com) pre-configured.

<Callout title="Why Tailwind CSS and Base UI?" type="info">
  The combination of Tailwind CSS and Base UI gives ready-to-use, accessible UI components that can be fully customized to match your brand's design.
</Callout>

## Tailwind configuration

In the `packages/ui/shared/src/styles` directory, you will find shared CSS files with Tailwind CSS configuration. To change global styles, you can edit the files in this folder.

Here is an example of a shared CSS file that includes the Tailwind CSS configuration:

```css title="packages/ui/shared/src/styles/globals.css"
@import "tailwindcss";
@import "./themes.css";

@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.65rem;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);

  ...
}
```

For colors, we rely strictly on [CSS Variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) in [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) format to allow for easy theme management without the need for any JavaScript.

Also, each app has its own `globals.css` file, which extends the shared config and allows you to override the global styles.

Here is an example of an app's `globals.css` file:

```css title="apps/web/src/assets/styles/globals.css"
@import "@workspace/ui-web/globals.css";

@theme inline {
  /* Overridden theme variables for the app */
  --background: oklch(0.98 0.01 80);
  --foreground: oklch(0.22 0.03 120);
  --card: oklch(0.97 0.02 50);
  --card-foreground: oklch(0.18 0.01 280);
  ...
}
```

This way, we maintain a separation of concerns and a clear structure for the Tailwind CSS configuration.

## Themes

TurboStarter comes with **9+** predefined themes, which you can use to quickly change the look and feel of your app.

They're defined in the `packages/ui/shared/src/styles/themes` directory. Each theme is a set of variables that can be overridden:

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {
    background: [1, 0, 0],
    foreground: [0.141, 0.005, 285.823],
    card: [1, 0, 0],
    "card-foreground": [0.141, 0.005, 285.823],
    ...
  }
} satisfies ThemeColors;
```

Each variable is stored as a [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) array, which is then converted to a CSS variable at build time (by our custom build script). That way we can ensure full type-safety and reuse themes across different parts of our apps (e.g. use the same theme in emails).

Feel free to add your own themes or override the existing ones to match your brand's identity.

To apply a theme to your app, you can use the `data-theme` attribute on the `html` element:

```tsx title="apps/web/src/app/layout.tsx"
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body data-theme="orange">{children}</body>
    </html>
  );
}
```

## Dark mode

TurboStarter comes with built-in dark mode support.

Each theme has a corresponding set of dark mode variables, which are used to switch the theme to its dark mode counterpart.

```ts title="packages/ui/shared/src/styles/themes/colors/orange.ts"
export const orange = {
  light: {},
  dark: {
    background: [0.141, 0.005, 285.823],
    foreground: [0.985, 0, 0],
    card: [0.21, 0.006, 285.885],
    "card-foreground": [0.985, 0, 0],
    ...
  }
} satisfies ThemeColors;
```

Because the dark variant is defined to use a class (`@custom-variant dark (&:is(.dark *))`) in the shared Tailwind configuration, we need to add the `dark` class to the `html` element to apply dark mode styles.

For this purpose, we're using the [next-themes](https://github.com/pacocoursey/next-themes) package under the hood to handle user preference management.

```tsx title="apps/web/src/providers/theme.tsx"
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
  return (
    <NextThemeProvider
      attribute="class"
      defaultTheme={appConfig.theme.mode}
      enableSystem
    >
      {children}
      <ThemeConfigProvider />
    </NextThemeProvider>
  );
});
```

You can also define the default theme mode and color in the [app configuration](/docs/web/configuration/app).

<Cards>
  <Card title="Tailwind CSS" description="tailwindcss.com" href="https://tailwindcss.com/" />

  <Card title="Base UI" description="base-ui.com" href="https://base-ui.com/" />
</Cards>


# Database client
Source: https://www.turbostarter.dev/docs/web/database/client

The database client is an export of the Drizzle client. It is automatically typed by Drizzle based on the schema and is exposed as the db object from the database package (`@workspace/db`) in the monorepo.

This guide covers how to initialize the client and also basic operations, such as querying, creating, updating, and deleting records. To learn more about the Drizzle client, check out the [official documentation](https://orm.drizzle.team/kit-docs/overview).

## Initializing the client

Pass the validated `DATABASE_URL` to the client to initialize it.

```ts title="server.ts"
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

import { env } from "../env";

const client = postgres(env.DATABASE_URL);
export const db = drizzle(client);
```

Now it's exported from the `@workspace/db` package and can be used across the codebase (server-side).

## Querying data

To query data, you can use the `db` object and its methods:

```ts title="query.ts"
import { eq } from "@workspace/db";
import { db } from "@workspace/db/server";
import { customer } from "@workspace/db/schema";

export const getCustomerByUserId = async (userId: string) => {
  const [data] = await db
    .select()
    .from(customer)
    .where(eq(customer.userId, userId));

  return data ?? null;
};
```

<Cards className="sm:grid-cols-3">
  <Card title="Select" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/select" />

  <Card title="Filters" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/operators" />

  <Card title="Joins" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/joins" />
</Cards>

## Mutating data

You can use the exported utilities to mutate data. Insert, update or delete records in fast and fully type-safe way:

```ts title="mutation.ts"
import { eq } from "@workspace/db";
import { db } from "@workspace/db/server";
import { customer } from "@workspace/db/schema";

export const upsertCustomer = (data: InsertCustomer) => {
  return db.insert(customer).values(data).onConflictDoUpdate({
    target: customer.userId,
    set: data,
  });
};
```

<Cards className="sm:grid-cols-3">
  <Card title="Insert" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/insert" />

  <Card title="Update" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/update" />

  <Card title="Delete" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/delete" />
</Cards>


# Migrations
Source: https://www.turbostarter.dev/docs/web/database/migrations

You have your schema in place, and you want to apply your changes to the database. TurboStarter provides you a convenient way to do so with pre-configured CLI commands.

## Generating migration

To generate a migration, from the schema you need to run the following command:

```bash
pnpm with-env turbo db:generate
```

This will create a new `.sql` file in the `migrations` directory.

<Callout>
  Drizzle will also generate a `.json` representation of the migration in the `meta` directory, but it's for its internal purposes and you shouldn't need to touch it.
</Callout>

## Applying migrations

To apply the migrations to the database, you need to run the following command:

```bash
pnpm with-env pnpm --filter @workspace/db db:migrate
```

This will apply all the migrations that have not been applied yet. If any conflicts arise, you can resolve them by modifying the generated migration file.

## Pushing changes

To push changes directly to the database, you can use the following command:

```bash
pnpm with-env pnpm --filter @workspace/db db:push
```

This lets you push your schema changes directly to the database and omit managing SQL migration files.

<Callout type="warn" title="Use with caution!">
  Pushing changes directly to the database (without using migrations) could be risky. Please be careful when using it; we recommend it only for local development and local databases.

  [Read more about it in the Drizzle docs](https://orm.drizzle.team/kit-docs/overview#prototyping-with-db-push).
</Callout>


# MySQL
Source: https://www.turbostarter.dev/docs/web/database/mysql

TurboStarter ships with [PostgreSQL](https://www.postgresql.org/) by default, but you can also run it on [MySQL](https://www.mysql.com/) if that better matches your infrastructure.

[MySQL](https://www.mysql.com/) is a reasonable choice when your team already runs MySQL-compatible infrastructure, you want a familiar managed database offering, or you prefer staying closer to the default PostgreSQL setup than a move to [SQLite](https://www.sqlite.org/) would allow. The important nuance is that MySQL is still a customization rather than a built-in preset.

<Callout title="PostgreSQL is still the default">
  At the time of writing, the starter is wired to PostgreSQL in `packages/db/src/server.ts`, `packages/db/drizzle.config.ts`, `packages/auth/src/server.ts`, `packages/db/src/schema/*`, and parts of `packages/db/src/utils/index.ts`.
</Callout>

## When MySQL makes sense

MySQL is a good fit when you want:

* compatibility with existing MySQL infrastructure
* a traditional client/server SQL database instead of a local file database
* a dialect that is operationally familiar to your team

You should usually stay on PostgreSQL if you want:

* the exact default path used by the starter
* the fewest code changes in the database layer
* direct reuse of the current PostgreSQL-oriented schema helpers and upsert patterns

## What you need to change

Moving to MySQL usually means updating these areas:

* `packages/db/package.json` - add a MySQL driver
* `packages/db/src/server.ts` - initialize Drizzle with a MySQL client
* `packages/db/drizzle.config.ts` - switch Drizzle Kit from `postgresql` to `mysql`
* `packages/auth/src/server.ts` - change Better Auth from `provider: "pg"` to `provider: "mysql"`
* `packages/db/src/schema/*` - replace `pg-core` schema utilities with `mysql-core`
* `packages/db/src/utils/index.ts` - remove PostgreSQL-specific types and SQL fragments
* `packages/billing/shared/src/server/*` and `packages/auth/src/scripts/seed.ts` - replace PostgreSQL-style upserts and `returning()` calls

MySQL is closer to PostgreSQL than SQLite is, but there are still a few important Drizzle differences that affect the current codebase directly.

<Steps>
  <Step>
    ## Install the MySQL driver

    For Drizzle, the standard driver is [`mysql2`](https://github.com/sidorares/node-mysql2).

    ```bash
    pnpm --filter @workspace/db add mysql2
    pnpm --filter @workspace/db remove postgres
    ```
  </Step>

  <Step>
    ## Update environment variables

    The project already validates `DATABASE_URL`, so the main change is using a MySQL connection string instead of a PostgreSQL one.

    ```dotenv title=".env.local"
    DATABASE_URL="mysql://root:password@127.0.0.1:3306/turbostarter"
    ```

    If you run a managed MySQL provider, use that provider's connection string instead.
  </Step>

  <Step>
    ## Replace the database client

    The current database package uses [`postgres-js`](https://github.com/porsager/postgres). Replace it with a [`mysql2`](https://github.com/sidorares/node-mysql2) connection or pool in `packages/db/src/server.ts`.

    ```ts title="packages/db/src/server.ts"
    import { drizzle } from "drizzle-orm/mysql2";
    import mysql from "mysql2/promise";

    import { env } from "./env";
    import * as schema from "./schema";

    const client = mysql.createPool(env.DATABASE_URL);

    export const db = drizzle({
      client,
      schema,
      casing: "snake_case",
    });
    ```

    The rest of the package structure can stay the same.
  </Step>

  <Step>
    ## Switch Drizzle Kit to MySQL

    Update `packages/db/drizzle.config.ts` to use the [Drizzle Kit](https://orm.drizzle.team/docs/drizzle-config-file) MySQL dialect:

    ```ts title="packages/db/drizzle.config.ts"
    import { defineConfig } from "drizzle-kit";

    import { env } from "./src/env";

    export default defineConfig({
      out: "./migrations",
      schema: "./src/schema/index.ts",
      dialect: "mysql",
      casing: "snake_case",
      dbCredentials: {
        url: env.DATABASE_URL,
      },
    });
    ```
  </Step>

  <Step>
    ## Update Better Auth

    The auth package is PostgreSQL-first today. In `packages/auth/src/server.ts`, switch the [Better Auth](https://better-auth.com/) Drizzle adapter provider to MySQL:

    ```ts title="packages/auth/src/server.ts"
    database: drizzleAdapter(db, {
      provider: "mysql",
      schema,
    }),
    ```

    Better Auth's Drizzle adapter supports MySQL directly, so this part is straightforward.
  </Step>

  <Step>
    ## Convert the schema to MySQL

    The schema files currently use `drizzle-orm/pg-core` in:

    * `packages/db/src/schema/auth.ts`
    * `packages/db/src/schema/billing.ts`

    Move those files to `drizzle-orm/mysql-core` and replace the PostgreSQL-only schema helpers.

    The most common replacements are:

    * `pgTable` -> `mysqlTable`
    * `pgEnum(...)` -> `mysqlEnum(...)`
    * PostgreSQL timestamp definitions -> MySQL `datetime(...)` or `timestamp(...)` equivalents
    * PostgreSQL-specific helper imports -> MySQL or dialect-neutral types

    For example, the billing enums in `packages/db/src/schema/billing.ts` should become MySQL enums instead of PostgreSQL enums:

    ```ts title="packages/db/src/schema/billing.ts"
    import { mysqlEnum, mysqlTable, text } from "drizzle-orm/mysql-core";

    export const subscriptionStatusEnum = mysqlEnum("subscription_status", [
      "active",
      "canceled",
      "incomplete",
      "incomplete_expired",
      "past_due",
      "paused",
      "trialing",
      "unpaid",
    ]);
    ```

    `packages/db/src/utils/index.ts` also needs attention. Right now it imports `PgTable` and `PgTableWithColumns`, and its `buildConflictUpdateColumns` helper emits raw SQL using the PostgreSQL/SQLite `excluded.column_name` pattern.

    That helper is not directly reusable for MySQL upserts.

    <Callout type="warn" title="Review utility types and raw SQL carefully">
      Schema conversion is not limited to the table files. `packages/db/src/utils/index.ts` contains PostgreSQL-specific typing and conflict-update SQL, so it should be reviewed as part of the dialect switch.
    </Callout>
  </Step>

  <Step>
    ## Update upserts and returning queries

    This is the biggest MySQL-specific difference in the current setup.

    The current codebase uses PostgreSQL-style upserts with `.onConflictDoUpdate(...)` and frequently calls `.returning()` after inserts and updates. MySQL does not follow the same pattern.

    In practice, you need to review files such as:

    * `packages/billing/shared/src/server/customer.ts`
    * `packages/billing/shared/src/server/subscription.ts`
    * `packages/billing/shared/src/server/order.ts`
    * `packages/auth/src/scripts/seed.ts`

    The usual query changes are:

    * `.onConflictDoUpdate(...)` -> `.onDuplicateKeyUpdate(...)`
    * `.returning()` after inserts -> `$returningId()` only when you need inserted primary keys, or a follow-up `select`
    * `.returning()` after updates -> a follow-up `select` if you need the updated row data back

    For example, this current PostgreSQL-style upsert:

    ```ts title="packages/billing/shared/src/server/customer.ts"
    return db
      .insert(customer)
      .values(data)
      .onConflictDoUpdate({
        target: [customer.externalId, customer.provider],
        set: data,
      })
      .returning();
    ```

    needs to be reworked for MySQL around `onDuplicateKeyUpdate`, without relying on PostgreSQL conflict targets or `returning()`.
  </Step>

  <Step>
    ## Regenerate migrations from scratch

    Once the schema, utilities, and query layer are updated, generate a fresh MySQL migration set.

    The existing migration history in `packages/db/migrations` was generated for PostgreSQL, so don't reuse it as-is for MySQL.

    ```bash
    rm -rf packages/db/migrations
    pnpm with-env turbo db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    After that, you can keep using the same Drizzle workflows:

    * `pnpm with-env turbo db:generate`
    * `pnpm with-env pnpm --filter @workspace/db db:migrate`
    * `pnpm with-env pnpm --filter @workspace/db db:studio`
  </Step>
</Steps>

## Recommended migration order

If you're converting an existing project, this is the safest order:

1. Commit the PostgreSQL version first.
2. Switch the driver, Drizzle config, and Better Auth provider.
3. Convert `packages/db/src/schema/*` from `pg-core` to `mysql-core`.
4. Update `packages/db/src/utils/index.ts` for MySQL-compatible typing and upsert helpers.
5. Replace PostgreSQL-only `.onConflictDoUpdate(...)` and `.returning()` usage in service and seed files.
6. Delete old PostgreSQL migrations and generate fresh MySQL migrations.
7. Smoke test auth, organizations, billing sync, and seed scripts.

## Final notes

MySQL is a viable option, but it is not a connection-string-only switch. The biggest changes are not the schema files themselves, but the PostgreSQL-style query patterns already used across billing and auth scripts.

If you want the smoothest path away from PostgreSQL, review query semantics early, not just schema declarations.

<Cards className="sm:grid-cols-3">
  <Card title="Drizzle MySQL" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/get-started-mysql" />

  <Card title="Drizzle Upsert" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/guides/upsert" />

  <Card title="Better Auth Drizzle" description="better-auth.com" href="https://better-auth.com/docs/adapters/drizzle" />
</Cards>


# Overview
Source: https://www.turbostarter.dev/docs/web/database/overview

We're using [Drizzle ORM](https://orm.drizzle.team) to interact with the database. It basically adds a little layer of abstraction between our code and the database.

> If you know SQL, you know Drizzle.

For the database we're leveraging [PostgreSQL](https://www.postgresql.org), but you could use any other database that Drizzle ORM supports (basically any SQL database e.g. [MySQL](https://orm.drizzle.team/docs/get-started-mysql), [SQLite](https://orm.drizzle.team/docs/get-started-sqlite), etc.).

<Callout title="Why Drizzle ORM?">
  Drizzle ORM is a powerful tool that allows you to interact with the database in a type-safe manner. It ships with **0** (!) dependencies and is designed to be fast and easy to use.
</Callout>

## Setup

To start interacting with the database you first need to ensure that your database service instance is up and running.

<Tabs items={["Local development", "Cloud instance"]}>
  <Tab value="Local development">
    For local development we recommend using the [Docker](https://hub.docker.com/_/postgres) container.

    You can start the container with the following command:

    ```bash
    pnpm services:setup
    ```

    This will start all the services (including the database container) and initialize the database with the latest schema.

    **Where is DATABASE\_URL?**

    `DATABASE_URL` is a connection string that is used to connect to the database. When the command will finish it will be displayed in the console and setup to your environment variables.
  </Tab>

  <Tab value="Cloud instance">
    You can also use a cloud instance of database (e.g. [Supabase](/docs/web/recipes/supabase), [Neon](https://neon.com/), [Turso](https://turso.tech/), etc.), although it's not recommended for local development.

    If you choose Supabase as your provider, follow the [Supabase recipe](/docs/web/recipes/supabase#configure-environment-variables) for details on configuring `DATABASE_URL` and running migrations.

    **Where is DATABASE\_URL?**

    It's available in your provider's project dashboard. You'll need to copy the connection string from there and add it to your `.env.local` file. The format will look something like:

    * Neon: `postgresql://user:password@ep-xyz-123.region.aws.neon.tech/dbname`
    * Turso: `libsql://your-db-xyz.turso.io`

    Make sure to keep this URL secure and never commit it to version control.
  </Tab>
</Tabs>

Then, set the `DATABASE_URL` environment variable in the **root** `.env.local` file. The value should match the Postgres container started by `pnpm services:setup` (also pre-filled in `.env.example`):

```dotenv title=".env.local"
# The database URL is used to connect to your database.
DATABASE_URL="postgresql://turbostarter:turbostarter@localhost:5432/core"
```

<Callout title="Using local Supabase instead?" type="info">
  If you run Postgres through the [Supabase CLI](/docs/web/recipes/supabase#optional-use-supabase-locally-with-docker) rather than `pnpm services:setup`, use the DB URL from `supabase status` instead (often port `54322`).
</Callout>

You're ready to go! 🥳

## Studio

TurboStarter provides you also with an interactive UI where you can explore your database and test queries called Studio.

To run the Studio, you can use the following command:

```bash
pnpm with-env pnpm --filter @workspace/db db:studio
```

This will start the Studio on [https://local.drizzle.studio](https://local.drizzle.studio).

![Drizzle Studio](/images/docs/db-studio.webp)

## Next steps

* [Update schema](/docs/web/database/schema) - learn about schema and how to update it.
* [Generate & run migrations](/docs/web/database/migrations) - migrate your changes to the database.
* [Initialize client](/docs/web/database/client) - initialize the database client and start interacting with the database.
* [Switch to Prisma](/docs/web/recipes/prisma) - replace Drizzle with Prisma ORM if your team prefers a Prisma-first database workflow.


# Schema
Source: https://www.turbostarter.dev/docs/web/database/schema

Creating a schema for your data is one of the primary tasks when building a new application.

You can find the schema of each table in `packages/db/src/schema` directory. The schema is organized by domain and each file groups related tables together (e.g. `billing.ts` contains the `customer`, `order` and `subscription` tables).

## Defining schema

The schema is defined using SQL-like utilities from [drizzle-orm](https://orm.drizzle.team/docs/sql-schema-declaration).

It supports all the SQL features, such as enums, indexes, foreign keys, extensions and more.

<Callout title="Code-first approach">
  We're relying on the [code-first approach](https://orm.drizzle.team/docs/migrations), where we define the schema in code and then generate the SQL from it. That way we can approach full type-safety and the simplest flow for database updates and migrations.
</Callout>

## Example

Let's take a look at the `subscription` table, where we store information about our customers' subscriptions.

```typescript title="billing.ts"
export const subscription = pgTable(
  "subscription",
  {
    id: text().primaryKey().$defaultFn(generateId),
    customerId: text()
      .references(() => customer.id, {
        onDelete: "cascade",
      })
      .notNull(),
    externalId: text().notNull(),
    variantId: text().notNull(),
    status: subscriptionStatusEnum().notNull(),
    store: text().notNull(),
    periodStartsAt: timestamp().notNull(),
    periodEndsAt: timestamp().notNull(),
    trialStartsAt: timestamp(),
    trialEndsAt: timestamp(),
    createdAt: timestamp().notNull().defaultNow(),
    updatedAt: timestamp()
      .notNull()
      .$onUpdate(() => new Date()),
  },
  (t) => [unique().on(t.externalId, t.store)],
);
```

We're using a few native SQL utilities here, such as:

* `pgTable` - a table definition.
* `primaryKey` - a primary key.
* `defaultFn` - a default function.
* `$onUpdate` - an on update function.
* `notNull` - a not null constraint.
* `defaultNow` - a default now function.
* `timestamp` - a timestamp.
* `text` - a text.
* `unique` - a unique constraint.
* `references` - a reference to another table.

What's more, Drizzle gives us the ability to export the TypeScript types for the table, which we can reuse e.g. for the API calls.

Also, we can use the drizzle extension [drizzle-zod](https://orm.drizzle.team/docs/zod) to generate the Zod schemas for the table.

```typescript title="customer.ts"
import { createInsertSchema, createSelectSchema } from "drizzle-zod";

export const insertSubscriptionSchema = createInsertSchema(subscription);
export const selectSubscriptionSchema = createSelectSchema(subscription);
export const updateSubscriptionSchema = createUpdateSchema(subscription);

export type SelectSubscription = z.infer<typeof selectSubscriptionSchema>;
export type InsertSubscription = z.infer<typeof insertSubscriptionSchema>;
export type UpdateSubscription = z.infer<typeof updateSubscriptionSchema>;
```

Then we can use the generated schemas in API handlers and form validations to validate the data.


# SQLite
Source: https://www.turbostarter.dev/docs/web/database/sqlite

TurboStarter ships with [PostgreSQL](https://www.postgresql.org/) by default, but you can absolutely run it on [SQLite](https://www.sqlite.org/) if that fits your product better.

[SQLite](https://www.sqlite.org/) is a good option when you want a simpler local setup, a single file database for development, or a [libSQL](https://libsql.org/) provider such as [Turso](https://turso.tech/) in production. The important thing to understand is that SQLite is a customization rather than a one-line toggle.

<Callout title="PostgreSQL is still the default">
  At the time of writing, the starter is wired to PostgreSQL in `packages/db/src/server.ts`, `packages/db/drizzle.config.ts`, `packages/auth/src/server.ts`, and the schema files in `packages/db/src/schema`.
</Callout>

## When SQLite makes sense

SQLite is a strong fit when you want:

* local development without a database container
* a simpler deployment story for smaller products
* an edge-friendly database provider such as Turso/libSQL

You should usually stay on PostgreSQL if you need:

* heavier concurrent write traffic
* PostgreSQL-specific features
* a fully drop-in experience with the starter's default schema and migrations

## What you need to change

Moving to SQLite usually means updating these areas:

* `packages/db/package.json` - add a SQLite driver
* `packages/db/src/env.ts` - validate the new connection settings
* `packages/db/src/server.ts` - initialize Drizzle with a SQLite client
* `packages/db/drizzle.config.ts` - switch Drizzle Kit from `postgresql` to `sqlite`
* `packages/auth/src/server.ts` - change Better Auth from `provider: "pg"` to `provider: "sqlite"`
* `packages/db/src/schema/*` - replace PostgreSQL-only schema utilities with SQLite-compatible ones
* `packages/db/src/utils/index.ts` - review helper types that still import PostgreSQL table types

The good news is that not everything needs to be rewritten. For example, helper utilities such as `buildConflictUpdateColumns` already support both `PgTable` and `SQLiteTable`.

<Steps>
  <Step>
    ## Install the SQLite driver

    For this setup, the most practical choice is [libSQL](https://orm.drizzle.team/docs/get-started-sqlite), because the same driver works with both local SQLite files and remote Turso databases.

    ```bash
    pnpm --filter @workspace/db add @libsql/client
    pnpm --filter @workspace/db remove postgres
    ```
  </Step>

  <Step>
    ## Update environment variables

    For local development, point `DATABASE_URL` to a SQLite file. [Drizzle ORM](https://orm.drizzle.team/)'s libSQL tooling expects the `file:` prefix.

    ```dotenv title=".env.local"
    DATABASE_URL="file:./.data/local.db"
    ```

    If you use Turso, set the remote URL and auth token instead:

    ```dotenv title=".env.local"
    DATABASE_URL="libsql://your-database.turso.io"
    DATABASE_AUTH_TOKEN="your-token"
    ```

    <Callout title="Ignore local database files">
      If you keep a local SQLite file inside the repository, add its directory to `.gitignore` so you don't commit the database by accident. A common choice is `.data/`.
    </Callout>
  </Step>

  <Step>
    ## Replace the database client

    The current database package uses [`postgres-js`](https://github.com/porsager/postgres). Swap it to the libSQL client in `packages/db/src/server.ts`.

    ```ts title="packages/db/src/server.ts"
    import { createClient } from "@libsql/client";
    import { drizzle } from "drizzle-orm/libsql";

    import { env } from "./env";
    import * as schema from "./schema";

    const client = createClient({
      url: env.DATABASE_URL,
      authToken: env.DATABASE_AUTH_TOKEN,
    });

    export const db = drizzle(client, {
      schema,
      casing: "snake_case",
    });
    ```

    You should also relax the env validation in `packages/db/src/env.ts` so it accepts both local `file:` URLs and remote `libsql://` URLs:

    ```ts title="packages/db/src/env.ts"
    export const preset = {
      id: "db",
      server: {
        DATABASE_URL: z.string().min(1),
        DATABASE_AUTH_TOKEN: z.string().optional(),
      },
    } as const;
    ```
  </Step>

  <Step>
    ## Switch Drizzle Kit to SQLite

    Update `packages/db/drizzle.config.ts` to use the [Drizzle Kit](https://orm.drizzle.team/docs/drizzle-config-file) SQLite dialect:

    ```ts title="packages/db/drizzle.config.ts"
    import { defineConfig } from "drizzle-kit";

    import { env } from "./src/env";

    export default defineConfig({
      out: "./migrations",
      schema: "./src/schema/index.ts",
      dialect: "sqlite",
      casing: "snake_case",
      dbCredentials: {
        url: env.DATABASE_URL,
        authToken: env.DATABASE_AUTH_TOKEN,
      },
    });
    ```
  </Step>

  <Step>
    ## Update Better Auth

    The auth package is also PostgreSQL-first today. In `packages/auth/src/server.ts`, change the [Better Auth](https://better-auth.com/) Drizzle adapter provider:

    ```ts title="packages/auth/src/server.ts"
    database: drizzleAdapter(db, {
      provider: "sqlite",
      schema,
    }),
    ```

    This is the key Better Auth change when you keep using the Drizzle adapter.
  </Step>

  <Step>
    ## Convert the schema to SQLite

    This is the largest part of the migration.

    Today the starter's schema uses `drizzle-orm/pg-core` utilities such as `pgTable` and `pgEnum` in `packages/db/src/schema/auth.ts` and `packages/db/src/schema/billing.ts`. SQLite uses `drizzle-orm/sqlite-core` instead, so you need to review both files carefully.

    The most common replacements are:

    * `pgTable` -> `sqliteTable`
    * `pgEnum(...)` -> `text(..., { enum: [...] })`
    * PostgreSQL-typed helpers such as `PgTable` / `PgTableWithColumns` -> SQLite-compatible or dialect-neutral equivalents
    * PostgreSQL-specific raw SQL such as `excluded.column_name` -> keep only where the target dialect supports it

    For example, enum-like fields from `packages/db/src/schema/billing.ts` can be modeled as text columns:

    ```ts title="packages/db/src/schema/billing.ts"
    import { sqliteTable, text } from "drizzle-orm/sqlite-core";

    const subscriptionStatuses = [
      "active",
      "canceled",
      "incomplete",
      "incomplete_expired",
      "past_due",
      "paused",
      "trialing",
      "unpaid",
    ] as const;

    export const subscription = sqliteTable("subscription", {
      id: text("id").primaryKey(),
      status: text("status", { enum: subscriptionStatuses }).notNull(),
    });
    ```

    The `buildConflictUpdateColumns` helper is already partly prepared for SQLite because it accepts `SQLiteTable`, but `getOrderByFromSort` still imports PostgreSQL-only types:

    ```ts title="packages/db/src/utils/index.ts"
    import type { SQLiteTable } from "drizzle-orm/sqlite-core";
    // replace PgTable / PgTableWithColumns imports as needed
    ```

    <Callout type="warn" title="Review PostgreSQL-only columns carefully">
      The largest schema changes are the `pgEnum` definitions in `packages/db/src/schema/billing.ts` and the PostgreSQL-only helper types in `packages/db/src/utils/index.ts`. This is not a blind search-and-replace migration.
    </Callout>
  </Step>

  <Step>
    ## Regenerate migrations from scratch

    Once the schema and config are updated, generate a fresh SQLite migration set.

    Because the existing migration history was generated for PostgreSQL, don't reuse it as-is for SQLite.

    ```bash
    rm -rf packages/db/migrations
    pnpm with-env turbo db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    After that, you can keep using the same Drizzle workflows:

    * `pnpm with-env turbo db:generate`
    * `pnpm with-env pnpm --filter @workspace/db db:migrate`
    * `pnpm with-env pnpm --filter @workspace/db db:studio`
  </Step>
</Steps>

## Recommended migration order

If you're converting an existing project, this is the safest order:

1. Commit your PostgreSQL version first.
2. Switch the client, Drizzle config, and Better Auth provider.
3. Convert the schema files from `pg-core` to `sqlite-core`.
4. Update `packages/db/src/utils/index.ts` for SQLite-compatible typing where needed.
5. Delete old PostgreSQL migrations.
6. Generate fresh SQLite migrations.
7. Smoke test sign-in, sign-up, billing, and organization flows.

## Final notes

SQLite works well with the starter, but it is not currently the "default path". Treat it as an intentional database adapter swap, not just a connection string change.

If you want the smoothest setup, prefer `@libsql/client`, keep the schema conservative, and regenerate your migrations cleanly once the dialect switch is complete.

<Cards className="sm:grid-cols-3">
  <Card title="Drizzle SQLite" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/get-started-sqlite" />

  <Card title="Drizzle Config" description="orm.drizzle.team" href="https://orm.drizzle.team/docs/drizzle-config-file" />

  <Card title="Better Auth Drizzle" description="better-auth.com" href="https://better-auth.com/docs/adapters/drizzle" />
</Cards>


# AWS Amplify
Source: https://www.turbostarter.dev/docs/web/deployment/amplify

[AWS Amplify](https://aws.amazon.com/amplify/) is a fully managed service that makes it easy to build, deploy, and host modern web applications. It provides features like continuous deployment, serverless functions, authentication, and more - all integrated into a seamless developer experience.

This guide explains how to deploy your TurboStarter app on AWS Amplify. You'll learn how to set up your repository for automated deployments, configure build settings, manage environment variables, and ensure your application runs smoothly in production. **AWS Amplify handles the infrastructure management, allowing you to focus on developing your application.**

<Callout type="warn" title="Prerequisite: AWS account">
  To deploy to AWS Amplify, you need to have an AWS account. You can create one [here](https://aws.amazon.com/amplify/).
</Callout>

<Steps>
  <Step>
    ## Create configuration file

    To deploy your TurboStarter app to AWS Amplify, you need to create a config file. This file will contain the necessary information to connect your repository to AWS Amplify and deploy your application.

    Let's create a new file called `amplify.yml` in the root of your project:

    ```yaml title="amplify.yml"
    version: 1
    applications:
      - frontend:
          buildPath: "/"
          phases:
            preBuild:
              commands:
                - npm install -g pnpm
                - pnpm install
            build:
              commands:
                - pnpm dlx turbo build --filter=web
          artifacts:
            baseDirectory: apps/web/.next
            files:
              - "**/*"
          cache:
            paths:
              - node_modules/**/*
              - apps/web/.next/cache/**/*
        appRoot: apps/web
    ```

    This configuration file tells AWS Amplify how to build and deploy your application:

    * The `version` field specifies the Amplify configuration version
    * Under `applications`, we define the build settings for our web app:
      * `buildPath` indicates where to run the build commands
      * `preBuild` phase installs pnpm and project dependencies
      * `build` phase runs the Turborepo build command for the web app
      * `artifacts` specifies which files to deploy (the Next.js build output)
      * `cache` configures which directories to cache between builds
      * `appRoot` points to the web application directory

    AWS Amplify will use this configuration to automatically build and deploy your app whenever you push changes to your repository. It also useful to define other resources that you can use and link to your project.
  </Step>

  <Step>
    ## Create a new Amplify project

    We'll use the [AWS Amplify](https://aws.amazon.com/amplify/) web interface to deploy our app. First, let's create a new project.

    ![Amplify create project](/images/docs/web/deployment/amplify/create-project.png)

    Proceed with the option to *Deploy an app*.
  </Step>

  <Step>
    ## Connect repository

    Choose the Git provider of your project and select the repository you want to deploy.

    ![Amplify connect repository](/images/docs/web/deployment/amplify/connect-repository.png)

    <Callout title="Authorization needed">
      If your repository is private you need to authorize Amplify to access it. It's recommended to follow a *least privileged access* approach, so to only grant access to the repository you want to deploy, not the entire account.
    </Callout>

    Select the branch you want to deploy and make sure to enable the *My app is a monorepo* option - configure it with the path to the app that you want to deploy (e.g. `apps/web`).

    ![Amplify repository and branch](/images/docs/web/deployment/amplify/repository.png)
  </Step>

  <Step>
    ## Configure build settings

    Finalize your deployment by configuring the build settings to match your project's specific needs. Refer to the points below to ensure a seamless deployment process.

    ![Amplify build settings](/images/docs/web/deployment/amplify/build-settings.png)

    Make sure that the build command and build output directory is set to the correct values (it should be defined based on your configuration file from Step 1.).

    ### Environment variables

    In the *Advanced settings* section, you can define environment variables that will be available to your application at runtime.

    ![Amplify environment variables](/images/docs/web/deployment/amplify/environment-variables.png)

    Verify that all required environment variables are defined, so your app can be build and deployed successfully.
  </Step>

  <Step>
    ## Review and deploy!

    On the next step, you'll be able to review the configuration that you've created and deploy your app. It's the right time to make sure that everything is set up correctly.

    ![Amplify review and deploy](/images/docs/web/deployment/amplify/review.png)

    After making sure that everything is set up correctly, you can click on the *Save and deploy* button to start the deployment process.

    When your app is deployed, you'll be able to access it via the URL provided in the Amplify console:

    ![Amplify deployed app](/images/docs/web/deployment/amplify/deployed.png)

    That's it! Your app is now deployed to AWS Amplify, congratulations! 🎉
  </Step>
</Steps>

Feel free to scale your deployment to multiple regions, add custom domains, and use other Amplify features to make your app more robust and scalable.
Check out the [AWS Amplify documentation](https://docs.aws.amazon.com/amplify/latest/userguide/welcome.html) for more information on how to use Amplify to its full potential.


# Standalone API
Source: https://www.turbostarter.dev/docs/web/deployment/api

Sometimes you want to deploy your API as a standalone service. This is useful if you want to deploy your API to a different domain or to deploy it as a microservice. You can also follow this approach if you don't need a web app, but still need API service for [mobile app](/docs/mobile) or [browser extension](/docs/extension).

Deploying your API as a standalone service provides enhanced flexibility and scalability. This allows you to independently scale your API from your web app. It's particularly beneficial for executing "long-running" tasks on your backend, such as report generation, real-time data processing, or background tasks that are likely to timeout in a serverless environment.

This guide explains how to deploy your TurboStarter API as a standalone service. As Hono has multiple deployment options (e.g. [Deno](https://hono.dev/docs/getting-started/deno), [Bun](https://hono.dev/docs/getting-started/bun)), this guide will focus primarily on the [Node.js](https://hono.dev/docs/getting-started/nodejs) deployment.

<Steps>
  <Step>
    ## Create separate API app

    We have a [dedicated guide](/docs/web/customization/add-app) on how to add another app to your project. However, in this case, only a few files need to be added, so we can do it quickly here.

    First, let's create an `api` directory inside the `apps` directory - it will be the root of your API app.

    Next, add the following files into the `apps/api` directory:

    <Tabs items={["package.json", "tsconfig.json", "src/index.ts"]}>
      <Tab value="package.json">
        ```json
        {
          "name": "api",
          "version": "0.1.0",
          "private": true,
          "scripts": {
            "build": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.js",
            "clean": "git clean -xdf dist .turbo node_modules",
            "dev": "dotenv -c -- tsx watch src/index.ts",
            "start": "node dist/index.js",
            "typecheck": "tsc --noEmit"
          },
          "dependencies": {
            "@hono/node-server": "1.13.7",
            "@workspace/api": "workspace:*"
          },
          "devDependencies": {
            "@workspace/tsconfig": "workspace:*",
            "@types/node": "24.0.0",
            "esbuild": "0.24.2",
            "tsx": "4.19.2",
            "typescript": "catalog:"
          }
        }
        ```
      </Tab>

      <Tab value="tsconfig.json">
        ```json
        {
          "extends": "@workspace/tsconfig/base.json",
          "include": ["src"],
          "exclude": ["node_modules"]
        }
        ```
      </Tab>

      <Tab value="src/index.ts">
        ```ts
        import { serve } from "@hono/node-server";
        import { appRouter } from "@workspace/api";

        serve(
          {
            fetch: appRouter.fetch,
            port: Number(process.env.PORT) || 3001,
          },
          ({ port }) => {
            console.log(`Server is running on ${port} 🚀`);
          },
        );
        ```
      </Tab>
    </Tabs>

    This will enable you to have a minimal configuration required to run your API as a standalone service. For sure, you can add more configuration if needed, we just want to keep it minimal for the sake of this guide.
  </Step>

  <Step>
    ## Connect web app to API

    The API will be running on a different URL than your web app. For the minimal setup and to avoid handling [cross-origin resource sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) issues, we will rewrite the API URL in the web app.

    To do this, you will need to change your `next.config.ts` file to include the API URL rewrite:

    ```js title="apps/web/next.config.ts"
    import type { NextConfig } from "next";

    const config: NextConfig = {
      rewrites: async () => [
        {
          source: "/api/:path*",
          destination: `${env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"}/api/:path*`,
        },
      ],
    };
    ```

    <Callout title="Use environment variable to set API url">
      It's recommended to use an environment variable (e.g. `NEXT_PUBLIC_API_URL`) to set the API URL. This is a good practice to make it easier to change the API URL in different environments (e.g. development, staging, production).
    </Callout>

    Now you should be able to run your API as a standalone service. When you run the project with `pnpm dev`, you will see the new app called `api` with your API server running on [http://localhost:3001](http://localhost:3001).
  </Step>

  <Step>
    ## Deploy!

    You can basically deploy your API as any other Node.js project. We will quickly go through the two most popular options: [PaaS](https://en.wikipedia.org/wiki/Platform_as_a_service) and [Docker](https://www.docker.com/).

    ### Platform as a Service (PaaS)

    PaaS providers like [Vercel](https://vercel.com/), [Heroku](https://www.heroku.com/), or [Netlify](https://www.netlify.com/) allow you to deploy your Node.js app with a few clicks. You can follow our [dedicated guides](/docs/web/deployment/checklist#deploy-web-app-to-production) for the most popular providers. Every process is similar, and will contains a few crucial steps:

    1. Connecting your repository to the PaaS provider
    2. Setting up build settings (e.g. build command, output directory)
    3. Setting up environment variables
    4. Deploying the project

    <Callout title="Ensure correct commands">
      To make sure your API is built and run correctly, you will need to ensure that appropriate commands are correctly set up. In our case, the following commands will need to be configured:

      <Tabs items={["Build command", "Start command"]}>
        <Tab value="Build command">
          ```bash
          pnpm turbo build --filter=api
          ```
        </Tab>

        <Tab value="Start command">
          ```bash
          pnpm --filter=api start
          ```
        </Tab>
      </Tabs>

      This is required to ensure that the PaaS provider of your choice will be able to build and run your application correctly.
    </Callout>

    ### Docker

    Deploying your API as a Docker container is a good option if you want to have more control over the deployment process. You can follow our [dedicated guide](/docs/web/deployment/docker) to learn how to deploy your API as a Docker container.

    For the API application, the `Dockerfile` will be located in the `apps/api` directory and it could look like this:

    ```dockerfile title="apps/api/Dockerfile"
    FROM node:24-alpine AS base
    ENV PNPM_HOME="/pnpm"
    ENV PATH="$PNPM_HOME:$PATH"
    RUN corepack enable

    FROM base AS pruner
    WORKDIR /app
    RUN apk add --no-cache libc6-compat
    COPY . .
    RUN pnpm dlx turbo prune api --docker

    FROM base AS builder
    WORKDIR /app
    RUN apk add --no-cache libc6-compat
    COPY --from=pruner /app/out/json/ .
    COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
    RUN pnpm install --frozen-lockfile --ignore-scripts --prefer-offline && pnpm store prune
    ENV SKIP_ENV_VALIDATION=1 \
        NODE_ENV=production
    COPY --from=pruner /app/out/full/ .
    RUN pnpm dlx turbo build --filter=api

    FROM base AS runner
    WORKDIR /app
    RUN addgroup -g 1001 -S nodejs && \
        adduser -S api -u 1001 -G nodejs
    COPY --from=builder --chown=api:nodejs /app/apps/api/dist/ ./
    USER api
    EXPOSE 3001
    CMD ["node", "index.js"]
    ```

    To test if everything works correctly, you can run a [container](https://docs.docker.com/get-started/workshop/02_our_app/) locally with the following commands:

    ```bash
    docker build -f ./apps/api/Dockerfile . -t turbostarter-api
    docker run -p 3001:3001 turbostarter-api
    ```

    Make sure to also [pass](https://docs.docker.com/reference/cli/docker/container/run/#env) all the required environment variables to the container, so your API can start without any issues.

    Deploying your API as a Docker container is a great way to isolate your API from the host environment, making it easier to deploy and scale. It also simplifies the workflow if you're working with a team, as you can easily share the Docker image with your colleagues and they will run the API in the **exact same** environment.
  </Step>
</Steps>

That's it! You can now grow your API layer as a standalone service, separated from other apps in your project, and deploy it anywhere you want.


# Checklist
Source: https://www.turbostarter.dev/docs/web/deployment/checklist

When you're ready to deploy your project to production, follow this checklist.

This process may take a few hours and some trial and error, so buckle up - you're almost there!

<Steps>
  <Step>
    ## Create database instance

    **Why it's necessary?**

    A production-ready database instance is essential for storing your application's data securely and reliably in the cloud. [PostgreSQL](https://www.postgresql.org/) is the recommended database for TurboStarter due to its robustness, features, and wide support.

    **How to do it?**

    You have several options for hosting your PostgreSQL database:

    * [Supabase](/docs/web/recipes/supabase) - Provides a fully managed Postgres database with additional features
    * [Vercel Postgres](https://vercel.com/storage/postgres) - Serverless SQL database optimized for Vercel deployments
    * [Neon](https://neon.com/) - Serverless Postgres with automatic scaling
    * [Turso](https://turso.tech/) - Edge database built on libSQL with global replication
    * [DigitalOcean](https://www.digitalocean.com/products/managed-databases) - Managed database clusters with automated failover

    Choose a provider based on your needs for:

    * Pricing and budget
    * Geographic region availability
    * Scaling requirements
    * Additional features (backups, monitoring, etc.)
  </Step>

  <Step>
    ## Migrate database

    **Why it's necessary?**

    Pushing database migrations ensures that your database schema in the remote database instance is configured to match TurboStarter's requirements. This step is crucial for the application to function correctly.

    **How to do it?**

    You basically have two possibilities of doing a migration:

    <Tabs items={["Using Github Actions (recommended)", "Running locally"]}>
      <Tab value="Using Github Actions (recommended)">
        TurboStarter comes with predefined Github Actions workflow to handle database migrations. You can find its definition in the `.github/workflows/publish-db.yml` file.

        What you need to do is to set your `DATABASE_URL` as a [secret for your Github repository](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions).

        Then, you can run the workflow which will publish the database schema to your remote database instance.

        [Check how to run Github Actions workflow.](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow)
      </Tab>

      <Tab value="Running locally">
        You can also run your migrations locally, although this is not recommended for production.

        To do so, set the `DATABASE_URL` environment variable to your database URL (that comes from your database provider) in `.env.local` file and run the following command:

        ```bash
        pnpm with-env pnpm --filter @workspace/db db:migrate
        ```

        This command will run the migrations and apply them to your remote database.

        [Learn more about database migrations.](/docs/web/database/migrations)
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ## Configure OAuth Providers

    **Why it's necessary?**

    Configuring OAuth providers like [Google](https://better-auth.com/docs/authentication/google) or [Github](https://better-auth.com/docs/authentication/github) ensures that users can log in using their existing accounts, enhancing user convenience and security. This step involves setting up the OAuth credentials in the provider's developer console, configuring the necessary environment variables, and setting up callback URLs to point to your production app.

    **How to do it?**

    1. Follow the provider-specific guides to set up OAuth credentials for the providers you want to use. For example:
       * [Apple OAuth setup guide](https://better-auth.com/docs/authentication/apple)
       * [Google OAuth setup guide](https://better-auth.com/docs/authentication/google)
       * [Github OAuth setup guide](https://better-auth.com/docs/authentication/github)
    2. Once you have the credentials, set the corresponding environment variables in your project. For the example providers above:
       * For Apple: `APPLE_CLIENT_ID` and `APPLE_CLIENT_SECRET`
       * For Google: `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
       * For Github: `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`
    3. Ensure that the callback URLs for each provider are set to point to your production app. **This is crucial for the OAuth flow to work correctly.**

    You can add or remove OAuth providers based on your needs. Just make sure to follow the provider's setup guide, set the required environment variables, and configure the callback URLs correctly.
  </Step>

  <Step>
    ## Setup billing provider

    **Why it's necessary?**

    Well - you want to get paid, right? Setting up billing ensures that you can charge your users for using your SaaS application, enabling you to monetize your service and cover operational costs.

    **How to do it?**

    * Create a [Stripe](/docs/web/billing/stripe), [Lemon Squeezy](/docs/web/billing/lemon-squeezy), [Polar](/docs/web/billing/polar), or [Dodo Payments](/docs/web/billing/dodo-payments) account.
    * Update the environment variables with the correct values for your billing service.
    * Point webhooks from Stripe, Lemon Squeezy, Polar, or Dodo Payments to `/api/billing/webhook`.
    * Refer to the [relevant documentation](/docs/web/billing/overview) for more details on setting up billing.
  </Step>

  <Step>
    ## Setup emails provider

    **Why it's necessary?**

    Setting up an email provider is crucial for your SaaS application to send notifications, confirmations, and other important messages to your users. This enhances user experience and engagement, and is a standard practice in modern web applications.

    **How to do it?**

    * Create an account with an email service provider of your choice. See [available providers](/docs/web/emails/configuration#providers) for more information.
    * Update the environment variables with the correct values for your email service.
    * Refer to the [relevant documentation](/docs/web/emails/overview) for more details on setting up email.
  </Step>

  <Step>
    ## Setup storage provider

    **Why it's necessary?**

    Don't forget to configure your storage provider, if you want to operate on files in your app. By default, this is optional — the app can run without a storage provider — but some features could be unavailable (e.g., avatar uploads and other file-related actions).

    **How to do it?**

    * Review the [Storage overview](/docs/web/storage/overview).
    * Follow [Storage configuration](/docs/web/storage/configuration) to choose and set up a provider.
    * Add any required environment variables in your **hosting provider**.
  </Step>

  <Step>
    ## Environment variables

    **Why it's necessary?**

    Setting the correct environment variables is essential for the application to function correctly. These variables include API keys, database URLs, and other configuration details required for your app to connect to various services.

    **How to do it?**

    Use our `.env.example` files to get the correct environment variables for your project. Then add them to your **hosting provider's environment variables**. Redeploy the app once you have the URL to set in the environment variables.
  </Step>

  <Step>
    ## Deploy web app to production

    **Why it's necessary?**

    Because your users are waiting! Deploying your Next.js app to a hosting provider makes it accessible to users worldwide, allowing them to interact with your application.

    **How to do it?**

    Deploy your Next.js app to chosen hosting provider. **Copy the deployment URL and set it as an environment variable in your project's settings.** Feel free to check out our dedicated guides for the most popular hosting providers:

    <Cards>
      <Card title="Vercel" description="Deploy your TurboStarter web app to Vercel platform." href="/docs/web/deployment/vercel" />

      <Card title="Cloudflare" description="Deploy your TurboStarter web app to Cloudflare Workers." href="/docs/web/deployment/cloudflare" />

      <Card title="Netlify" description="Deploy your TurboStarter web app to Netlify platform." href="/docs/web/deployment/netlify" />

      <Card title="Render" description="Deploy your TurboStarter web app to Render platform." href="/docs/web/deployment/render" />

      <Card title="Railway" description="Deploy your TurboStarter web app to Railway platform." href="/docs/web/deployment/railway" />

      <Card title="AWS Amplify" description="Deploy your TurboStarter web app to AWS Amplify platform." href="/docs/web/deployment/amplify" />

      <Card title="Docker" description="Containerize your TurboStarter web app using Docker." href="/docs/web/deployment/docker" />

      <Card title="VPS" description="Deploy your TurboStarter web app to your own VPS with Docker." href="/docs/web/deployment/vps" />

      <Card title="Fly.io" description="Deploy your TurboStarter web app to Fly.io platform." href="/docs/web/deployment/fly" />
    </Cards>

    We also have a dedicated guide for [deploying your API as a standalone service](/docs/web/deployment/api).
  </Step>
</Steps>

That's it! Your app is now live and accessible to your users, good job! 🎉

<Callout title="Other things to consider">
  * Run through the [security checklist](/docs/web/security/checklist) (secrets, auth, webhooks, storage).
  * Update the legal pages with your company's information (privacy policy, terms of service, etc.).
  * Remove the placeholder blog and documentation content / or replace it with your own.
  * Customize authentication emails and other email templates.
  * Update the favicon and logo with your own branding.
  * Update the FAQ and other static content with your own information.
</Callout>


# Cloudflare
Source: https://www.turbostarter.dev/docs/web/deployment/cloudflare

[Cloudflare Workers](https://developers.cloudflare.com/workers/) can run your TurboStarter web app close to your users on Cloudflare's global network. TurboStarter uses [OpenNext for Cloudflare](https://opennext.js.org/cloudflare) to build the Next.js app into a Worker, [Wrangler](https://developers.cloudflare.com/workers/wrangler/) to preview and deploy it, [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for database connections, and [R2](https://developers.cloudflare.com/r2/) for Next.js incremental cache.

This guide focuses on the most convenient path: generate the Cloudflare files, create the required Cloudflare resources, preview the app locally in the Workers runtime, then deploy.

<Callout type="warn" title="Prerequisites">
  Before deploying, make sure you have:

  * a [Cloudflare account](https://dash.cloudflare.com/login)
  * a production [database](/docs/web/database/overview) with migrations already applied
  * your production [environment variables](/docs/web/configuration/environment-variables) ready
  * [Wrangler](https://developers.cloudflare.com/workers/wrangler/) authenticated locally with `pnpm --filter web exec wrangler login`

  Cloudflare Workers have script [size limits](https://developers.cloudflare.com/workers/platform/limits/#script-size). Wrangler prints the compressed upload size during deployment, so check that output if the upload is rejected.
</Callout>

![Cloudflare Workers & Pages dashboard](/images/docs/web/deployment/cloudflare/workers.png)

<Steps>
  <Step>
    ## Generate the Cloudflare setup

    Run the Cloudflare generator from the repository root:

    ```bash
    pnpm turbo gen cloudflare
    ```

    The generator asks for:

    * **Worker name** - the Cloudflare Worker name, usually your product slug
    * **R2 bucket name** - the bucket used by OpenNext for incremental cache
    * **Hyperdrive config id** - paste this after you create Hyperdrive in the next step
    * **Local database URL** - used by Wrangler preview
    * **App URL** - used by local Cloudflare preview
    * **Wrangler compatibility date** - keep the default unless you have a reason to pin it

    It creates and updates the files needed by the web app:

    * `apps/web/wrangler.jsonc`
    * `apps/web/open-next.config.ts`
    * `apps/web/scripts/cf-build.mjs`
    * `apps/web/.dev.vars.example`
    * `apps/web/next.config.ts`
    * `apps/web/package.json`
    * `apps/web/middleware.ts`
    * `packages/db/src/server.ts`
    * `turbo.json`

    It also adds scripts like `cf:build`, `cf:preview`, `cf:deploy`, `cf:upload`, and `cf:typegen` to the web package.

    <Callout title="Prefer doing it manually?">
      You can add the same files yourself. Use the generated setup as the source of truth: `wrangler.jsonc` points to `.open-next/worker.js`, enables `nodejs_compat`, binds `ASSETS`, `IMAGES`, `NEXT_INC_CACHE_R2_BUCKET`, and `HYPERDRIVE`, while `open-next.config.ts` enables the R2 incremental cache override.
    </Callout>
  </Step>

  <Step>
    ## Create Hyperdrive

    Cloudflare Workers run globally, while most Postgres databases live in one region. [Hyperdrive](https://developers.cloudflare.com/hyperdrive/get-started/) keeps pooled connections inside Cloudflare's network and exposes a Worker binding called `HYPERDRIVE`.

    Create a Hyperdrive configuration with your production database URL:

    ```bash
    pnpm --filter web exec wrangler hyperdrive create <name> --connection-string="$DATABASE_URL"
    ```

    Copy the returned `id` into `apps/web/wrangler.jsonc`:

    ```json title="apps/web/wrangler.jsonc"
    {
      "hyperdrive": [
        {
          "binding": "HYPERDRIVE",
          "id": "<your-hyperdrive-id>",
          "localConnectionString": "postgresql://turbostarter:turbostarter@localhost:5432/core"
        }
      ]
    }
    ```

    Keep `localConnectionString` pointed at your local database. Wrangler uses it when you run `cf:preview`.

    ![Hyperdrive mechanics](/images/docs/web/deployment/cloudflare/hyperdrive.png)
  </Step>

  <Step>
    ## Create the R2 cache bucket

    The generated `open-next.config.ts` configures OpenNext to use R2 for incremental cache. Create the bucket with the same name you entered in the generator:

    ```bash
    pnpm --filter web exec wrangler r2 bucket create <bucket-name>
    ```

    Then verify the binding in `apps/web/wrangler.jsonc`:

    ```json title="apps/web/wrangler.jsonc"
    {
      "r2_buckets": [
        {
          "binding": "NEXT_INC_CACHE_R2_BUCKET",
          "bucket_name": "<bucket-name>"
        }
      ]
    }
    ```

    If you are not using ISR or cached server data yet, you can still keep this bucket configured. It gives your app the right production shape before you need it.

    <Callout title="Using R2 for app uploads">
      This bucket is for OpenNext's incremental cache. If your app stores user uploads, exports, avatars, or generated files, create a separate [Cloudflare R2](https://developers.cloudflare.com/r2/) bucket and wire it through the [storage package](/docs/web/storage/overview). Keeping cache and user files separate makes permissions, lifecycle rules, and cleanup much easier.
    </Callout>
  </Step>

  <Step>
    ## Configure environment variables

    Cloudflare needs two kinds of variables:

    * **Build-time variables** - available when `cf:build` runs, especially `NEXT_PUBLIC_` values and anything used by static generation
    * **Runtime variables and secrets** - available to the deployed Worker

    For local preview, copy the generated example file:

    ```bash
    cp apps/web/.dev.vars.example apps/web/.dev.vars
    ```

    Keep your normal local values in `.env.local` and `apps/web/.env.local` as described in [environment variables](/docs/web/configuration/environment-variables). Use `.dev.vars` only for values Wrangler needs during Worker preview, such as `DATABASE_URL` and `URL`.

    For production, add the same variables you use for other deployments:

    ```dotenv title="Production variables"
    URL="https://example.com"
    BETTER_AUTH_URL="https://example.com"
    NEXT_PUBLIC_URL="https://example.com"
    DATABASE_URL="postgresql://..."
    BETTER_AUTH_SECRET="..."
    ```

    You can set them in the Cloudflare dashboard or with Wrangler:

    ```bash
    pnpm --filter web exec wrangler secret put DATABASE_URL
    pnpm --filter web exec wrangler secret put BETTER_AUTH_SECRET
    ```

    The generated `wrangler.jsonc` includes `keep_vars: true`, so deployments will not remove variables you manage in the Cloudflare dashboard.

    <Callout title="Use separate staging and production values">
      If you deploy multiple environments, keep the same variable names and change only the values. The [multiple environments recipe](/docs/web/recipes/multiple-environments) shows the recommended structure.
    </Callout>

    ![Cloudflare variables](/images/docs/web/deployment/cloudflare/secrets.png)
  </Step>

  <Step>
    ## Generate Cloudflare types

    After `wrangler.jsonc` has a real Hyperdrive id and R2 bucket name, generate Worker binding types:

    ```bash
    pnpm --filter web cf:typegen
    ```

    This creates `apps/web/cloudflare-env.d.ts`. The generator also adds that file to the web app's TypeScript config.
  </Step>

  <Step>
    ## Preview locally

    Run the app in the same Workers runtime it will use in production:

    ```bash
    pnpm --filter web cf:preview
    ```

    Test the paths that depend on external services:

    * sign in and sign out
    * organization creation
    * database reads and writes
    * billing checkout and webhooks
    * email sending
    * file uploads, if your app uses storage

    <Callout title="Preview before every first deploy">
      `pnpm dev` is still the fastest daily development command. `cf:preview` is the production-shape check that catches Worker, Hyperdrive, and environment issues before your users do.
    </Callout>
  </Step>

  <Step>
    ## Deploy

    Deploy the Worker from your local machine:

    ```bash
    pnpm --filter web cf:deploy
    ```

    This runs the custom Cloudflare build script, transforms the Next.js output with OpenNext, uploads the Worker, and deploys it to Cloudflare.

    If you want to upload a version without immediately routing traffic to it, use:

    ```bash
    pnpm --filter web cf:upload
    ```

    After deployment, open the Worker URL and update your production `URL`, `NEXT_PUBLIC_URL`, `BETTER_AUTH_URL`, OAuth callbacks, and billing webhooks to use the final domain.

    ![Cloudflare deployment](/images/docs/web/deployment/cloudflare/deployment.png)
  </Step>

  <Step>
    ## Add a custom domain

    In the Cloudflare dashboard, open your Worker and add a custom domain or route for your production host.

    After the domain is active:

    * update `URL`, `NEXT_PUBLIC_URL`, and `BETTER_AUTH_URL`
    * update OAuth redirect URLs in each provider
    * update billing webhook URLs to `/api/billing/webhook`
    * redeploy with `pnpm --filter web cf:deploy`

    ![Custom domain](/images/docs/web/deployment/cloudflare/custom-domain.png)
  </Step>
</Steps>

## Optional Cloudflare services

You do not need these services for the first deployment, but they are useful once your app grows beyond the default Worker, Hyperdrive, and R2 cache setup.

<Cards>
  <Card title="R2" description="Store user uploads, exports, generated files, and private assets without S3 egress fees." href="https://developers.cloudflare.com/r2/" />

  <Card title="Images" description="Optimize, resize, and serve images from Cloudflare's edge." href="https://developers.cloudflare.com/images/" />

  <Card title="Workers KV" description="Store global read-heavy data such as feature flags, public config, or cached lookup tables." href="https://developers.cloudflare.com/kv/" />

  <Card title="Queues" description="Run async work such as email fan-out, imports, webhooks, and retryable background jobs." href="https://developers.cloudflare.com/queues/" />

  <Card title="Durable Objects" description="Coordinate stateful features like live collaboration, presence, chat, or rate limits." href="https://developers.cloudflare.com/durable-objects/" />

  <Card title="D1" description="Use Cloudflare's serverless SQLite database for small edge-native features or per-tenant data." href="https://developers.cloudflare.com/d1/" />

  <Card title="Turnstile" description="Protect sign-up, contact, invite, and waitlist forms with Cloudflare's CAPTCHA alternative." href="https://developers.cloudflare.com/turnstile/" />

  <Card title="Zaraz" description="Load analytics, pixels, and marketing tools through Cloudflare instead of adding more client scripts." href="https://developers.cloudflare.com/zaraz/" />
</Cards>

<Callout title="Start small">
  Keep Postgres as the main TurboStarter database unless you intentionally redesign that layer. Services like KV, D1, Durable Objects, and Queues are best added for specific workloads, not as replacements for the default app database on day one.
</Callout>

## Useful commands

```bash
# Build the OpenNext Worker output
pnpm --filter web cf:build

# Preview locally in the Workers runtime
pnpm --filter web cf:preview

# Deploy to Cloudflare
pnpm --filter web cf:deploy

# Upload a new Worker version without deploying traffic immediately
pnpm --filter web cf:upload

# Regenerate Cloudflare binding types
pnpm --filter web cf:typegen
```

## Troubleshooting

### Build fails because an environment variable is missing

TurboStarter validates environment variables during build. Make sure build-time values exist locally before `cf:build`, and runtime values exist in Cloudflare before the deployed Worker starts.

For `NEXT_PUBLIC_` values, remember that Next.js inlines them during the build.

### Hyperdrive binding is missing

Check that `apps/web/wrangler.jsonc` includes a `hyperdrive` entry with `binding: "HYPERDRIVE"` and a real `id`. Then rerun:

```bash
pnpm --filter web cf:typegen
pnpm --filter web cf:preview
```

### Upload fails because the Worker is too large

Wrangler prints the compressed Worker size during upload. If it exceeds your Cloudflare plan limit, remove unused server dependencies, avoid importing large packages into server routes, or upgrade the Workers plan.

### A package expects unsupported Node.js behavior

The generated `wrangler.jsonc` enables `nodejs_compat`, which is required for many Next.js and database use cases on Workers. Some Node.js APIs are still only partially supported, so replace Node-only libraries with HTTP/fetch-based providers when needed.

### A route uses the Edge runtime

OpenNext for Cloudflare is designed around the Next.js Node.js runtime on Workers. If you added `export const runtime = "edge"` to a route, remove it and preview again.

## Next steps

<Cards>
  <Card title="Production checklist" description="Review the full launch checklist before going live." href="/docs/web/deployment/checklist" />

  <Card title="Environment variables" description="Set the required root and web app variables." href="/docs/web/configuration/environment-variables" />

  <Card title="Multiple environments" description="Keep staging and production values cleanly separated." href="/docs/web/recipes/multiple-environments" />

  <Card title="Docker" description="Use containers instead when you need full Node.js runtime control." href="/docs/web/deployment/docker" />
</Cards>

## Official references

<Cards>
  <Card title="OpenNext for Cloudflare" description="Learn how the Next.js app is converted into a Cloudflare Worker." href="https://opennext.js.org/cloudflare" />

  <Card title="Cloudflare Next.js guide" description="Review Cloudflare's framework guide for Next.js apps." href="https://developers.cloudflare.com/workers/framework-guides/web-apps/nextjs/" />

  <Card title="Hyperdrive" description="Set up pooled database connections for Workers." href="https://developers.cloudflare.com/hyperdrive/get-started/" />

  <Card title="Wrangler" description="Use Cloudflare's CLI to preview, configure, and deploy Workers." href="https://developers.cloudflare.com/workers/wrangler/" />

  <Card title="Cloudflare bindings" description="Bind services like R2, KV, D1, Queues, and Durable Objects to your Worker." href="https://developers.cloudflare.com/workers/runtime-apis/bindings/" />
</Cards>


# Docker
Source: https://www.turbostarter.dev/docs/web/deployment/docker

[Docker](https://docker.com) is a popular platform for containerizing applications, making it easy to package your app with all its dependencies for consistent performance across environments. It simplifies development, testing, and deployment.

This guide explains how to containerize your TurboStarter app using Docker. The fastest path is to use the built-in generator, but you can also add the files manually if you prefer full control.

<Steps>
  <Step>
    ## Generate Docker files

    Run the Docker generator from the repository root:

    ```bash
    pnpm turbo gen docker
    ```

    It will:

    * configure `apps/web/next.config.ts` to use [Next.js standalone output](https://nextjs.org/docs/pages/api-reference/config/next-config-js/output)
    * create `apps/web/Dockerfile`
    * create `.dockerignore`

    <Callout title="Prefer doing it manually?">
      Follow the next two steps to add the same files yourself. The generator is only a shortcut for the setup shown below.
    </Callout>
  </Step>

  <Step>
    ## Configure Next.js manually

    First of all, we need to configure Next.js to output the build files in the [standalone format](https://nextjs.org/docs/pages/api-reference/config/next-config-js/output) - it's required for the Docker image to work. To do this, we need to add the following to our `next.config.ts` file:

    ```js title="apps/web/next.config.ts"
    import type { NextConfig } from "next";

    const config: NextConfig = {
      output: "standalone",

      ...
    };
    ```
  </Step>

  <Step>
    ## Create a Dockerfile manually

    [Dockerfile](https://docs.docker.com/get-started/02_our_app/) is a text file that contains the instructions for building a [Docker image](https://docs.docker.com/get-started/02_our_app/). It defines the environment, dependencies, and commands needed to run your app. You can safely copy the following Dockerfile to your project:

    ```dockerfile title="apps/web/Dockerfile"
    FROM node:24-alpine AS base
    ENV PNPM_HOME="/pnpm"
    ENV PATH="$PNPM_HOME:$PATH"
    RUN corepack enable

    FROM base AS pruner
    WORKDIR /app
    RUN apk add --no-cache libc6-compat
    COPY . .
    RUN pnpm dlx turbo prune web --docker

    FROM base AS builder
    WORKDIR /app
    RUN apk add --no-cache libc6-compat
    COPY --from=pruner /app/out/json/ .
    COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
    RUN pnpm install --frozen-lockfile --ignore-scripts --prefer-offline && pnpm store prune
    ENV SKIP_ENV_VALIDATION=1 \
        NODE_ENV=production
    COPY --from=pruner /app/out/full/ .
    RUN pnpm dlx turbo build --filter=web

    FROM base AS runner
    WORKDIR /app
    RUN addgroup -g 1001 -S nodejs && \
        adduser -S web -u 1001 -G nodejs
    COPY --from=builder --chown=web:nodejs /app/apps/web/.next/standalone ./
    COPY --from=builder --chown=web:nodejs /app/apps/web/.next/static ./apps/web/.next/static
    COPY --from=builder --chown=web:nodejs /app/apps/web/public ./apps/web/public
    USER web
    EXPOSE 3000
    CMD ["node", "apps/web/server.js"]
    ```

    Feel free to check out our [self-hosting guide](/blog/self-host-your-nextjs-turborepo-app-with-docker-in-5-minutes) for more details on how each stage of the Dockerfile works.

    And that's all we need! You can now build and run your Docker image to deploy your app anywhere you want in an [isolated environment](https://docs.docker.com/get-started/workshop/04_sharing_app/).
  </Step>

  <Step>
    ## Create a .dockerignore manually

    If you skipped the generator, add a `.dockerignore` file in the repository root. This keeps the Docker build context small and prevents local secrets from being copied into the image:

    ```txt title=".dockerignore"
    # Docker
    Dockerfile
    .dockerignore
    **/Dockerfile

    # Dependencies
    node_modules
    **/node_modules
    .pnpm-store

    # Build outputs
    .next
    **/.next
    .turbo
    **/.turbo
    dist
    **/dist
    build
    **/build
    .cache
    **/.cache
    .content-collections
    **/.content-collections
    coverage
    **/coverage

    # Other apps
    apps/mobile
    apps/extension

    # Native / tooling artifacts
    .expo
    **/.expo
    .wxt
    **/.wxt
    .wrangler
    **/.wrangler
    **/*.ipa
    **/*.apk
    apps/*/ios
    apps/*/android

    # Git / IDE
    .git
    .github

    # Env & secrets
    .env
    .env*.local
    .dev.vars
    .dev.vars.*

    # Vercel / Cloudflare local state
    .vercel
    **/.vercel
    .open-next
    **/.open-next
    .nitro
    **/.nitro
    .output
    **/.output
    ```
  </Step>

  <Step>
    ## Run a container

    To test if everything works correctly, you can run a [container](https://www.docker.com/resources/what-container/) locally with the following commands:

    ```bash
    docker build -f ./apps/web/Dockerfile . -t turbostarter
    docker run -p 3000:3000 turbostarter
    ```

    Make sure to also [pass](https://docs.docker.com/reference/cli/docker/container/run/#env) all the required environment variables to the container, so your app can start without any issues.

    If everything works correctly, you should be able to access your app at [http://localhost:3000](http://localhost:3000).
  </Step>
</Steps>

That's it! You can now build and deploy your app as a Docker container to any supported hosting (e.g. [Fly.io](/docs/web/deployment/fly)) or your own [VPS](/docs/web/deployment/vps).

Using Docker containers is a great way to isolate your app from the host environment, making it easier to deploy and scale. It also simplifies the workflow if you're working with a team, as you can easily share the Docker image with your colleagues and they will run the app in the **exact same** environment.

<Cards>
  <Card title="Cloudflare" description="Deploy to Cloudflare Workers instead of a container runtime." href="/docs/web/deployment/cloudflare" />

  <Card title="Production checklist" description="Review the full launch checklist before going live." href="/docs/web/deployment/checklist" />
</Cards>


# Fly.io
Source: https://www.turbostarter.dev/docs/web/deployment/fly

[Fly.io](https://fly.io) makes deploying web applications to the cloud easy and efficient. It handles scaling, monitoring, and logging so you can focus on building your app.

This guide explains how to deploy your TurboStarter app on Fly.io. You'll learn how to leverage [Docker](/docs/web/deployment/docker) containers to deploy your app, set up builds, and manage environment variables for a smooth and reliable deployment.

<Callout type="warn" title="Prerequisite: Fly account and Docker configured">
  To deploy to Fly.io, you need to have an account. You can create one [here](https://fly.io/app/sign-up).

  You also need to have [Docker](/docs/web/deployment/docker) configured in your project.
</Callout>

<Steps>
  <Step>
    ## Setup Fly CLI

    As we will be using Fly CLI to launch and manage our app, you need to install and setup it on your machine.

    [Check the official documentation on how to install Fly CLI](https://fly.io/docs/flyctl/install/).

    After you've installed Fly CLI, you need to login to your Fly account and connect it with your machine:

    ```bash
    fly auth login
    ```

    [Read more about authenticating CLI](https://fly.io/docs/flyctl/auth/#available-commands).

    Now you're ready to launch your app!
  </Step>

  <Step>
    ## Launch project

    Use a [Dockerfile](/docs/web/deployment/docker) to launch your app with [Fly CLI](https://fly.io/docs/flyctl/). You can use the following command to do this from your local machine:

    ```bash
    fly launch --dockerfile apps/web/Dockerfile
    ```

    Make sure to set all the required configuration in the CLI steps (e.g. set port to `3000`, setup additional services, choose billing plan, etc.).

    ![Fly launch](/images/docs/web/deployment/fly/launch.png)

    <Callout title="Customize region for better performance">
      If you want to achieve better performance and lower latency in your API requests, you can customize the region of your Render service. Make sure to set it to the region closest to your database and users.
    </Callout>

    After the launch is complete, Fly will output your project configuration into `fly.toml` file. The configuration of your project is stored there, feel free to customize it to your needs:

    ```toml title="fly.toml"
    app = 'web-aged-sky-5596'
    primary_region = 'ams'

    [build]
      dockerfile = 'apps/web/Dockerfile'

    [http_service]
      internal_port = 3000
      force_https = true
      auto_stop_machines = 'stop'
      auto_start_machines = true
      min_machines_running = 0
      processes = ['app']

    [[vm]]
      memory = '512mb'
      cpu_kind = 'shared'
      cpus = 1
    ```

    See [Fly.io documentation](https://fly.io/docs/reference/configuration) for more information on how to use this file.
  </Step>

  <Step>
    ## Set up secrets

    To make your app fully functional, you need to set up required environment variables. You can do this by running the following command:

    ```bash
    fly secrets set --app <your-app-name> DATABASE_URL=...
    ```

    They will be automatically added to your app's runtime environment.
  </Step>

  <Step>
    ## Deploy!

    Each time you make changes to `fly.toml` or secrets, you need to re-deploy your app to apply changes to the running app.

    To do this, just run the following command in your project directory:

    ```bash
    fly deploy
    ```

    This will build your app and deploy it to Fly.io with the latest code version.

    ![Fly deploy](/images/docs/web/deployment/fly/deploy.png)

    That's it! Your app is now deployed to Fly.io, congratulations! 🎉
  </Step>
</Steps>

Fly is a platform that allows you to deploy and manage applications in the cloud. It provides a simple and intuitive way to deploy your app, with features such as automatic scaling, load balancing, and rolling updates. With Fly, you can focus on building your app without worrying about the underlying infrastructure.


# Netlify
Source: https://www.turbostarter.dev/docs/web/deployment/netlify

[Netlify](https://netlify.com) is a powerful platform for deploying modern web applications. It offers continuous deployment, serverless functions, and a global CDN to ensure your application is fast and reliable.

In this guide, we will walk through the steps to deploy your TurboStarter app to Netlify. You will learn how to connect your repository, configure build settings, and manage environment variables to ensure a smooth deployment process.

<Callout type="warn" title="Prerequisite: Netlify account">
  To deploy to Netlify, you need to have an account. You can create one [here](https://netlify.com/signup).
</Callout>

<Steps>
  <Step>
    ## Create new site

    Once you've created your account and logged in, the Netlify dashboard will display an option to add a new site. Click on the *Import from Git* button to begin connecting your Git repository.

    ![Create new site](/images/docs/web/deployment/netlify/create-site.png)

    If you've already had a Netlify account, you can get to this step by clicking on the *Sites* tab in the navigation menu.
  </Step>

  <Step>
    ## Connect your repository

    Choose the Git provider of your project and select the repository you want to deploy.

    ![Connect repository](/images/docs/web/deployment/netlify/connect-repository.png)

    <Callout title="Authorization needed">
      To connect your repository, you need to authorize Netlify to access it. It's recommended to follow a *least privileged access* approach, so to only grant access to the repository you want to deploy, not the entire account.
    </Callout>
  </Step>

  <Step>
    ## Configure build settings

    Last step before deploying! Configure the build settings according to your project configuration. Use the screenshots provided below for reference to ensure a smooth deployment process.

    ![Netlify build settings](/images/docs/web/deployment/netlify/build-settings.png)

    Also, add all environment variables under *Environment variables* section - it's required to make the build process work.
  </Step>

  <Step>
    ## Deploy!

    Click on the *Deploy* button to start the deployment process.

    ![Netlify deploy](/images/docs/web/deployment/netlify/deploy.png)

    That's it! Your app is now deployed to Netlify, congratulations! 🎉
  </Step>
</Steps>

<Callout title="Customize region for better performance">
  If you want to achieve better performance and lower latency in your API requests, you can customize the region of your Netlify serverless functions. Make sure to set it to the region closest to your database and users.

  ![Netlify region](/images/docs/web/deployment/netlify/region.png)

  Unfortunately, it's a paid feature, so you need to upgrade your Netlify account to be able to change it.
</Callout>


# Railway
Source: https://www.turbostarter.dev/docs/web/deployment/railway

[Railway](https://railway.app) is a platform that allows you to deploy your web applications to a cloud environment. It provides a simple and efficient way to manage your application's infrastructure, including scaling, monitoring, and logging.

This guide provides a step-by-step walkthrough for deploying your TurboStarter app on Railway, and taking advantage of its features in production environment. You'll discover how to link your repository, tailor build settings, and oversee environment variables, ensuring a smooth and optimized deployment process that leverages Railway's capabilities.

<Callout type="warn" title="Prerequisite: Railway account">
  To deploy to Railway, you need to have an account. You can create one [here](https://railway.app/signup).
</Callout>

<Steps>
  <Step>
    ## Create new project

    We'll use [Railway](https://railway.app) web app to deploy our project. First, let's create a new project.

    ![Railway create project](/images/docs/web/deployment/railway/create-project.png)

    Proceed with the option to *Deploy from Github repo*.
  </Step>

  <Step>
    ## Connect repository

    Choose the Git provider of your project and select the repository you want to deploy.

    ![Connect repository](/images/docs/web/deployment/railway/connect-repository.png)

    <Callout title="Authorization needed">
      If your repository is private you need to authorize Railway to access it. It's recommended to follow a *least privileged access* approach, so to only grant access to the repository you want to deploy, not the entire account.
    </Callout>
  </Step>

  <Step>
    ## Configure project settings

    Finalize your deployment by configuring the build settings to match your project's specific needs. Refer to the points below to ensure a seamless deployment process.

    ### Commands

    Configure the build and start commands to ensure that your project is built and started correctly.

    ![Railway project commands](/images/docs/web/deployment/railway/commands.png)

    Make sure to set them to the following values:

    * **Build command** - `pnpm dlx turbo build --filter=web`
    * **Start command** - `pnpm --filter=web start`

    ### Environment variables

    Last, but not least, you need to set the environment variables for your project. Make sure to check if all the required variables are set.

    ![Railway environment variables](/images/docs/web/deployment/railway/environment-variables.png)

    <Callout title="Customize region for better performance and reliability">
      If you want to achieve better performance, lower latency in your API requests or add some replicas of your application, you can customize the region of your Railway instance. Make sure to set it to the region closest to your database and users.

      ![Railway region](/images/docs/web/deployment/railway/region.png)
    </Callout>

    You can also use a [Railway config file](https://docs.railway.com/guides/config-as-code) to manage your project's settings in one place, as a code.
  </Step>

  <Step>
    ## Deploy!

    Click on the *Deploy* button to start the deployment process.

    ![Railway deploy](/images/docs/web/deployment/railway/deploy.png)

    That's it! Your app is now deployed to Railway, congratulations! 🎉
  </Step>
</Steps>

Feel free to scale your deployment to multiple regions or isolate it in the separate network. Check out the [Railway documentation](https://docs.railway.app) for more information about which services are available.


# Render
Source: https://www.turbostarter.dev/docs/web/deployment/render

[Render](https://render.com) offers a unique combination of features that make it an ideal platform for deploying modern web applications. With Render, you can leverage continuous deployment, managed databases, and a global CDN to ensure your application is not only fast and reliable but also scalable and secure.

In this guide, we will walk through the steps to deploy your TurboStarter app to Render, highlighting the benefits of using Render's platform. You will learn how to connect your repository, configure build settings, and manage environment variables to ensure a seamless and efficient deployment process that takes advantage of Render's features.

<Callout type="warn" title="Prerequisite: Render account">
  To deploy to Render, you need to have an account. You can create one [here](https://dashboard.render.com/register).
</Callout>

<Steps>
  <Step>
    ## Create a new service

    Navigate to the [Render dashboard](https://dashboard.render.com) and click on the *New* button.

    ![Create new service](/images/docs/web/deployment/render/create-service.png)

    Pick the *Web Service* option and proceed to the next step.
  </Step>

  <Step>
    ## Connect your repository

    Choose the Git provider of your project and select the repository you want to deploy.

    ![Connect repository](/images/docs/web/deployment/render/connect-repository.png)

    <Callout title="Authorization needed">
      If your repository is private you need to authorize Render to access it. It's recommended to follow a *least privileged access* approach, so to only grant access to the repository you want to deploy, not the entire account.
    </Callout>
  </Step>

  <Step>
    ## Configure service settings

    Finalize your deployment by configuring the build settings to match your project's specific needs. Refer to the screenshots below to ensure a seamless deployment process.

    ![Render service settings](/images/docs/web/deployment/render/general-settings.png)

    You can also group your service with other services (e.g. [databases](https://render.com/docs/postgresql-creating-connecting) or [cron jobs](https://render.com/docs/cronjobs)) in a [Project](https://render.com/docs/projects), which will help you manage them together.

    [Read official documentation for more information](https://render.com/docs/projects).

    <Callout title="Customize region for better performance">
      If you want to achieve better performance and lower latency in your API requests, you can customize the region of your Render service. Make sure to set it to the region closest to your database and users.
    </Callout>

    ### Commands

    Configure the build and start commands to ensure that your project is built and started correctly.

    ![Render service commands](/images/docs/web/deployment/render/commands.png)

    Make sure to set them to the following values:

    * **Build command** - `pnpm install --frozen-lockfile; pnpm dlx turbo build --filter=web`
    * **Start command** - `pnpm --filter=web start`

    ### Instance type

    Select a plan that fits your project's needs.

    ![Render instance type](/images/docs/web/deployment/render/instance-type.png)

    For testing purposes or MVPs, you can safely use the *Free* plan. Although, for the production version, it's recommended to upgrade your plan, as it offers more resources and your project won't be paused after periods of inactivity.

    ### Environment variables

    Last, but not least, you need to set the environment variables for your project. Make sure to check if all the required variables are set.

    ![Render environment variables](/images/docs/web/deployment/render/environment-variables.png)

    You can also modify *Advanced settings* to set e.g. [health checks](https://render.com/docs/deploys#health-checks) or modify [auto deploy](https://render.com/docs/deploys#automatic-git-deploys) triggers.
  </Step>

  <Step>
    ## Deploy!

    Click on the *Deploy Web Service* button to start the deployment process.

    ![Render deploy](/images/docs/web/deployment/render/deploy.png)

    That's it! Your app is now deployed to Render, congratulations! 🎉
  </Step>
</Steps>

Render is a powerful platform with a lot of integrations and features. Feel free to check out the [official documentation](https://render.com/docs) for more information.


# Vercel
Source: https://www.turbostarter.dev/docs/web/deployment/vercel

In general you can deploy the application to any hosting provider that supports Node.js, but we recommend using [Vercel](https://vercel.com) for the best experience.

Vercel is the easiest way to deploy Next.js apps. It's the company behind Next.js and has first-class support for Next.js.

<Callout type="warn" title="Prerequisite: Vercel account">
  To deploy to Vercel, you need to have an account. You can create one [here](https://vercel.com/signup).
</Callout>

TurboStarter has two, separate ways to deploy to Vercel, each ships with **one-click deployment**. Choose the one that best fits your needs.

<Tabs items={["Connecting repository", "Github Actions"]}>
  <Tab value="Connecting repository">
    Deploying with this method is the easiest and fastest way to get your app up and running on the cloud provider. Follow these steps:

    <Steps>
      <Step>
        ## Connect your git repository

        After signing up you will be promted to import a git repository. Select the git provider of your project and connect your git account with Vercel.

        ![Vercel import project](/images/docs/web/deployment/vercel/connect-repository.webp)
      </Step>

      <Step>
        ## Configure project settings

        As we're working in monorepo, some additional settings are required to make the build process work.

        Make sure to set the following settings:

        * **Build command**: `pnpm turbo build --filter=web` - to build only the web app
        * **Root directory**: `apps/web` - to make sure Vercel uses the web folder as the root directory (make sure to check *Include files outside the root directory in the Build Step* option, it will ensure that all packages from your monorepo are included in the build process)

        ![Vercel project settings](/images/docs/web/deployment/vercel/project-settings.png)

        <Cards>
          <Card title="Build and development settings" description="vercel.com" href="https://vercel.com/docs/deployments/configure-a-build#build-and-development-settings" />

          <Card title="Root directory" description="vercel.com" href="https://vercel.com/docs/deployments/configure-a-build#root-directory" />
        </Cards>
      </Step>

      <Step>
        ## Configure environment variables

        Please make sure to set all the environment variables required for the project to work correctly. You can find the list of required environment variables in the `.env.example` file in the `apps/web` directory.

        The environment variables can be set in the Vercel dashboard under *Project Settings* > *Environment Variables*. Make sure to set them for all environments (Production, Preview, and Development) as needed.

        **Failure to set the environment variables will result in the project not working correctly.**

        If the build fails, deep dive into the logs to see what is the issue. Our Zod configuration will validate and report any missing environment variables. To find out which environment variables are missing, please check the logs.

        <Callout title="First deployment may fail">
          The first time this may fail if you don't yet have a custom domain connected since you cannot place it in the environment variables yet. It's fine. Make the first deployment fail, then pick the domain and add it. Redeploy.
        </Callout>
      </Step>

      <Step>
        ## Deploy!

        Click on the *Deploy* button to start the deployment process.

        ![Vercel deploy](/images/docs/web/deployment/vercel/success.png)

        That's it! Your app is now deployed to Vercel, congratulations! 🎉
      </Step>
    </Steps>
  </Tab>

  <Tab value="Github Actions">
    Despite connecting your repository is the easiest way to deploy to Vercel, we recommend using preconfigured Github Actions for the most granular control over your deployments.

    We'll leverage [Vercel CLI](https://vercel.com/docs/cli) to deploy the application on the CI/CD pipeline. [See official documentation on deploying to Github Actions](https://vercel.com/guides/how-can-i-use-github-actions-with-vercel).

    <Steps>
      <Step>
        ## Get Vercel Access Token

        To deploy the application, we need to get Vercel access token.

        Please, follow [this guide](https://vercel.com/guides/how-do-i-use-a-vercel-api-access-token) to create one.

        ![Vercel access token](/images/docs/web/deployment/vercel/access-token.avif)
      </Step>

      <Step>
        ## Install Vercel CLI

        We need to install [Vercel CLI](https://vercel.com/docs/cli) locally to be able to get required credentials for our Github Actions.

        You can install it using following command:

        ```bash
        pnpm i -g vercel
        ```

        Then, login to Vercel using following command:

        ```bash
        vercel login
        ```
      </Step>

      <Step>
        ## Get credentials

        Inside your folder, run following command to create a new project:

        ```bash
        vercel link
        ```

        This will generate a `.vercel` folder, where you can find `project.json` file with `projectId` and `orgId`.
      </Step>

      <Step>
        ## Configure Github Actions

        Inside GitHub, add `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` as [secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions) to your repository.

        ![Github secrets](/images/docs/web/deployment/vercel/github-tokens.png)

        This will allow Github Actions to access your settings and deploy the application to Vercel.
      </Step>

      <Step>
        ## Configure project settings

        As we're working in monorepo, some additional settings are required to make the build process work.

        Make sure to set the following settings:

        * **Build command**: `pnpm turbo build --filter=web` - to build only the web app
        * **Root directory**: `apps/web` - to make sure Vercel uses the web folder as the root directory (make sure to check *Include files outside the root directory in the Build Step* option, it will ensure that all packages from your monorepo are included in the build process)

        ![Vercel project settings](/images/docs/web/deployment/vercel/project-settings.png)

        <Cards>
          <Card title="Build and development settings" description="vercel.com" href="https://vercel.com/docs/deployments/configure-a-build#build-and-development-settings" />

          <Card title="Root directory" description="vercel.com" href="https://vercel.com/docs/deployments/configure-a-build#root-directory" />
        </Cards>
      </Step>

      <Step>
        ## Configure environment variables

        Please make sure to set all the environment variables required for the project to work correctly. You can find the list of required environment variables in the `.env.example` file in the `apps/web` directory.

        The environment variables can be set in the Vercel dashboard under *Project Settings* > *Environment Variables*. Make sure to set them for all environments (Production, Preview, and Development) as needed.

        **Failure to set the environment variables will result in the project not working correctly.**

        If the build fails, deep dive into the logs to see what is the issue. Our Zod configuration will validate and report any missing environment variables. To find out which environment variables are missing, please check the logs.

        <Callout title="First deployment may fail">
          The first time this may fail if you don't yet have a custom domain connected since you cannot place it in the environment variables yet. It's fine. Make the first deployment fail, then pick the domain and add it. Redeploy.
        </Callout>
      </Step>

      <Step>
        ## Deploy!

        By default, TurboStarter comes with a Github Actions workflow that can be [triggered manually](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow).

        The configuration is located in `.github/workflows/publish-web.yml`, you can easily customize it to your needs, for example to trigger a deployment from `main` branch.

        ```diff title=".github/workflows/publish-web.yml"
        on:
        - workflow_dispatch:
        + push:
        +   branches:
        +     - main
        ```

        Then, every time you push to `main` branch, the workflow will be triggered and the application will be deployed to Vercel.

        ![Vercel deploy](/images/docs/web/deployment/vercel/success.png)

        That's it! Your app is now deployed to Vercel, congratulations! 🎉
      </Step>
    </Steps>
  </Tab>
</Tabs>

<Card title="Vercel" href="https://vercel.com" description="vercel.com" />

## Troubleshooting

In some cases, users have reported issues with the deployment to Vercel using the default parameters. If you encounter problems, try these troubleshooting steps:

1. **Check root directory settings**
   * Set the root directory to `apps/web`
   * Enable *Include source files outside of the Root Directory* option

2. **Verify build configuration**
   * Ensure the framework preset is set to Next.js
   * Set build command to `pnpm turbo build --filter=web`
   * Set install command to `pnpm install`

3. **Review deployment logs**
   * If deployment fails, carefully review the build logs
   * Look for any error messages about missing dependencies or environment variables
   * Verify that all required environment variables are properly configured

If issues persist after trying these steps, check the [deployment troubleshooting guide](/docs/web/troubleshooting/deployment) for additional help.


# VPS
Source: https://www.turbostarter.dev/docs/web/deployment/vps

A [VPS](https://en.wikipedia.org/wiki/Virtual_private_server) gives you full control over the runtime, network, reverse proxy, backups, and deployment cadence. It is a good option if you want predictable costs, long-running Node.js processes, or a single server that hosts your app, proxy, and supporting services.

This guide explains how to deploy the TurboStarter web app to a VPS using [Docker](/docs/web/deployment/docker), [Docker Compose](https://docs.docker.com/compose/), and [Caddy](https://caddyserver.com/) as a reverse proxy with automatic HTTPS.

<Callout type="warn" title="Prerequisites">
  Before deploying, make sure you have:

  * a VPS running Ubuntu or another Linux distribution
  * a domain pointed to the VPS public IP address
  * Docker and Docker Compose installed on the server
  * a production [database](/docs/web/database/overview) with migrations already applied
  * your production [environment variables](/docs/web/configuration/environment-variables) ready
  * a Dockerfile configured for the web app, as described in the [Docker guide](/docs/web/deployment/docker)
</Callout>

<Steps>
  <Step>
    ## Prepare the server

    Connect to your server over SSH and install the basic runtime packages:

    ```bash
    sudo apt update
    sudo apt install -y git ca-certificates curl
    ```

    Install Docker using the [official Docker installation guide](https://docs.docker.com/engine/install/). After Docker is installed, verify that Compose is available:

    ```bash
    docker compose version
    ```

    <Callout title="Keep ports 80 and 443 open">
      Your reverse proxy needs inbound HTTP and HTTPS traffic. If your VPS provider has a firewall, allow ports `80` and `443`. Keep the app container private and expose only the reverse proxy to the internet.
    </Callout>
  </Step>

  <Step>
    ## Clone the repository

    Clone your project on the server:

    ```bash
    git clone <your-repository-url> turbostarter
    cd turbostarter
    ```

    If you deploy from a private repository, use a deploy key or a fine-scoped access token. Avoid using a personal token with access to all repositories on the server.
  </Step>

  <Step>
    ## Configure production environment variables

    Create production environment files on the server. The root file should contain shared values like the database URL and app URL:

    ```dotenv title=".env.production"
    NODE_ENV="production"
    DATABASE_URL="postgresql://..."
    URL="https://example.com"
    BETTER_AUTH_URL="https://example.com"
    NEXT_PUBLIC_URL="https://example.com"
    BETTER_AUTH_SECRET="..."
    ```

    Add app-specific variables in `apps/web/.env.production`:

    ```dotenv title="apps/web/.env.production"
    NEXT_PUBLIC_PRODUCT_NAME="${PRODUCT_NAME}"
    NEXT_PUBLIC_SITE_LINK="${URL}"
    NEXT_PUBLIC_SITE_TITLE="TurboStarter"
    NEXT_PUBLIC_SITE_DESCRIPTION="Production-ready SaaS starter kit"

    RESEND_API_KEY="..."
    EMAIL_FROM="..."
    ```

    Use the [environment variables guide](/docs/web/configuration/environment-variables) as the source of truth for your project. The exact list depends on the features you enabled, such as billing, analytics, emails, storage, AI providers, or background jobs.

    <Callout title="Public variables are build-time variables">
      Variables prefixed with `NEXT_PUBLIC_` are bundled into the client app during the Docker build. If you change the production URL or another public variable, rebuild the image and restart the container.
    </Callout>
  </Step>

  <Step>
    ## Run database migrations

    Run migrations before sending traffic to the app. The recommended approach is to use the [GitHub Actions](https://docs.github.com/en/actions) workflow shipped with TurboStarter and set `DATABASE_URL` as a repository secret.

    You can also run migrations locally against the production database:

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    <Callout title="Prefer a managed production database">
      For most apps, use a managed Postgres provider such as [Supabase](/docs/web/recipes/supabase), [Neon](https://neon.com/), [Railway](/docs/web/deployment/railway), [Render](/docs/web/deployment/render), or [DigitalOcean Managed Databases](https://www.digitalocean.com/products/managed-databases). Hosting Postgres on the same VPS is possible, but you must own backups, upgrades, storage monitoring, and recovery.
    </Callout>
  </Step>

  <Step>
    ## Create Docker Compose configuration

    Create a production [Docker Compose](https://docs.docker.com/compose/) file in the repository root:

    ```yaml title="compose.production.yml"
    services:
      web:
        build:
          context: .
          dockerfile: apps/web/Dockerfile
        restart: unless-stopped
        env_file:
          - ./.env.production
          - ./apps/web/.env.production
        environment:
          NODE_ENV: production
          PORT: 3000
        expose:
          - "3000"

      caddy:
        image: caddy:2-alpine
        restart: unless-stopped
        depends_on:
          - web
        ports:
          - "80:80"
          - "443:443"
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - caddy_data:/data
          - caddy_config:/config

    volumes:
      caddy_data:
      caddy_config:
    ```

    Then create the Caddy configuration:

    ```txt title="Caddyfile"
    example.com {
      encode zstd gzip
      reverse_proxy web:3000

      header {
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
      }
    }
    ```

    Replace `example.com` with your production domain. Caddy will automatically request and renew TLS certificates when the domain points to the VPS and ports `80` and `443` are reachable.
  </Step>

  <Step>
    ## Deploy the app

    Build and start the production stack:

    ```bash
    docker compose -f compose.production.yml up -d --build
    ```

    Watch the logs after the first deployment:

    ```bash
    docker compose -f compose.production.yml logs -f web
    docker compose -f compose.production.yml logs -f caddy
    ```

    Open your domain and verify the main production flows:

    * sign in and sign out
    * database reads and writes
    * organization creation
    * billing checkout and webhooks
    * email sending
    * file uploads, if your app uses storage

    <Callout title="First deployment may need one redeploy">
      If you did not know the final domain before the first build, update `URL`, `NEXT_PUBLIC_URL`, `BETTER_AUTH_URL`, OAuth callbacks, and billing webhook URLs, then rebuild the image.
    </Callout>
  </Step>

  <Step>
    ## Update the deployment

    For future releases, pull the latest changes and rebuild the stack:

    ```bash
    git pull
    docker compose -f compose.production.yml up -d --build
    ```

    If you want to free disk space after several deployments, prune unused images:

    ```bash
    docker image prune -f
    ```

    For zero-downtime or multi-instance deployments, put a [load balancer](https://en.wikipedia.org/wiki/Load_balancing_\(computing\)) in front of multiple app containers or move to a platform with rolling deploys. For a single VPS, expect a short restart window during updates.
  </Step>
</Steps>

That's it! Your TurboStarter web app is now running on your own VPS with Docker, a reverse proxy, and HTTPS.

<Callout title="Production checklist">
  Before launch, also review the [deployment checklist](/docs/web/deployment/checklist), configure OAuth callbacks for your final domain, point billing webhooks to `/api/webhooks/billing`, and make sure your database backups are enabled.
</Callout>


# Configuration
Source: https://www.turbostarter.dev/docs/web/emails/configuration

The `@workspace/email` package provides a simple and flexible way to send emails using various email providers. It abstracts the complexity of different email services and offers a consistent interface for sending emails with pre-defined templates.

To configure the email service, you need to set a few environment variables.

```dotenv
EMAIL_FROM="hello@resend.dev"
EMAIL_THEME="orange"
```

Let's break them down:

* `EMAIL_FROM` - The email address that emails will be sent from. **Please make sure that the mail address and domain are verified in your mail provider.**
* `EMAIL_THEME` - The theme color to use for the emails. See [Themes](/docs/web/customization/styling#themes) for more information.

The email provider is configured by modifying the exports in `packages/email` package. By default, [Nodemailer](/docs/web/emails/configuration#nodemailer) is used.

Configuration will be validated against the schema, so you will see the error messages in the console if something is not right.

## Providers

TurboStarter supports multiple email providers, each with its own configuration. Below, you'll find detailed information on how to set up and use each supported provider. Choose the one that best fits your needs and follow the instructions in the respective accordion section.

<Accordions>
  <Accordion title="Resend" id="resend">
    To use Resend as your email provider, you need to [create an account](https://resend.com/) and [obtain your API key](https://resend.com/docs/dashboard/api-keys/introduction).

    Then, set it as an environment variable in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    RESEND_API_KEY="your-api-key"
    ```

    Also, make sure to activate Resend as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:resend]
        export * from "./resend";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:resend]
        export * from "./resend/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/resend` directory.

    For more information, please refer to the [Resend documentation](https://resend.com/docs).
  </Accordion>

  <Accordion title="SendGrid" id="sendgrid">
    To use SendGrid as your email provider, you need to [create an account](https://signup.sendgrid.com/) and [obtain your API key](https://www.twilio.com/docs/sendgrid/ui/account-and-settings/api-keys).

    Then, set it as an environment variable in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    SENDGRID_API_KEY="your-api-key"
    ```

    Also, make sure to activate SendGrid as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:sendgrid]
        export * from "./sendgrid";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:sendgrid]
        export * from "./sendgrid/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/sendgrid` directory.

    For more information, please refer to the [SendGrid documentation](https://www.twilio.com/docs/sendgrid).
  </Accordion>

  <Accordion title="Postmark" id="postmark">
    To use Postmark as your email provider, you need to [create an account](https://postmarkapp.com/) and [obtain your server API token](https://postmarkapp.com/support/article/1008-what-are-the-account-and-server-api-tokens).

    Then, set it as an environment variable in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    POSTMARK_API_KEY="your-secret-api-token"
    ```

    Also, make sure to activate Postmark as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        export * from "./postmark";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:postmark]
        export * from "./postmark/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/postmark` directory.

    For more information, please refer to the [Postmark documentation](https://postmarkapp.com/developer).
  </Accordion>

  <Accordion title="Plunk" id="plunk">
    To use Plunk as your email provider, you need to [create an account](https://plunk.dev/) and [obtain your API key](https://docs.useplunk.com/guides/api-keys).

    Then, set it as an environment variable in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    PLUNK_API_KEY="your-api-key"
    ```

    Also, make sure to activate Plunk as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:plunk]
        export * from "./plunk";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:plunk]
        export * from "./plunk/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/plunk` directory.

    For more information, please refer to the [Plunk documentation](https://docs.useplunk.com).
  </Accordion>

  <Accordion title="Mailgun" id="mailgun">
    To use Mailgun as your email provider, you need to [create an account](https://www.mailgun.com/) and [obtain your API key and sending domain](https://documentation.mailgun.com/docs/mailgun/api-reference/mg-auth).

    Then, set the required environment variables in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv
    MAILGUN_API_KEY="your-api-key"
    MAILGUN_DOMAIN="your-sending-domain.com"
    ```

    If your Mailgun account is in the EU region, also set the API URL:

    ```dotenv
    MAILGUN_API_URL="https://api.eu.mailgun.net"
    ```

    `MAILGUN_API_URL` is optional and defaults to `https://api.mailgun.net` for US accounts.

    Also, make sure to activate Mailgun as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:mailgun]
        export * from "./mailgun";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:mailgun]
        export * from "./mailgun/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/mailgun` directory.

    For more information, please refer to the [Mailgun documentation](https://documentation.mailgun.com/).
  </Accordion>

  <Accordion title="nodemailer" id="nodemailer">
    If you're using the `nodemailer` as your email provider, you'll need to set the following SMTP configuration in your environment variables:

    ```dotenv
    NODEMAILER_HOST="your-smtp-host"
    NODEMAILER_PORT="your-smtp-port"
    NODEMAILER_USER="your-smtp-user"
    NODEMAILER_PASSWORD="your-smtp-password"
    ```

    The variables are:

    * `NODEMAILER_HOST`: The host of your SMTP server.
    * `NODEMAILER_PORT`: The port of your SMTP server.
    * `NODEMAILER_USER`: The email address user of your SMTP server.
    * `NODEMAILER_PASSWORD`: The password for the email account.

    Also, make sure to activate nodemailer as your email provider by updating the exports in:

    <Tabs items={["index.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:nodemailer]
        export * from "./nodemailer";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:nodemailer]
        export * from "./nodemailer/env";
        ```
      </Tab>
    </Tabs>

    To customize the provider, you can find its definition in `packages/email/src/providers/nodemailer` directory.

    For more information, please refer to the [nodemailer documentation](https://nodemailer.com/smtp).
  </Accordion>
</Accordions>

## Templates

In the `@workspace/email` package, we provide a set of pre-defined templates for you to use. You can find them in the `packages/email/src/templates` directory.

When you run your development server, you will be able to preview all available templates in the browser under [http://localhost:3005](http://localhost:3005).

![Email preview](/images/docs/web/emails/development.png)

Next to the templates, you can also find some shared components that you can use in your emails. The file structure looks like this:

<Files>
  <Folder name="templates" defaultOpen>
    <Folder name="_components - Shared components used in emails" />

    <Folder name="auth - Authentication related emails" />

    <File name="index.ts - Main entrypoint for the templates" />
  </Folder>
</Files>

Feel free to add your own templates and components or modify existing ones to match them with your brand and style.

### How to add a new template?

We'll go through the process of adding a new template, as it requires a few steps to make sure everything works correctly.

<Steps>
  <Step>
    #### Define types

    Let's assume that we want to add a **welcome email**, that new users will receive after signing up.

    We'll start with defining new template type in `packages/email/src/types/templates.ts` file:

    ```ts title="templates.ts"
    export const EmailTemplate = {
      ...AuthEmailTemplate,
      WELCOME: "welcome",
    } as const;
    ```

    Also, we would need to add types for variables that we'll pass to the template (if any), in our case it will be just a `name` of the user:

    ```ts title="templates.ts"
    type WelcomeEmailVariables = {
      welcome: {
        name: string;
      };
    };

    export type EmailVariables = AuthEmailVariables | WelcomeEmailVariables;
    ```

    By doing this, we ensure that payload passed to the template will have all required properties and we won't end up with an email that tells your user "Hey, undefined!".
  </Step>

  <Step>
    #### Create template

    Next up, we need to create a file with the template itself. We'll create an `welcome.tsx` file in `packages/email/src/templates` directory.

    ```tsx title="welcome.tsx"
    import { Heading, Preview, Text } from "@react-email/components";

    import { Button } from "../_components/button";
    import { Layout } from "../_components/layout/layout";

    import type { EmailTemplate, EmailVariables } from "../../types";

    type Props = EmailVariables[typeof EmailTemplate.WELCOME];

    export const Welcome = ({ name }: Props) => {
      return (
        <Layout>
          <Preview>Welcome to TurboStarter!</Preview>
          <Heading>Hi, {name}!</Heading>

          <Text>Start your journey with our app by clicking the button below.</Text>

          <Button>Start</Button>
        </Layout>
      );
    };

    Welcome.subject = "Welcome to TurboStarter!";

    Welcome.PreviewProps = {
      name: "John Doe",
    };

    export default Welcome;
    ```

    As you can see, by defining appropriate types for the template, we can safely use the variables as a props in the template.

    To learn more about supported components, please refer to the [React Email documentation](https://react.email/docs/components).
  </Step>

  <Step>
    #### Register template

    We have to register the template in the main entrypoint of the templates in `packages/email/src/templates/index.ts` file:

    ```ts title="index.ts"
    import { Welcome } from "./welcome";

    export const templates = {
      ...
      [EmailTemplate.WELCOME]: Welcome,
    } as const;
    ```

    That way, it will be available in the `sendEmail` function, enabling us to send it from the server-side of your application.

    ```ts
    import { sendEmail } from "@workspace/email/server";

    sendEmail({
      to: "user@example.com",
      template: EmailTemplate.WELCOME,
      variables: {
        name: "John Doe",
      },
    });
    ```

    Learn more about sending emails in the [dedicated section](/docs/web/emails/sending).
  </Step>
</Steps>

Et voilà! You've just added a new email template to your application 🎉

### Translating templates

You can also translate your templates to support multiple languages. Each mail template is passed the `locale` property, which you can use to get the translation for the current locale. This allows you to maintain consistent translations across your application and emails.

The translation system [uses the same i18n setup](/docs/web/internationalization/overview) as your main application, so you can reuse your existing translation files and namespaces. The translations are loaded server-side when the email is generated, ensuring the correct language is used based on the user's preferences.

Here's how you can implement translations in your email templates:

```tsx
import { Heading, Preview, Text } from "@react-email/components";

import { getTranslation } from "@workspace/i18n/server";

import { Button } from "../_components/button";
import { Layout } from "../_components/layout/layout";

import type {
  EmailTemplate,
  EmailVariables,
  CommonEmailProps,
} from "../../types";

type Props = EmailVariables[typeof EmailTemplate.WELCOME] & CommonEmailProps;

export const Welcome = async ({ name, locale }: Props) => {
  const { t } = await getTranslation({ locale, ns: "auth" });

  return (
    <Layout locale={locale}>
      <Preview>{t("account.welcome.preview")}</Preview>
      <Heading>{t("account.welcome.heading", { name })}</Heading>

      <Text>{t("account.welcome.body")}</Text>

      <Button>{t("account.welcome.cta")}</Button>
    </Layout>
  );
};

Welcome.subject = async ({ locale }: CommonEmailProps) => {
  const { t } = await getTranslation({ locale, ns: "auth" });
  return t("account.welcome.subject");
};

Welcome.PreviewProps = {
  name: "John Doe",
  locale: "en",
};

export default Welcome;
```

To send the email in the specified language, you can pass the optional `locale` argument to the `sendEmail` function:

```ts
sendEmail({
  to: "user@example.com",
  template: EmailTemplate.WELCOME,
  variables: {
    name: "John Doe",
  },
  locale: "en", // [!code highlight]
});
```

Learn more about translations in the [dedicated section](/docs/web/internationalization/translations).


# Overview
Source: https://www.turbostarter.dev/docs/web/emails/overview

For mailing functionality, TurboStarter integrates [React Email](https://react.email/docs/introduction) which enables you to build your emails from composable React components.

<Callout title="Why React Email?">
  It's a simple, yet powerful library that allows you to **write your emails in React**.

  It also allows you to use **Tailwind CSS for styling**, which is a huge advantage, as we can share almost everything from the main app with the emails package, keeping them consistent with rest of the app.
</Callout>

You can read more about `react-email` package in the [official documentation](https://react.email/docs/introduction).

## Providers

TurboStarter implements multiple providers for managing and sending emails. To learn more about each provider and how to configure them, see the respective section:

<Cards>
  <Card title="Resend" href="/docs/web/emails/configuration#resend" />

  <Card title="SendGrid" href="/docs/web/emails/configuration#sendgrid" />

  <Card title="Postmark" href="/docs/web/emails/configuration#postmark" />

  <Card title="Plunk" href="/docs/web/emails/configuration#plunk" />

  <Card title="Mailgun" href="/docs/web/emails/configuration#mailgun" />

  <Card title="Nodemailer" href="/docs/web/emails/configuration#nodemailer" />
</Cards>

All configuration and setup is built-in with a unified API, so you can switch between providers by simply changing the exports and even introduce your own provider without breaking any sending-related logic.

## Development

When you [setup your development environment](/docs/web/installation/development) and run `pnpm dev` command a new app will start at [http://localhost:3005](http://localhost:3005).

![Email preview](/images/docs/web/emails/development.png)

There you'll be able to check your email templates and send test emails from your app. It includes hot-reloading, so when you make change in the code - it will be reflected in the browser.

### Local SMTP server

For local email development and testing, TurboStarter supports [Mailpit](https://mailpit.axllent.org/), an all-in-one SMTP server and web UI for capturing and viewing emails sent from your app.

![Mailpit](/images/docs/web/emails/mailpit.png)

Mailpit lets you see, inspect, and debug outgoing emails directly in your browser, making it easy to ensure your templates and workflows are working as intended.

Learn more about configuration and setup of the emails in TurboStarter in the following sections.


# Sending emails
Source: https://www.turbostarter.dev/docs/web/emails/sending

The strategy for sending emails, that every provider has to implement, is **extremely simple**:

```ts
export interface EmailProviderStrategy {
  send: (args: {
    to: string;
    subject: string;
    text: string;
    html?: string;
  }) => Promise<void>;
}
```

<Callout>
  You don't need to worry much about it, as all the providers are already configured for you. Just be aware of it if you want to add your custom provider.
</Callout>

Then, we define a general `sendEmail` function that you can use as an API for sending emails in your app:

```ts
const sendEmail = async <T extends EmailTemplate>({
  to,
  template,
  variables,
  locale,
}: {
  to: string;
  template: T;
  variables: EmailVariables[T];
  locale?: string;
}) => {
  const { html, text, subject } = await getTemplate({
    id: template,
    variables,
    locale,
  });

  return send({ to, subject, html, text });
};
```

The arguments are:

* `to`: The recipient's email address.
* `template`: The email template to use.
* `variables`: The variables to pass to the template.
* `locale`: The locale to use for the email.

It returns a promise that resolves when the email is sent successfully. If there is an error, the promise will be rejected with an error message.

To send an email, just invoke the `sendEmail` with the correct arguments from the **server-side** of your application:

```ts
import { sendEmail } from "@workspace/email/server";

sendEmail({
  to: "user@example.com",
  template: EmailTemplate.WELCOME,
  variables: {
    name: "John Doe",
  },
  locale: "en",
});
```

And that's it! You're ready to send emails in your application 🚀

## Authentication emails

TurboStarter comes with a set of pre-configured authentication emails for various purposes, including magic links and password reset functionality.

To handle the sending of these emails at the right time, we use [Better Auth Hooks](https://better-auth.com/docs/concepts/email), which trigger when specific authentication events occur.

The logic for determining which email to send is already implemented for you in the `packages/auth/src/server.ts` file, alongside your [authentication configuration](/docs/web/auth/configuration):

```ts title="server.ts"
export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
    sendResetPassword: async ({ user, url }) =>
      sendEmail({
        to: user.email,
        template: EmailTemplate.RESET_PASSWORD,
        variables: {
          url,
        },
      }),
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url }) =>
      sendEmail({
        to: user.email,
        template: EmailTemplate.CONFIRM_EMAIL,
        variables: {
          url,
        },
      }),
  },

  /* other options */
});
```

As you can see, the authentication emails are automatically sent when needed (e.g. when user requests password reset or needs to verify their email address).

You can customize authentication templates by modifying them in the `packages/email/src/templates` directory, or create your own templates for other use cases in your application.


# Extras
Source: https://www.turbostarter.dev/docs/web/extras

## Tips and Tricks

In many places, next to the code you will find some marketing tips, design suggestions, and potential risks. This is to help you build a better product and avoid common pitfalls.

```tsx title="Hero.tsx"
return (
  <header>
    {/* 💡 Use something that user can visualize e.g. 
    "Ship your startup while on the toilet" */}
    <h1>Best startup on the world</h1>
  </header>
);
```

### Submission tips

When it comes to mobile app and browser extension, you must submit your product to review from Apple/Google etc. We have some tips for you to make sure your submission goes smoothly.

```json title="app.json"
{
  "ios": {
    "infoPlist": {
      /* 🍎 add descriptive justification of using this permission on iOS */
      "NSCameraUsageDescription": "This app uses the camera to scan barcodes on event tickets."
    }
  }
}
```

As well as providing you with the info on how to make your store listings better:

```json title="package.json"
{
  "manifest": {
    /* 💡 Use localized messages to get more visibility in web stores */
    "name": "__MSG_extensionName__",
    "default_locale": "en"
  }
}
```

## 25+ SaaS Ideas

Not sure what to build? We have a list of **25+** SaaS ideas that you can use to get started 🔥

Grouped by category, these ideas are a great way to get inspired and start building your next project.

Including design, copies, marketing tips and potential risks, this list is a great resource for anyone looking to build a SaaS product.

![SaaS Ideas](/images/docs/saas-ideas.png)

## AI rules, skills, subagents and commands

TurboStarter ships with a set of custom AI rules, skills, subagents, and commands you can use in popular AI editors and tools. They help the AI understand the codebase conventions and generate changes faster and more reliably.

To learn how to set them up and use them effectively, see the [AI-assisted development docs](/docs/web/installation/ai-development).

## Discord community

We have a Discord community where you can ask questions and share your projects. It's a great place to get help and meet other developers. Check more details at [/discord](/discord).

<DiscordCta source="extras" />

![Discord](/images/docs/discord.png)


# FAQ
Source: https://www.turbostarter.dev/docs/web/faq

## Why isn't everything hidden and configured with one BIG config file?

TurboStarter intentionally exposes the underlying code rather than hiding it behind configuration files (like some starters do). This design choice follows our **you own your code** philosophy, giving you full control and flexibility over your codebase.

While a single config file might seem simpler initially, it often becomes restrictive when you need to customize functionality beyond what the config allows. With direct access to the code, you can modify any part of the system to match your specific requirements.

## I don't know some technology! Should I buy TurboStarter?

You should be prepared for a learning curve or consider learning it first. However, TurboStarter will still work for you if you're willing to learn.

Even without knowing some technologies, you can still use the rest of the features.

## I don't need mobile app or browser extension, what should I do?

You can simply ignore the mobile app and browser extension parts of the project. You can remove the `apps/mobile` and `apps/extension` directories from the project.

The modular nature of TurboStarter allows you to remove parts of the project that you don't need without affecting the rest of the stack.

## I want to use a different provider for X

Sure! TurboStarter is designed to be modular, so configuring new provider (e.g. for emails, billing or any other service) is straightforward. You just need to make sure your configuration is compatible with common interface to be able to plug it into the codebase.

## Will you add more packages in the future?

Yes, we will keep updating TurboStarter with new packages and features. This kit is designed to be modular, allowing for new features and packages to be added without interfering with your existing code. You can always [update your project](/docs/web/installation/update) to the latest version.

## Can I use this kit for a non-SaaS project?

This kit is mainly designed for SaaS projects. If you're building something other than a SaaS, the Next.js SaaS Boilerplate might include features you don't need. You can still use it for non-SaaS projects, but you may need to remove or modify features that are specific to SaaS use cases.

## Can I disable organizations (personal accounts only)?

Yes. Personal accounts are already the default path after signup. To fully turn off organizations (no create, join, or switch), follow the [Disable organizations](/docs/web/recipes/disable-organizations) recipe.

## Can I disable personal accounts (organizations only)?

Yes. Force users to create or join an organization before they can use the product, and hide the personal workspace. Follow the [Disable personal accounts](/docs/web/recipes/disable-personal-accounts) recipe.

## Does it set up the production instance for me?

No, TurboStarter does not set up the production instance for you. This includes setting up databases, Stripe, or any other services you need. TurboStarter does not have access to your Stripe or Resend accounts, so setup on your end is required. TurboStarter provides the codebase and documentation to help you set up your SaaS project.

## Does the starter include Solito?

No. Solito will not be included in this repo. It is a great tool if you want to share code between your Next.js and Expo app. However, the main purpose of this repo is not the integration between Next.js and Expo — it's the code splitting of your SaaS platforms into a monorepo. You can utilize the monorepo with multiple apps, and it can be any app such as Vite, Electron, etc.

Integrating Solito into this repo isn't hard, and there are a few [official templates](https://github.com/nandorojo/solito/tree/master/example-monorepos) by the creators of Solito that you can use as a reference.

## Does this pattern leak backend code to my client applications?

No, it does not. The `api` package should only be a production dependency in the Next.js application where it's served. The Expo app, browser extension, and all other apps you may add in the future should only add the `api` package as a dev dependency. This lets you have full type safety in your client applications while keeping your backend code safe.

If you need to share runtime code between the client and server, you can create a separate `shared` package for this and import it on both sides.

## How do I get support if I encounter issues?

For support, you can:

1. Visit our [Discord](https://discord.com/invite/KjpK2uk3JP)
2. Contact us via support email ([hello@turbostarter.dev](mailto:hello@turbostarter.dev))

## Are there any example projects or demos?

Yes - feel free to check out our demo app at [demo.turbostarter.dev](https://demo.turbostarter.dev). Also, you can get inspired by projects built by our customers - take a look at [Showcase](/showcase).

## How do I deploy my application?

Please check the [production checklist](/docs/web/deployment/checklist) for more information.

## How do I update my project when a new version of the boilerplate is released?

Please read the [documentation for updating your TurboStarter code](/docs/web/installation/update).

## Can I use the React package X with this kit?

Yes, you can use any React package with this kit. The kit is based on React, so you are generally only constrained by the underlying technologies and not by the kit itself. Since you own and can edit all the code, you can adapt the kit to your needs. However, if there are limitations with the underlying technology, you might need to work around them.

## Can I integrate TurboStarter into an existing project?

TurboStarter is a full-stack starter intended to be used as the foundation of your app. You can copy individual modules or patterns into an existing codebase, but retrofitting the entire starter into a mature project is typically not recommended and is not officially supported. If you choose to copy parts, prefer isolating boundaries (e.g., `packages/` modules) and aligning interfaces first.

## Can I combine Core Kit and AI Kit?

Yes. Keep one kit as the application foundation and port selected features from the other instead of merging both repositories wholesale.

If Core Kit is your base, follow the platform recipes for [web](/docs/web/recipes/ai-kit), [mobile](/docs/mobile/recipes/ai-kit), and [browser extension](/docs/extension/recipes/ai-kit). If your product already uses AI Kit, follow [Integrate with Core Kit](/ai/docs/integrate-core-kit).

## Where can I deploy my application?

TurboStarter targets modern Node.js/Next.js runtimes. You can deploy to providers that support these environments, such as [Vercel](/docs/web/deployment/vercel), [Railway](/docs/web/deployment/railway), [Render](/docs/web/deployment/render), [Fly](/docs/web/deployment/fly), or [Netlify](/docs/web/deployment/netlify) - following their Next.js guidance. Review our [production checklist](/docs/web/deployment/checklist) before going live.

## Can I easily swap providers (billing, email, etc.)?

Yes. The starter organizes integrations behind clear interfaces so you can replace providers (e.g., billing or email) with minimal surface changes. Keep your implementation behind a module boundary and adapt to the existing types to avoid ripple effects.


# Configuration
Source: https://www.turbostarter.dev/docs/web/flags/configuration

The `@workspace/flags-web` package wraps OpenFeature providers behind a single client and server strategy. Swap the active provider by changing the re-exports under `packages/flags/web/src/providers`, then set any env vars that provider needs.

<Callout>
  The default provider is **in-memory**. You can ship and evaluate `Flag.DEMO` locally with no third-party account. Connect PostHog or GrowthBook when you need remote targeting, percentages, or a dashboard.
</Callout>

## Providers

TurboStarter ships three flag providers. Open the accordion for the one you want to activate.

<Accordions>
  <Accordion title="In-memory" id="in-memory">
    Use this for local development and simple on/off toggles that live in code. Flag definitions come from `packages/flags/shared/src/in-memory.ts`:

    ```ts title="packages/flags/shared/src/in-memory.ts"
    export const inMemoryConfig = {
      [Flag.DEMO]: {
        disabled: false,
        variants: {
          on: true,
          off: false,
        },
        defaultVariant: "on",
      },
    } as const;
    ```

    With this config, `Flag.DEMO` evaluates to `true` (the `"on"` variant). Flip `defaultVariant` to `"off"`, or set `disabled: true`, to hide the demo banner without touching UI code.

    Activate the provider (already the default) by exporting it from:

    <Tabs items={["index.ts", "server.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts title="packages/flags/web/src/providers/index.ts"
        // [!code word:in-memory]
        export * from "./in-memory";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts title="packages/flags/web/src/providers/server.ts"
        // [!code word:in-memory]
        export * from "./in-memory/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts title="packages/flags/web/src/providers/env.ts"
        // [!code word:in-memory]
        export * from "./in-memory/env";
        ```
      </Tab>
    </Tabs>

    No environment variables are required. Customize the provider under `packages/flags/web/src/providers/in-memory`.
  </Accordion>

  <Accordion title="PostHog" id="posthog">
    <Callout title="Reuse your PostHog project">
      If you already use PostHog for [analytics](/docs/web/analytics/configuration#posthog) or [monitoring](/docs/web/monitoring/posthog), the same `NEXT_PUBLIC_POSTHOG_KEY` and host power feature flags. Create the flag once in PostHog and evaluate it through OpenFeature.
    </Callout>

    1. Create or open a [PostHog](https://app.posthog.com/signup) project (Cloud or [self-hosted](https://posthog.com/docs/self-host)).
    2. Copy the project API key and host from [project settings](https://app.posthog.com/project/settings).
    3. Create a feature flag whose key matches your app constant (for example `demo` for `Flag.DEMO`).

    Set the env vars in `apps/web/.env.local` and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_POSTHOG_KEY="your-posthog-api-key"
    NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
    ```

    Activate PostHog as the flags provider:

    <Tabs items={["index.ts", "server.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts title="packages/flags/web/src/providers/index.ts"
        // [!code word:posthog]
        export * from "./posthog";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts title="packages/flags/web/src/providers/server.ts"
        // [!code word:posthog]
        export * from "./posthog/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts title="packages/flags/web/src/providers/env.ts"
        // [!code word:posthog]
        export * from "./posthog/env";
        ```
      </Tab>
    </Tabs>

    The client strategy identifies users when targeting context is set (see [Usage](/docs/web/flags/usage#targeting-context)), so PostHog can apply person-based rules. Server evaluation uses `posthog-node` via `@posthog/openfeature-node-provider`.

    Customize the provider under `packages/flags/web/src/providers/posthog`.

    <Cards>
      <Card title="PostHog feature flags" href="https://posthog.com/docs/feature-flags" description="posthog.com" />

      <Card title="PostHog OpenFeature" href="https://posthog.com/docs/libraries/openfeature" description="posthog.com" />
    </Cards>

    ![PostHog feature flags dashboard](/images/docs/web/flags/posthog.png)
  </Accordion>

  <Accordion title="GrowthBook" id="growthbook">
    GrowthBook is a dedicated feature-flag and experimentation platform. Use it when you want rich targeting and experiments without tying flags to your analytics product.

    1. Create a [GrowthBook](https://app.growthbook.io/) account (or self-host).
    2. Create an SDK connection and copy the **client key**.
    3. Create a feature whose key matches your app constant (for example `demo`).

    Set the env vars in `apps/web/.env.local` and your deployment environment:

    ```dotenv
    NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY="your-growthbook-client-key"
    NEXT_PUBLIC_GROWTHBOOK_API_HOST="https://cdn.growthbook.io"
    ```

    Activate GrowthBook as the flags provider:

    <Tabs items={["index.ts", "server.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts title="packages/flags/web/src/providers/index.ts"
        // [!code word:growthbook]
        export * from "./growthbook";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts title="packages/flags/web/src/providers/server.ts"
        // [!code word:growthbook]
        export * from "./growthbook/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts title="packages/flags/web/src/providers/env.ts"
        // [!code word:growthbook]
        export * from "./growthbook/env";
        ```
      </Tab>
    </Tabs>

    Client evaluation uses `@openfeature/growthbook-client-provider`. Server evaluation uses `@openfeature/growthbook-provider`. Customize under `packages/flags/web/src/providers/growthbook`.

    <Card title="GrowthBook docs" href="https://docs.growthbook.io/" description="docs.growthbook.io" />

    ![GrowthBook features dashboard](/images/docs/web/flags/growthbook.png)
  </Accordion>
</Accordions>

## Flags provider

Client-side evaluation needs OpenFeature mounted in the React tree. The kit already wraps the app with `FlagsProvider` in `apps/web/src/lib/providers/providers.tsx`. That wrapper also syncs the signed-in user into targeting context so remote rules can key off `targetingKey`, `email`, and `name`:

```tsx title="apps/web/src/lib/providers/flags.tsx"
"use client";

import { useEffect } from "react";

import {
  clearContext,
  FlagsProvider as Provider,
  setContext,
} from "@workspace/flags-web";

import { authClient } from "~/lib/auth/client";

export const FlagsProvider = ({ children }: { children: React.ReactNode }) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    if (session.data?.user) {
      const { id, email, name } = session.data.user;
      void setContext({ targetingKey: id, email, name });
      return;
    }

    void clearContext();
  }, [session]);

  return <Provider>{children}</Provider>;
};
```

With this in place, hooks from `@workspace/flags` work in Client Components. Server helpers from `@workspace/flags-web/server` do not depend on this React provider; pass `targetingKey` explicitly when you call them. See [Usage](/docs/web/flags/usage) for both paths.


# Overview
Source: https://www.turbostarter.dev/docs/web/flags/overview

Feature flags let you turn product behavior on and off without a redeploy. Roll out a new settings page to a cohort, hide unfinished UI behind a key, or A/B test a CTA, all from one evaluation API.

TurboStarter wires this through [OpenFeature](https://openfeature.dev/) so your app code stays provider-agnostic. You evaluate flags the same way whether you use the built-in in-memory provider, [PostHog](https://posthog.com/docs/feature-flags), or [GrowthBook](https://docs.growthbook.io/).

Out of the box you get:

* Shared flag keys in `@workspace/flags` (`Flag.DEMO` ships as a working example)
* Platform package `@workspace/flags-web` with client hooks and **server** evaluators
* A `FlagsProvider` that syncs targeting context from the signed-in user (`targetingKey`, `email`, `name`)
* In-memory provider as the default (no account required for local development)
* Optional PostHog and GrowthBook providers, swapped by changing a few exports

The demo flag is already evaluated on **Dashboard → Settings**. With the default in-memory config it renders a banner that links back here, so you can confirm the pipeline before connecting a remote provider.

## Architecture

Flags live in a small monorepo slice next to analytics and billing:

<Files>
  <Folder name="packages/flags" defaultOpen>
    <Folder name="shared - Shared keys, hooks, server helpers" defaultOpen>
      <File name="keys.ts - Flag key constants" />

      <File name="in-memory.ts - Default local definitions" />

      <File name="react.tsx - createFlagsReact + hooks" />

      <File name="server.ts - createFlagsServer helpers" />
    </Folder>

    <Folder name="web - @workspace/flags-web" defaultOpen>
      <File name="index.tsx - FlagsProvider, setContext, clearContext" />

      <File name="server.ts - getBooleanValue, getStringValue, …" />

      <Folder name="providers - in-memory / posthog / growthbook" />
    </Folder>
  </Folder>
</Files>

App wiring sits in `apps/web/src/lib/providers/flags.tsx` and is mounted with the rest of your providers. When a session appears, context is set; on logout it is cleared. PostHog uses that to `identify` / `reset` so remote rules can target real users.

## Providers

Pick the backend that matches how you want to manage rollouts:

<Cards>
  <Card title="In-memory" href="/docs/web/flags/configuration#in-memory" description="Local defaults, zero config. Great for development." />

  <Card title="PostHog" href="/docs/web/flags/configuration#posthog" description="Flags next to analytics and monitoring." />

  <Card title="GrowthBook" href="/docs/web/flags/configuration#growthbook" description="Dedicated experimentation and targeting." />
</Cards>

Switch providers by updating the exports under `packages/flags/web/src/providers`. Your React and server evaluation code stays the same.

In the following sections you'll learn how to configure and use the providers.


# Usage
Source: https://www.turbostarter.dev/docs/web/flags/usage

Once a provider is active, reading a flag is a one-liner. Use React hooks in Client Components and the `@workspace/flags-web/server` helpers in Server Components, route handlers, and other server code.

## Flag keys

Keys live in one place so every platform stays aligned:

```ts title="packages/flags/shared/src/keys.ts"
export const Flag = {
  DEMO: "demo",
} as const;
```

Import `Flag` from `@workspace/flags` and pass the constant (not a raw string) into evaluators. That keeps typos out of production and makes refactors easy.

## Client-side evaluation

OpenFeature hooks are re-exported from `@workspace/flags`:

```tsx
"use client";

import { Flag, useBooleanFlagValue } from "@workspace/flags";

export const NewCheckoutButton = () => {
  const enabled = useBooleanFlagValue(Flag.DEMO, false);

  if (!enabled) {
    return null;
  }

  return <button type="button">Try the new checkout</button>;
};
```

Other value types:

| Hook                  | Typical use                          |
| --------------------- | ------------------------------------ |
| `useBooleanFlagValue` | On/off gates                         |
| `useStringFlagValue`  | Variant copy, theme names, URLs      |
| `useNumberFlagValue`  | Limits, percentages, experiment arms |
| `useObjectFlagValue`  | Structured payloads / config blobs   |

Always pass a sensible **default** as the second argument. That value is used while the provider loads, or if evaluation fails.

## Server-side evaluation

Web is the only platform package that exposes a server API. Prefer it in RSC pages so gated UI never flashes the wrong state:

```tsx title="apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx"
import { Flag } from "@workspace/flags";
import { getBooleanValue } from "@workspace/flags-web/server";

const demo = await getBooleanValue(Flag.DEMO, false, {
  targetingKey: user.id,
});
```

Available helpers from `@workspace/flags-web/server`:

* `getBooleanValue(flag, defaultValue, context?)`
* `getStringValue(flag, defaultValue, context?)`
* `getNumberValue(flag, defaultValue, context?)`
* `getObjectValue(flag, defaultValue, context?)`

Pass `targetingKey` (and optional traits) when the provider needs identity for rules. The demo settings page does exactly that with the signed-in user id.

## Targeting context

`apps/web/src/lib/providers/flags.tsx` already syncs auth state into OpenFeature:

```tsx title="apps/web/src/lib/providers/flags.tsx"
if (session.data?.user) {
  const { id, email, name } = session.data.user;
  void setContext({ targetingKey: id, email, name });
  return;
}

void clearContext();
```

You rarely need to call `setContext` / `clearContext` yourself. When you do (for example, org-scoped targeting), import them from `@workspace/flags-web`.

With PostHog, `syncContext` maps this to `identify` / `reset` so dashboard rules see the same person as your analytics events.

## Add a new flag

<Steps>
  <Step>
    ## Declare the key

    Add a constant in `packages/flags/shared/src/keys.ts`:

    ```ts
    export const Flag = {
      DEMO: "demo",
      NEW_CHECKOUT: "NEW_CHECKOUT", // [!code ++]
    } as const;
    ```
  </Step>

  <Step>
    ## Update in-memory defaults

    Give local development a known value in `packages/flags/shared/src/in-memory.ts`:

    ```ts
    [Flag.NEW_CHECKOUT]: {
      disabled: false,
      variants: { on: true, off: false },
      defaultVariant: "off",
    },
    ```
  </Step>

  <Step>
    ## Create it remotely (if needed)

    In PostHog or GrowthBook, create a flag with the **same key** (`NEW_CHECKOUT`). Configure rollouts and targeting there.
  </Step>

  <Step>
    ## Evaluate it in the UI

    Use a hook on the client or `getBooleanValue` on the server:

    ```tsx
    const showCheckout = useBooleanFlagValue(Flag.NEW_CHECKOUT, false);
    ```
  </Step>
</Steps>

## Troubleshooting

| Symptom                      | What to check                                                                                                                                                  |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Demo banner never appears    | In-memory `defaultVariant` is `"on"` by default. If you switched to PostHog/GrowthBook, ensure a remote flag named `demo` exists and is enabled for your user. |
| Client and server disagree   | Confirm `providers/index.ts`, `server.ts`, and `env.ts` all point at the **same** provider.                                                                    |
| Remote rules ignore the user | Confirm you are signed in so `targetingKey` is set. Check PostHog person / GrowthBook attributes.                                                              |
| Missing env / boot errors    | Set `NEXT_PUBLIC_POSTHOG_*` or `NEXT_PUBLIC_GROWTHBOOK_*` for the active provider. Restart `pnpm dev` after changes.                                           |

<Cards>
  <Card title="Configuration" href="/docs/web/flags/configuration" description="Switch providers and set env vars." />

  <Card title="OpenFeature React SDK" href="https://openfeature.dev/docs/reference/technologies/client/web/react" description="openfeature.dev" />
</Cards>


# Introduction
Source: https://www.turbostarter.dev/docs/web

Welcome to the TurboStarter **web** documentation. This is your starting point for the Next.js app, shared API, and the product surfaces that live on the web - auth, billing, admin, marketing, and more.

<ThemedImage light="/images/docs/demo/light.webp" dark="/images/docs/demo/dark.webp" alt="TurboStarter demo" width={2311} height={1562} zoomable priority fetchPriority="high" />

## What is TurboStarter?

TurboStarter is a fullstack starter kit for production SaaS. The web kit is the hub: Next.js frontend, serverless API, database, emails, and marketing - with [mobile](/docs/mobile) and [browser extension](/docs/extension) apps sharing the same backend.

Looking to bootstrap quickly? Check out the [TurboStarter CLI guide](/blog/the-only-turbo-cli-you-need-to-start-your-next-project-in-seconds).

## Demo apps

Try the live demos - web, mobile, and browser extensions:

<DemoBadges
  urls={{
  android:
    "https://play.google.com/store/apps/details?id=com.turbostarter.core",
  ios: "https://apps.apple.com/us/app/turbostarter/id6754278899",
  chrome:
    "https://chromewebstore.google.com/detail/turbostarter/bcjmonmlfbnngpkllpnpmnjajaciaboo",
  firefox: "https://addons.mozilla.org/en-US/firefox/addon/turbostarter_",
  edge: "https://microsoftedge.microsoft.com/addons/detail/turbostarter/ianbflanmmoeleokihabnmmcahhfijig",
  web: "https://demo.turbostarter.dev",
}}
/>

## Philosophy

* **As simple as possible** - easy to understand, easy to use, no overengineering.
* **As few dependencies as possible** - stay in control of every part of the project.
* **As performant as possible** - fast and light without unnecessary overhead.

## Features

Web-first capabilities below. Most product features also ship on [mobile](/docs/mobile) and [extension](/docs/extension) against the same API - those docs cover platform-specific pieces (push, store billing, content scripts, and so on).

For chatbots, agents, and image generation, see [TurboStarter AI](/ai/docs).

### Authentication

<Cards>
  <Card title="Ready-to-use components and views" description="Pre-built authentication components and pages that match your brand." href="/docs/web/auth/flow" />

  <Card title="Email/password authentication" description="Traditional email and password auth with validation and security best practices." href="/docs/web/auth/overview" />

  <Card title="Magic links & OTP" description="Passwordless magic links and email one-time passwords with rate limiting." href="/docs/web/auth/configuration" />

  <Card title="Password recovery" description="Complete password reset flow with email verification and secure token handling." href="/docs/web/auth/flow" />

  <Card title="Multi-factor authentication (MFA)" description="2FA with authenticator apps and TOTP - ready to use and customizable." href="/docs/web/auth/2fa" />

  <Card title="Passkeys (passwordless)" description="Passkeys (FIDO2/WebAuthn) for seamless, phishing-resistant sign-ins." href="/docs/web/auth/overview" />

  <Card title="Anonymous & Google One Tap" description="Anonymous sessions and one-click Google One Tap on the web." href="/docs/web/auth/overview" />

  <Card title="OAuth providers" description="Pre-configured social authentication for Google, GitHub, and Apple." href="/docs/web/auth/oauth" />
</Cards>

### Organizations / teams

<Cards>
  <Card title="Multi-tenancy" description="Multi-tenant organization model with ownership and membership." href="/docs/web/organizations/overview" />

  <Card title="Teams and members" description="Create teams, invite members, assign roles, and manage seats." href="/docs/web/organizations/overview" />

  <Card title="Invitations" description="Email-based invites with role presets and expiry." href="/docs/web/organizations/invitations" />

  <Card title="Roles per organization" description="Role-based permissions scoped to each organization." href="/docs/web/organizations/rbac" />

  <Card title="Subdomain multi-tenancy" description="Map organizations to subdomains for B2B SaaS routing." href="/docs/web/recipes/subdomain-multi-tenancy" />
</Cards>

### Billing

<Cards>
  <Card title="Subscriptions" description="Recurring plans with monthly/yearly intervals, trials, and upgrades." href="/docs/web/billing/subscriptions" />

  <Card title="One-time payments" description="Simple payment processing with secure checkout and confirmation." href="/docs/web/billing/one-time" />

  <Card title="Metered usage" description="Charge for API calls, tokens, or other usage with tiered pricing." href="/docs/web/billing/metered-usage" />

  <Card title="Per-seat billing" description="Price by team size with seats synced to organization membership." href="/docs/web/billing/per-seat" />

  <Card title="Credits" description="Grant and consume prepaid balances for usage-based products and AI features." href="/docs/web/billing/credits" />

  <Card title="Plan limits & entitlements" description="Gate features and quotas by plan - enforce access in the API and UI." href="/docs/web/recipes/feature-based-access" />

  <Card title="Organization billing" description="Bill users or organizations - B2B checkout, orders, and admin visibility." href="/docs/web/billing/overview" />

  <Card title="Webhooks" description="Real-time billing events and payment provider synchronization." href="/docs/web/billing/webhooks" />

  <Card title="Multiple providers" description="Unified API for Stripe, Lemon Squeezy, Polar, and Dodo Payments." href="/docs/web/billing/configuration" />
</Cards>

### Database

<Cards>
  <Card title="Advanced querying" description="Type-safe SQL queries, relational joins, filters, ordering, and pagination." href="/docs/web/database/client" />

  <Card title="Schema migrations" description="Automated schema migrations with version control and auto-generation." href="/docs/web/database/migrations" />

  <Card title="PostgreSQL, MySQL & SQLite" description="Pick your engine - Postgres by default, with MySQL and SQLite guides." href="/docs/web/database/overview" />

  <Card title="Data validation" description="End-to-end validation using shared types and schema definitions." href="/docs/web/database/schema" />
</Cards>

### API

<Cards>
  <Card title="Serverless architecture" description="Modern serverless infrastructure with auto-scaling and high availability." href="/docs/web/api/overview" />

  <Card title="Protected routes" description="Secure endpoints with role-based access control and rate limiting." href="/docs/web/api/protected-routes" />

  <Card title="Feature-based access" description="Access control based on features and subscription plans." href="/docs/web/recipes/feature-based-access" />

  <Card title="Typesafe client" description="Fully typesafe frontend client with automatic type generation." href="/docs/web/api/client" />

  <Card title="OpenAPI & API docs" description="Generate an OpenAPI spec and explore it with an interactive docs UI." href="/docs/web/api/openapi" />
</Cards>

### Admin

<Cards>
  <Card title="Super admin UI" description="Centralized admin workspace with overview metrics and quick actions." href="/docs/web/admin/ui" />

  <Card title="User management" description="Search, filter, and manage users, status, auth methods, and MFA." href="/docs/web/admin/overview" />

  <Card title="Roles and permissions" description="Granular access control for admins, moderators, and support staff." href="/docs/web/admin/overview" />

  <Card title="Impersonation" description="Securely impersonate users to reproduce issues and provide support." href="/docs/web/admin/overview" />
</Cards>

### AI

<Cards>
  <Card title="Multiple providers" description="OpenAI, Anthropic, Groq, Mistral, Gemini, and more. For advanced AI, see TurboStarter AI." href="/docs/web/ai/overview" />

  <Card title="Ready-to-use components" description="Pre-built chatbot and assistant components with real-time streaming." href="/docs/web/ai/overview" />

  <Card title="Streaming responses" description="Real-time AI response delivery including progress indicators." href="/docs/web/ai/overview" />

  <Card title="AI-assisted development" description="Rules, skills, and MCP setup so AI editors follow this repo's conventions." href="/docs/web/installation/ai-development" />
</Cards>

### Internationalization

<Cards>
  <Card title="Locale routing" description="Smart routing based on user locale and automatic language detection." href="/docs/web/internationalization/overview" />

  <Card title="Multiple languages" description="Comprehensive multi-language support and translation management." href="/docs/web/internationalization/overview" />

  <Card title="Language switching" description="One-click language changes and persistent preferences." href="/docs/web/internationalization/configuration" />

  <Card title="Mail templates" description="Multi-language email templates including fallback options." href="/docs/web/emails/overview" />
</Cards>

### Emails

<Cards>
  <Card title="Transactional emails" description="Automated email delivery including tracking and analytics." href="/docs/web/emails/sending" />

  <Card title="Marketing emails" description="Create and send marketing campaigns using beautiful templates." href="/docs/web/emails/overview" />

  <Card title="Email templates" description="Responsive email templates supporting dark mode customization." href="/docs/web/emails/overview" />

  <Card title="Multiple providers" description="Resend, Mailgun, SendGrid, Postmark, Plunk, and Nodemailer." href="/docs/web/emails/configuration" />
</Cards>

### Marketing & landing page

<Cards>
  <Card title="Hero & landing sections" description="Hero, features, pricing, testimonials, FAQ, and reusable CTAs." href="/docs/web/marketing/pages" />

  <Card title="SEO" description="Complete SEO toolkit including automatic sitemap generation." href="/docs/web/marketing/seo" />

  <Card title="Waitlist & newsletter" description="Pre-launch waitlist and homepage newsletter signup." href="/docs/web/marketing/pages" />

  <Card title="Onboarding flow" description="Dashboard onboarding wizard for first-run web users." href="/docs/web/recipes/onboarding" />

  <Card title="Blog" description="Full-featured blog system including categories and RSS feed." href="/docs/web/cms/blog" />

  <Card title="Legal pages" description="Pre-built legal templates including version control." href="/docs/web/marketing/legal" />

  <Card title="Contact form" description="Smart contact form featuring spam protection and auto-responses." href="/docs/web/marketing/pages" />
</Cards>

### Storage

<Cards>
  <Card title="File uploads" description="Complete file upload system including progress tracking and validation." href="/docs/web/storage/overview" />

  <Card title="S3 storage" description="S3-compatible storage with presigned URLs and file optimization." href="/docs/web/storage/overview" />
</Cards>

### CMS

<Cards>
  <Card title="Blog pages" description="Complete blog management system including categories and tags." href="/docs/web/cms/blog" />

  <Card title="MDX content collections" description="Organized content structure using MDX collections and custom frontmatter." href="/docs/web/cms/content-collections" />
</Cards>

### Theming

<Cards>
  <Card title="Built-in themes" description="10+ pre-built themes with customizable color schemes." href="/docs/web/customization/styling" />

  <Card title="Dark mode" description="Built-in dark mode with system preference detection." href="/docs/web/customization/styling" />

  <Card title="Components CLI" description="Component generation following best practices and TypeScript standards." href="/docs/web/customization/components" />

  <Card title="Design system" description="Complete atomic design system including accessibility features." href="/docs/web/customization/components" />
</Cards>

### Analytics

<Cards>
  <Card title="Event tracking" description="Custom event tracking plus automatic session management." href="/docs/web/analytics/tracking" />

  <Card title="Page views" description="Automatic page view capture including bounce rate metrics." href="/docs/web/analytics/overview" />

  <Card title="User identification" description="Cross-device user tracking and session management." href="/docs/web/analytics/tracking" />

  <Card title="Multiple providers" description="Google Analytics, PostHog, Plausible, Umami, Open Panel, Vemetric, and more." href="/docs/web/analytics/configuration" />

  <Card title="Cookie consent" description="c15t banner and preferences with analytics gated on measurement consent." href="/docs/web/recipes/cookie-consent" />
</Cards>

### Feature flags

<Cards>
  <Card title="OpenFeature API" description="Provider-agnostic flag evaluation on client and server." href="/docs/web/flags/overview" />

  <Card title="Multiple providers" description="In-memory defaults, PostHog, or GrowthBook via export swap." href="/docs/web/flags/configuration" />

  <Card title="Client and server evaluation" description="React hooks plus getBooleanValue in Server Components." href="/docs/web/flags/usage" />

  <Card title="Targeting context" description="Signed-in user id, email, and name synced automatically." href="/docs/web/flags/usage#targeting-context" />
</Cards>

### Monitoring

<Cards>
  <Card title="Auto-capture exceptions" description="Automatically capture exceptions and errors in your application." href="/docs/web/monitoring/overview" />

  <Card title="Track performance metrics" description="Track performance metrics such as page views, user sessions, and more." href="/docs/web/monitoring/overview" />

  <Card title="Source maps" description="Automatically generate source maps to improve error reporting." href="/docs/web/monitoring/sentry" />

  <Card title="Multiple providers" description="Seamless integration with Sentry, PostHog, and more." href="/docs/web/monitoring/overview" />
</Cards>

### Security

<Cards>
  <Card title="Security overview" description="Threat model, boundaries, and how TurboStarter keeps the web kit safe." href="/docs/web/security/overview" />

  <Card title="Access control" description="Sessions, roles, and plan entitlements enforced on the server." href="/docs/web/security/access-control" />

  <Card title="Secrets & validation" description="Env secrets, input validation, and integration hardening." href="/docs/web/security/secrets" />

  <Card title="Security checklist" description="Ship-ready checks before you go to production." href="/docs/web/security/checklist" />
</Cards>

### Background tasks

<Cards>
  <Card title="Background jobs overview" description="Durable async work for emails, webhooks, and long-running workflows." href="/docs/web/background-tasks/overview" />

  <Card title="Trigger.dev" description="Type-safe background tasks with retries and observability." href="/docs/web/background-tasks/trigger" />

  <Card title="Inngest & QStash" description="Event-driven and HTTP-based job runners as alternatives." href="/docs/web/background-tasks/inngest" />

  <Card title="Vercel Workflows" description="Native Vercel workflows for durable multi-step jobs." href="/docs/web/background-tasks/vercel-workflows" />
</Cards>

### Deployment

<Cards>
  <Card title="Deployment checklist" description="Everything to verify before you ship to production." href="/docs/web/deployment/checklist" />

  <Card title="Vercel, Cloudflare & more" description="Guides for Vercel, Cloudflare, Netlify, Docker, VPS, Fly, and others." href="/docs/web/deployment/vercel" />

  <Card title="CI/CD workflows" description="Pre-configured deployment pipelines including automated testing." href="/docs/web/deployment/checklist" />

  <Card title="Multiple environments" description="Dev, staging, and production configs across the monorepo." href="/docs/web/recipes/multiple-environments" />
</Cards>

### Testing

<Cards>
  <Card title="Unit tests" description="Fast unit tests for individual functions and components." href="/docs/web/tests/unit" />

  <Card title="Code coverage" description="Coverage metrics that show what code is and isn't tested." href="/docs/web/tests/unit" />

  <Card title="E2E tests" description="Simulate real user scenarios across the stack with automated E2E tools." href="/docs/web/tests/e2e" />
</Cards>

## Use like LEGO blocks

Use the entire stack or just the parts you need. Remove what you don't use without breaking the rest.

* **Easy feature integration** - plug new features in with minimal changes.
* **Simplified maintenance** - keep the codebase clean and maintainable.
* **Core vs custom** - distinguish kit features from your product logic.
* **Additional modules** - add billing, CMS, monitoring, mailer, and more as needed.

## Scope of this documentation

This documentation focuses on TurboStarter's web kit - how to configure, run, and deploy it. For underlying libraries, follow the official docs linked from each section.

## Enjoy!

Questions? Reach out at [hello@turbostarter.dev](mailto:hello@turbostarter.dev).

Explore new features, build amazing apps, and have fun! 🚀


# AI-assisted development
Source: https://www.turbostarter.dev/docs/web/installation/ai-development

TurboStarter includes pre-configured rules, skills, subagents, and commands for AI coding assistants. These help AI understand your codebase, follow project conventions, and produce consistent, high-quality code.

Everything works out-of-the-box with all major AI tools like [Cursor](https://cursor.com), [Claude Code](https://claude.ai/code), [ChatGPT Codex](https://openai.com/codex), [Antigravity](https://antigravity.dev), and many more. Just open the project in your AI tool and start coding with the help of LLMs.

## Structure

The codebase organizes AI-specific configuration in the following structure:

<Files>
  <Folder name=".agents" defaultOpen>
    <Folder name="agents - Custom AI personas" defaultOpen>
      <File name="code-reviewer.md" />
    </Folder>

    <Folder name="commands - Slash commands for prompts" defaultOpen>
      <File name="setup-new-feature.md" />
    </Folder>

    <Folder name="skills - Domain-specific skills" defaultOpen>
      <Folder name="better-auth" />

      <Folder name="building-native-ui" />

      <Folder name="vercel-react-best-practices" />
    </Folder>
  </Folder>

  <Folder name=".cursor - Cursor specific configuration">
    <Folder name="agents" />

    <Folder name="commands" />

    <Folder name="skills" />
  </Folder>

  <Folder name=".claude - Claude specific configuration">
    <Folder name="agents" />

    <Folder name="commands" />

    <Folder name="skills" />
  </Folder>

  <Folder name=".github - Github Copilot specific configuration">
    <Folder name="agents" />

    <Folder name="commands" />

    <Folder name="skills" />
  </Folder>

  <File name="AGENTS.md - Main rules file (auto-loaded by AI tools)" />

  <File name="CLAUDE.md - References AGENTS.md for Claude compatibility" />
</Files>

The `.agents/` directory contains shared skills, commands, and agents that ship with TurboStarter. The tool-specific folders (e.g., `.cursor/`, `.claude/`, `.github/`) are [symlinked](https://en.wikipedia.org/wiki/Symbolic_link) to the `.agents/` directory, allowing you to add your own skills, commands, and agents to all tools at once while also customizing them individually.

## Rules

Rules provide persistent instructions that LLMs can read when they need to know more about specific parts of your project. They define code conventions, project structure, and workflow guidelines.

### AGENTS.md

The `AGENTS.md` file at the project root is the primary rules file. It uses a standardized format recognized by [most](https://agents.md) AI coding tools.

```md title="AGENTS.md"
## Agent rules

**DO:**

- Read existing files before editing; understand imports and structure first
- Keep diffs minimal and scoped to the request
  ...

**DON'T:**

- Commit, push, or modify git state unless explicitly asked
- Run destructive commands (`reset --hard`, force-push) without permission
  ...

## Code conventions

- TypeScript: functional, declarative; no classes
- File layout: exported component → subcomponents → helpers → types
```

Rules should be concise and actionable. Include only information the AI **cannot infer from code alone**, such as:

* Bash commands and common workflows
* Code style rules that differ from defaults
* Architectural decisions specific to your project
* Common gotchas or non-obvious behaviors

<Callout type="warn">
  Keep rules short. Overly long files cause AI to ignore important instructions. If you notice the AI not following a rule, the file might be too verbose.
</Callout>

### CLAUDE.md

The `CLAUDE.md` file provides compatibility with Claude-specific tools. In TurboStarter, it simply references the main rules file:

```md title="CLAUDE.md"
@AGENTS.md
```

This ensures consistent behavior across all AI tools without duplicating content.

<Callout title="Nested AGENTS.md files">
  You can also nest AGENTS.md files in subdirectories to create more granular rules for specific parts of your project.

  For example, you can create an `AGENTS.md` file in the `apps/web/` directory to add rules for the web application, or an `AGENTS.md` file in the `packages/api/` directory to add specific rules for the API.

  The right approach depends on your project's complexity and where you need more targeted AI assistance.
</Callout>

<Callout title="Provider-specific rules">
  Most providers allow you to add tool-specific rules. For example, Cursor rules go in the `.cursor/` directory, while Claude rules go in the `.claude/` directory.

  If you primarily use one AI tool in your workflow, consider creating tool-specific rules rather than relying solely on the shared `AGENTS.md` file.
</Callout>

## Skills

Skills are modular capabilities that extend AI functionality with domain-specific knowledge. They package instructions, workflows, and reference materials that AI loads on-demand when relevant.

### How skills work

Skills are organized as directories containing a `SKILL.md` file and optionally a `references/` directory with additional documentation:

<Files>
  <Folder name=".agents" defaultOpen>
    <Folder name="skills" defaultOpen>
      <Folder name="better-auth-best-practices" defaultOpen>
        <File name="SKILL.md" />
      </Folder>

      <Folder name="building-native-ui" defaultOpen>
        <File name="SKILL.md" />

        <Folder name="references" defaultOpen>
          <File name="animations.md" />

          <File name="tabs.md" />
        </Folder>
      </Folder>
    </Folder>
  </Folder>
</Files>

Each skill includes YAML frontmatter that describes when to use it:

```md title="SKILL.md"
---
name: better-auth-best-practices
description: Skill for integrating Better Auth - the comprehensive TypeScript authentication framework.
---

# Better Auth Integration Guide

**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.**

...
```

AI tools read the `description` field to determine when to apply the skill automatically. When triggered, the full skill content loads into context.

### Included skills

TurboStarter ships with several pre-configured skills covering common development scenarios:

| Skill                         | Description                                    |
| ----------------------------- | ---------------------------------------------- |
| `turborepo`                   | Turborepo best practices and configuration     |
| `better-auth-best-practices`  | Auth integration patterns and API reference    |
| `building-native-ui`          | Mobile UI patterns with Expo and React Native  |
| `native-data-fetching`        | Network requests, caching, and offline support |
| `vercel-react-best-practices` | React and Next.js performance optimization     |
| `vercel-composition-patterns` | Component architecture and API design          |
| `web-design-guidelines`       | UI review and accessibility compliance         |
| `find-skills`                 | Discover and install additional skills         |

### Installing skills

To install additional skills, we recommend using [Skills CLI](https://skills.sh), which allows you to easily install skills from the [open skills ecosystem](https://skills.sh). To install a skill, run:

```bash
npx skills add <owner/repo>
```

Browse available skills at [skills.sh](https://skills.sh).

### Creating custom skills

If you have project-specific workflows, you can create your own skills:

<Steps>
  <Step>
    Create a directory in `.agents/skills/`:

    ```bash
    mkdir -p .agents/skills/my-custom-skill
    ```
  </Step>

  <Step>
    Add a `SKILL.md` file with frontmatter:

    ```md title=".agents/skills/my-custom-skill/SKILL.md"
    ---
    name: my-custom-skill
    description: Handles X workflow. Use when working with Y or when user asks about Z.
    ---

    # My Custom Skill

    ## Instructions

    1. First, check the existing patterns in `packages/api/`
    2. Follow the established naming conventions
    3. ...
    ```
  </Step>

  <Step>
    The skill will be automatically available in your AI tool. Test by asking about the topic described in the `description` field.
  </Step>
</Steps>

## Subagents

Subagents are specialized AI assistants that handle specific types of tasks in isolation. They operate in their own context window, preventing long research or review tasks from cluttering your main conversation.

### Included subagents

TurboStarter includes a code reviewer subagent:

```md title=".agents/agents/code-reviewer.md"
---
name: code-reviewer
description: Reviews code for quality, conventions, and potential issues.
model: inherit
readonly: true
---

You are a senior code reviewer for the TurboStarter project...
```

The subagent runs in read-only mode and checks for:

* TypeScript best practices (no `any`, explicit types)
* Component conventions (named exports, props interface)
* Architecture patterns (shared logic in packages)
* Security issues (no hardcoded secrets, proper auth)

### Using subagents

Invoke subagents explicitly in your prompts:

```txt
Use the code-reviewer to review the changes in src/modules/auth/
```

Or let the AI delegate automatically based on the task.

### Creating custom subagents

Add subagent definitions to `.agents/agents/`:

```md title=".agents/agents/security-auditor.md"
---
name: security-auditor
description: Security specialist. Use when implementing auth, payments, or handling sensitive data.
model: inherit
readonly: true
---

You are a security expert auditing code for vulnerabilities.

When invoked:

1. Identify security-sensitive code paths
2. Check for common vulnerabilities (injection, XSS, auth bypass)
3. Verify secrets are not hardcoded
4. Review input validation and sanitization

Report findings by severity: Critical, High, Medium, Low.
```

## Commands

Commands are reusable workflows triggered with a `/` prefix in chat. They standardize common tasks and encode institutional knowledge.

### Included commands

TurboStarter includes a feature setup command:

```md title=".agents/commands/setup-new-feature.md"
# Setup New Feature

Set up a new feature in the TurboStarter.dev website following project conventions.

## Before starting

1. **Clarify scope**: What part of the site needs this feature?
2. **Check existing code**: Look in `packages/*` for reusable logic
3. **Identify shared vs app-specific**: Shared logic goes in `packages/*`

## Project structure

...
```

### Using commands

Type `/` in chat to see available commands:

```txt
/setup-new-feature
```

Follow the guided workflow to scaffold features consistently.

### Creating custom commands

Add command definitions to `.agents/commands/`:

```md title=".agents/commands/fix-issue.md"
# Fix GitHub Issue

Fix a GitHub issue following project conventions.

## Steps

1. Use `gh issue view <number>` to get issue details
2. Search the codebase for relevant files
3. Implement the fix following existing patterns
4. Write tests to verify the fix
5. Run `pnpm typecheck` and `pnpm lint`
6. Create a descriptive commit message
7. Push and create a PR
```

## Model Context Protocol (MCP)

MCP enables AI tools to connect to external services like databases, APIs, and third-party tools. This allows AI to access real data and perform actions beyond code generation.

### Common MCP integrations

| Service                                                                                        | Use case                               |
| ---------------------------------------------------------------------------------------------- | -------------------------------------- |
| [GitHub](https://github.com/github/github-mcp-server)                                          | Create issues, open PRs, read comments |
| [Database](https://github.com/crystaldba/postgres-mcp)                                         | Query schemas, inspect data            |
| [Figma](https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server) | Import designs for implementation      |
| [Linear](https://linear.app/docs/mcp)/[Jira](https://github.com/sooperset/mcp-atlassian)       | Read tickets, update status            |
| [Browser](https://browsermcp.io/)                                                              | Test UI, take screenshots              |

For a full list of available MCP servers, see the [Cursor documentation](https://cursor.com/docs/context/mcp/directory) or the [MCP directory](https://www.pulsemcp.com/servers/).

### Setting up MCP

MCP configuration varies by tool. Generally, you create a configuration file that specifies server connections:

```json title="mcp.json"
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "${env:GITHUB_TOKEN}"
      }
    }
  }
}
```

Consult your AI tool's documentation for specific setup instructions.

## Documentation

Like the rest of TurboStarter, the documentation is optimized for AI-assisted workflows. You can chat with it and get answers about specific features using the **most up-to-date** information.

### MCP server

TurboStarter provides a hosted MCP server that lets AI assistants search and read the official documentation directly:

```txt
https://www.turbostarter.dev/mcp
```

Use it when your AI tool supports MCP and you want source-grounded answers without copying docs into the chat manually. See the [MCP server guide](/docs/web/installation/mcp) for setup instructions.

### `llms.txt`

You can access the entire TurboStarter documentation in Markdown format at [/llms.txt](/llms.txt). This allows you to ask any LLM (assuming it has a large enough context window) questions about TurboStarter using the most up-to-date documentation.

#### Example usage

For example, to prompt an LLM with questions about TurboStarter:

1. Copy the documentation contents from [/llms.txt](/llms.txt)
2. Use the following prompt format:

```txt
Documentation:
{paste documentation here}
---
Based on the above documentation, answer the following:
{your question}
```

This works with any AI tool that accepts large context, regardless of whether it has native integration with your editor.

### Markdown format

Each documentation page is also available in raw Markdown format. You can copy the contents using the *Copy Markdown* button in the page header.

You can also access it directly by adding the `.mdx` extension to the specific documentation page. For example, to access this page, visit [/docs/web/installation/ai-development.mdx](/docs/web/installation/ai-development.mdx).

### Open in ...

To make chatting with TurboStarter documentation even more convenient, each page includes an *Open in...* button in the header that opens the documentation directly in your preferred chatbot.

For example, opening the documentation page in [ChatGPT](https://chatgpt.com) will create a new chat with the documentation automatically attached as a context:

![ChatGPT example](/images/docs/open-in-chatgpt.png)

## Best practices

Following best practices helps you get the most out of AI-assisted development. Review the tips below and share your experiences on our [Discord](https://discord.com/invite/KjpK2uk3JP) server.

### Plan before coding

The most impactful change you can make is planning before implementation. Planning forces clear thinking about what you're building and gives the AI concrete goals to work toward.

For complex tasks, use this workflow:

1. **Explore**: Have the AI read files and understand the existing architecture
2. **Plan**: Ask for a detailed implementation plan with file paths and code references
3. **Implement**: Execute the plan, verifying against each step
4. **Commit**: Review changes and commit with descriptive messages

Not every task needs a detailed plan. For quick changes or familiar patterns, jumping straight to implementation is fine.

### Provide verification criteria

AI performs dramatically better when it can verify its own work. Include tests, screenshots, or expected outputs:

```txt
// Instead of:
"implement email validation"

// Use:
"write a validateEmail function. test cases: user@example.com → true,
invalid → false, user@.com → false. run tests after implementing."
```

Without clear success criteria, the AI might produce something that looks right but doesn't actually work. Verification can be a test suite, a linter, or a command that checks output.

### Write specific prompts

The more precise your instructions, the fewer corrections you'll need. Reference specific files, mention constraints, and point to example patterns:

| Strategy               | Before                  | After                                                                                                                                             |
| ---------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Scope the task**     | "add tests for auth"    | "write a test for `auth.ts` covering the logout edge case, using patterns in `__tests__/` and avoiding mocks"                                     |
| **Reference patterns** | "add a calendar widget" | "look at how existing widgets are implemented. `HotDogWidget.tsx` is a good example. follow the pattern to implement a calendar widget"           |
| **Describe symptoms**  | "fix the login bug"     | "users report login fails after session timeout. check the auth flow in `src/auth/`, especially token refresh. write a failing test, then fix it" |

### Use absolute rules

When writing rules, be direct. Absolute rules beat suggestions. "Always verify ownership with `userId` before database writes" works. "Consider checking ownership" gets ignored.

Structure rules with clear "MUST do" and "MUST NOT do" sections:

```md
## MUST DO

- Verify ownership before ALL database writes
- Run `pnpm typecheck` after every implementation
- Use `@workspace/ui` components - never install shadcn directly

## MUST NOT DO

- Never use `any` type - fix the types instead
- Never store secrets in code - use environment variables
- Never create new UI components if one exists in @workspace/ui
```

### Use rules as a router

Tell AI where and how to find things. This prevents hallucinated file paths and inconsistent patterns:

```md
## Where to find things

- Database schemas: `packages/db/src/schema/`
- Server action patterns: `apps/web/app/api/`
- UI components: `packages/ui/src/`
- Existing features to reference: `apps/web/app/`
```

### Course-correct early

Stop AI mid-action if it goes off track. Most tools support an interrupt key (usually `Esc`). Redirect early rather than waiting for a complete but wrong implementation.

If you've corrected the AI more than twice on the same issue in one session, the context is cluttered with failed approaches. Start fresh with a more specific prompt that incorporates what you learned.

### Manage context aggressively

Long sessions accumulate irrelevant context that degrades AI performance. Clear context between unrelated tasks or start fresh sessions for new features.

**Start a new conversation when:**

* You're moving to a different task or feature
* The AI seems confused or keeps making the same mistakes
* You've finished one logical unit of work

**Continue the conversation when:**

* You're iterating on the same feature
* The AI needs context from earlier in the discussion
* You're debugging something it just built

### Use subagents for research

When exploring unfamiliar code, delegate to subagents. They run in separate context windows and report back summaries, keeping your main conversation clean for implementation.

This is especially useful for:

* Codebase exploration that might read many files
* Code review (fresh context prevents bias toward code just written)
* Security audits and performance analysis

### Review AI-generated code carefully

AI-generated code can look right while being subtly wrong. Read the diffs and review carefully. The faster the AI works, the more important your review process becomes.

For significant changes, consider:

* Running a dedicated review pass after implementation
* Asking the AI to generate architecture diagrams
* Using a separate AI session to review the changes (fresh context)

### Add business domain context

Generic rules produce generic code. Add your application's domain to help AI understand context:

```md
## Business Domain

This application is a project management tool for software teams.

### Key Entities

- **Projects**: User-created workspaces containing tasks
- **Tasks**: Work items with status, assignee, and due date

### Business Rules

- Projects belong to organizations (use organizationId for queries)
- Tasks require project membership to view (check via RBAC)
- Deleted projects cascade-delete all tasks
```

## Troubleshooting

Common issues when using AI coding assistants and how to resolve them:

<Accordions type="multiple">
  <Accordion title="Rules not being applied">
    1. Check that `AGENTS.md` exists at the project root
    2. Verify the file contains valid Markdown
    3. Some tools require reopening the project to reload rules
    4. Check if the file is too long—important rules may be getting lost in the noise
    5. Try adding emphasis (e.g., "IMPORTANT" or "MUST") to critical instructions
  </Accordion>

  <Accordion title="Context drift in long conversations">
    Long sessions cause AI to "forget" rules and earlier instructions. This happens because:

    * Context windows fill up with irrelevant information
    * Important instructions get pushed out during summarization
    * Failed approaches pollute the conversation

    **Solutions:**

    1. Start fresh sessions for complex or unrelated tasks
    2. Re-state important rules when you notice drift
    3. After two failed corrections, clear context and write a better initial prompt
  </Accordion>

  <Accordion title="Skills not triggering">
    1. Verify the skill's `description` field clearly describes when to use it
    2. Try invoking the skill explicitly by name (e.g., `/skill-name`)
    3. Check that the `SKILL.md` file has valid YAML frontmatter
    4. Skills may require explicit invocation for workflows with side effects
  </Accordion>

  <Accordion title="Subagents not available">
    1. Ensure subagent files are in the correct directory (`.agents/agents/`)
    2. Check the frontmatter for syntax errors
    3. Some tools require specific configuration to enable subagents
    4. Verify the `name` and `description` fields are properly defined
  </Accordion>

  <Accordion title="AI generating incorrect or hallucinated code">
    AI can produce plausible-looking implementations that don't handle edge cases or reference non-existent APIs.

    **Prevention:**

    1. Always provide verification criteria (tests, expected outputs)
    2. Use typed languages and configure linters
    3. Point AI to reference implementations rather than documenting APIs
    4. Run verification commands after every implementation

    **Recovery:**

    1. Don't try to fix incorrect code through follow-up prompts repeatedly
    2. Revert changes and start fresh with a more specific prompt
    3. Use a dedicated review pass to catch issues before committing
  </Accordion>

  <Accordion title="Conflicting rules between files">
    When you have multiple `AGENTS.md` files (root and package-level), they can conflict. Generally, the more specific file (closer to the code being edited) takes priority.

    **Solutions:**

    1. Check which `AGENTS.md` is being read by asking the AI
    2. Consolidate conflicting rules into one location
    3. Use package-level rules only for domain-specific guidance
  </Accordion>

  <Accordion title="AI exploring too many files">
    Unbounded exploration fills context with irrelevant information.

    **Solutions:**

    1. Scope investigations narrowly: "search for JWT validation in `src/auth/`" instead of "find auth code"
    2. Use subagents for exploration so it doesn't consume your main context
    3. Specify file types or directories to limit search scope
  </Accordion>

  <Accordion title="High resource usage or slow performance">
    Large codebases or long sessions can consume significant resources.

    **Solutions:**

    1. Use compact/summarize features regularly to reduce context size
    2. Close and restart between major tasks
    3. Add large build directories (e.g., `node_modules`, `dist`) to `.gitignore`
    4. Disable unnecessary extensions that might impact performance
  </Accordion>
</Accordions>

## Learn more

Dive deeper into AI-assisted development with these resources. They cover open standards, tool directories, and specifications that power modern AI coding workflows.

<Cards>
  <Card title="Agent Skills specification" description="Open standard for defining reusable AI skills." href="https://agentskills.io" />

  <Card title="Skills directory" description="Browse and install community-built skills." href="https://skills.sh" />

  <Card title="Model Context Protocol" description="Connect AI tools to external services and APIs." href="https://modelcontextprotocol.io" />

  <Card title="AGENTS.md standard" description="Standardized rules file format for AI tools." href="https://agents.md" />
</Cards>


# Cloning repository
Source: https://www.turbostarter.dev/docs/web/installation/clone

<Callout type="info" title="Prerequisite: Git installed">
  Ensure you have Git installed on your local machine before proceeding. You can download Git from [here](https://git-scm.com).
</Callout>

## Git clone

Clone the repository using the following command:

```bash
git clone git@github.com:turbostarter/core
```

By default, we're using [SSH](https://docs.github.com/en/authentication/connecting-to-github-with-ssh) for all Git commands. If you don't have it configured, please refer to the [official documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh) to set it up.

Alternatively, you can use HTTPS to clone the repository:

```bash
git clone https://github.com/turbostarter/core
```

Another alternative could be to use the [Github CLI](https://cli.github.com/) or [Github Desktop](https://desktop.github.com/) for Git operations.

<Card title="Git clone" description="git-scm.com" href="https://git-scm.com/docs/git-clone" />

## Git remote

After cloning the repository, remove the original origin remote:

```bash
git remote rm origin
```

Add the upstream remote pointing to the original repository to pull updates:

```bash
git remote add upstream git@github.com:turbostarter/core
```

Once you have your own repository set up, add your repository as the origin:

```bash
git remote add origin <your-repository-url>
```

<Card title="Git remote" description="git-scm.com" href="https://git-scm.com/docs/git-remote" />

## Staying up to date

To pull updates from the upstream repository, run the following command daily (preferably with your morning coffee ☕):

```bash
git pull upstream main
```

This ensures your repository stays up to date with the latest changes.

Check [Updating codebase](/docs/web/installation/update) for more details on updating your codebase.


# Common commands
Source: https://www.turbostarter.dev/docs/web/installation/commands

<Callout>
  For sure, you don't need these commands to kickstart your project, but it's useful to know they exist for when you need them.
</Callout>

<Callout title="Want shorter commands?">
  You can set up aliases for these commands in your shell configuration file. For example, you can set up an alias for `pnpm` to `p`:

  ```bash title="~/.bashrc"
  alias p='pnpm'
  ```

  Or, if you're using [Zsh](https://ohmyz.sh/), you can add the alias to `~/.zshrc`:

  ```bash title="~/.zshrc"
  alias p='pnpm'
  ```

  Then run `source ~/.bashrc` or `source ~/.zshrc` to apply the changes.

  You can now use `p` instead of `pnpm` in your terminal. For example, `p i` instead of `pnpm install`.
</Callout>

<Callout title="Injecting environment variables">
  To inject environment variables into the command you run, prefix it with `with-env`:

  ```bash
  pnpm with-env <command>
  ```

  For example, `pnpm with-env pnpm build` will run `pnpm build` with the environment variables injected.

  Some commands, like `pnpm dev`, automatically inject the environment variables for you.
</Callout>

## Installing dependencies

To install the dependencies, run:

```bash
pnpm install
```

## Starting development server

Start development server by running:

```bash
pnpm dev
```

## Building project

To build the project (including all apps and packages), run:

```bash
pnpm build
```

## Building specific app/package

To build a specific app/package, run:

```bash
pnpm turbo build --filter=<package-name>
```

## Cleaning project

To clean the project, run:

```bash
pnpm clean
```

Then, reinstall the dependencies:

```bash
pnpm install
```

## Formatting code

To check for formatting errors using [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), run:

```bash
pnpm format
```

To fix formatting errors using [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), run:

```bash
pnpm format:fix
```

## Linting code

To check for linting errors using [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), run:

```bash
pnpm lint
```

To fix linting errors using [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), run:

```bash
pnpm lint:fix
```

## Adding UI components

<Tabs items={["Web", "Mobile"]}>
  <Tab value="Web">
    To add a new web component, run:

    ```bash
    pnpm --filter @workspace/ui-web ui:add
    ```

    This command will add and export a new component to `@workspace/ui-web` package.
  </Tab>

  <Tab value="Mobile">
    To add a new mobile component, run:

    ```bash
    pnpm --filter @workspace/ui-mobile ui:add
    ```

    This command will add and export a new component to `@workspace/ui-mobile` package.
  </Tab>
</Tabs>

## Services commands

<Callout title="Prerequisite: Docker installed">
  To run the services containers locally, you need to have [Docker](https://www.docker.com/) installed on your machine.

  You can always use the cloud-hosted solution (e.g. [Neon](https://neon.com/), [Turso](https://turso.tech/) for database) for your projects.
</Callout>

We have a few commands to help you manage the services containers (for local development).

### Starting containers

To start the services containers, run:

```bash
pnpm services:start
```

It will run all the services containers. You can check their configs in `docker-compose.yml`.

### Setting up services

To setup all the services, run:

```bash
pnpm services:setup
```

It will start all the services containers and run necessary setup steps.

### Stopping containers

To stop the services containers, run:

```bash
pnpm services:stop
```

### Displaying status

To check the status and logs of the services containers, run:

```bash
pnpm services:status
```

### Displaying logs

To display the logs of the services containers, run:

```bash
pnpm services:logs
```

### Database commands

We have a few commands to help you manage the database leveraging [Drizzle CLI](https://orm.drizzle.team/kit-docs/commands).

#### Generating migrations

To generate a new migration, run:

```bash
pnpm with-env turbo db:generate
```

It will create a new migration `.sql` file in the `packages/db/migrations` folder.

#### Running migrations

To run the migrations against the db, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:migrate
```

It will apply all the pending migrations.

#### Pushing changes directly

<Callout type="warn" title="Don't mess up with your schema!">
  Make sure you know what you're doing before pushing changes directly to the db.
</Callout>

To push changes directly to the db, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:push
```

It lets you push your schema changes directly to the database and omit managing SQL migration files.

#### Checking database status

To check the status of the database, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:status
```

It will display the status of the applied migrations and the pending ones.

```bash
Applied migrations:
- 0000_cooing_vargas
- 0001_curious_wallflower
- 0002_good_vertigo
- 0003_peaceful_devos
- 0004_fat_mad_thinker
- 0005_yummy_bucky
- 0006_glorious_vargas

Pending migrations:
- 0007_nebulous_havok
```

#### Resetting database

To reset the database, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:reset
```

It will reset the database to the initial state.

#### Seeding database

To seed the database with some example data (for development purposes), run:

```bash
pnpm with-env turbo db:seed
```

It will populate your database with some example data.

#### Checking database

To check the database schema consistency, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:check
```

#### Studying database

To study the database schema in the browser, run:

```bash
pnpm with-env pnpm --filter @workspace/db db:studio
```

This will start the Studio on [https://local.drizzle.studio](https://local.drizzle.studio).

## Tests commands

### Running tests

To run the tests, run:

```bash
pnpm test
```

This will run all the tests in the project using Turbo tasks. As it leverages Turbo caching, it's [recommended](/docs/web/tests/unit#configuration) to run it in your CI/CD pipeline.

### Running tests projects

To run tests for all Vitest [Test Projects](https://vitest.dev/guide/projects), run:

```bash
pnpm test:projects
```

This will run all the tests in the project using Vitest.

### Watching tests

To watch the tests, run:

```bash
pnpm test:projects:watch
```

This will watch the tests for all [Test Projects](https://vitest.dev/guide/projects) and run them automatically when you make changes.

### Generating code coverage

To generate code coverage report, run:

```bash
pnpm turbo test:coverage
```

This will generate a code coverage report in the `coverage` directory under `tooling/vitest` package.

### Viewing code coverage

To preview the code coverage report in the browser, run:

```bash
pnpm turbo test:coverage:view
```

This will launch the report's `.html` file in your default browser.

### Running E2E tests

To run web end-to-end tests, run:

```bash
pnpm --filter web test:e2e
```

See [E2E tests](/docs/web/tests/e2e) for setup, Playwright UI mode, and CI configuration.


# Conventions
Source: https://www.turbostarter.dev/docs/web/installation/conventions

You're not required to follow these conventions; they're simply a standard set of practices used in the core kit. If you like them, we encourage you to keep them during your usage of the kit so you have a consistent code style that you and your teammates understand.

## Turborepo packages

In this project, we use [Turborepo packages](https://turborepo.dev/repo/docs/core-concepts/internal-packages) to define reusable code that can be shared across multiple applications.

* **Apps** are used to define the main application, including routing, layout, and global styles.
* **Packages** share reusable code and add functionality across multiple applications. They're configurable from the main application.

<Callout title="Should I create a new package?">
  **Recommendation:** Do not create a package for your app code unless you plan to reuse it across multiple applications or are experienced in writing library code.

  If your application is not intended for reuse, keep all code in the app folder. This approach saves time and reduces complexity, both of which are beneficial for fast shipping.

  **Experienced developers:** If you have the experience, feel free to create packages as needed.
</Callout>

## Imports and paths

When importing modules from packages or apps, use the following conventions:

* **From a package:** Use `@workspace/package-name` (e.g., `@workspace/ui`, `@workspace/api`, etc.).
* **From an app:** Use `~/` (e.g., `~/components`, `~/config`, etc.).

## Enforcing conventions

We don't enforce complex rules or specific style guides that are not relevant to the project, giving you more freedom to customize things to your needs.

To enforce these conventions, we use the following tools:

* [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) is a [Prettier-compatible](https://oxc.rs/docs/guide/usage/formatter/migrate-from-prettier.html) tool used to enforce code formatting.
* [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) is an [ESLint-compatible](https://oxc.rs/docs/guide/usage/linter/migrate-from-eslint.html) tool used to enforce code quality and best practices.
* [TypeScript](https://www.typescriptlang.org/) is used to enforce type safety.

<Cards className="grid-cols-2 sm:grid-cols-3">
  <Card title="Oxfmt" href="https://oxc.rs/docs/guide/usage/formatter.html" description="oxc.rs" />

  <Card title="Oxlint" href="https://oxc.rs/docs/guide/usage/linter.html" description="oxc.rs" />

  <Card title="TypeScript" href="https://www.typescriptlang.org/" description="typescriptlang.org" />
</Cards>

## Code health

TurboStarter provides a set of tools to ensure code health and quality in your project.

### GitHub Actions

By default, TurboStarter sets up GitHub Actions to run tests on every push to the repository. You can find the workflow configuration in the `.github/workflows` directory.

The workflow has multiple stages:

* `format` - runs Oxfmt to format the code.
* `lint` - runs Oxlint to check for linting errors.
* `test` - runs tests.

### Git hooks

Together with TurboStarter, we have set up a `pre-commit` hook that will check for linting and formatting errors in the files being committed.

It's configured using [Lefthook](https://lefthook.dev), which supports multiple hooks and can be configured to run commands on specific files or directories.

Feel free to customize the hook to your needs, e.g. to check consistency of commit messages (useful for generating changelogs) using [commitlint](https://commitlint.js.org/):

```yaml title="lefthook.yml"
commit-msg:
  commands:
    "lint commit message":
      run: pnpm commitlint --edit {1}
```


# Managing dependencies
Source: https://www.turbostarter.dev/docs/web/installation/dependencies

As the package manager we chose [pnpm](https://pnpm.io/).

<Callout title="Why pnpm?">
  It is a fast, disk space efficient package manager that uses hard links and symlinks to save one version of a module only ever once on a disk. It also has a great [monorepo support](https://pnpm.io/workspaces). Of course, you can change it to use [Bun](https://bunpkg.com), [yarn](https://yarnpkg.com) or [npm](https://www.npmjs.com) with minimal effort.
</Callout>

## Install dependency

To install a package you need to decide whether you want to install it to the root of the monorepo or to a specific workspace. Installing it to the root makes it available to all packages, while installing it to a specific workspace makes it available only to that workspace.

To install a package globally, run:

```bash
pnpm add -w <package-name>
```

To install a package to a specific workspace, run:

```bash
pnpm add --filter <workspace-name> <package-name>
```

For example:

```bash
pnpm add --filter @workspace/ui motion
```

It will install `motion` to the `@workspace/ui` workspace.

## Remove dependency

Removing a package is the same as installing but with the `remove` command.

To remove a package globally, run:

```bash
pnpm remove -w <package-name>
```

To remove a package from a specific workspace, run:

```bash
pnpm remove --filter <workspace-name> <package-name>
```

## Update a package

Updating is a bit easier since there is a nice way to update a package in all workspaces at once:

```bash
pnpm update -r <package-name>
```

<Callout title="Semantic versioning">
  When you update a package, pnpm will respect the [semantic versioning](https://docs.npmjs.com/about-semantic-versioning) rules defined in the `package.json` file. If you want to update a package to the latest version, you can use the `--latest` flag.
</Callout>

## Renovate bot

By default, TurboStarter comes with [Renovate](https://www.npmjs.com/package/renovate) enabled. It is a tool that helps you manage your dependencies by automatically creating pull requests to update your dependencies to the latest versions. You can find its configuration in the `.github/renovate.json` file. Learn more about it in the [official docs](https://docs.renovatebot.com/configuration-options/).

When it creates a pull request, it is treated as a normal PR, so all tests and preview deployments will run. **It is recommended to always preview and test the changes in the staging environment before merging the PR to the main branch to avoid breaking the application.**

<Card href="https://docs.renovatebot.com" title="Renovate" description="renovatebot.com" />


# Development
Source: https://www.turbostarter.dev/docs/web/installation/development

## Prerequisites

To get started with TurboStarter, ensure you have the following installed and set up:

* [Node.js](https://nodejs.org/en) (24.x or higher)
* [Docker](https://www.docker.com) (only if you want to use local services e.g. database)
* [pnpm](https://pnpm.io)

## Project development

<Steps>
  <Step>
    ### Install dependencies

    Install the project dependencies by running the following command:

    ```bash
    pnpm i
    ```

    <Callout title="Why pnpm?">
      It is a fast, disk space efficient package manager that uses hard links and symlinks to save one version of a module only ever once on a disk. It also has a great [monorepo support](https://pnpm.io/workspaces). Of course, you can change it to use [Bun](https://bunpkg.com), [yarn](https://yarnpkg.com) or [npm](https://www.npmjs.com) with minimal effort.
    </Callout>
  </Step>

  <Step>
    ### Setup environment variables

    Create a `.env.local` files from `.env.example` files and fill in the required environment variables.

    You can use the following command to recursively copy the `.env.example` files to the `.env.local` files:

    <Tabs items={["Unix (MacOS/Linux)", "Windows"]}>
      <Tab value="Unix (MacOS/Linux)">
        ```bash
        find . -name ".env.example" -exec sh -c 'cp "$1" "${1%.example}.local"' _ {} \;
        ```
      </Tab>

      <Tab value="Windows">
        ```bash
        Get-ChildItem -Recurse -Filter ".env.example" | ForEach-Object {
            Copy-Item $_.FullName ($\_.FullName -replace '\.example$', '.local')
        }
        ```
      </Tab>
    </Tabs>

    Check [Environment variables](/docs/web/configuration/environment-variables) for more details on setting up environment variables.
  </Step>

  <Step>
    ### Setup services

    If you want to use local services like [database](/docs/web/database/overview) (**recommended for development purposes**), ensure Docker is running, then setup them with:

    ```bash
    pnpm services:setup
    ```

    This command initiates the containers and runs necessary setup steps, ensuring your services are up to date and ready to use.
  </Step>

  <Step>
    ### Start development server

    To start the application development server, run:

    ```bash
    pnpm dev
    ```

    Your app should now be up and running at [http://localhost:3000](http://localhost:3000) 🎉
  </Step>

  <Step>
    ### Deploy to Production

    When you're ready to deploy the project to production, follow the [checklist](/docs/web/deployment/checklist) to ensure everything is set up correctly.
  </Step>
</Steps>


# Editor setup
Source: https://www.turbostarter.dev/docs/web/installation/editor-setup

Of course, you can use any IDE you like, but you'll have the best possible developer experience with this starter kit when using a **VSCode-based** editor with the suggested settings and extensions.

## Settings

We've included most recommended settings in the `.vscode/settings.json` file to make your development experience as smooth as possible. It includes configuration for tools like [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), and Tailwind CSS, which are used to enforce conventions across the codebase. You can adjust them to your needs.

```json title=".vscode/settings.json"
{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.oxc": "always"
  },
 "editor.formatOnSave": true
  ...
}
```

## Extensions

Once you've cloned the project and opened it in VSCode, you should be prompted to install the suggested extensions, which are defined in `.vscode/extensions.json`. If you'd rather install them manually, you can do so at any time.

These are the extensions we recommend:

### OXC

Global extension for static code analysis. It will help you find and fix problems in your JavaScript/TypeScript code using [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) and [Oxlint](https://oxc.rs/docs/guide/usage/linter.html). It's compatible with [Prettier](https://prettier.io/) and [ESLint](https://eslint.org/).

<Card title="Download Oxfmt" href="https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode" description="marketplace.visualstudio.com" />

### Pretty TypeScript Errors

Improves TypeScript error messages shown in the editor.

<Card title="Download Pretty TypeScript Errors" href="https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors" description="marketplace.visualstudio.com" />

### Tailwind CSS IntelliSense

Adds IntelliSense for Tailwind CSS classes to enable autocompletion and linting.

<Card title="Download Tailwind CSS IntelliSense" href="https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss" description="marketplace.visualstudio.com" />


# MCP server
Source: https://www.turbostarter.dev/docs/web/installation/mcp

TurboStarter provides a public [Model Context Protocol](https://modelcontextprotocol.io/) server for its documentation. It lets AI assistants search docs and read pages as markdown, so answers can stay grounded in the latest official documentation.

The server is exposed through a Streamable HTTP endpoint:

```txt
https://www.turbostarter.dev/mcp
```

<Callout title="Use the hosted endpoint">
  You do not need to install or run anything locally. Point your MCP client to the hosted TurboStarter docs endpoint: `https://www.turbostarter.dev/mcp`.
</Callout>

## What it provides

The MCP server gives AI clients direct access to TurboStarter documentation.

<Cards>
  <Card title="Search docs" description="Find relevant documentation pages by title, description, and URL." />

  <Card title="Read markdown" description="Fetch the full markdown content for a documentation page." />

  <Card title="Stay source-grounded" description="Help your assistant answer from official docs instead of guessing." />
</Cards>

## Quick start

<Steps>
  <Step>
    ### Add TurboStarter to your MCP client

    Configure your AI assistant to connect to the Streamable HTTP endpoint. Most MCP clients support either a project-level config file or a settings UI.

    <Tabs items={["Cursor", "Claude Code", "VS Code", "ChatGPT Codex"]}>
      <Tab value="Cursor">
        Create or update `.cursor/mcp.json`:

        ```json title=".cursor/mcp.json"
        {
          "mcpServers": {
            "turbostarter": {
              "url": "https://www.turbostarter.dev/mcp"
            }
          }
        }
        ```

        Then enable the server in Cursor settings. Once connected, Cursor should show the available TurboStarter docs tools.
      </Tab>

      <Tab value="Claude Code">
        Create or update `.mcp.json` in your project:

        ```json title=".mcp.json"
        {
          "mcpServers": {
            "turbostarter": {
              "type": "http",
              "url": "https://www.turbostarter.dev/mcp"
            }
          }
        }
        ```

        Restart Claude Code and use `/mcp` to confirm the server is connected.
      </Tab>

      <Tab value="VS Code">
        Create or update `.vscode/mcp.json`:

        ```json title=".vscode/mcp.json"
        {
          "servers": {
            "turbostarter": {
              "type": "http",
              "url": "https://www.turbostarter.dev/mcp"
            }
          }
        }
        ```

        Start the server from the MCP controls in VS Code.
      </Tab>

      <Tab value="ChatGPT Codex">
        Add the server to `~/.codex/config.toml`:

        ```toml title="~/.codex/config.toml"
        [mcp_servers.turbostarter]
        type = "http"
        url = "https://www.turbostarter.dev/mcp"
        ```

        Restart ChatGPT Codex so it picks up the new server.
      </Tab>
    </Tabs>
  </Step>

  <Step>
    ### Ask TurboStarter-specific questions

    Try prompts that need documentation context:

    * Search the TurboStarter docs for billing webhooks.
    * Read the TurboStarter authentication overview and summarize the setup.
    * Find where TurboStarter documents AI-assisted development.
    * Explain how the web app is structured using the TurboStarter docs.
  </Step>
</Steps>

## Available tools

Your MCP client will see two documentation tools.

### `search_docs`

Searches TurboStarter documentation by title, description, and URL.

```json title="Input"
{
  "query": "billing webhooks",
  "limit": 10
}
```

`limit` is optional and defaults to `10`. The server accepts values from `1` to `50`.

### `read_doc`

Reads a documentation page as markdown.

```json title="Input"
{
  "url": "/docs/web/billing/webhooks.mdx"
}
```

The `url` can be either the regular docs URL or the markdown URL returned by `search_docs`.

## Troubleshooting

<Accordions>
  <Accordion title="The MCP client cannot connect">
    Make sure the URL is exactly `https://www.turbostarter.dev/mcp` and that your MCP client supports Streamable HTTP servers.
  </Accordion>

  <Accordion title="No tools appear in the MCP client">
    Restart the MCP client after editing its configuration. Some clients also require enabling the server in settings before tools appear.
  </Accordion>

  <Accordion title="Requests fail in production">
    Check that your network can reach `https://www.turbostarter.dev/mcp` over HTTPS. If your company uses a proxy or firewall, allow requests to the TurboStarter docs domain.
  </Accordion>

  <Accordion title="The assistant gives generic answers">
    Ask it to use the TurboStarter docs MCP server explicitly. For example: "Use the TurboStarter docs MCP server to search for authentication setup, then explain the steps."
  </Accordion>
</Accordions>


# Project structure
Source: https://www.turbostarter.dev/docs/web/installation/structure

The main directories in the project are:

* `apps` - the location of the main apps
* `packages` - the location of the shared code and the API

### `apps` Directory

This is where the apps live. It includes web app (Next.js), mobile app (React Native - Expo), and the browser extension (WXT - Vite + React). Each app has its own directory.

### `packages` Directory

This is where the shared code and the API for packages live. It includes the following:

* shared libraries (database, mailers, cms, billing, etc.)
* shared features (auth, mails, billing, ai etc.)
* UI components (buttons, forms, modals, etc.)

All apps can use and reuse the API exported from the packages directory. This makes it easy to have one, or many apps in the same codebase, sharing the same code.

## Repository structure

By default the monorepo contains the following apps and packages:

<Files>
  <Folder name="apps" defaultOpen>
    <Folder name="web - Web app (Next.js)" />

    <Folder name="mobile - Mobile app (React Native - Expo)" />

    <Folder name="extension - Browser extension (WXT - Vite + React)" />
  </Folder>

  <Folder name="packages" defaultOpen>
    <Folder name="analytics - Analytics setup" />

    <Folder name="api - API server (including all features logic)" />

    <Folder name="auth - Authentication setup" />

    <Folder name="billing - Billing config and providers" />

    <Folder name="cms - CMS setup and providers" />

    <Folder name="db - Database setup" />

    <Folder name="email - Mail templates and providers" />

    <Folder name="flags - Feature flags" />

    <Folder name="i18n - Internationalization setup" />

    <Folder name="monitoring - Monitoring setup" />

    <Folder name="shared - Shared utilities and helpers" />

    <Folder name="storage - Storage setup" />

    <Folder name="ui - Atomic UI components" />
  </Folder>

  <Folder name="tooling" defaultOpen>
    <Folder name="github - Github actions" />

    <Folder name="oxfmt - Oxfmt config" />

    <Folder name="oxlint - Oxlint config" />

    <Folder name="typescript - TypeScript config" />

    <Folder name="vitest - Vitest config" />
  </Folder>
</Files>

## Web application structure

The web application is located in the `apps/web` folder. It contains the following folders:

<Files>
  <Folder name="public - Static assets" />

  <Folder name="src" defaultOpen>
    <Folder name="app - Main application" />

    <Folder name="assets - Optimized static assets" />

    <Folder name="config - Global app config" />

    <Folder name="modules - Application modules" />

    <Folder name="lib - Communication with third-party packages" />

    <Folder name="utils - Shared utilities" />
  </Folder>

  <File name=".env.local" />

  <File name="env.config.ts" />

  <File name="oxlint.config.ts" />

  <File name="next.config.ts" />

  <File name="package.json" />

  <File name="tsconfig.json" />

  <File name="turbo.json" />
</Files>


# Updating codebase
Source: https://www.turbostarter.dev/docs/web/installation/update

If you've been following along with our previous guides, you should already have a Git repository set up for your project, with an `upstream` remote pointing to the original repository.

Updating your project involves fetching the latest changes from the `upstream` remote and merging them into your project. Let's dive into the steps!

<Steps>
  <Step>
    ## Stash changes

    <Callout title="Don't have changes?">
      If you don't have any changes to stash, you can skip this step and proceed with the update process.

      Alternatively, you can [commit](https://git-scm.com/docs/git-commit) your changes.
    </Callout>

    If you have any uncommitted changes, stash them before proceeding. It will allow you to avoid any conflicts that may arise during the update process.

    ```bash
    git stash
    ```

    This command will save your changes in a temporary location, allowing you to retrieve them later. Once you're done updating, you can apply the stash to your working directory.

    ```bash
    git stash apply
    ```
  </Step>

  <Step>
    ## Pull changes

    Pull the latest changes from the `upstream` remote.

    ```bash
    git pull upstream main
    ```

    When prompted the first time, please opt for merging instead of rebasing.

    Don't forget to run `pnpm i` in case there are any updates in the dependencies.
  </Step>

  <Step>
    ## Resolve conflicts

    If there are any conflicts during the merge, Git will notify you. You can resolve them by opening the conflicting files in your code editor and making the necessary changes.

    <Callout title="Conflicts in pnpm-lock.yaml?">
      If you find conflicts in the `pnpm-lock.yaml file`, accept either of the two changes (avoid manual edits), then run:

      ```bash
      pnpm i
      ```

      Your lock file will now reflect both your changes and the updates from the upstream repository.
    </Callout>
  </Step>

  <Step>
    ## Run a health check

    After resolving the conflicts, it's time to test your project to ensure everything is working as expected. Run your project locally and navigate through the various features to verify that everything is functioning correctly.

    For a quick health check, you can run:

    ```bash
    pnpm lint
    pnpm typecheck

    ```

    If everything looks good, you're all set! Your project is now up to date with the latest changes from the `upstream` repository.
  </Step>

  <Step>
    ## Commit and push

    Once everything is working fine, don't forget to commit your changes using:

    ```bash
    git commit -m "<your-commit-message>"
    ```

    and push them to your remote repository with:

    ```bash
    git push origin <your-branch-name>
    ```
  </Step>
</Steps>


# Configuration
Source: https://www.turbostarter.dev/docs/web/internationalization/configuration

The default global configuration is defined in the `@workspace/i18n` package and shared across all applications. You can override it in each app to customize the internationalization setup for that specific app.

The configuration is defined in the `packages/i18n/src/config.ts` file:

```ts title="packages/i18n/src/config.ts"
export const config = {
  locales: ["en", "es"],
  defaultLocale: "en",
  namespaces: [
    "common",
    "admin",
    "organization",
    "dashboard",
    "auth",
    "billing",
    "marketing",
    "validation",
  ],
  cookie: "locale",
} as const;
```

Let's break down the configuration options:

* `locales`: An array of all supported locales.
* `defaultLocale`: The default locale to use if no other locale is detected.
* `namespaces`: An array of all namespaces used in the application.
* `cookie`: The name of the cookie to store the detected locale (acts as a cache).

## Translation files

The core of the whole internationalization setup is the translation files. They are stored in the `packages/i18n/src/translations` directory and are used to store the translations for each locale and namespace.

Each directory represents a locale and contains a set of files, each corresponding to a specific namespace (e.g. `en/common.json`). Inside we define the keys and values for the translations.

```ts title="packages/i18n/src/translations/en/common.json"
{
  "hello": "Hello, world!"
}
```

That way we can ensure that we have a single source of truth for the translations and we can use them consistently in all the applications.

## Locales

The `locales` array in the configuration defines the list of supported languages in your application. Each locale is represented by a string that uniquely identifies the language.

To add a new locale, you need to:

1. Add the new locale to the `locales` array in the configuration.
2. Create a new directory in the `packages/i18n/src/translations` directory.
3. Create a new file in the new directory for each namespace and add the translations for the new locale.

For example, if you want to add the `fr` locale, you need to:

1. Add `fr` to the `locales` array in the configuration.
2. Create a new directory in the `packages/i18n/src/translations` directory.
3. Create a new file for each namespace in the created directory and add the translations for the new locale.

### Fallback locale

The `defaultLocale` option in the configuration defines the fallback locale. If a translation is not found for a specific locale, the fallback locale will be used.

We can also override this setting in each [app configuration](/docs/web/configuration/app) by configuring the `locale` property.

## Namespaces

`namespaces` are used to group translations by feature or module. This helps in organizing the translations and makes it easier to maintain them.

### Why not one big namespace?

Using multiple namespaces instead of one large namespace helps with:

1. **Performance:** load translations on-demand instead of all at once, reducing the initial bundle size.
2. **Organization:** group translations by feature (e.g., `auth`, `common`, `dashboard`).
3. **Maintenance:** easier to update and manage smaller translation files.
4. **Development:** better TypeScript support and team collaboration.

For example, you might structure your namespaces like this:

<Tabs items={["Common", "Auth", "Billing"]}>
  <Tab value="Common">
    ```ts title="packages/i18n/src/translations/en/common.json"
    {
      "hello": "Hello, world!"
    }
    ```
  </Tab>

  <Tab value="Auth">
    ```ts title="packages/i18n/src/translations/en/auth.json"
    {
      "login": "Login",
      "register": "Register"
    }
    ```
  </Tab>

  <Tab value="Billing">
    ```ts title="packages/i18n/src/translations/en/billing.json"
    {
      "invoice": "Invoice",
      "payment": "Payment",
      "subscription": "Subscription"
    }
    ```
  </Tab>
</Tabs>

Remember that while you can create as many namespaces as needed, it's important to maintain a balance - too many namespaces can lead to unnecessary complexity, while too few might defeat the purpose of separation.

## Routing

TurboStarter implements locale-based routing by placing pages under the `[locale]` folder. However, the default locale (usually `en`) is not prefixed in the URL for better SEO and user experience.

For example, with English as the default locale and Polish as an additional language:

* `/dashboard` → English version (default locale)
* `/pl/dashboard` → Polish version

The app also automatically detects the user's preferred language through cookies, HTML `lang` attribute, and browser's `Accept-Language` header.

This ensures a seamless experience where users get content in their preferred language while maintaining clean URLs for the default locale.

<Callout>
  You can override the locale by manually setting the cookie or by navigating to
  a URL with a different locale prefix.
</Callout>


# Overview
Source: https://www.turbostarter.dev/docs/web/internationalization/overview

TurboStarter uses [i18next](https://www.i18next.com/) for internationalization, which is one of the most popular and mature (over 10 years of development!) i18n frameworks for JavaScript.

<Callout title="Why i18next?">
  With i18next, you can easily translate your application into multiple
  languages, handle complex pluralization rules, format dates and numbers
  according to locale, and much more. The framework is highly extensible through
  plugins and provides excellent TypeScript support out of the box.
</Callout>

You can read more about `i18next` package in the [official documentation](https://www.i18next.com/overview/getting-started).

![i18next logo](/images/docs/i18next.jpg)

## Getting started

TurboStarter comes with `i18next` pre-configured and abstracted behind the `@workspace/i18n` package. This abstraction layer ensures that any future changes to the underlying translation library won't impact your application code. The internationalization setup is ready to use out of the box and includes:

* Multiple language support out of the box
* Type-safe translations with generated types
* Automatic language detection
* Easy-to-use React hooks for translations
* Built-in number and date formatting
* Support for nested translation keys
* Pluralization handling

To start using internationalization in your app, you'll need to:

1. Configure your supported languages
2. Add translation files
3. Use translation hooks in your components

Check out the following sections to learn more about each step:


# Translating app
Source: https://www.turbostarter.dev/docs/web/internationalization/translations

TurboStarter provides a flexible and powerful translation system that works seamlessly across your entire application. Whether you're working with React Server Components (RSC), client-side components, or server-side rendering, you can easily integrate translations to create a fully internationalized experience.

The translation system supports:

* **Server components (RSC)** for efficient server-side translations
* **Client components** for dynamic language switching
* **Server-side rendering** for SEO-friendly translated content

## Server components (RSC)

To get the translations in a server component, you can use the `getTranslation` method:

```tsx
import { getTranslation } from "@workspace/i18n";

export default async function MyComponent() {
  const { t } = await getTranslation();

  return <div>{t("common:hello")}</div>;
}
```

There is also a possibility to use the [Trans](https://react.i18next.com/latest/trans-component) component, which could be useful e.g. for interpolating variables:

```tsx
import { Trans } from "@workspace/i18n";
import { withI18n } from "@workspace/i18n/with-i18n";

const Page = () => {
  return <Trans i18nKey="common:hello" components={{ bold: <b /> }} />;
};

export default withI18n(Page);
```

Although, to make it available in the server component, you need to wrap it with the `withI18n` HOC.

Given that server components are rendered in parallel, it's uncertain which one will render first. Therefore, it's crucial to initialize the translations before rendering the server component on each page/layout.

## Client components

For client components, you can use the `useTranslation` hook from the `@workspace/i18n` package:

```tsx
"use client";

import { useTranslation } from "@workspace/i18n";

export default function MyComponent() {
  const { t } = useTranslation();

  return <div>{t("common:hello")}</div>;
}
```

That's the simplest way to get the translations in a client component.

## Server-side

In all other places (e.g. metadata, API routes, sitemaps etc.) you can use the `getTranslation` method to get the translations server-side:

```ts
import { getTranslation } from "@workspace/i18n";

export const generateMetadata = async () => {
  const { t } = await getTranslation();

  return {
    title: t("common:title"),
  };
};
```

It automatically checks the user's preferred locale and uses the correct translation.

## Language switcher

TurboStarter ships with a language customizer component that allows you to switch between languages. You can import and use the `LocaleCustomizer` component and drop it anywhere in your application to allow users to change the language seamlessly.

```tsx
import { LocaleCustomizer } from "@workspace/ui-web/i18n";

export default function MyComponent() {
  return <LocaleCustomizer />;
}
```

The component automatically displays all languages configured in your i18n settings. When a user switches languages, it will:

1. Update the URL to include the new locale prefix (e.g. `/es/dashboard`)
2. Store the selected locale in a cookie for persistence
3. Refresh translations across the entire application
4. Preserve the current page/route during the language switch

This provides a seamless localization experience without requiring any additional configuration.

## Best practices

Here are some recommended best practices for managing translations in your application:

* Use descriptive translation keys that follow a logical hierarchy

  ```ts
  // ✅ Good
  "auth.login.title";

  // ❌ Bad
  "loginTitleForAuth";
  ```

* Keep translations organized in separate namespaces/files based on features or sections

  ```
  translations/
  ├── en/
  │   ├── auth.json
  │   └── common.json
  └── pl/
      ├── auth.json
      └── billing.json
  ```

* Avoid hardcoding text strings - always use translation keys even for seemingly static content

* Always provide a fallback language (usually English) for when translations are missing

* Use pluralization and interpolation features when dealing with dynamic content

  ```ts
  // Pluralization
  t("items", { count: 2 }); // "2 items"

  // Interpolation
  t("welcome", { name: "John" }); // "Welcome, John!"
  ```

* Regularly review and clean up unused translation keys to keep files maintainable

* Use TypeScript for type-safe translation keys


# Legal pages
Source: https://www.turbostarter.dev/docs/web/marketing/legal

Legal pages are defined in the `apps/web/src/app/[locale]/(marketing)/legal` directory.

TurboStarter comes with the following legal pages:

* **Terms and Conditions**: to define the terms and conditions of your application
* **Privacy Policy**: to define the privacy policy of your application
* **Cookie Policy**: to define the cookie policy of your application

For obvious reasons, **these pages are empty and you need to fill in the content.**

## Content from CMS

Content for legal pages are stored as [MDX](https://mdxjs.com/) files in [content collection](/docs/web/cms/content-collections) in `packages/cms/src/content/collections/legal` directory.

Then it's parsed and rendered as a Next.js page under corresponding slug:

```tsx title="apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx"
import {
  CollectionType,
  getContentItemBySlug,
  getContentItems,
} from "@workspace/cms";

export default async function Page({ params }: PageParams) {
  const item = getContentItemBySlug({
    collection: CollectionType.LEGAL,
    slug: (await params).slug,
    locale: (await params).locale,
  });

  if (!item) {
    return notFound();
  }

  return <Mdx mdx={item.mdx} />;
}

export function generateStaticParams() {
  return getContentItems({ collection: CollectionType.LEGAL }).items.map(
    ({ slug, locale }) => ({
      slug,
      locale,
    }),
  );
}
```

As it's fully typesafe it also allows us to generate metadata for each page based on the frontmatter that you define in the MDX file:

```tsx title="apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx"
export async function generateMetadata({ params }: PageParams) {
  const item = getContentItemBySlug({
    collection: CollectionType.LEGAL,
    slug: (await params).slug,
    locale: (await params).locale,
  });

  if (!item) {
    return notFound();
  }

  return getMetadata({
    title: item.title,
    description: item.description,
  })({ params });
}
```

Read more about it in the [CMS section](/docs/web/cms/overview).

## ChatGPT prompts

Each `.mdx` file with legal content include a set of useful prompts that you can use to generate the content.

<Callout type="warn" title="Please, be aware of this!">
  Please, be aware that **ChatGPT is not a lawyer** and the content generated by it should be reviewed by one before publishing. Take your time and treat the generated content as a starting point not a final document.
</Callout>

```mdx title="privacy-policy.mdx"
---
title: Privacy Policy
description: Our privacy policy outlines how we collect, use, and protect your personal information.
---

{/* 💡 You can use one of the following ChatGPT prompts to generate this 💡 */}

...
```

Feel free to add your own content or even additional pages to the `legal` collection.

The cookie consent banner and dialog link to the privacy and cookie policy pages. Configure that flow in [Cookie consent](/docs/web/recipes/cookie-consent).


# Marketing pages
Source: https://www.turbostarter.dev/docs/web/marketing/pages

TurboStarter comes with pre-defined marketing pages to help you get started with your SaaS application. These pages are built with Next.js and Tailwind CSS and are located in the `apps/web/src/app/[locale]/(marketing)` directory.

TurboStarter comes with the following marketing pages:

* **Home**: conversions-optimized [landing page](https://demo.turbostarter.dev) with [hero section](https://demo.turbostarter.dev#hero), [features](https://demo.turbostarter.dev#features), [pricing](https://demo.turbostarter.dev#pricing), [testimonials](https://demo.turbostarter.dev#testimonials), [FAQ](https://demo.turbostarter.dev#faq), newsletter signup, and more
* **Waitlist**: dedicated email capture page for pre-launch and early-access campaigns
* [Blog](/docs/web/cms/blog): to display your blog posts
* **Pricing**: to display your pricing plans
* **Contact**: to enable users to contact you with a contact form

## Waitlist

The waitlist page lives at `apps/web/src/app/[locale]/waitlist` and includes an email signup form with validation and success feedback. Use it for early access, product launches, or closed betas before your full marketing site is live.

The homepage also ships a **newsletter** section (`apps/web/src/modules/marketing/home/newsletter.tsx`) so you can collect subscribers without a separate page.

## Contact form

To make the contact form work, you need to add the following environment variable:

```dotenv
CONTACT_EMAIL=
```

Set this variable to the email address where you want to receive contact form submissions. The sender's email address will match what you configured in your [mailing configuration](/docs/web/emails/configuration).

## Adding a new marketing page

To add a new marketing page, create a new directory in `apps/web/src/app/[locale]/(marketing)` with the desired route name.

The page will automatically become available in your application at the corresponding URL path.

For example, to create a page accessible at `/about`, create a directory named `about` and add a `page.tsx` file inside it. The complete path would be `apps/web/src/app/[locale]/(marketing)/about/page.tsx`.

```tsx title="apps/web/src/app/[locale]/(marketing)/about/page.tsx"
export default function AboutPage() {
  return <div>About</div>;
}
```

This page inherits the layout at `apps/web/src/app/[locale]/(marketing)/layout.tsx`. You can customize the layout by editing this file - but remember that it will affect all marketing pages.


# SEO
Source: https://www.turbostarter.dev/docs/web/marketing/seo

SEO is an important part of building a website. It helps search engines understand your website and rank it higher in search results. In this guide, you'll learn how to improve your SaaS application's search engine optimization (SEO).

<Callout title="Already optimized!">
  TurboStarter is already optimized for SEO out of the box (including meta tags, sitemaps, robots files and many more). However, there are a few things you can do to improve your application's SEO.
</Callout>

**Content:** High-quality, relevant content is the cornerstone of effective SEO. Focus on **creating valuable, engaging content** that addresses your customers' needs and questions. Regularly update your content to keep it fresh and relevant.

**Keyword optimization:** Conduct thorough keyword research to identify terms your target audience is searching for. Incorporate these keywords naturally into your content, titles, meta descriptions, and headers. Avoid keyword stuffing; prioritize readability and user experience.

**On-Page SEO:**

* Use descriptive, keyword-rich titles and meta descriptions for each page.
* Implement a clear heading structure (H1, H2, H3) to organize your content.
* Optimize images with descriptive file names and alt text.
* Ensure your URLs are clean, descriptive, and include relevant keywords.

**Technical SEO:**

* Improve website loading speed by optimizing images, minifying CSS and JavaScript, and leveraging browser caching.
* Ensure your website is mobile-friendly and responsive across all devices.
* Implement schema markup to help search engines better understand your content.
* Use HTTPS to secure your website and boost search rankings.

**User experience:**

* Design an intuitive site structure and navigation to improve user engagement.
* Reduce bounce rates by creating compelling, easy-to-read content.
* Implement internal linking to guide users through your site and distribute page authority.

**Link building:**

* Create high-quality, shareable content to naturally attract backlinks.
* Engage in guest posting on reputable sites within your industry.
* Participate in industry forums and discussions, providing valuable insights and linking to your content when relevant.
* Leverage social media to increase content visibility and encourage sharing.

**Local SEO (if applicable):**

* Claim and optimize your Google My Business listing.
* Ensure consistent NAP (Name, Address, Phone) information across all online directories.
* Encourage customer reviews on Google and other relevant platforms.

**Monitor and analyze:**

* Use [Google Search Console](https://search.google.com/search-console/about) to monitor your site's performance in search results and identify issues.
* Regularly analyze your SEO efforts using tools like Google Analytics to understand user behavior and refine your strategy.

**Stay updated:**

* Keep abreast of SEO best practices and algorithm updates to continually refine your strategy.
* Regularly audit your website to identify and fix any SEO issues.

## Sitemap

Generally speaking, Google will find your pages without a sitemap as it follows the link in your website. However, you can add pages to the sitemap by adding them to the `apps/web/src/app/sitemap.ts` file, which is used to generate the sitemap.

If you add more static pages to your website, you can add them to the sitemap by adding them to the `apps/web/src/app/sitemap.ts` returned array.

```tsx title="sitemap.ts"
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      ...getEntry(pathsConfig.index),
      lastModified: new Date(),
      changeFrequency: "monthly",
      priority: 1,
    },
    ...getContentItems({
      collection: CollectionType.BLOG,
      locale: appConfig.locale,
    }).items.map<MetadataRoute.Sitemap[number]>((post) => ({
      ...getEntry(pathsConfig.marketing.blog.post(post.slug)),
      lastModified: new Date(post.lastModifiedAt),
      changeFrequency: "monthly",
      priority: 0.7,
    })),

    /* other pages */
  ];
}
```

All the existing pages are already added to the sitemap. You don't need to add them manually.

## Meta tags

TurboStarter provides a helper function called `getMetadata` to easily set meta tags for your pages. This helper ensures consistent metadata formatting across your site and includes essential SEO tags like title, description, and Open Graph tags. You can use it in any page's metadata export:

```tsx title="page.tsx"
export const generateMetadata = getMetadata({
  title: "My Page Title",
  description: "My Page Description",
});
```

This will generate the following meta tags:

```html
<meta name="description" content="My Page Description" />
<meta property="og:title" content="My Page Title" />
<meta property="og:description" content="My Page Description" />
```

The `getMetadata` helper is really useful for generating consistent meta tags across your site, making SEO optimization simpler and more reliable.

<Callout title="Translations supported!">
  `getMetadata` also supports translations. You can pass a translation key to the `title` and `description` parameters, and it will automatically use the correct translation for the current locale.

  ```tsx
  export const generateMetadata = getMetadata({
    title: "billing:title",
    description: "billing:description",
  });
  ```

  In this example, the `title` and `description` will be fetched from the `billing` namespace for the current locale and placed in the meta tags.
</Callout>

## Backlinks

Backlinks are said to be the **most important factor** in modern SEO. The more backlinks you have from high-quality websites, the higher your website will rank in search results - and the more traffic you'll get.

How do you acquire backlinks? The most effective strategy is to create high-quality, valuable content that naturally attracts links from other websites. However, there are several other methods to build backlinks:

1. **Guest blogging:** Contribute articles to reputable websites within your industry. This not only provides backlinks but also exposes your brand to a new audience.
2. **Strategic outreach:** Identify websites that could benefit from linking to your content. Reach out with a personalized pitch, explaining the value your content adds to their audience.
3. **Digital PR:** Create newsworthy content or conduct original research that journalists and bloggers will want to reference and link to.
4. **Broken link building:** Find broken links on relevant websites and suggest your content as a replacement.
5. **Resource page link building:** Find resource pages in your niche and suggest your content for inclusion.
6. **Social media engagement:** While not directly impacting SEO, active social media presence can increase content visibility and indirectly lead to more backlinks.
7. **Create linkable assets:** Develop infographics, tools, or comprehensive guides that others in your industry will want to reference.
8. **Participate in industry forums and discussions:** Contribute meaningfully to conversations in your field, including your website when relevant.

Remember, the quality of backlinks is more important than quantity. Focus on acquiring links from authoritative, relevant websites in your niche. Avoid any black-hat techniques or link schemes that could result in penalties from search engines.

## Adding your website to Google Search Console

Once you've optimized your website for SEO, you can add it to Google Search Console. Google Search Console is a free tool that helps you monitor and maintain your website's presence in Google search results.

You can use it to check your website's indexing status, submit sitemaps, and get insights into how Google sees your website.

The first thing you need to do is verify your website in Google Search Console. You can do this by adding a meta tag to your website's HTML or by uploading an HTML file to your website.

Once you've verified your website, you can submit your sitemap to Google Search Console. This will help Google find and index your website's pages faster.

Please submit your sitemap to Google Search Console by going to the `Sitemaps` section and adding the URL of your sitemap. The URL of your sitemap is `https://your-website.com/sitemap.xml`.

Of course, please replace `your-website.com` with your actual website URL.

## Content

When it comes to internal factors, **content is king**. Make sure your content is relevant, useful, and engaging. Make sure it's updated regularly and optimized for SEO.

<Callout title="What should you write about?">
  Most importantly, you want to think about how your customers will search for the problem your SaaS is solving. For example, if you're building a project management tool, you might want to write about project management best practices, how to manage a remote team, or how to use your tool to improve productivity.
</Callout>

You can use the blog and documentation features in TurboStarter to create high-quality content that will help your website rank higher in search results - and help your customers find what they're looking for.

## Indexing and ranking take time

New websites can take a while to get indexed by search engines. It can take anywhere from a few days to a few weeks (in some cases, even months!) for your website to show up in search results. Be patient and keep updating your content and optimizing your website for search engines.

Also, you can edit `robots.ts` file to control which pages are indexed by search engines:

```tsx title="robots.ts"
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/api", "/dashboard", "/auth"],
    },
    sitemap: appConfig.url + "/sitemap.xml",
  };
}
```

Remember, **SEO is an ongoing process.** Consistently apply these practices and adapt your strategy based on performance data and industry changes to improve your search engine visibility over time.


# Overview
Source: https://www.turbostarter.dev/docs/web/monitoring/overview

TurboStarter includes lightweight monitoring hooks so you can quickly answer: **what's failing**, **where it's failing**, and **who it's affecting**. Out of the box, the web app can report exceptions from both the client and the server, and it's designed to be easy to extend with your preferred provider.

## Capturing exceptions

Monitoring starts with capturing exceptions reliably in the places that matter most:

* **Client-side errors**: the Next.js App Router error boundary reports unexpected runtime errors so you get visibility without leaving users stuck on a broken screen.
* **Server-side errors**: API failures (for example, Hono errors in production) can be reported with a stable, anonymous distinct id so you can spot recurring issues and correlate them with sessions.
* **Manual reporting**: you can also report exceptions from your own `try/catch` blocks to add extra context around critical flows (payments, onboarding, imports, etc.).

<Tabs items={["Client-side", "Server-side"]}>
  <Tab value="Client-side">
    ```tsx
    "use client";

    import { captureException } from "@workspace/monitoring-web";

    export default function ExampleComponent() {
      const handleClick = () => {
        try {
          /* some risky operation */
        } catch (error) {
          captureException(error);
        }
      };

      return <button onClick={handleClick}>Trigger Exception</button>;
    }
    ```
  </Tab>

  <Tab value="Server-side">
    ```ts
    import { captureException } from "@workspace/monitoring-web/server";

    try {
      /* do something */
    } catch (error) {
      captureException(error);
    }
    ```
  </Tab>
</Tabs>

<Callout type="error" title="Ensure correct import!">
  Make sure to use the correct import for the `captureException` function. We're using the same name for both client and server monitoring, but they are different functions. For server-side, just add `/server` to the import path (`@workspace/monitoring-web/server`).

  <Tabs items={["Client-side", "Server-side"]}>
    <Tab value="Client-side">
      ```tsx
      import { captureException } from "@workspace/monitoring-web";
      ```
    </Tab>

    <Tab value="Server-side">
      ```tsx
      // [!code word:server]
      import { captureException } from "@workspace/monitoring-web/server";
      ```
    </Tab>
  </Tabs>
</Callout>

## Identifying users

Exception reports become dramatically more actionable once they're tied to a real user. TurboStarter automatically identifies signed-in users (based on the current auth session), which allows your monitoring provider to associate exceptions and sessions with a user profile.

If you want richer debugging, identify users with traits (like email, plan, or role) so you can filter and segment issues by the people impacted.

```tsx title="monitoring.tsx"
"use client";

import { useEffect } from "react";
import { identify } from "@workspace/monitoring-web";
import { authClient } from "~/lib/auth/client";

export const MonitoringProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const session = authClient.useSession();

  useEffect(() => {
    if (session.isPending) {
      return;
    }

    identify(session.data?.user ?? null);
  }, [session]);

  return <>{children}</>;
};
```

<Callout title="Identifying users on the server" type="warn">
  On the server, there are no dedicated identification helper. Most providers that support user-level tracking expect you to pass an identifier or traits directly within the `captureException` call (for example, as a `userId` or similar property), so make sure to check your specific provider's documentation for the recommended way to include user information.
</Callout>

## Providers

The starter implements multiple providers for managing monitoring. To learn more about each provider and how to configure them, see their respective sections:

<Cards>
  <Card title="Sentry" href="/docs/web/monitoring/sentry" />

  <Card title="PostHog" href="/docs/web/monitoring/posthog" />
</Cards>

Configuration and setup are handled for you via a unified API, making it easy to switch monitoring providers by just updating the exports. You can also add custom providers without disrupting any monitoring-related logic.

## Best practices

Below are some guidelines to keep monitoring useful, low-noise, and privacy-safe.

<Cards>
  <Card title="Capture actionable errors" className="shadow-none">
    Report unexpected exceptions and failed business-critical operations; avoid
    logging “expected” states (validation errors, user cancellations, missing
    optional data).
  </Card>

  <Card title="Add context" className="shadow-none">
    Include what the user was doing (route/action), relevant IDs (request id,
    order id), and a clear message so you can reproduce and triage quickly.
  </Card>

  <Card title="Identify users, but avoid PII" className="shadow-none">
    Identify with stable IDs; only attach traits that are necessary for
    debugging. Don’t send secrets or sensitive fields (tokens, passwords, raw
    payment details).
  </Card>

  <Card title="Deduplicate and rate-limit" className="shadow-none">
    If a loop or retry can fire many times, guard your capture calls so you
    don’t spam your provider (and your budget).
  </Card>

  <Card title="Separate environments" className="shadow-none">
    Keep dev/staging/prod isolated (separate projects or environment tags) so
    production alerts stay meaningful.
  </Card>

  <Card title="Alert on symptoms that matter" className="shadow-none">
    Set alerts for spikes in error rate, degraded performance, and failures in
    critical flows (auth, checkout, billing webhooks), not for every single
    exception.
  </Card>
</Cards>

Application monitoring helps you track errors, exceptions, and performance issues for better app reliability. With multiple provider support, you can quickly spot and resolve problems.

Focus on actionable errors, useful context, and user privacy to get the most value from your monitoring.


# PostHog
Source: https://www.turbostarter.dev/docs/web/monitoring/posthog

[PostHog](https://posthog.com/) is a comprehensive product analytics platform that includes error tracking, session replay, feature flags, and more. It helps developers identify, diagnose, and fix issues in their applications by capturing and reporting errors and exceptions in real time.

With features like automatic error reporting, stack trace visualization, and user/session context, PostHog provides deep insight into how your application is behaving in production so you can quickly resolve problems and improve reliability.

<Callout type="warn" title="Prerequisite: PostHog account">
  To use PostHog as your monitoring provider, you need to have an account. You can create one [here](https://app.posthog.com/signup) or [self-host](https://posthog.com/docs/self-host) it.
</Callout>

<Callout type="info" title="You can also use it for analytics!">
  PostHog is also one of pre-configured providers for [analytics](/docs/web/analytics/overview) and [feature flags](/docs/web/flags/configuration#posthog) in TurboStarter. You can learn more about analytics [here](/docs/web/analytics/configuration#posthog).
</Callout>

![PostHog banner](/images/docs/web/monitoring/posthog/banner.jpg)

## Configuration

PostHog integrates seamlessly with TurboStarter, enabling you to monitor application errors and performance from development to production. By configuring PostHog as your monitoring provider, you'll be able to detect, track, and resolve issues proactively, leading to a more stable and reliable app.

Follow the simple setup instructions below to get started with PostHog in your TurboStarter project.

<Steps>
  <Step>
    ### Create a project

    First, you need to create a [project](https://app.posthog.com/project/settings) in PostHog. You can do it directly from your [dashboard](https://app.posthog.com) by clicking on the *New Project* button.
  </Step>

  <Step>
    ### Activate PostHog as your monitoring provider

    The monitoring provider to use is determined by the exports in `packages/monitoring/web` package. To activate PostHog as your monitoring provider, you need to update the exports in:

    <Tabs items={["index.ts", "server.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:posthog]
        export * from "./posthog";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:posthog]
        export * from "./posthog/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:posthog]
        export * from "./posthog/env";
        ```
      </Tab>
    </Tabs>

    If you want to customize the provider, you can find its definition in `packages/monitoring/web/src/providers/posthog` directory.
  </Step>

  <Step>
    ### Set environment variables

    Based on your [project settings](https://app.posthog.com/project/settings), fill the following environment variables in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv title="apps/web/.env.local"
    NEXT_PUBLIC_POSTHOG_KEY="your-posthog-api-key"
    NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
    ```
  </Step>
</Steps>

That's it! You can now start your app and see the errors and exceptions in your [PostHog dashboard](https://app.posthog.com/project/error_tracking).

![PostHog error](/images/docs/web/monitoring/posthog/error.png)

Feel free to customize the configuration to your needs. For more information, please refer to the [PostHog documentation](https://posthog.com/docs/error-tracking/installation/nextjs).

<Cards>
  <Card title="Error tracking" href="https://posthog.com/docs/error-tracking" description="posthog.com" />

  <Card title="Next.js error tracking installation" href="https://posthog.com/docs/error-tracking/installation/nextjs" description="posthog.com" />
</Cards>

## Uploading source maps

**Source maps** are files that map your minified or transpiled code (such as the JavaScript code generated by frameworks like Next.js) back to your original source code (for example, TypeScript or unbundled JavaScript). When your app is running in production, the code is often bundled and minified to improve performance, which makes stack traces and error messages hard to read and debug.

<Callout>
  With source maps enabled and uploaded to your monitoring provider (like PostHog), error reports include references to the original lines of your source code, not just the processed/minified output.
</Callout>

PostHog can automatically provide readable stack traces for errors using source maps. The `@posthog/nextjs-config` package handles source map generation and upload automatically during the build process.

To start using source maps, install the package `@posthog/nextjs-config` in `apps/web/package.json` as a dependency.

```bash
pnpm i @posthog/nextjs-config --filter web
```

Next, extend your app's Next.js options by adding `withPostHogConfig` into the `next.config.ts` file:

```ts title="apps/web/next.config.ts"
import { withPostHogConfig } from "@posthog/nextjs-config";

const config = {
  /* existing Next.js configuration options */
};

export default withPostHogConfig(config, {
  personalApiKey: process.env.POSTHOG_API_KEY,
  envId: process.env.POSTHOG_ENV_ID,
  host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  sourcemaps: {
    enabled: true, // Enable sourcemaps generation and upload
    project: "my-application", // Optional: Project name, defaults to repository name
    version: "1.0.0", // Optional: Release version, defaults to current git commit
    deleteAfterUpload: true, // Delete sourcemaps after upload, defaults to true
  },
});
```

Make sure you have set the following environment variables locally and in your deployment environment:

* `POSTHOG_API_KEY` - Your [Personal API Key](https://app.posthog.com/settings/user-api-keys#variables) with write access on error tracking
* `POSTHOG_ENV_ID` - Project ID from [project settings](https://app.posthog.com/settings/environment#variables)
* `NEXT_PUBLIC_POSTHOG_HOST` - Your PostHog instance URL

<Callout type="warn" title="Verify source map generation, upload and injection">
  Before proceeding, confirm that source maps are being generated by checking for `.js.map` files in your `dist` directory. These are the symbol sets that will be used to unminify stack traces in PostHog.

  Next, confirm that source maps are successfully uploaded to PostHog by checking the [symbol sets](https://app.posthog.com/project/settings/symbol-sets) section in your project settings.

  Finally, confirm that the served files are injected with the correct source map comment in production. You can do this by inspecting your deployed app in browser dev tools and looking for a comment like this at the end of your JavaScript bundles:

  ```js
  //# chunkId=0197e6db-9a73-7b91-9e80-4e1b7158db5c
  ```
</Callout>

Once everything is set up, PostHog will provide you with detailed, easy-to-read error reports that link directly back to your original source code - even after your code has been bundled or minified. This makes diagnosing and fixing production issues much simpler.

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Upload source maps for Next.js" href="https://posthog.com/docs/error-tracking/upload-source-maps/nextjs" description="posthog.com" />
</Cards>


# Sentry
Source: https://www.turbostarter.dev/docs/web/monitoring/sentry

[Sentry](https://sentry.io/welcome/) is a popular error monitoring and performance tracking platform. It helps developers identify, diagnose, and fix issues in their applications by capturing and reporting errors and exceptions in real time.

With features like automatic error reporting, stack trace visualization, and user/session context, Sentry provides deep insight into how your application is behaving in production so you can quickly resolve problems and improve reliability.

<Callout type="warn" title="Prerequisite: Sentry account">
  To use Sentry as your monitoring provider, you need to have an account. You can create one [here](https://sentry.io/signup).
</Callout>

![Sentry banner](/images/docs/web/monitoring/sentry/banner.png)

## Configuration

Sentry integrates seamlessly with TurboStarter, enabling you to monitor application errors and performance from development to production. By configuring Sentry as your monitoring provider, you’ll be able to detect, track, and resolve issues proactively, leading to a more stable and reliable app.

Follow the simple setup instructions below to get started with Sentry in your TurboStarter project.

<Steps>
  <Step>
    ### Create a project

    First, you need to create a [project](https://docs.sentry.io/product/projects/) in Sentry. You can do it directly from your [dashboard](https://sentry.io/settings/account/projects/) by clicking on the *Create Project* button.
  </Step>

  <Step>
    ### Activate Sentry as your monitoring provider

    The monitoring provider to use is determined by the exports in `packages/monitoring/web` package. To activate Sentry as your monitoring provider, you need to update the exports in:

    <Tabs items={["index.ts", "server.ts", "env.ts"]}>
      <Tab value="index.ts">
        ```ts
        // [!code word:sentry]
        export * from "./sentry";
        ```
      </Tab>

      <Tab value="server.ts">
        ```ts
        // [!code word:sentry]
        export * from "./sentry/server";
        ```
      </Tab>

      <Tab value="env.ts">
        ```ts
        // [!code word:sentry]
        export * from "./sentry/env";
        ```
      </Tab>
    </Tabs>

    If you want to customize the provider, you can find its definition in `packages/monitoring/web/src/providers/sentry` directory.
  </Step>

  <Step>
    ### Set environment variables

    Based on your [project settings](https://sentry.io/project/settings), fill the following environment variables in your `.env.local` file in `apps/web` directory and your deployment environment:

    ```dotenv title="apps/web/.env.local"
    NEXT_PUBLIC_SENTRY_DSN="your-sentry-dsn"
    NEXT_PUBLIC_PROJECT_ENVIRONMENT="your-project-environment"
    ```
  </Step>

  <Step>
    ### Apply instrumentation to your app

    Install the package `@sentry/nextjs` in `apps/web/package.json` as a dependency.

    ```bash
    pnpm i @sentry/nextjs --filter web
    ```

    Next, extend your app's Next.js options by adding `withSentryConfig` into the `next.config.ts` file:

    ```ts title="apps/web/next.config.ts"
    import { withSentryConfig } from "@sentry/nextjs";

    const config = {
      /* existing Next.js configuration options */
    };

    export default withSentryConfig(config, {
      org: "your-sentry-org",
      project: "your-sentry-project",
    });
    ```
  </Step>
</Steps>

That's it! You can now start your app and see the errors and exceptions in your [Sentry dashboard](https://sentry.io/settings/account/projects/).

![Sentry error](/images/docs/web/monitoring/sentry/error.jpg)

Feel free to customize the configuration to your needs. For more information, please refer to the [Sentry documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/).

<Cards>
  <Card title="Quick Start" href="https://docs.sentry.io/platforms/javascript/guides/nextjs/" description="docs.sentry.io" />

  <Card title="Manual Setup" href="https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/" description="docs.sentry.io" />
</Cards>

## Uploading source maps

**Source maps** are files that map your minified or transpiled code (such as the JavaScript code generated by frameworks like Next.js) back to your original source code (for example, TypeScript or unbundled JavaScript). When your app is running in production, the code is often bundled and minified to improve performance, which makes stack traces and error messages hard to read and debug.

<Callout>
  With source maps enabled and uploaded to your monitoring provider (like Sentry), error reports include references to the original lines of your source code, not just the processed/minified output.
</Callout>

Sentry can automatically provide readable stack traces for errors using source maps, requiring a [Sentry auth token](https://docs.sentry.io/account/auth-tokens/).

Update your `next.config.ts` file with the following options:

```ts title="apps/web/next.config.ts"
import { withSentryConfig } from "@sentry/nextjs";

const config = {
  /* existing Next.js configuration options */
};

export default withSentryConfig(config, {
  org: "your-sentry-org",
  project: "your-sentry-project",

  // An auth token is required for uploading source maps.
  authToken: process.env.SENTRY_AUTH_TOKEN, // [!code ++]

  // Upload a larger set of source maps for prettier stack traces (increases build time)
  widenClientFileUpload: true, // [!code ++]
});
```

Then, set the `SENTRY_AUTH_TOKEN` environment variable in your `.env.local` file in `apps/web` directory and your deployment environment:

```dotenv title="apps/web/.env.local"
SENTRY_AUTH_TOKEN="your-sentry-auth-token"
```

With these steps, your Sentry integration will give you clear, actionable error reports tied directly to your source code - even after bundling and minification. This makes it much easier to debug and resolve production issues.

Take a moment to test your setup and ensure source maps are correctly resolving stack traces in your [Sentry dashboard](https://sentry.io/settings/account/projects/). For deeper customization or additional troubleshooting, always consult the [official Sentry documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/sourcemaps/).

<Cards>
  <Card title="What are source maps?" href="https://web.dev/articles/source-maps" description="web.dev" />

  <Card title="Source maps" href="https://docs.sentry.io/platforms/javascript/guides/nextjs/sourcemaps/" description="docs.sentry.io" />
</Cards>


# Active organization
Source: https://www.turbostarter.dev/docs/web/organizations/active-organization

The active organization is tracked based on the **URL slug** and the **session state**. We made it **as simple as possible** to use, introducing our custom hooks and an abstraction to sync it both ways.

Below you can find more details about how to access the active organization across different contexts in your application.

You can customize the behavior to your needs—for example, to restrict users to at most one organization at a time.

## Server component

You have two separate ways to access the active organization of the currently logged-in user on the server:

* from the URL slug (organization-scoped routes)
* from the session (when no slug is present or you don't want to use it)

We recommend always using the URL slug when you're doing something inside an organization-scoped route. This keeps the URL as the source of truth and works seamlessly with SSR and caching.

```tsx title="page.tsx"
import { getOrganization } from "~/lib/auth/server";

export default async function Page({
  params,
}: {
  params: Promise<{
    organization: string;
  }>;
}) {
  const organization = (await params).organization;
  const activeOrganization = await getOrganization({ slug: organization });

  return <>{activeOrganization?.name}</>;
}
```

Alternatively, you can use the session to access the active organization. This reads `session.activeOrganizationId` and resolves the organization by its stable ID.

```tsx title="page.tsx"
import { getOrganization, getSession } from "~/lib/auth/server";

export default async function Page() {
  const { session } = await getSession();
  const activeOrganization = await getOrganization({
    id: session.activeOrganizationId,
  });

  return <>{activeOrganization?.name}</>;
}
```

Be aware that sometimes you might encounter synchronization issues between the URL slug and the session, for example when a user opens multiple tabs to different organizations. More on this in the [Edge cases](#edge-cases) section.

## Client component

On the client side, we designed a dedicated hook to access the active organization - `useActiveOrganization`. It's a simple wrapper around the API that returns the active organization based on the URL slug or the session. It also helps keep the state in sync with the server session.

```tsx title="client.tsx"
"use client";

import { useActiveOrganization } from "~/lib/hooks/use-active-organization";

export default function ClientComponent() {
  const { activeOrganization, activeMember } = useActiveOrganization();

  return (
    <>
      <p>{activeOrganization?.name}</p>
      <p>{activeMember?.role}</p>
    </>
  );
}
```

Using the hook is recommended over direct API calls, as it will keep the state in sync with the server session.

It also returns the active member of the active organization, so you can access the user's role and other member-specific data.

## API route

To access the active organization data in an API route, you can read it from the session that is appended to the context when you use [authentication middleware](/docs/web/api/protected-routes).

```ts title="action/router.ts"
export const actionRouter = new Hono().post("/", enforceAuth, async (c) => {
  const organizationId = c.var.user.activeOrganizationId;
  const organization = await getOrganization({ id: organizationId });
  return c.json(organization);
});
```

Although it's the simplest way, we recommend directly passing the `organizationId` together with the payload when you need to perform an action.

```ts title="action/router.ts"
export const actionRouter = new Hono().post(
  "/",
  enforceAuth,
  validate(
    "json",
    z.object({
      organizationId: z.string(),
      /* rest of the payload */
    }),
  ),
  async (c) => {
    const { organizationId, ...payload } = c.req.valid("json");
    const organization = await getOrganization({ id: organizationId });
    return c.json(await performAction(organization, payload));
  },
);
```

This ensures that the action is performed on the correct organization, even if the user has multiple organizations open in different tabs. See [Edge cases](#edge-cases) for more details.

## Edge cases

* **Expected and harmless:** Short periods where the URL slug and server session differ can happen (for example, with multiple tabs or quick switching). The active tab always treats the slug as the source of truth and the session catches up.
* **Multiple tabs:** Each tab maintains its own org context from its slug. As you switch focus, the shared session updates; brief divergence is normal and safe.
* **Rapid switching/slow network:** During fast navigation or poor connectivity, you may momentarily see the previous org while the session updates. Show a small loading state; cancel in-flight requests tied to the old org.
* **Missing/invalid slug:** If the slug is missing or invalid, we fall back to the session’s `activeOrganizationId` or redirect to a safe default.
* **Access or permission changes:** If a user loses access to the org they’re viewing, the data is cleared from the session and the user is redirected to a valid organization or personal dashboard.

<Callout type="warn" title="Invalidation">
  Whenever the active organization changes, the server session is updated and the client is redirected to the new organization scope.

  All caches keyed by organization are invalidated to avoid leaking data between organizations.
</Callout>


# Data model
Source: https://www.turbostarter.dev/docs/web/organizations/data-model

Our multi-tenant model is organized around the concept of an **organization**. An organization represents a single tenant and is the primary boundary for data isolation, access control, and routing.

Users can belong to multiple organizations through a membership. Invitations let organization admins onboard new members by email with a specific role.

<OrganizationsDbFlow />

## Entities

### Organization

The tenant. Stores human-friendly `name`, unique `slug` (used in URLs and lookups), optional `logo`, and optional `metadata` for extensibility (feature flags, billing context, UI preferences, etc.). `createdAt` provides auditability. The `slug` is globally unique to keep URLs stable and predictable.

### User

The identity of a person. Users are global and can join many organizations. Account-level fields (e.g., `name`, `email`, verification, avatar, security flags) live here.

<Callout type="warn">
  A user's application-wide properties (like a global `role` or moderation flags) are distinct from their per-organization role.
</Callout>

### Member (Membership)

The join between a `user` and an `organization`. This is where multi-tenancy permissions are enforced. Each membership stores the `role` the user holds in that specific organization (for example, `member`, `admin`).

Memberships include timestamps for auditing and can be cascaded when a user or organization is removed.

### Invitation

Represents an invite to join an organization by `email` with an intended `role`. It includes `status` (e.g., pending, accepted, revoked), `expiresAt`, and `inviterId` for traceability.

On acceptance, an invitation creates a corresponding membership if one does not already exist.

## Relationships and constraints

<Accordions type="multiple">
  <Accordion title="Many-to-many">
    Users and organizations are related many-to-many through memberships. A user
    can join multiple organizations; an organization has multiple members.
  </Accordion>

  <Accordion title="Uniqueness">
    We keep `organization.slug` unique across the system to ensure
    consistent routing and discoverability. Within a single organization, each
    `userId` should only appear once in memberships; enforce this
    at the application layer or with a composite unique index
    `(organizationId, userId)`.
  </Accordion>

  <Accordion title="Cascades">
    * Deleting an organization removes its dependent memberships and invitations.
    * Deleting a user removes their memberships and invitations.

    These cascades preserve referential integrity and prevent orphaned records.
  </Accordion>
</Accordions>

## Tenancy and isolation

### Tenant separator

`organizationId` is the tenant key. All tenant-scoped data should either live under the organization or reference it directly. Every read/write path in the application should be constrained by the current `organizationId`.

### Query guardrails

Derive the active `organizationId` from authenticated context (session or URL slug → lookup → id). Apply `organizationId` filters at the repository/service layer to avoid cross‑tenant reads. Add composite indexes that include `organizationId` on frequently queried relations.

### Isolation level

All organizations share the same database and schema, separated by `organizationId`. This keeps operations simple and cost‑effective. If stricter isolation is needed, evolve toward schema‑per‑tenant or database‑per‑tenant with care, as operational overhead increases.

<Callout title="Rename organizations">
  The term "organizations" is used throughout the starter kit to identify a group of users. However, depending on your application's needs, you might want to represent these groups with a different name, such as "Teams" or "Workspaces."

  If that's the case, we suggest retaining "organization" as the internal term within your codebase (to avoid the complexity of renaming it everywhere), while customizing the UI labels to your preferred terminology. To do this, simply update all user-facing instances of "Organization" in your interface to reflect the term that best fits your application.
</Callout>

## Lifecycle flows

* **Create organization**: Create an organization (with `name`, `slug`, optional `logo`/`metadata`) and immediately create a membership for the creator with an elevated role (commonly `owner`).
* **Invite member**:
  1. Admin creates an invitation specifying `email` and intended `role`.
  2. The invite is sent by email with an expiring token.
  3. On acceptance, if the user exists they are added as a member; otherwise they register and then join.
  4. Handle idempotency so repeated accepts don’t duplicate memberships.
* **Leave or remove**: Members can leave an organization and admins can remove members. The policy that "at least one owner must remain" is enforced at the application layer.


# Invitations
Source: https://www.turbostarter.dev/docs/web/organizations/invitations

You can invite teammates **by email** to join an organization straight from your organization settings.

Acceptance is frictionless: we verify the invite, create (or reuse) the membership with the intended role, and activate the organization in the user's session.

The implementation is based on the [Better Auth plugin](https://better-auth.com/docs/plugins/organization) and designed to drive engagement, minimize back-and-forth and keep admins in control.

![Invitations list](/images/docs/web/organizations/invitations/list.png)

## Model

As we can see inside our [data model](/docs/web/organizations/data-model), an invitation targets an `email`, carries the intended `role`, records the `inviterId`, and is scoped to an `organizationId`.

```ts
export const invitation = pgTable("invitation", {
  id: text().primaryKey(),
  organizationId: text()
    .notNull()
    .references(() => organization.id, { onDelete: "cascade" }),
  email: text().notNull(),
  role: text(),
  status: text().default("pending").notNull(),
  inviterId: text()
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  createdAt: timestamp().defaultNow().notNull(),
  expiresAt: timestamp().notNull(),
});
```

The invitations expire at `expiresAt` to keep links short‑lived.

## Status

An invitation can be in one of three states:

* **Pending**: created/sent, awaiting acceptance.
* **Accepted**: verified; membership created or reused.
* **Rejected**: manually invalidated or removed via cascades.

<Callout>
  Expiration is controlled by `expiresAt` (not a separate status). After this timestamp, the link is invalid and should be resent.
</Callout>

## Flow

1. Admin creates an invite with `email` and `role`. The `organizationId` is inferred from the context.
2. System generates a signed, single-use token bound to the invite and `expiresAt` and sends a CTA link.
3. Recipient opens the link; we verify the token and email.
4. On success, we proceed to acceptance.

## Onboarding

### Existing user

After verification, we create (or reuse) a membership with the invited role and set the active organization in the session.
![Join organization prompt](/images/docs/web/organizations/invitations/join.png)

### New user

We attach the invite context to signup; after registration, we create the membership and activate the organization - no detours required.
![Invitation disclaimer](/images/docs/web/organizations/invitations/sign-in-disclaimer.png)

You can fully customize the invitation flow to fit your organization's needs. For example, you can add extra onboarding steps, capture additional user information, or implement advanced verification logic as part of the invite process.

The system is designed to be extensible—tailor it to match your team's requirements and user experience preferences.

## Automatic invalidation

An invitation is automatically revoked in the following scenarios:

* **The user accepts the invitation:** Once accepted, the token becomes invalid.
* **The user changes their email address:** To prevent misuse, any changes to the associated email automatically invalidate the token.
* **The user deletes their account:** Invitations linked to a deleted account are revoked to maintain data integrity.

This ensures that invitations remain secure and aligned with the current state of user accounts.

## Invitation management

Admins of the organization and [super admins](/docs/web/admin/overview) can manage invitations via a dedicated section in the dashboard, where they can:

* View the status of all invitations (`pending`, `accepted`, `rejected`).
* Resend invitations who did not respond.
* Revoke invitations if they were sent to the wrong email or are no longer needed.
* Adjust the role of an invitation if not yet accepted


# Overview
Source: https://www.turbostarter.dev/docs/web/organizations/overview

Organizations let you build teams and multi-tenant SaaS out of the box, which is a widely used pattern, especially in a [B2B](https://en.wikipedia.org/wiki/Business-to-business) apps. Users can create organizations, invite teammates, assign roles, and seamlessly switch between workspaces.

<Callout title="What is multi-tenancy?">
  [Multi-tenancy](https://www.ibm.com/think/topics/multi-tenant) is a software architecture pattern where a single instance of an application serves multiple tenants, each with its own data and configuration.
</Callout>

The feature is mostly powered by the [Better Auth organization plugin](https://better-auth.com/docs/plugins/organization) and integrates with TurboStarter's API, routing, data layer, and UI components. This allows you to share most of the code between the web app, [mobile app](/docs/mobile/organizations/overview), and [extension](/docs/extension/organizations).

<ThemedImage light="/images/docs/web/organizations/multi-tenancy/light.png" dark="/images/docs/web/organizations/multi-tenancy/dark.png" alt="Architecture" width={1375} zoomable height={955} />

## Architecture

TurboStarter uses a pragmatic multi-tenant architecture:

* **Tenant context** lives in the session as the active organization ID (derived from the user's selection or defaults). Server handlers read this context to enforce scoping.
* **Data scoping** is performed via `organizationId` on tenant-owned tables and guard clauses in queries. Background tasks and API routes receive the same context.
* **Authorization** combines tenant scoping with role checks. We separate “can access this tenant?” from “can perform this action within the tenant?”.
* **Extensibility**: add new tenant-bound entities by including `organizationId` and using the provided helpers to read the active organization.

This keeps data isolated per organization while remaining simple to reason about and customize.

<Callout>
  You can restrict who can create organizations, perform actions within it, and hook into
  lifecycle events using our API.

  Check dedicated [Data model](/docs/web/organizations/data-model), [RBAC](/docs/web/organizations/rbac) and [Invitations](/docs/web/organizations/invitations) sections or direct [Better Auth docs](https://better-auth.com/docs/plugins/organization) for more details.
</Callout>

## Concepts

To effectively use multi-tenancy in your app, we introduced a few core concepts that define how the whole system works:

| Concept                 | Description                                                                                     |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Organization**        | A workspace that owns resources and settings, acting as an isolated tenant.                     |
| **Member**              | A user assigned to an organization.                                                             |
| **Role**                | Access level within an organization (see [RBAC](/docs/web/organizations/rbac)).                 |
| **Invitation**          | Email request to join an organization (see [Invitations](/docs/web/organizations/invitations)). |
| **Active organization** | The currently selected organization in a user's session, used to scope data and permissions.    |

These concepts provide the building blocks for flexible team management and secure, multi-tenant SaaS applications.

## Development data

In development, TurboStarter automatically [seeds](/docs/web/installation/commands#seeding-database) some example data when you [setup services](/docs/web/installation/commands#setting-up-services):

* One organization is created by default.
* All default roles are created and assigned within that organization.
* Sample invitations are generated so you can test the invite flow.

You can safely experiment with these sample organizations, roles, and invitations to understand multi-tenancy features - [reset](/docs/web/installation/commands#resetting-database) or [reseed](/docs/web/installation/commands#seeding-database) anytime to return to the default state.

The default credentials for demo users can be customized using the `SEED_EMAIL` and `SEED_PASSWORD` environment variables.

<Callout type="error" title="Never run in production">
  The default development data and setup are intended for local development and
  testing only. **Never** use these seeds or configurations in a production
  environment - they are insecure and may expose sensitive functionality.
</Callout>

## Customization

You have flexibility to adapt organizations to fit your product. For example, you might rename labels (such as Organization to *Team* or *Workspace*), and update the UI copy accordingly.

You can adjust the available [roles and permissions](/docs/web/organizations/rbac) to suit your access model.

The [invitation flow](/docs/web/organizations/invitations) can be customized, including how verification, onboarding, or metadata capture work.

You may also want to introduce tenant-specific policies, like usage limits, feature flags, or billing rules.

Building a B2C product with **no teams**? Personal dashboards and user-scoped billing already work without creating an organization. To remove org create, join, and switch entirely, follow [Disable organizations](/docs/web/recipes/disable-organizations).

Building B2B where **every user must belong to a workspace**? Turn off the personal product home and require create-or-join first with [Disable personal accounts](/docs/web/recipes/disable-personal-accounts).

Feel free to check how to configure all of these features in the dedicated sections below.


# RBAC (Roles & Permissions)
Source: https://www.turbostarter.dev/docs/web/organizations/rbac

Role-based access control (RBAC) lets you define who can do what in an organization.

<Callout title="New to RBAC?">
  If you're new to the RBAC concept, a simple mental model is:

  * Users belong to organizations.
  * Users get roles.
  * Roles map to permissions on resources.
</Callout>

In TurboStarter, we primarily rely on the [Better Auth plugin](https://better-auth.com/docs/plugins/organization) for the heavy lifting - roles, permissions, teams, and member management - while handling critical logic with our own code.

This provides a flexible access control system, letting you control user access based on their role in the organization. You can also define custom permissions per role.

<Callout title="Everything is configured out of the box!">
  TurboStarter ships with the default RBAC system configured out of the box. This setup may be enough if you're not planning a very complex access control system, but you can also easily customize it to your needs.

  It also includes [protecting routes](/docs/web/api/protected-routes) that users with specific roles can access by adding custom middlewares and disabling certain actions in the UI.
</Callout>

## Roles

Roles are named bundles of permissions. Keep them few and well-defined. By default, we have the following roles:

```ts
const MemberRole = {
  MEMBER: "member",
  ADMIN: "admin",
  OWNER: "owner",
} as const;
```

A user can have multiple roles in an organization. For example, a user can be a member and an admin (if it makes sense for your application).

<Callout type="warn" title="Don't confuse organization admin with super admin">
  The organization's `admin` role is **different** from the user's global `admin` role.

  The organization `admin` governs permissions only inside the organization, whereas the global `admin` controls access to the [super admin dashboard](/docs/web/admin/overview).
</Callout>

To create additional roles with custom permissions, see the [official documentation](https://better-auth.com/docs/plugins/organization#create-access-control) for more details.

## Permissions

Permissions represent what actions a role can perform on which resources. To check if the current user has permission to perform an action, you can use the `hasPermission` function.

```ts
const canCreateProject = await authClient.organization.hasPermission({
  permissions: {
    project: ["create"],
  },
});
```

Or, if you're performing the check on the server, you can use the `hasPermission` function from the `auth.api` object.

```ts
await auth.api.hasPermission({
  headers: await headers(),
  body: {
    permissions: {
      project: ["create"], // This must match the structure in your access control
    },
  },
});
```

Once your roles and permissions are defined, you can avoid server checks (e.g., to reduce API calls) by using the client-side `checkRolePermission` function.

```ts
const { activeMember } = useActiveOrganization();

const canUpdateProject = authClient.organization.checkRolePermission({
  permission: {
    project: ["update"],
  },
  role: activeMember.role,
});
```

We leverage the existing custom hook to retrieve the active member role within the [active organization](/docs/web/organizations/active-organization) context. That way, you can easily check whether a member has permission to perform an action without a server round trip.

<Callout type="warn">
  This does not include any dynamic roles or permissions because everything runs synchronously on the client-side. Use the `hasPermission` APIs to include checks for dynamic roles and permissions.
</Callout>

If you need to add more granular permissions to existing roles, or create new ones, use the [`createAccessControl`](https://better-auth.com/docs/plugins/organization#custom-permissions) API.

For further customization - such as dynamic access control, lifecycle hooks, or team management - see the guidance in the [official documentation](https://better-auth.com/docs/plugins/organization).


# Integrate AI Kit
Source: https://www.turbostarter.dev/docs/web/recipes/ai-kit

Core Kit already includes a small authenticated streaming chat endpoint. [AI Kit](/ai/docs) adds complete product slices: persisted chat, model selection, attachments, web search, image generation, RAG, text to speech, voice, and a credits example.

Treat the integration as a **feature port**, not a repository merge. Keep Core Kit as the owner of shared infrastructure, then bring over only the AI capabilities your product needs.

<Callout type="warn" title="Do not merge the repositories wholesale">
  Both kits define `apps/web`, `packages/api`, `packages/auth`, `packages/db`, `packages/i18n`, `packages/shared`, `packages/storage`, and UI packages. Replacing those directories with their AI Kit versions removes Core features such as organizations, billing, admin, email, analytics, and monitoring.
</Callout>

The paths below assume both repositories are cloned next to each other:

<Files>
  <Folder name="project" defaultOpen>
    <Folder name="core - Your application and integration target" />

    <Folder name="ai - Source for the AI features" />
  </Folder>
</Files>

## Decide ownership

Use Core Kit as the source of truth for the application shell. AI Kit should contribute feature code behind those existing boundaries.

| Concern            | Keep from Core Kit                                      | Port from AI Kit                                                  |
| ------------------ | ------------------------------------------------------- | ----------------------------------------------------------------- |
| Authentication     | Better Auth config, account flows, organizations, hooks | AI routes that consume `c.var.user.id`                            |
| Billing and access | Plans, subscriptions, feature access                    | Credit costs only if your product needs a separate credit balance |
| API                | Existing Hono app, middleware, error handling           | AI route files under `packages/api/src/modules/ai`                |
| Database           | Existing auth and billing schema                        | Selected AI schemas such as `chat.ts`, `rag.ts`, and `image.ts`   |
| Storage            | Existing `@workspace/storage` package and credentials   | AI upload paths and attachment logic                              |
| UI and i18n        | Core packages, theme, layouts, translations             | Feature-specific components, routes, and strings                  |

This ownership rule is the main protection against difficult future updates. A shared package has one owner; imported AI features adapt to it.

## Choose a first vertical slice

Start with one template and make it work end to end before adding another.

| Template       | AI package                              | Database schema              | Web surface                     |
| -------------- | --------------------------------------- | ---------------------------- | ------------------------------- |
| Chat           | `packages/ai/core`, `packages/ai/chat`  | `chat.ts`, optional credits  | `modules/chat`, `(apps)/chat`   |
| Image          | `packages/ai/core`, `packages/ai/image` | `image.ts`, optional credits | `modules/image`, `(apps)/image` |
| RAG            | `packages/ai/core`, `packages/ai/rag`   | `rag.ts`, pgvector           | `modules/rag`, `(apps)/rag`     |
| Text to speech | `packages/ai/core`, `packages/ai/tts`   | Optional credits only        | `modules/tts`, `(apps)/tts`     |
| Voice          | `packages/ai/core`, `packages/ai/voice` | Optional credits only        | `modules/voice`, `(apps)/voice` |

Chat is the best first integration because it exercises authentication, streaming, persistence, storage, and provider configuration without native dependencies.

<Steps>
  <Step>
    ## Copy the selected AI packages

    Copy `../ai/packages/ai/core` and the feature package you selected into `packages/ai/`. Keep their package names, such as `@workspace/ai` and `@workspace/ai-chat`, so the source imports continue to resolve.

    ```bash
    mkdir -p packages/ai
    cp -R ../ai/packages/ai/core packages/ai/core
    cp -R ../ai/packages/ai/chat packages/ai/chat
    ```

    Do not copy `../ai/pnpm-lock.yaml`. Merge the required entries from AI Kit's `pnpm-workspace.yaml` into Core Kit's catalogs, then run `pnpm install` from Core Kit so pnpm resolves one version per dependency.

    Copy dependencies from the selected package's `package.json` rather than installing every provider. For example, the chat package lists its model and web-search providers in `../ai/packages/ai/chat/package.json`.
  </Step>

  <Step>
    ## Merge the database schema

    Copy only the schema files used by the selected template into `packages/db/src/schema/`. Do not replace Core Kit's `auth.ts`, `billing.ts`, or the existing migration history.

    AI Kit groups template tables into PostgreSQL schemas such as `chat`, `rag`, and `image`. The files reference Core Kit's existing `user` table, so authenticated and anonymous Core users can own AI data without a second identity system.

    Merge the exports into the existing index:

    ```ts title="packages/db/src/schema/index.ts"
    export * from "./auth";
    export * from "./billing";
    export * from "./chat";
    ```

    AI Kit's credit example defines a `customer` table in `customer.ts`. Core billing already defines its own `customer`, so **do not copy that file unchanged**. Choose one design:

    * Rename the AI table and exports to a dedicated `ai_credit` or credit-ledger model if credits are separate.
    * Replace `deductCredits` with plan or entitlement checks if AI usage belongs to a Core subscription.
    * Remove the credit middleware and UI if the feature is unlimited.

    RAG also requires the PostgreSQL `vector` extension. Use a database image or hosted provider with pgvector, enable `CREATE EXTENSION vector`, and include that change in the migration review.

    Generate a new migration from the combined schema:

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    Review the generated SQL before applying it to an existing production database. It should add the selected AI tables and schemas, not recreate Core auth tables.
  </Step>

  <Step>
    ## Replace the demo AI route with the selected routers

    Core Kit already mounts `aiRouter` at `/api/ai` in `packages/api/src/index.ts`. AI Kit uses the same mount point, so preserve Core's Hono app and replace the small `packages/api/src/modules/ai/router.ts` implementation with the selected nested routers.

    For chat, port:

    ```text
    ../ai/packages/api/src/modules/ai/chat.ts
    ../ai/packages/api/src/modules/ai/router.ts
    ```

    Then add the selected workspace packages to `packages/api/package.json`. Keep Core Kit's existing `middleware.ts`, and port only the AI-specific `deductCredits` logic if you kept credits. Core's `enforceAuth` already resolves the session and sets `c.var.user`.

    <Callout title="Route collision">
      The Core example exposes `POST /api/ai/chat`. AI Kit chat exposes its persisted operations below `/api/ai/chat/chats`, plus model and sharing routes. Remove or rename the example route before registering the AI Kit router.
    </Callout>

    Keep provider SDKs and provider keys in the server-side AI packages. Browser components should only call the Hono API.
  </Step>

  <Step>
    ## Port the web feature

    Copy the selected route group and feature module from AI Kit:

    ```text
    ../ai/apps/web/src/app/[locale]/(apps)/chat
    ../ai/apps/web/src/modules/chat
    ```

    Chat also imports shared AI application components from `../ai/apps/web/src/modules/common`. Copy the components reached by the feature's imports, but adapt them to Core's existing app layout, navigation, auth client, query provider, theme, and UI package. Do not replace Core's root layout.

    Use Core's authenticated dashboard when the AI feature is part of the product. A typical destination is:

    ```text
    apps/web/src/app/[locale]/dashboard/(user)/chat
    apps/web/src/modules/ai/chat
    ```

    Moving the files is optional, but keeping AI product features under the dashboard makes Core authorization and navigation boundaries explicit.

    Merge the selected app dependencies from `../ai/apps/web/package.json`. In particular, persisted chat uses `@ai-sdk/react` on the client while provider SDKs remain dependencies of the server-side AI package.

    Port the AI elements imported by the feature from `../ai/packages/ui/web/src/components/ai-elements` and merge the `ai` translation namespace from `../ai/packages/i18n/src/translations`. Add each new `@workspace/ai*` package to Core's `INTERNAL_PACKAGES` list in `apps/web/next.config.ts` so Next.js transpiles workspace source.

    Voice also runs `@workspace/ai-voice` as a separate LiveKit worker. Copying its web screen does not deploy that worker.
  </Step>

  <Step>
    ## Merge environment validation

    Copy variable **definitions**, not `.env` files. Add only the providers and tools used by your selected feature to:

    1. `apps/web/.env.example`
    2. `apps/web/env.config.ts`
    3. The relevant package `env.ts` presets
    4. `apps/web/turbo.json` for variables required by build or dev tasks
    5. Your deployment environment

    For a Gateway-backed chat, the minimal provider variable is:

    ```dotenv title="apps/web/.env.local"
    AI_GATEWAY_API_KEY=""
    ```

    Attachments also need the existing Core storage variables. Web search needs the key for the strategy you enable, such as `TAVILY_API_KEY`, `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`, or `FIRECRAWL_API_KEY`.

    Do not expose provider keys with `NEXT_PUBLIC_`.
  </Step>

  <Step>
    ## Verify the integrated slice

    Run the checks from the Core repository:

    ```bash
    pnpm install
    pnpm lint
    pnpm --filter web build
    ```

    Then verify the behavior that crosses package boundaries:

    1. Sign in with a normal Core account and, if enabled, an anonymous account.
    2. Send a request and confirm the response streams.
    3. Reload and confirm persisted content still belongs to the same user.
    4. Try to request another user's chat directly and expect an authorization failure.
    5. Upload an attachment if the selected feature uses storage.
    6. Confirm rate limits and plan or credit checks fail before invoking a paid provider.
  </Step>
</Steps>

## Production readiness

The template gives you the feature shape. Before charging users, tighten the boundaries that retries and long streams exercise:

* Make usage or credit deductions idempotent with a generation or message ID.
* Pass `c.req.raw.signal` into model calls so cancelled requests stop provider work.
* Persist provider usage separately from product credits for reconciliation.
* Add structured AI telemetry without recording prompts or sensitive attachments by default.
* Use resumable streams backed by durable state if conversations must survive navigation, reconnects, or multiple server instances.
* Rate-limit by authenticated user or organization, with an IP fallback for anonymous traffic.

## Other client platforms

The server integration is shared. Once the web slice works, add only the client layer required by each platform:

<Cards>
  <Card title="Mobile integration" description="Port the Expo screen and native dependencies while reusing the Core web API." href="/docs/mobile/recipes/ai-kit" />

  <Card title="Browser extension integration" description="Build a thin extension client for the same server-side AI routes." href="/docs/extension/recipes/ai-kit" />

  <Card title="AI Kit perspective" description="Start from AI Kit and bring Core product infrastructure into it." href="/ai/docs/integrate-core-kit" />
</Cards>


# API keys and webhooks
Source: https://www.turbostarter.dev/docs/web/recipes/api-keys-webhooks

B2B customers expect two integration surfaces: **API keys** to call your API programmatically, and **outbound webhooks** so your app pushes events to their stack. TurboStarter already handles **inbound** billing webhooks from Stripe, Polar, Lemon Squeezy, and friends. This recipe adds the **outbound** path plus Better Auth API keys on the same Hono stack.

<Callout title="TL;DR">
  1. Enable Better Auth's API Key plugin with `enableSessionForAPIKeys`, regenerate auth schema, and allow `x-api-key` in CORS.
  2. Add `webhook_endpoint` + `webhook_delivery` tables and a small `@workspace/webhooks` package (emit, sign, deliver, verify helper).
  3. Expose session-only CRUD under Settings → Developers (keys + endpoints).
  4. Call `emitWebhookEvent` from auth hooks and billing upserts.
  5. Deliver with retries and a Stripe-style HMAC signature customers can verify.
</Callout>

## Inbound vs outbound

| Direction    | Who calls whom                | In TurboStarter today                                                                               |
| ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| **Inbound**  | Billing provider → your API   | Ships at `POST /api/billing/webhook/:provider`. See [Billing webhooks](/docs/web/billing/webhooks). |
| **Outbound** | Your app → customer HTTPS URL | This recipe                                                                                         |
| **API keys** | Customer → your Hono routes   | This recipe (Better Auth plugin)                                                                    |

Do not reuse the billing webhook handlers for customer endpoints. Inbound handlers verify **provider** signatures and update your DB. Outbound delivery signs **your** payloads and POSTs to URLs your customers register.

## Philosophy

When something meaningful happens (member joined, subscription updated, and so on), call `emitWebhookEvent({ type, data, organizationId? })`. That helper inserts a pending `webhook_delivery` row and runs (or queues) delivery: a signed `POST` to the customer URL with an `X-Webhook-Signature` header. A `2xx` response marks the delivery as delivered; `5xx` or timeouts retry with backoff.

API keys sit on the request path: customers send `x-api-key` (plugin default) or `Authorization: Bearer …`. Your middleware resolves a user the same way a cookie session does, so existing `enforceAuth` routes keep working.

## Building blocks

| Layer                         | Location                                                       |
| ----------------------------- | -------------------------------------------------------------- |
| Auth plugin                   | `packages/auth` + regenerated `packages/db/src/schema/auth.ts` |
| CORS / headers                | `packages/api/src/index.ts` (`x-api-key`)                      |
| Webhook schema + emit/deliver | `packages/db` + new `@workspace/webhooks`                      |
| CRUD API                      | `packages/api/src/modules/webhooks/` (session auth only)       |
| Dashboard UI                  | Settings → Developers (user + org)                             |

<Callout type="warn" title="Not a drop-in kit feature">
  Core does not ship this UI or package yet. Treat the snippets as a production pattern to implement on top of Better Auth, Drizzle, and Hono, the same way [Prisma](/docs/web/recipes/prisma) or [feature-based access](/docs/web/recipes/feature-based-access) recipes extend the kit.
</Callout>

<Steps>
  <Step>
    ## Enable Better Auth API keys

    Install the plugin next to your other Better Auth packages (pin the same version as `better-auth` in `packages/auth/package.json`):

    ```bash
    pnpm --filter @workspace/auth add @better-auth/api-key@1.6.22
    ```

    Register it on the server:

    ```ts title="packages/auth/src/server.ts"
    import { apiKey } from "@better-auth/api-key"; // [!code ++]

    export const auth = betterAuth({
      // ...
      plugins: [
        // ...existing plugins
        apiKey({
          enableMetadata: true,
          // Lets auth.api.getSession resolve a user from x-api-key
          enableSessionForAPIKeys: true,
          rateLimit: {
            enabled: true,
            maxRequests: 1000,
            timeWindow: 1000 * 60 * 60, // 1 hour
          },
        }), // [!code ++]
        nextCookies(),
      ],
    });
    ```

    Re-export the client plugin and wire it in the web auth client:

    ```ts title="packages/auth/src/client/web.ts"
    export { apiKeyClient } from "@better-auth/api-key/client"; // [!code ++]
    ```

    ```ts title="apps/web/src/lib/auth/client.ts"
    import {
      // ...
      apiKeyClient, // [!code ++]
    } from "@workspace/auth/client/web";

    export const authClient = createAuthClient({
      // ...
      plugins: [
        // ...existing plugins
        apiKeyClient(), // [!code ++]
        inferAdditionalFields<typeof auth>(),
      ],
    });
    ```

    Regenerate the auth schema and apply migrations:

    ```bash
    pnpm --filter @workspace/auth db:generate
    pnpm with-env turbo db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    <Callout type="info" title="Org-owned keys">
      For organization-scoped keys, pass a config array with `references: "organization"` (and a `configId` such as `org-keys`). Creating those keys requires an `organizationId` in the create body. Start with user-owned keys plus metadata (`label`, optional `organizationId`) if you want a smaller first ship.
    </Callout>
  </Step>

  <Step>
    ## Accept API keys on Hono routes

    With `enableSessionForAPIKeys: true`, existing `enforceAuth` already works: it calls `auth.api.getSession({ headers })`, and Better Auth treats a valid `x-api-key` as a session for that user. You do **not** need a separate verify path for cookie-vs-key.

    Allow the header in CORS (browser-based tools and local demos):

    ```ts title="packages/api/src/index.ts"
    cors({
      origin: "*",
      allowHeaders: ["Content-Type", "Authorization", "x-api-key"], // [!code ++]
      maxAge: 3600,
      credentials: true,
    }),
    ```

    Smoke test against any existing protected route:

    ```bash
    curl -H "x-api-key: YOUR_KEY" http://localhost:3000/api/organizations
    ```

    Server-side check (same as the plugin docs):

    ```ts
    const session = await auth.api.getSession({
      headers: new Headers({ "x-api-key": apiKey }),
    });
    ```

    If you need permission checks on the key itself (not only "is this a valid user?"), call `auth.api.verifyApiKey({ body: { key, permissions } })` in dedicated middleware.

    <Callout type="warn" title="Keep webhook CRUD on sessions">
      Customers should manage keys and webhook endpoints from the dashboard with a normal login. Do not allow creating or rotating endpoints with an API key unless you add explicit scopes later.
    </Callout>
  </Step>

  <Step>
    ## Add webhook tables

    Follow the app-owned table style in `packages/db/src/schema/billing.ts` (`generateId`, timestamps, Zod insert schemas):

    ```ts title="packages/db/src/schema/webhook.ts"
    import { relations } from "drizzle-orm";
    import {
      boolean,
      integer,
      jsonb,
      pgEnum,
      pgTable,
      text,
      timestamp,
    } from "drizzle-orm/pg-core";

    import { generateId } from "@workspace/shared/utils";

    import { createInsertSchema, createSelectSchema } from "../lib/zod";

    import { organization, user } from "./auth";

    export const webhookDeliveryStatusEnum = pgEnum("webhook_delivery_status", [
      "pending",
      "delivered",
      "failed",
    ]);

    export const webhookEndpoint = pgTable("webhook_endpoint", {
      id: text().primaryKey().$defaultFn(generateId),
      organizationId: text().references(() => organization.id, {
        onDelete: "cascade",
      }),
      userId: text()
        .references(() => user.id, { onDelete: "cascade" })
        .notNull(),
      url: text().notNull(),
      secretHash: text().notNull(),
      events: jsonb().$type<string[]>().notNull(),
      enabled: boolean().notNull().default(true),
      createdAt: timestamp().notNull().defaultNow(),
      updatedAt: timestamp()
        .notNull()
        .$onUpdate(() => new Date()),
    });

    export const webhookDelivery = pgTable("webhook_delivery", {
      id: text().primaryKey().$defaultFn(generateId),
      endpointId: text()
        .references(() => webhookEndpoint.id, { onDelete: "cascade" })
        .notNull(),
      eventType: text().notNull(),
      payload: jsonb().notNull(),
      status: webhookDeliveryStatusEnum().notNull().default("pending"),
      attempts: integer().notNull().default(0),
      responseCode: integer(),
      createdAt: timestamp().notNull().defaultNow(),
      updatedAt: timestamp()
        .notNull()
        .$onUpdate(() => new Date()),
    });

    export const webhookEndpointRelations = relations(
      webhookEndpoint,
      ({ many }) => ({
        deliveries: many(webhookDelivery),
      }),
    );

    export const insertWebhookEndpointSchema = createInsertSchema(webhookEndpoint);
    export const selectWebhookEndpointSchema = createSelectSchema(webhookEndpoint);
    ```

    Export from `packages/db/src/schema/index.ts`, then generate and migrate.

    Store only a **hash** of the signing secret. Return the plaintext secret once on create (same UX as API keys). Reject non-HTTPS URLs in v1.
  </Step>

  <Step>
    ## Create `@workspace/webhooks`

    Add a focused package for the event catalog, signing, emit, and a customer-facing verify helper. Keep CRUD in `packages/api`.

    ```text
    packages/webhooks/
      package.json          # name: @workspace/webhooks
      src/
        events.ts           # Zod payloads + event type union
        sign.ts             # HMAC sign / verify
        emit.ts             # look up endpoints, enqueue deliveries
        deliver.ts          # POST + retries
        index.ts
    ```

    ### Event catalog

    Start with a small set wired to hooks that already exist in core:

    | Event                           | Emit from                                                                          |
    | ------------------------------- | ---------------------------------------------------------------------------------- |
    | `user.created`                  | `packages/auth/src/hooks/user/create.ts` (`after` hook)                            |
    | `organization.member.joined`    | `afterAddMember` / `afterAcceptInvitation` in `hooks/organization/add-member.ts`   |
    | `organization.member.removed`   | `afterRemoveMember` in `hooks/organization/remove-member.ts`                       |
    | `billing.subscription.updated`  | After `upsertSubscription` in `packages/billing/shared/src/server/subscription.ts` |
    | `billing.subscription.canceled` | Same path when status is `canceled`                                                |
    | `webhook.ping`                  | Dashboard “Send test event”                                                        |

    Payload shape:

    ```ts title="packages/webhooks/src/events.ts"
    import * as z from "zod";

    export const webhookEventTypes = [
      "user.created",
      "organization.member.joined",
      "organization.member.removed",
      "billing.subscription.updated",
      "billing.subscription.canceled",
      "webhook.ping",
    ] as const;

    export const webhookEnvelopeSchema = z.object({
      id: z.string(),
      type: z.enum(webhookEventTypes),
      apiVersion: z.literal("2026-06-28"),
      createdAt: z.string().datetime(),
      data: z.record(z.string(), z.unknown()),
    });

    export type WebhookEnvelope = z.infer<typeof webhookEnvelopeSchema>;
    ```

    ### Signature (Stripe-style)

    ```ts title="packages/webhooks/src/sign.ts"
    const encoder = new TextEncoder();

    export const signPayload = async (secret: string, body: string, t: number) => {
      const key = await crypto.subtle.importKey(
        "raw",
        encoder.encode(secret),
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["sign"],
      );
      const mac = await crypto.subtle.sign(
        "HMAC",
        key,
        encoder.encode(`${t}.${body}`),
      );
      const v1 = [...new Uint8Array(mac)]
        .map((b) => b.toString(16).padStart(2, "0"))
        .join("");
      return `t=${t},v1=${v1}`;
    };

    export const verifySignature = async (
      secret: string,
      body: string,
      header: string,
      toleranceSec = 300,
    ) => {
      const parts = Object.fromEntries(
        header.split(",").map((p) => p.trim().split("=")),
      );
      const t = Number(parts.t);
      if (!t || !parts.v1) return false;
      if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

      const expected = await signPayload(secret, body, t);
      const expectedV1 = expected.split("v1=")[1]!;
      return timingSafeEqual(expectedV1, parts.v1);
    };

    const timingSafeEqual = (a: string, b: string) => {
      if (a.length !== b.length) return false;
      let result = 0;
      for (let i = 0; i < a.length; i++) {
        result |= a.charCodeAt(i) ^ b.charCodeAt(i);
      }
      return result === 0;
    };
    ```

    Customers verify with the same helper (document it; mirror Lemon Squeezy's inbound verify style in `packages/billing/web/.../webhook/verify.ts`).

    ### Emit + deliver

    ```ts title="packages/webhooks/src/emit.ts"
    import { and, eq, sql } from "@workspace/db";
    import { webhookDelivery, webhookEndpoint } from "@workspace/db/schema";
    import { db } from "@workspace/db/server";
    import { generateId } from "@workspace/shared/utils";

    import { deliverWebhook } from "./deliver";

    import type { WebhookEnvelope } from "./events";

    export const emitWebhookEvent = async (input: {
      type: WebhookEnvelope["type"];
      data: Record<string, unknown>;
      organizationId?: string;
      userId?: string;
    }) => {
      const envelope: WebhookEnvelope = {
        id: generateId(),
        type: input.type,
        apiVersion: "2026-06-28",
        createdAt: new Date().toISOString(),
        data: input.data,
      };

      const endpoints = await db
        .select()
        .from(webhookEndpoint)
        .where(
          and(
            eq(webhookEndpoint.enabled, true),
            input.organizationId
              ? eq(webhookEndpoint.organizationId, input.organizationId)
              : input.userId
                ? eq(webhookEndpoint.userId, input.userId)
                : sql`false`,
          ),
        );

      const matching = endpoints.filter((endpoint) =>
        endpoint.events.includes(input.type),
      );

      for (const endpoint of matching) {
        const [row] = await db
          .insert(webhookDelivery)
          .values({
            endpointId: endpoint.id,
            eventType: input.type,
            payload: envelope,
            status: "pending",
          })
          .returning();

        if (row) {
          void deliverWebhook(row.id);
        }
      }
    };
    ```

    `deliverWebhook` should load the delivery + endpoint, rebuild the plaintext secret only if you store an encrypted form (or keep a separate vault), sign the JSON body, `POST` with a short timeout, and update `attempts` / `status` / `responseCode`. Retry with exponential backoff on network errors and `5xx` (for example 3-5 attempts). Treat `2xx` as success; treat `4xx` as permanent failure unless you intentionally retry `429`.

    Core has **no** job runner today. For low volume, inline `void deliverWebhook(...)` is enough to ship. When you need durable retries, follow the same optional-provider approach Achromatic documents for background work: Trigger.dev, Upstash QStash, or Inngest. Install one, enqueue `deliveryId`, and keep idempotent status transitions in Postgres.
  </Step>

  <Step>
    ## Wire emit points in existing hooks

    User create currently only has a `before` hook. Add `after` and emit:

    ```ts title="packages/auth/src/hooks/user/create.ts"
    import { emitWebhookEvent } from "@workspace/webhooks";

    export const create = {
      before: async (user) => {
        /* existing name normalization */
      },
      after: async (user) => {
        await emitWebhookEvent({
          type: "user.created",
          userId: user.id,
          data: { id: user.id, email: user.email, name: user.name },
        });
      },
    };
    ```

    Organization membership:

    ```ts title="packages/auth/src/hooks/organization/add-member.ts"
    afterAcceptInvitation: async ({ organization, member }) => {
      await syncSubscriptionSeats(organization.id);
      await emitWebhookEvent({
        type: "organization.member.joined",
        organizationId: organization.id,
        data: {
          organizationId: organization.id,
          userId: member.userId,
          role: member.role,
        },
      });
    },
    ```

    Billing: emit once after the shared upsert so every provider benefits:

    ```ts title="packages/billing/shared/src/server/subscription.ts"
    export const upsertSubscription = async (data: InsertSubscription) => {
      const rows = await db
        .insert(subscription)
        .values(data)
        .onConflictDoUpdate({
          /* existing conflict target */
        })
        .returning();

      const row = rows[0];
      if (row) {
        await emitWebhookEvent({
          type:
            row.status === "canceled"
              ? "billing.subscription.canceled"
              : "billing.subscription.updated",
          data: {
            id: row.id,
            status: row.status,
            variantId: row.variantId,
            customerId: row.customerId,
          },
        });
      }

      return rows;
    };
    ```

    Resolve `organizationId` / `userId` for billing events from the related `customer.referenceId` when you need tenant-scoped fan-out.
  </Step>

  <Step>
    ## Add session-only webhook API routes

    Create `packages/api/src/modules/webhooks/router.ts` with:

    * `GET /`: list endpoints for the current user or org
    * `POST /`: create endpoint (HTTPS URL, event list, generate secret, return secret once)
    * `DELETE /:id`: revoke
    * `POST /:id/rotate-secret`: regenerate signing secret
    * `POST /:id/ping`: emit `webhook.ping`
    * `GET /:id/deliveries`: last 7 days of delivery rows

    Protect with `enforceAuth` (and `enforceMembership` / org permissions for org-scoped endpoints). Register the router in `packages/api/src/index.ts`:

    ```ts title="packages/api/src/index.ts"
    .route("/webhooks", webhooksRouter) // [!code ++]
    ```

    API key management can stay on Better Auth endpoints (`authClient.apiKey.create` / `.list` / `.delete`) from the dashboard. No need to duplicate CRUD unless you want a Hono facade.
  </Step>

  <Step>
    ## Build Settings → Developers UI

    Add paths and nav entries next to security / billing:

    ```ts title="apps/web/src/config/paths.ts"
    settings: {
      index: `${DASHBOARD_PREFIX}/settings`,
      security: `${DASHBOARD_PREFIX}/settings/security`,
      billing: `${DASHBOARD_PREFIX}/settings/billing`,
      developers: `${DASHBOARD_PREFIX}/settings/developers`, // [!code ++]
    },
    ```

    Mirror the same under `dashboard.organization(slug).settings.developers` for org admins.

    Reuse existing patterns:

    | UI need                   | Mirror                                                                   |
    | ------------------------- | ------------------------------------------------------------------------ |
    | Settings section shell    | `SettingsCard*` in `~/modules/common/layout/dashboard/settings-card`     |
    | Create + show secret once | Modal + copy button (passkeys / invite member flows)                     |
    | Revoke with confirm       | Passkeys `ConfirmModal` in `modules/user/settings/security/passkeys.tsx` |
    | Delivery log table        | `MembersDataTable` + `useDataTable`                                      |

    Two tabs on the page:

    1. **API keys**: `authClient.apiKey.list()`, create with label, show full key once, revoke.
    2. **Webhooks**: list endpoints, multi-select events, test ping, recent deliveries.

    Keep copy short: show the key/secret once, then only prefixes / last-used metadata.
  </Step>

  <Step>
    ## Verify the happy path

    1. `pnpm services:setup` && `pnpm dev`
    2. Open Settings → Developers, create an API key, call a protected route with `x-api-key`
    3. Register a [webhook.site](https://webhook.site) HTTPS URL, subscribe to `webhook.ping`, click **Send test event**
    4. Confirm the signature header and `apiVersion` on the received body
    5. Accept an org invite and confirm `organization.member.joined` delivery
    6. Revoke the key → same curl returns `401`
    7. Disable the endpoint → no further deliveries

    Document a customer verify snippet beside your public API docs (Node sample using `verifySignature` is enough for v1).
  </Step>
</Steps>

## Security checklist

| Rule                            | Why                                                              |
| ------------------------------- | ---------------------------------------------------------------- |
| HTTPS-only endpoint URLs        | Avoid cleartext payloads and SSRF to local networks              |
| Hash secrets / keys at rest     | Better Auth hashes API keys; do the same for webhook secrets     |
| Constant-time signature compare | Prevent timing leaks (same idea as Lemon Squeezy inbound verify) |
| Reject stale timestamps         | Replay protection (`toleranceSec`)                               |
| Session-only management APIs    | Stolen API keys should not rotate webhooks                       |
| Idempotent delivery updates     | Retries must not flip `delivered` → `pending` twice              |

## Delivery at scale

| Volume                  | Approach                                           |
| ----------------------- | -------------------------------------------------- |
| Early / low traffic     | Inline `deliverWebhook` + DB status columns        |
| Serverless with retries | Upstash QStash signed HTTP jobs                    |
| Durable workflows       | Trigger.dev or Inngest tasks keyed by `deliveryId` |

Always record pending → delivered/failed in Postgres so the dashboard stays the source of truth regardless of the worker.

## Troubleshooting

| Symptom                          | What to check                                                                                        |
| -------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `401` with a fresh key           | Header name (`x-api-key` vs `Authorization`), key revoked/expired, CORS `allowHeaders`               |
| Auth schema drift                | Ran `pnpm --filter @workspace/auth db:generate` after adding the plugin?                             |
| Ping never arrives               | Endpoint `enabled`, event list includes `webhook.ping`, HTTPS URL reachable from your host           |
| Signature always invalid         | Customer must verify `t.body` bytes exactly (raw JSON string you signed), not a re-serialized object |
| Duplicate customer side effects  | Make their handlers idempotent on envelope `id`                                                      |
| Billing events missing org scope | Join `customer.referenceId` before `emitWebhookEvent`                                                |

## File structure

<Files>
  <Folder name="packages" defaultOpen>
    <Folder name="auth/src" defaultOpen>
      <File name="server.ts - apiKey() plugin" />

      <Folder name="hooks" defaultOpen>
        <File name="user/create.ts - user.created emit" />

        <File name="organization/add-member.ts - member.joined emit" />
      </Folder>
    </Folder>

    <Folder name="db/src/schema" defaultOpen>
      <File name="webhook.ts - endpoints + deliveries" />
    </Folder>

    <Folder name="webhooks/src - NEW" defaultOpen>
      <File name="events.ts" />

      <File name="sign.ts" />

      <File name="emit.ts" />

      <File name="deliver.ts" />
    </Folder>

    <Folder name="api/src" defaultOpen>
      <File name="middleware.ts - session or API key" />

      <Folder name="modules/webhooks" defaultOpen>
        <File name="router.ts - session CRUD + ping" />
      </Folder>
    </Folder>
  </Folder>

  <Folder name="apps/web/src" defaultOpen>
    <Folder name="app/.../settings/developers - NEW">
      <File name="page.tsx" />
    </Folder>

    <Folder name="modules/developers - NEW">
      <File name="api-keys.tsx" />

      <File name="webhooks.tsx" />
    </Folder>
  </Folder>
</Files>

## Checklist

| Layer                  | Done when…                                                                  |
| ---------------------- | --------------------------------------------------------------------------- |
| **Auth**               | Plugin enabled; `apikey` table migrated; client can create/list/delete      |
| **API auth**           | `enableSessionForAPIKeys` + CORS; `x-api-key` works on `enforceAuth` routes |
| **Webhooks package**   | Emit → delivery row → signed POST → status update                           |
| **CRUD**               | Session-only routes; secret shown once                                      |
| **UI**                 | Developers settings for user (and org admin)                                |
| **Events**             | At least ping + one real domain event from an existing hook                 |
| **Docs for customers** | Signature verification sample published                                     |

## Related

<Cards>
  <Card title="Billing webhooks" description="Inbound provider webhooks (already shipped)." href="/docs/web/billing/webhooks" />

  <Card title="Protected routes" description="enforceAuth and org membership middleware." href="/docs/web/api/protected-routes" />

  <Card title="Build a production feature" description="Same DB → Hono → UI layering for new modules." href="/docs/web/recipes/build-a-feature" />

  <Card title="Better Auth API Key plugin" description="better-auth.com" href="https://www.better-auth.com/docs/plugins/api-key" />
</Cards>


# Build a production feature
Source: https://www.turbostarter.dev/docs/web/recipes/build-a-feature

Most features follow the same path: persist data, expose an API, build UI, translate strings, and ship. TurboStarter wires those layers together with shared types end to end, so you never re-declare the same shape three times.

In this recipe you'll build a **feedback widget**: a floating button that opens a dialog, collects a message, and stores it in Postgres. It's small enough to finish in one sitting, but large enough to touch every layer you'll use for bigger features like support tickets, feature requests, or in-app surveys.

<Callout title="What you'll ship">
  A production-ready feedback flow:

  * Drizzle table with optional link to the signed-in user
  * Protected Hono `POST /api/feedback` endpoint with Zod validation
  * React form wired through TanStack Query and the typed API client
  * English copy in `packages/i18n` (extend to more locales the same way)
  * Floating widget on marketing pages
</Callout>

## Architecture

Data flows from the UI module (`FeedbackWidget`) through `modules/feedback/lib/api.ts`, into the Hono router at `POST /api/feedback`, and finally into the Drizzle table in `packages/db`. The typed `hc<AppRouter>` client keeps the contract aligned between client and server.

TurboStarter already ships full-stack examples you can peek at while you build:

<Cards>
  <Card title="Organizations" description="Custom Hono reads + Better Auth mutations, data tables, org-scoped access." href="/docs/web/organizations/overview" />

  <Card title="Billing" description="Custom tables, webhooks, and plan-aware API routes." href="/docs/web/billing/overview" />

  <Card title="Storage" description="Small protected router. Good template for upload-style endpoints." href="/docs/web/storage/overview" />
</Cards>

<Steps>
  <Step>
    ## Add the database table

    Create a dedicated schema file and export it from the barrel.

    ```ts title="packages/db/src/schema/feedback.ts"
    import { relations } from "drizzle-orm";
    import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";

    import { generateId } from "@workspace/shared/utils";

    import { createInsertSchema } from "../lib/zod";

    import { user } from "./auth";

    export const feedbackTypeEnum = pgEnum("feedback_type", [
      "general",
      "bug",
      "feature",
    ]);

    export const feedback = pgTable("feedback", {
      id: text("id").primaryKey().$defaultFn(generateId),
      userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
      message: text("message").notNull(),
      type: feedbackTypeEnum("type").notNull().default("general"),
      email: text("email"),
      createdAt: timestamp("created_at").defaultNow().notNull(),
    });

    export const feedbackRelations = relations(feedback, ({ one }) => ({
      user: one(user, {
        fields: [feedback.userId],
        references: [user.id],
      }),
    }));

    export const insertFeedbackSchema = createInsertSchema(feedback, {
      message: (schema) => schema.min(10).max(1000),
      email: (schema) => schema.email().optional(),
    });
    ```

    ```ts title="packages/db/src/schema/index.ts"
    export * from "./auth";
    export * from "./billing";
    export * from "./feedback";
    ```

    Generate and apply the migration:

    ```bash
    pnpm with-env turbo db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    <Callout>
      Need a refresher on Drizzle workflows? See [Migrations](/docs/web/database/migrations) and [Database overview](/docs/web/database/overview).
    </Callout>
  </Step>

  <Step>
    ## Expose the API

    ### Request & response schemas

    Keep API contracts in `packages/api` so every app imports the same Zod types.

    ```ts title="packages/api/src/schema/feedback.ts"
    import * as z from "zod";

    export const createFeedbackInputSchema = z.object({
      message: z.string().min(10).max(1000),
      type: z.enum(["general", "bug", "feature"]).default("general"),
      email: z.string().email().optional(),
    });

    export const createFeedbackResponseSchema = z.object({
      id: z.string(),
    });

    export type CreateFeedbackInput = z.infer<typeof createFeedbackInputSchema>;
    export type CreateFeedbackResponse = z.infer<
      typeof createFeedbackResponseSchema
    >;
    ```

    Export from `packages/api/src/schema/index.ts`.

    ### Query function

    ```ts title="packages/api/src/modules/feedback/queries.ts"
    import { db } from "@workspace/db/server";
    import { feedback } from "@workspace/db/schema";

    import type { CreateFeedbackInput } from "../../schema/feedback";

    export const createFeedback = async ({
      userId,
      ...input
    }: CreateFeedbackInput & { userId?: string }) => {
      const [row] = await db
        .insert(feedback)
        .values({
          ...input,
          userId,
        })
        .returning({ id: feedback.id });

      return row;
    };
    ```

    ### Router

    Feedback can be submitted by guests **or** signed-in users. Use `enforceAuth` only when you need a session. Here we read the session inside the handler and attach `userId` when present.

    ```ts title="packages/api/src/modules/feedback/router.ts"
    import { Hono } from "hono";

    import { auth } from "@workspace/auth/server";

    import { enforceAuth, validate } from "../../middleware";
    import {
      createFeedbackInputSchema,
      createFeedbackResponseSchema,
    } from "../../schema/feedback";

    import { createFeedback } from "./queries";

    export const feedbackRouter = new Hono().post(
      "/",
      validate("json", createFeedbackInputSchema),
      async (c) => {
        const session = await auth.api.getSession({ headers: c.req.raw.headers });
        const input = c.req.valid("json");

        const row = await createFeedback({
          ...input,
          userId: session?.user.id,
          // Guests can pass email; signed-in users don't need to
          email: session?.user.email ?? input.email,
        });

        return c.json(createFeedbackResponseSchema.parse(row));
      },
    );
    ```

    Register the router next to the other modules:

    ```ts title="packages/api/src/index.ts"
    import { feedbackRouter } from "./modules/feedback/router";

    const appRouter = new Hono()
      .basePath("/api")
      // ...existing routers
      .route("/feedback", feedbackRouter);
    ```

    <Callout title="Protection levels">
      | Endpoint                 | Middleware                          |
      | ------------------------ | ----------------------------------- |
      | Public read (blog posts) | `validate` only                     |
      | User-specific write      | `enforceAuth`                       |
      | Org-scoped resource      | `enforceAuth` + `enforceMembership` |

      See [Protected routes](/docs/web/api/protected-routes) and [Adding new endpoint](/docs/web/api/new-endpoint) for the full menu.
    </Callout>
  </Step>

  <Step>
    ## Wire the web client

    Each feature gets a `lib/api.ts` beside its UI. Same pattern as [organizations](/docs/web/organizations/overview) and billing.

    ```ts title="apps/web/src/modules/feedback/lib/api.ts"
    import { mutationOptions } from "@tanstack/react-query";

    import { createFeedbackResponseSchema } from "@workspace/api/schema";
    import { handle } from "@workspace/api/utils";

    import { api } from "~/lib/api/client";

    const KEY = "feedback";

    export const feedback = {
      mutations: {
        create: mutationOptions({
          mutationKey: [KEY, "create"],
          mutationFn: (json: { message: string; type: string; email?: string }) =>
            handle(api.feedback.$post, {
              schema: createFeedbackResponseSchema,
            })({ json }),
        }),
      },
    };
    ```

    The `handle()` helper parses the response with your Zod schema. If the API shape drifts, TypeScript and runtime validation both complain early.
  </Step>

  <Step>
    ## Build the UI

    TurboStarter forms are built in a familiar, easy-to-follow way: you use a form library to manage the form, a helper to check the input, and ready-made pieces for the form fields. This keeps your forms clear and consistent.

    ```tsx title="apps/web/src/modules/feedback/feedback-widget.tsx"
    "use client";

    import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
    import { useMutation } from "@tanstack/react-query";
    import { Controller, useForm } from "react-hook-form";

    import { createFeedbackInputSchema } from "@workspace/api/schema";
    import { useTranslation } from "@workspace/i18n";
    import { Button } from "@workspace/ui-web/button";
    import { Field, FieldError, FieldLabel } from "@workspace/ui-web/field";
    import { Icons } from "@workspace/ui-web/icons";
    import { Input } from "@workspace/ui-web/input";
    import {
      Modal,
      ModalBody,
      ModalClose,
      ModalContent,
      ModalFooter,
      ModalHeader,
      ModalTitle,
      ModalTrigger,
    } from "@workspace/ui-web/modal";
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from "@workspace/ui-web/select";
    import { Textarea } from "@workspace/ui-web/textarea";
    import { toast } from "sonner";

    import { authClient } from "~/lib/auth/client";

    import { feedback } from "./lib/api";

    import type { CreateFeedbackInput } from "@workspace/api/schema";

    export const FeedbackWidget = () => {
      const { t } = useTranslation(["common", "feedback"]);
      const session = authClient.useSession();
      const user = session.data?.user;

      const create = useMutation({
        ...feedback.mutations.create,
        onSuccess: () => {
          form.reset();
          toast.success(t("feedback:success"));
        },
        onError: () => toast.error(t("feedback:error")),
      });

      const form = useForm<CreateFeedbackInput>({
        resolver: standardSchemaResolver(createFeedbackInputSchema),
        defaultValues: { message: "", type: "general", email: "" },
      });

      return (
        <Modal>
          <ModalTrigger
            render={
              <Button
                variant="outline"
                size="sm"
                className="fixed right-4 bottom-4 z-50 shadow-lg"
              >
                <Icons.MessageCircle className="mr-2 size-4" />
                {t("feedback:button")}
              </Button>
            }
          />
          <ModalContent className="sm:max-w-md">
            <ModalHeader>
              <ModalTitle>{t("feedback:title")}</ModalTitle>
            </ModalHeader>
            <ModalBody>
              <form
                id="feedback-form"
                className="flex flex-col gap-4"
                onSubmit={form.handleSubmit((data) => create.mutate(data))}
              >
                <Controller
                  name="type"
                  control={form.control}
                  render={({ field, fieldState }) => (
                    <Field data-invalid={fieldState.invalid}>
                      <FieldLabel>{t("feedback:type.label")}</FieldLabel>
                      <Select value={field.value} onValueChange={field.onChange}>
                        <SelectTrigger>
                          <SelectValue />
                        </SelectTrigger>
                        <SelectContent>
                          <SelectItem value="general">
                            {t("feedback:type.general")}
                          </SelectItem>
                          <SelectItem value="bug">
                            {t("feedback:type.bug")}
                          </SelectItem>
                          <SelectItem value="feature">
                            {t("feedback:type.feature")}
                          </SelectItem>
                        </SelectContent>
                      </Select>
                      {fieldState.invalid && (
                        <FieldError errors={[fieldState.error]} />
                      )}
                    </Field>
                  )}
                />

                {!user && (
                  <Controller
                    name="email"
                    control={form.control}
                    render={({ field, fieldState }) => (
                      <Field data-invalid={fieldState.invalid}>
                        <FieldLabel>{t("common:email")}</FieldLabel>
                        <Input {...field} type="email" />
                        {fieldState.invalid && (
                          <FieldError errors={[fieldState.error]} />
                        )}
                      </Field>
                    )}
                  />
                )}

                <Controller
                  name="message"
                  control={form.control}
                  render={({ field, fieldState }) => (
                    <Field data-invalid={fieldState.invalid}>
                      <FieldLabel>{t("feedback:message.label")}</FieldLabel>
                      <Textarea {...field} className="min-h-24" />
                      {fieldState.invalid && (
                        <FieldError errors={[fieldState.error]} />
                      )}
                    </Field>
                  )}
                />
              </form>
            </ModalBody>
            <ModalFooter>
              <ModalClose
                render={
                  <Button variant="outline" type="button">
                    {t("common:cancel")}
                  </Button>
                }
              />
              <Button
                type="submit"
                form="feedback-form"
                disabled={create.isPending}
              >
                {t("feedback:submit")}
              </Button>
            </ModalFooter>
          </ModalContent>
        </Modal>
      );
    };
    ```

    Drop the widget into a layout that should expose it globally:

    ```tsx title="apps/web/src/app/[locale]/(marketing)/layout.tsx"
    import { FeedbackWidget } from "~/modules/feedback/feedback-widget";

    export default function MarketingLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <>
          {children}
          <FeedbackWidget />
        </>
      );
    }
    ```
  </Step>

  <Step>
    ## Add translations

    Add a namespace file per feature under `packages/i18n/src/translations/en/`.

    ```json title="packages/i18n/src/translations/en/feedback.json"
    {
      "button": "Feedback",
      "title": "Send feedback",
      "submit": "Send",
      "success": "Thanks! We got your message.",
      "error": "Something went wrong. Try again.",
      "type": {
        "label": "Type",
        "general": "General",
        "bug": "Bug report",
        "feature": "Feature request"
      },
      "message": {
        "label": "Message"
      }
    }
    ```

    Register the namespace in the i18n config if your project requires explicit listing (follow the pattern used by `organization.json`).

    API validation errors can be localized too. The `validate` middleware maps Zod issues through `makeZodI18nMap` so form errors stay consistent with server responses.
  </Step>

  <Step>
    ## Verify & iterate

    **Manual smoke test**

    1. Start services: `pnpm services:setup` then `pnpm dev`
    2. Open a marketing page. The floating button should appear.
    3. Submit as a guest (email required) and as a signed-in user (email hidden)
    4. Confirm rows in Postgres: `select * from feedback;`

    **Optional: admin inbox**

    Add a `GET /api/feedback` route behind `enforceAuth` + `enforceAdmin`, then a page under `apps/web/src/app/[locale]/admin/`. The [admin module](/docs/web/admin/overview) is a ready-made shell for internal tools.

    **Optional: email on submit**

    Trigger an email template from the mutation handler or a background job. See [Emails](/docs/web/emails/overview).
  </Step>
</Steps>

## File structure

<Files>
  <Folder name="packages - Shared backend" defaultOpen>
    <Folder name="db/src/schema - Drizzle table definitions" defaultOpen>
      <File name="feedback.ts - feedback table + insert schema" />
    </Folder>

    <Folder name="api/src - Hono routes and Zod contracts" defaultOpen>
      <Folder name="modules/feedback - Route handlers" defaultOpen>
        <File name="router.ts - POST /api/feedback" />

        <File name="queries.ts - Database writes" />
      </Folder>

      <Folder name="schema - Request/response types" defaultOpen>
        <File name="feedback.ts - Zod input/output schemas" />
      </Folder>
    </Folder>

    <Folder name="i18n/src/translations/en - UI copy" defaultOpen>
      <File name="feedback.json - Widget strings" />
    </Folder>
  </Folder>

  <Folder name="apps/web/src - Web app" defaultOpen>
    <Folder name="modules/feedback - Feature UI + client API" defaultOpen>
      <File name="feedback-widget.tsx - Floating button + modal form" />

      <Folder name="lib - TanStack Query layer" defaultOpen>
        <File name="api.ts - Mutations and query keys" />
      </Folder>
    </Folder>
  </Folder>
</Files>

## Checklist

| Layer        | Done when…                                                   |
| ------------ | ------------------------------------------------------------ |
| **Database** | Migration applied; table visible in Postgres                 |
| **API**      | `POST /api/feedback` returns `{ id }` with valid body        |
| **Types**    | `AppRouter` includes `/feedback`. Client autocomplete works. |
| **UI**       | Form validates, shows loading state, toasts on success/error |
| **i18n**     | All user-visible strings use `t()`                           |
| **Auth**     | Guest vs signed-in behavior matches your product rules       |

## Next steps

<Cards>
  <Card title="Build a production feature (mobile)" description="Same API, native Bottom Sheet UI." href="/docs/mobile/recipes/build-a-feature" />

  <Card title="Build a production feature (extension)" description="Lightweight trigger in the popup." href="/docs/extension/recipes/build-a-feature" />

  <Card title="Feature-based access" description="Restrict feedback to paying users. Uses the same API and database table." href="/docs/web/recipes/feature-based-access" />

  <Card title="E2E testing" description="Lock the flow with Playwright. Uses the same API and database table." href="/docs/web/tests/e2e" />
</Cards>

The feedback widget is deliberately small. The same file layout and data flow scale to organizations-sized features. You're learning the **shape** TurboStarter expects, not a one-off pattern.


# Cookie consent
Source: https://www.turbostarter.dev/docs/web/recipes/cookie-consent

TurboStarter ships cookie consent for the web app with [c15t](https://c15t.com). The banner and preferences dialog are already wired into the provider tree, themed to your design tokens, and connected so [analytics](/docs/web/analytics/overview) only run after the user grants **measurement** consent.

You do not need to install another CMP for the happy path - customize categories, legal links, and storage mode in one place.

<Callout title="TL;DR">
  1. Consent lives in `apps/web/src/lib/providers/consent.tsx` and wraps analytics in `providers.tsx`.
  2. Leave `NEXT_PUBLIC_C15T_URL` unset for offline regional policy packs; set it for hosted consent.io / self-hosted backend.
  3. Analytics call `has("measurement")` and pass `enabled` into `@workspace/analytics-web` `Provider`.
  4. Users reopen preferences from the footer via `ConsentDialogLink`.
  5. Keep [privacy](/docs/web/marketing/legal) and cookie policy pages filled - the banner links to them.
</Callout>

## Offline vs hosted

| Mode                  | When                                                         | Behavior                                                                                            |
| --------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| **Offline** (default) | `NEXT_PUBLIC_C15T_URL` unset                                 | Client-side storage + regional policy packs: EU opt-in, California opt-out, rest of world no banner |
| **Hosted**            | `NEXT_PUBLIC_C15T_URL` set to your c15t / consent.io backend | Consent persisted on the backend for audits and multi-device consistency                            |

Use offline for local development and simple sites. Prefer hosted when you need a durable audit trail or a managed geo policy from [c15t](https://c15t.com/docs/frameworks/next/concepts/client-modes).

<Steps>
  <Step>
    ## Inspect the ConsentProvider

    The kit wraps the app with `ConsentManagerProvider`, renders the banner and dialog, and passes the active locale into c15t i18n:

    ```tsx title="apps/web/src/lib/providers/consent.tsx"
    <ConsentManagerProvider
      options={{
        ...(env.NEXT_PUBLIC_C15T_URL
          ? { mode: "hosted" as const, backendURL: env.NEXT_PUBLIC_C15T_URL }
          : {
              mode: "offline" as const,
              offlinePolicy: {
                policyPacks: [
                  policyPackPresets.europeOptIn(),
                  policyPackPresets.californiaOptOut(),
                  policyPackPresets.worldNoBanner(),
                ],
              },
            }),
        consentCategories: ["necessary", "measurement"],
        legalLinks: {
          privacyPolicy: {
            href: pathsConfig.marketing.legal("privacy-policy"),
            target: "_self",
          },
          cookiePolicy: {
            href: pathsConfig.marketing.legal("cookie-policy"),
            target: "_self",
          },
        },
        // i18n + theme…
      }}
    >
      <ConsentBanner
        hideBranding
        legalLinks={["privacyPolicy", "cookiePolicy"]}
        layout={["customize", ["reject", "accept"]]}
      />
      <ConsentDialog hideBranding legalLinks={["privacyPolicy", "cookiePolicy"]} />
      {children}
    </ConsentManagerProvider>
    ```

    `ConsentProvider` sits **outside** `AnalyticsProvider` in `providers.tsx` so analytics can read consent state on first paint.
  </Step>

  <Step>
    ## Choose offline or hosted storage

    ### Offline (default)

    Do nothing. Without `NEXT_PUBLIC_C15T_URL`, the provider uses offline mode and the three policy packs above. That matches typical GDPR (opt-in), CCPA (opt-out), and “no banner elsewhere” expectations without a backend.

    ### Hosted

    Create a [consent.io](https://consent.io) (or self-hosted c15t) instance, then set the public backend URL in `apps/web/.env.local` and your deployment env:

    ```dotenv title="apps/web/.env.local"
    NEXT_PUBLIC_C15T_URL="https://your-instance.c15t.dev"
    ```

    The value is validated as an optional URL in `apps/web/env.config.ts`. When it is present, the provider switches to `mode: "hosted"` and uses that URL as `backendURL`.

    <Callout type="info" title="Proxy the backend (optional)">
      For production, c15t recommends proxying the backend through your app (for example a Next.js rewrite to `/api/c15t`) so the browser never talks to the raw vendor host. Point `NEXT_PUBLIC_C15T_URL` at that same-origin path when you add the rewrite.
    </Callout>
  </Step>

  <Step>
    ## Keep legal pages linked

    The banner and dialog link to:

    * `/legal/privacy-policy`
    * `/legal/cookie-policy`

    Those routes come from the [legal pages](/docs/web/marketing/legal) CMS collection. Fill the MDX content before launch - empty policies with live consent UI still look unfinished to users and reviewers.

    Paths are built with `pathsConfig.marketing.legal(...)` so locale prefixes stay correct.
  </Step>

  <Step>
    ## Gate analytics on measurement consent

    `AnalyticsProvider` already checks the measurement category before identifying users or enabling the analytics `Provider`:

    ```tsx title="apps/web/src/lib/providers/analytics.tsx"
    const { has } = useConsentManager();
    const enabled = has("measurement");

    useEffect(() => {
      if (session.isPending) {
        return;
      }

      if (!enabled) {
        return reset();
      }

      if (session.data?.user) {
        const { id, email, name } = session.data.user;
        identify(id, { email, name });
      } else {
        reset();
      }
    }, [session, enabled]);

    return <Provider enabled={enabled}>{children}</Provider>;
    ```

    Each web analytics strategy honors `enabled` (for example PostHog opts out of capturing when it is `false`). When you add a **custom** analytics provider, accept `enabled?: boolean` on `Provider` and skip init / page views when it is false - see [analytics tracking](/docs/web/analytics/tracking).

    If you introduce marketing pixels later, add a `marketing` category to `consentCategories` and gate those scripts with `has("marketing")` the same way.
  </Step>

  <Step>
    ## Let users reopen preferences

    The marketing footer includes a **Cookie preferences** entry that opens the consent dialog without a separate route:

    ```tsx title="apps/web/src/modules/marketing/layout/footer/navigation.tsx"
    {
      title: "legal.cookies",
      component: ConsentDialogLink,
    },
    ```

    Copy lives under `common:legal.cookies` (`Cookie preferences` in English). Keep that link in any custom footer so users can revoke or change consent after the first choice.
  </Step>

  <Step>
    ## Customize branding, locale, and categories

    ### Theme

    c15t styles import from `@c15t/nextjs/styles.css`. TurboStarter maps c15t CSS variables to your theme tokens on `html:root[data-theme]` in `apps/web/src/assets/styles/globals.css` (`--c15t-primary` → `--primary`, surfaces, radii, and so on). Change the design system theme and the banner follows.

    Action button variants and banner max-width are set under `options.theme` in `consent.tsx`. `hideBranding` is already on so the vendor badge stays off.

    ### Locales

    Consent copy uses `@c15t/translations` for the locales you register. The kit maps `Locale.EN` / `Locale.ES` to `baseTranslations`. When you add a locale to `@workspace/i18n`, add the matching c15t messages object in the same `i18n.messages` map.

    `detectBrowserLanguage` is `false` so the UI locale stays tied to the Next.js `[locale]` segment.

    ### Categories

    Default:

    ```ts
    consentCategories: ["necessary", "measurement"];
    ```

    Extend only when you actually load scripts in that bucket (marketing, functional, and so on). Updating the array without gating new scripts leaves toggles that do nothing.

    ### Preview a region offline

    During local testing you can force a country override in offline policy options (see [c15t policy packs](https://c15t.com/docs/frameworks/next/policy-packs)) to verify EU opt-in vs California opt-out without a VPN.
  </Step>

  <Step>
    ## Verify the matrix

    | Scenario                                      | Expected                                                                     |
    | --------------------------------------------- | ---------------------------------------------------------------------------- |
    | Fresh visit from an EU region (offline packs) | Banner shown; analytics off until Accept (or Customize → enable measurement) |
    | Reject all                                    | `has("measurement")` false; analytics `Provider` disabled / reset            |
    | Accept all                                    | Analytics initializes; identify runs for signed-in users                     |
    | Footer → Cookie preferences                   | Dialog opens; changing measurement toggles analytics on/off                  |
    | `NEXT_PUBLIC_C15T_URL` set                    | Hosted mode; decisions go to your c15t backend                               |
    | Legal links in banner                         | Navigate to privacy and cookie policy pages                                  |

    Also confirm in DevTools that your analytics network calls only appear after measurement consent.
  </Step>
</Steps>

## Troubleshooting

| Symptom                              | What to check                                                                                                           |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| Banner never appears                 | Offline `worldNoBanner` for non-EU/CA geos; hosted geo rules; browser already stored a prior decision - clear site data |
| Analytics still fire after reject    | Confirm `ConsentProvider` wraps `AnalyticsProvider`; custom providers must respect `enabled`                            |
| Banner looks unstyled / wrong colors | `@c15t/nextjs/styles.css` import and `--c15t-*` bridge in `globals.css`; theme attribute on `html`                      |
| Hosted mode errors                   | Valid `NEXT_PUBLIC_C15T_URL`, CORS / rewrite to the backend, env present in the client build                            |
| Wrong language in dialog             | Locale passed into `ConsentProvider`; messages map includes that `Locale` key                                           |

## Checklist

* [ ] Privacy and cookie policy MDX filled in
* [ ] Offline packs acceptable for your markets, or hosted URL configured
* [ ] Only real tracking categories listed in `consentCategories`
* [ ] New analytics / pixels gated with `has(...)` or `enabled`
* [ ] Footer (or settings) still exposes cookie preferences
* [ ] Spot-check Accept / Reject / Customize in the regions you care about

<Cards>
  <Card title="Analytics configuration" href="/docs/web/analytics/configuration" description="Wire PostHog, GA, Plausible, and other web providers." />

  <Card title="Legal pages" href="/docs/web/marketing/legal" description="Privacy, cookie policy, and terms from the CMS collection." />

  <Card title="c15t Next.js docs" href="https://c15t.com/docs/frameworks/next/quickstart" description="c15t.com/docs" />

  <Card title="Mobile tracking permissions" href="/docs/mobile/analytics/configuration" description="ATT / tracking permission on iOS - separate from web CMP." />
</Cards>


# Disable organizations
Source: https://www.turbostarter.dev/docs/web/recipes/disable-organizations

TurboStarter ships [organizations](/docs/web/organizations/overview) for multi-tenant B2B SaaS, but **personal accounts already work as the default path**. Signup lands on `/dashboard`, billing can attach to the user (`referenceId = user.id`), and organizations are opt-in via the account switcher and create flow.

This recipe turns that default into a hard product rule: users never create, join, or switch organizations. Everything stays on the personal account.

<Callout title="TL;DR">
  1. Decide soft vs hard disable (keep the Better Auth plugin gated, or remove it).
  2. Block create on the server with `allowUserToCreateOrganization: false` (or remove `organization()` entirely).
  3. Strip org UI: account switcher create/list, organization picker, invitations, org dashboard routes, join page.
  4. Force billing to `BillingReference.USER` and drop org / per-seat checkout options.
  5. Mirror the same cuts on [mobile](/docs/mobile/organizations/overview) and the [extension](/docs/extension/organizations) if you ship them.
  6. Leave org tables in the database unless you need a clean schema. Do this before launch if you can.
</Callout>

## Use-cases

| Product shape                        | Recommendation                                                                      |
| ------------------------------------ | ----------------------------------------------------------------------------------- |
| B2C SaaS (one user, one account)     | Disable organizations                                                               |
| B2B with teams / workspaces          | Keep organizations                                                                  |
| B2B, no personal workspace           | [Disable personal accounts](/docs/web/recipes/disable-personal-accounts)            |
| Single tenant, invite-only workspace | Keep orgs, disable self-serve create (see Soft path)                                |
| Subdomain per customer               | Keep orgs, see [Subdomain multi-tenancy](/docs/web/recipes/subdomain-multi-tenancy) |

TurboStarter does **not** ship a single env flag for this (unlike some starters). You own the code: gate Better Auth, then remove the surfaces that assume tenants exist.

<Callout type="info" title="Personal account vs organization">
  A **personal account** is the authenticated user. Dashboard routes live under `pathsConfig.dashboard.user` (`/dashboard`, `/dashboard/settings`, …). Session `activeOrganizationId` is null.

  An **organization** is a separate tenant at `/dashboard/{slug}` with members, invitations, RBAC, and optional org-scoped billing. Signup does **not** create an organization.
</Callout>

## Soft vs hard disable

| Approach | What you do                                                             | Reversible | Best when                                 |
| -------- | ----------------------------------------------------------------------- | ---------- | ----------------------------------------- |
| **Soft** | Keep `organization()` plugin, block create, hide UI, force user billing | Easy       | You might re-enable teams later           |
| **Hard** | Remove plugin + clients, unmount org API/UI, simplify billing and hooks | More work  | You are sure the product is personal-only |

Both approaches should enforce rules on the **server**, not only in React. Closing the UI while leaving Better Auth endpoints open is not enough.

<Steps>
  <Step>
    ## Block organization creation on the server

    Open `packages/auth/src/server.ts` where the [Better Auth organization plugin](https://www.better-auth.com/docs/plugins/organization) is registered.

    ### Soft disable

    Keep the plugin, but stop self-serve creation:

    ```ts title="packages/auth/src/server.ts"
    organization({
      allowUserToCreateOrganization: false, // [!code ++]
      sendInvitationEmail: async ({ invitation, inviter, organization }, request) => {
        // ...
      },
      ac,
      roles,
      organizationHooks: hooks.organization,
    }),
    ```

    `allowUserToCreateOrganization` accepts a boolean or an async function (for example, only allow admins). Default is `true`.

    If you also want invite-only workspaces later, keep invitations and the join page, and only hide create. For true personal-only, also remove or dead-end invitations (next steps).

    ### Hard disable

    Remove the `organization({ ... })` plugin from the server config, and remove `organizationClient` from every auth client:

    * `apps/web/src/lib/auth/client.ts`
    * Mobile and extension auth clients that call `organizationClient()`
    * Re-exports in `packages/auth/src/client/web.ts` / `mobile.ts` if nothing else needs them

    After hard removal, Better Auth org endpoints and `authClient.organization.*` / `useListOrganizations` are gone. Update any imports that still reference them.
  </Step>

  <Step>
    ## Remove organization UI entry points (web)

    These surfaces are how users discover orgs today. Strip or simplify them so the product only shows a personal account.

    ### Account switcher

    `apps/web/src/modules/organization/account-switcher.tsx` is rendered from the dashboard sidebar (`modules/common/layout/dashboard/sidebar`). It lists organizations, switches context, and opens `CreateOrganizationModal`.

    For personal-only:

    * Replace `AccountSwitcher` with a simple user header (avatar + name), **or**
    * Keep the component but remove the org list, create CTA, and `CreateOrganizationModal`

    ### Personal dashboard home

    `apps/web/src/app/[locale]/dashboard/(user)/page.tsx` currently renders invitations and the org picker:

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/page.tsx"
    import { UserOrganizationInvitationsBanner } from "~/modules/organization/invitations/user/user-organization-invitations";
    import { OrganizationPicker } from "~/modules/organization/organization-picker";

    export default function UserPage() {
      return (
        <>
          <UserOrganizationInvitationsBanner />
          <OrganizationPicker />
        </>
      );
    }
    ```

    Replace this with your real personal home (product feed, empty state, onboarding CTA). See [Onboarding flow](/docs/web/recipes/onboarding) if you need a post-signup wizard without an organization step.

    ### Org dashboard, join, and admin

    Redirect or delete:

    | Surface             | Path                                                                 |
    | ------------------- | -------------------------------------------------------------------- |
    | Org dashboard       | `apps/web/src/app/[locale]/dashboard/[organization]/`                |
    | Join invitation     | `apps/web/src/app/[locale]/auth/join/`                               |
    | Admin organizations | `apps/web/src/app/[locale]/admin/organizations/` and related modules |

    Add redirects from `/dashboard/[organization]` and `/auth/join` to `/dashboard` so old links and bookmarks fail closed.

    For a hard disable, also stop mounting the Hono org router:

    ```ts title="packages/api/src/index.ts"
    .route("/organizations", organizationRouter) // [!code --]
    ```

    And remove or stop using `packages/api/src/modules/organization/**` plus admin org modules.
  </Step>

  <Step>
    ## Force personal billing

    Billing already supports a **user** as the customer. Org checkout is optional via the pricing "on behalf of" control.

    1. In `apps/web/src/modules/billing/pricing/controls/reference-selector.tsx` (and `controls/index.tsx`), remove org options or delete `ReferenceSelector` so checkout always uses `BillingReference.USER` and `referenceId = user.id`.
    2. Keep user billing pages under `/dashboard/settings/billing` (they already pass `BillingReference.USER`).
    3. Remove or ignore `/dashboard/[organization]/settings/billing` once org routes are gone.
    4. Optionally drop `BillingType.PER_SEAT` variants from `packages/billing/shared` config. Per-seat plans are filtered to organizations in `getFilteredPlans`. Without orgs they only add noise.

    Seat sync hooks under `packages/auth/src/hooks/organization/` only matter while the org plugin and member mutations exist. Soft disable can leave them; hard disable should remove those hooks and related helpers such as `syncSubscriptionSeats`.

    See [Billing overview](/docs/web/billing/overview) (B2C vs B2B) and [Per-seat](/docs/web/billing/per-seat) if you currently sell team seats.
  </Step>

  <Step>
    ## Optional: leave the schema, skip the seed org

    You do **not** need a migration to ship personal-only. Tables `organization`, `member`, `invitation`, and `session.active_organization_id` can stay unused (same idea as keeping unused team tables in other starters).

    If you want a cleaner local DB:

    * Stop creating the demo org in `packages/auth/src/scripts/seed.ts`
    * Drop org tables later only if you are sure you will not re-enable multi-tenancy

    Prefer deciding this **before production data**. Switching from org-scoped rows (`organizationId`) to user-scoped rows (`userId`) after launch means a real data migration.

    Your own product tables should reference `userId` for personal-only apps, not `organizationId`. See [Data model](/docs/web/organizations/data-model) for how tenant scoping works when orgs stay on.
  </Step>

  <Step>
    ## Mobile and extension

    If you keep `apps/mobile` or `apps/extension`, apply the same product rule there. Shared auth still exposes `organizationClient` until you remove it.

    **Mobile**

    * Org modules under `apps/mobile/src/modules/organization/`
    * Org dashboard routes under `apps/mobile/src/app/dashboard/organization/`
    * Root redirect that branches on `activeOrganizationId` (`apps/mobile/src/app/index.tsx`)

    **Extension**

    * User navigation that shows personal vs active org (`apps/extension/src/modules/user/user-navigation.tsx`)
    * Any deep links into web `/dashboard/{slug}`

    Point everything at the personal dashboard paths. Docs: [Organizations on mobile](/docs/mobile/organizations/overview), [Organizations in the extension](/docs/extension/organizations).
  </Step>
</Steps>

## Checklist

After the change, verify:

* [ ] New signup reaches `/dashboard` with no create-org prompt
* [ ] Account switcher (or header) shows only the user, no "Create organization"
* [ ] `authClient.organization.create` fails (soft) or does not exist (hard)
* [ ] `/dashboard/some-slug` and `/auth/join` redirect or 404
* [ ] Checkout and billing portal use `user.id` only
* [ ] Mobile / extension (if shipped) never set or require `activeOrganizationId`
* [ ] Admin UI no longer lists organizations (or the section is removed)

## Troubleshooting

See the troubleshooting section below for common issues and how to fix them.

| Symptom                                         | What to check                                                                |
| ----------------------------------------------- | ---------------------------------------------------------------------------- |
| Users can still create orgs via API / client    | `allowUserToCreateOrganization` on the server, or plugin still registered    |
| Create button still visible                     | `account-switcher.tsx`, `organization-picker.tsx`, `CreateOrganizationModal` |
| Pricing still offers "on behalf of" an org      | `ReferenceSelector` / pricing controls                                       |
| Session still has `activeOrganizationId`        | Soft: clear on personal routes; hard: plugin removed, redirect old org URLs  |
| Type errors on `organizationClient`             | Remove plugin from all auth clients and fix imports                          |
| Per-seat plans still show for personal checkout | `getFilteredPlans` / remove `PER_SEAT` variants from billing config          |

<Cards>
  <Card title="Organizations overview" href="/docs/web/organizations/overview" description="Check how multi-tenancy works in TurboStarter" />

  <Card title="Billing overview" href="/docs/web/billing/overview" description="Billing configuration and providers" />

  <Card title="Onboarding flow" href="/docs/web/recipes/onboarding" description="Post-signup onboarding flow" />

  <Card title="Disable personal accounts" href="/docs/web/recipes/disable-personal-accounts" description="Opposite mode - no personal workspace." />

  <Card title="Better Auth organization plugin" href="https://www.better-auth.com/docs/plugins/organization" description="better-auth.com" />
</Cards>


# Disable personal accounts
Source: https://www.turbostarter.dev/docs/web/recipes/disable-personal-accounts

By default, TurboStarter is **hybrid**: every user gets a personal workspace at `/dashboard`, and organizations are optional under `/dashboard/[slug]`. That matches products like [GitHub](https://github.com) (personal + teams).

Many B2B products need the opposite: **no personal workspace**. Every signed-in user must create an organization or accept an invitation before they can use the product. Billing, data, and the account switcher all stay organization-scoped.

Need the opposite (B2C, no teams)? See [Disable organizations](/docs/web/recipes/disable-organizations).

<Callout title="TL;DR">
  1. Add a dedicated `/dashboard/create-organization` route (no product sidebar).
  2. Guard product routes: no memberships → create-organization; memberships → redirect `/dashboard` to an org slug.
  3. Hide the personal row in `AccountSwitcher`.
  4. Point leave / delete / invalid-slug redirects at another org or create-organization (never personal home).
  5. Keep product billing on `organization.id`. Optionally auto-create an org on first signup instead of prompting.
</Callout>

## Default vs organizations-only

| Area                    | Default (hybrid)               | Organizations-only                         |
| ----------------------- | ------------------------------ | ------------------------------------------ |
| Post-signup land        | `/dashboard` (personal)        | Create org, or first membership            |
| Account switcher        | Personal + organizations       | Organizations only                         |
| Product data / billing  | `user.id` or `organization.id` | Prefer `organization.id`                   |
| Leave / delete last org | Back to personal home          | Create org (or block leaving the last one) |
| Invalid org slug        | Redirect to personal home      | First membership or create org             |

Use this when your product is seat-based or always shared (project tools, team chat, B2B admin). Stay hybrid when freelancers should start solo and invite later.

<Callout type="warn" title="Ship this before production data accumulates">
  Switching to organizations-only after users already created personal data means migrating or abandoning that data. Decide before launch. Pair with [Onboarding](/docs/web/recipes/onboarding) if you also collect profile answers in the same first-run flow.
</Callout>

## Prompt create vs auto-create

Two common patterns:

| Strategy          | UX                                                                                      | When to use                                          |
| ----------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| **Prompt create** | After signup, full-page form: name → slug → `/dashboard/{slug}`                         | You want the user to pick a company / workspace name |
| **Auto-create**   | Signup creates an org from the user name (or email local-part), then lands on that slug | You want zero friction; rename later in settings     |

Invitees who already accepted a membership should **skip** create and go straight to that organization.

<Steps>
  <Step>
    ## Register a create-organization path

    ```ts title="apps/web/src/config/paths.ts"
    dashboard: {
      user: {
        index: DASHBOARD_PREFIX,
        createOrganization: `${DASHBOARD_PREFIX}/create-organization`,
        // ...settings, ai
      },
      organization: (slug: string) => ({ /* unchanged */ }),
    },
    ```

    Use this constant in layout redirects, auth `redirectTo` / `callbackURL`, and leave/delete success handlers.
  </Step>

  <Step>
    ## Split the personal dashboard so create has no product shell

    Today `(user)/layout.tsx` always mounts the sidebar and prefetches **personal** billing (`referenceId: user.id`). For organizations-only, keep create-organization (and account settings if you want) outside the product shell.

    ```
    dashboard/(user)/
      layout.tsx                      ← session required only
      create-organization/page.tsx    ← full-page create form
      settings/...                    ← optional: account security without an org
      (app)/
        layout.tsx                    ← membership guard + sidebar
        page.tsx                      ← redirect to an org (see next step)
        ai/...
    ```

    Move the existing sidebar layout into `(app)/layout.tsx`. Parent `(user)/layout.tsx` only checks the session (same pattern as the [onboarding recipe](/docs/web/recipes/onboarding)).
  </Step>

  <Step>
    ## Guard access = require at least one membership

    List the user's organizations with Better Auth, then redirect.

    ```ts title="apps/web/src/lib/auth/server.ts"
    export const listOrganizations = cache(async () => {
      try {
        return await auth.api.listOrganizations({
          headers: await getHeaders(),
        });
      } catch {
        return [];
      }
    });
    ```

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/(app)/layout.tsx"
    import { redirect } from "next/navigation";

    import { pathsConfig } from "~/config/paths";
    import { getSession, listOrganizations } from "~/lib/auth/server";

    export default async function AppShellLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      const { user } = await getSession();

      if (!user) {
        return redirect(pathsConfig.auth.login);
      }

      const organizations = await listOrganizations();

      if (organizations.length === 0) {
        return redirect(pathsConfig.dashboard.user.createOrganization);
      }

      return /* existing SidebarProvider + children */;
    }
    ```

    On the personal home page, never render a personal product surface. Send people into an organization:

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/(app)/page.tsx"
    import { redirect } from "next/navigation";

    import { pathsConfig } from "~/config/paths";
    import { getSession, listOrganizations } from "~/lib/auth/server";

    export default async function UserDashboardPage() {
      const { session } = await getSession();
      const organizations = await listOrganizations();

      if (organizations.length === 0) {
        return redirect(pathsConfig.dashboard.user.createOrganization);
      }

      const activeId = session?.activeOrganizationId;
      const active =
        organizations.find((organization) => organization.id === activeId) ??
        organizations[0];

      return redirect(pathsConfig.dashboard.organization(active.slug).index);
    }
    ```

    Do the same for marketing CTAs and auth success URLs that currently point at `pathsConfig.dashboard.user.index`. After login, `/dashboard` still works: it immediately redirects to an org slug.
  </Step>

  <Step>
    ## Build the create-organization page

    Reuse the existing create flow in `apps/web/src/modules/organization/create-organization.tsx` (`getSlug` → `authClient.organization.create` → `pathsConfig.dashboard.organization(slug).index`). Extract the form into a shared component, then render it full-page:

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/create-organization/page.tsx"
    import { CreateOrganizationForm } from "~/modules/organization/create-organization-form";

    export default function CreateOrganizationPage() {
      return (
        <div className="mx-auto flex min-h-[80vh] max-w-lg flex-col justify-center gap-8 px-4">
          <CreateOrganizationForm />
        </div>
      );
    }
    ```

    Keep the modal for creating **additional** organizations from the switcher. The page is only for users with zero memberships (or for a first-run step inside [onboarding](/docs/web/recipes/onboarding)).

    If the user already has memberships and hits this URL, redirect them to their active (or first) organization so they cannot get stuck.
  </Step>

  <Step>
    ## Hide the personal account in the switcher

    In `apps/web/src/modules/organization/account-switcher.tsx`, remove the `personal-account` `CommandItem` and the separator above the organizations list. The trigger should always show the active organization (never `t("account.personal")`).

    Also stop treating "no active organization" as a valid product state in that component: if `activeOrganization` is missing but `organizations` is non-empty, navigate to the first org slug (the URL remains the source of truth; see [Active organization](/docs/web/organizations/active-organization)).
  </Step>

  <Step>
    ## Fix redirects that still assume a personal home

    Search the web app for `pathsConfig.dashboard.user.index` in organization flows and replace the fallbacks:

    | Location                                         | Default behavior          | Organizations-only                                           |
    | ------------------------------------------------ | ------------------------- | ------------------------------------------------------------ |
    | `[organization]/layout.tsx` when slug is missing | Redirect to personal home | `listOrganizations()` → first slug, else create-organization |
    | `leave-organization.tsx` `onSuccess`             | Personal home             | Next membership slug, else create-organization               |
    | `delete-organization.tsx` `onSuccess`            | Personal home             | Same as leave                                                |
    | Member remove / self-leave in the members table  | Personal home             | Same as leave                                                |
    | Invitation error CTAs                            | Personal home             | create-organization or login                                 |

    Example leave success handler:

    ```ts
    onSuccess: async () => {
      const { data } = await refetch();
      const next = data?.[0];

      toast.add({ title: t("leave.success"), type: "success" });

      router.replace(
        next
          ? pathsConfig.dashboard.organization(next.slug).index
          : pathsConfig.dashboard.user.createOrganization,
      );
    },
    ```

    Optional product rule: block leaving or deleting the **last** organization (always keep one workspace). TurboStarter already has `canLeave` helpers for owner/seat cases; extend that check for "last membership".
  </Step>

  <Step>
    ## Scope billing and product data to organizations

    Personal billing in `(user)` layouts uses `referenceId: user.id`. In organizations-only mode:

    * Prefer the org layout billing prefetch (`referenceId: activeOrganization.id`) for plan gates and checkout.
    * Hide or remove `/dashboard/settings/billing` if you no longer sell personal plans.
    * Gate features with the active organization's summary, same rules as [Feature-based access](/docs/web/recipes/feature-based-access).

    New tenant-owned tables should store `organizationId`, not `userId`, as the ownership key. User id still belongs on membership and audit columns.
  </Step>

  <Step>
    ## Handle invites and post-auth redirects

    Invitation accept already creates a membership. After accept, send the user to `pathsConfig.dashboard.organization(slug).index`, not personal home.

    For **new** self-serve signups (no `invitationId`), point `redirectTo` / `callbackURL` at create-organization (or onboarding that includes the create step). Returning users with memberships can keep `/dashboard` and rely on the home redirect.

    ```tsx
    // register / login success for users without an invitation
    redirectTo={pathsConfig.dashboard.user.createOrganization}
    ```

    Join / invite query params already flow through `apps/web/src/app/[locale]/auth/register/page.tsx` and friends. Do not force create-organization when `invitationId` is present.
  </Step>

  <Step>
    ## Optional: auto-create an organization on signup

    If you want MakerKit-style `organizations-only` (no create screen), create the org as soon as the user exists, then set it active and redirect to its slug.

    Practical places to do that:

    1. **Client after register success** - call the same `getSlug` + `organization.mutations.create` path used by the modal, then `router.replace` to the slug.
    2. **Server after first authenticated dashboard hit** - if `listOrganizations()` is empty, call `auth.api.createOrganization` with a derived name, then redirect.

    Sketch for a server helper:

    ```ts
    import { auth } from "@workspace/auth/server";
    // reuse packages/api/src/modules/organization/queries/generate-slug.ts
    // or call the same slug endpoint the create modal uses

    export async function ensureDefaultOrganization(user: {
      id: string;
      name: string;
      email: string;
    }) {
      const existing = await auth.api.listOrganizations({
        headers: await getHeaders(),
      });

      if (existing.length > 0) {
        return existing[0];
      }

      const name = user.name.trim() || user.email.split("@")[0] || "Workspace";
      const { slug } = await generateSlug(name);

      return auth.api.createOrganization({
        body: { name, slug },
        headers: await getHeaders(),
      });
    }
    ```

    Call it from the membership guard instead of redirecting to create-organization. Still offer rename in organization settings.

    <Callout type="info" title="Better Auth options">
      The organization plugin also supports `allowUserToCreateOrganization`, `organizationLimit`, and related options in `packages/auth/src/server.ts`. Those gate **who may create** orgs; they do not replace the UI/route work above for disabling personal workspaces.
    </Callout>
  </Step>

  <Step>
    ## Verify the matrix

    | Scenario                                | Expected                                                |
    | --------------------------------------- | ------------------------------------------------------- |
    | New signup (no invite)                  | Lands on create-organization (or auto-created org slug) |
    | Completes create                        | `/dashboard/{slug}`; switcher has no personal row       |
    | Open `/dashboard` with memberships      | Redirect to active or first org                         |
    | Open `/dashboard` with zero memberships | create-organization                                     |
    | Accept invite                           | Org dashboard; skip create                              |
    | Leave / delete when others remain       | Next org slug                                           |
    | Leave / delete last org                 | create-organization (or blocked)                        |
    | Invalid org slug                        | First membership or create-organization                 |
    | Checkout / plan gates                   | Use organization `referenceId`                          |
  </Step>
</Steps>

## Checklist

* [ ] `pathsConfig.dashboard.user.createOrganization` registered
* [ ] Create page outside the product sidebar shell
* [ ] `(app)` layout redirects when `listOrganizations()` is empty
* [ ] `/dashboard` home redirects to an org slug
* [ ] Personal row removed from `AccountSwitcher`
* [ ] Leave, delete, and invalid-slug fallbacks updated
* [ ] Auth / marketing redirects no longer assume a personal product home
* [ ] Billing and tenant data keyed by organization
* [ ] Invitees skip create

## Other platforms

Mobile and extension also expose personal vs organization switching. Apply the same rules there: hide personal workspace entry points, require a membership before product screens, and land leave/delete on another org or a create flow. Reuse `@workspace/auth` APIs; only the navigation shells differ.

<Cards>
  <Card title="Organizations overview" href="/docs/web/organizations/overview" description="Multi-tenancy model and Better Auth plugin." />

  <Card title="Active organization" href="/docs/web/organizations/active-organization" description="URL slug vs session activeOrganizationId." />

  <Card title="Onboarding flow" href="/docs/web/recipes/onboarding" description="Combine create-org with a first-run wizard." />

  <Card title="Disable organizations" href="/docs/web/recipes/disable-organizations" description="Opposite mode: personal accounts only." />

  <Card title="Feature-based access" href="/docs/web/recipes/feature-based-access" description="Gate features with organization billing." />

  <Card title="Better Auth organization plugin" href="https://www.better-auth.com/docs/plugins/organization" description="better-auth.com" />
</Cards>


# Feature-based access
Source: https://www.turbostarter.dev/docs/web/recipes/feature-based-access

Feature-based access lets you unlock parts of your product only when a customer is on the right [billing plan](/docs/web/billing/overview). Teams on **Free** might get basic reports, while **Premium** unlocks collaboration, and **Enterprise** adds SSO and audit logs.

TurboStarter already models plans, variants, subscriptions, and orders in one shared billing config. This recipe shows how to turn that data into real access control — on the server **and** in the React UI — without scattering plan checks across your codebase.

<Callout title="TL;DR">
  1. Declare typed feature keys in `packages/billing/shared/src/config/features.ts`.
  2. Attach those keys to each plan in the [billing configuration](/docs/web/billing/configuration).
  3. Resolve the active plan with `getActivePlan()` and check access with `isFeatureAvailable()`.
  4. Protect API routes with `enforceFeatureAvailable()` middleware.
  5. Gate UI with the same helpers and nudge upgrades with `getHigherPlans()`.
</Callout>

## What you are building

Most SaaS products mix two kinds of restrictions:

| Type                 | Example                 | TurboStarter helper    |
| -------------------- | ----------------------- | ---------------------- |
| **Boolean features** | "Teams" only on Premium | `isFeatureAvailable()` |
| **Usage limits**     | Max 3 projects on Free  | `checkPlanLimit()`     |

Boolean features answer *"can this user open this screen or call this endpoint?"* Limits answer *"can they create one more of this resource?"*

Both read from the same billing config, so your [pricing table](/docs/web/billing/configuration), API enforcement, and dashboard UI stay in sync.

## How plan resolution works

When a user (or organization) checks out, webhooks sync purchases into your database. At runtime, TurboStarter combines:

* **Subscriptions** — active recurring plans
* **Orders** — successful one-time purchases
* **Entitlements** — mobile store purchases merged on hybrid flows

`getActivePlan()` picks the **highest** matching plan and defaults to `free` when nothing is active:

```ts title="packages/billing/shared/src/utils/plan.ts"
export const getActivePlan = (summary?: Summary | Summary[]) => {
  // resolves subscriptions, orders, and entitlements
  // returns the highest plan id, or BillingPlan.FREE
};
```

`getPlanFeatures()` returns the **cumulative** feature set for a plan — higher tiers inherit everything below them:

```ts title="packages/billing/shared/src/utils/plan.ts"
export const getPlanFeatures = (id: string) => {
  const index = config.plans.findIndex((plan) => plan.id === id);
  return Array.from(
    new Set(config.plans.slice(0, index + 1).flatMap((p) => p.features)),
  );
};
```

That inheritance model means you only list *new* capabilities on higher tiers in `features.ts`, then spread lower tiers:

```ts title="packages/billing/shared/src/config/features.ts"
const FREE_FEATURES = {
  SYNC: "SYNC",
  BASIC_SUPPORT: "BASIC_SUPPORT",
  // ...
} as const;

const PREMIUM_FEATURES = {
  ...FREE_FEATURES,
  TEAM_COLLABORATION: "TEAM_COLLABORATION",
  ADVANCED_REPORTS: "ADVANCED_REPORTS",
} as const;

export const FEATURES = {
  [BillingPlan.FREE]: FREE_FEATURES,
  [BillingPlan.PREMIUM]: PREMIUM_FEATURES,
  // ...
} as const;

export type Feature = /* union of all feature values */;
```

<Callout type="info" title="One source of truth">
  Keep feature keys in `features.ts`, reference them in the billing config with `Object.values(FEATURES[BillingPlan.PREMIUM])`, and import the same constants everywhere else. Never hardcode `"TEAM_COLLABORATION"` in a component when the constant already exists.
</Callout>

<Steps>
  <Step>
    ## Add the `isFeatureAvailable` helper

    The kit ships `getActivePlan()` and `getPlanFeatures()` in `@workspace/billing`. Add a small helper that combines them — this is the function you will call from middleware, server actions, and React components.

    Add this to `packages/billing/shared/src/utils/plan.ts`:

    ```ts title="packages/billing/shared/src/utils/plan.ts"
    import type { Feature } from "../config/features";

    export const isFeatureAvailable = <
      Entitlement extends { id: string; active: boolean; variantId?: string },
      Subscription extends { status: SubscriptionStatus; variantId: string },
      Order extends { status: PaymentStatus; variantId: string },
      Summary extends {
        entitlements?: Entitlement[];
        subscriptions?: Subscription[];
        orders?: Order[];
      },
    >(
      summary: Summary | Summary[],
      feature: Feature,
    ) => {
      const plan = getActivePlan(summary);
      return getPlanFeatures(plan).includes(feature);
    };
    ```

    It is already re-exported through `@workspace/billing` via `packages/billing/shared/src/utils/index.ts`, so no extra export wiring is needed.
  </Step>

  <Step>
    ## Wire features into the billing config

    Each plan in `packages/billing/shared/src/config/index.ts` should list the features that plan unlocks for **access control**, not just marketing copy:

    ```ts title="packages/billing/shared/src/config/index.ts"
    import { FEATURES } from "./features";

    export const config = billingConfigSchema.parse({
      plans: [
        {
          id: BillingPlan.FREE,
          name: "plan.free.name",
          features: Object.values(FEATURES[BillingPlan.FREE]),
          limits: {
            projects: 3,
            members: 1,
          },
          variants: [
            /* ... */
          ],
        },
        {
          id: BillingPlan.PREMIUM,
          name: "plan.premium.name",
          features: Object.values(FEATURES[BillingPlan.PREMIUM]),
          limits: {
            projects: 10,
            members: 5,
            storage: null, // unlimited
          },
          variants: [
            /* ... */
          ],
        },
      ],
    }) satisfies BillingConfig;
    ```

    The `features` array powers:

    * the pricing table (`FeaturesList` component)
    * `getPlanFeatures()` / `isFeatureAvailable()`
    * translated labels via `billing:feature.*` keys

    Add i18n entries for each feature key in your locale files so the UI reads naturally.
  </Step>

  <Step>
    ## Enforce access on API routes

    Never rely on UI hiding alone. A motivated user can still call your API directly. Protect sensitive endpoints the same way you protect [authenticated routes](/docs/web/api/protected-routes).

    Create reusable middleware in `packages/api/src/middleware.ts`:

    ```ts title="packages/api/src/middleware.ts"
    import {
      FEATURES,
      getCustomersWithPurchasesByReferenceId,
      isFeatureAvailable,
    } from "@workspace/billing";
    import type { Feature } from "@workspace/billing";

    export const enforceFeatureAvailable = (feature: Feature) =>
      createMiddleware<{
        Variables: {
          user: User;
        };
      }>(async (c, next) => {
        const referenceId =
          c.req.query("referenceId") ??
          c.req.valid("json")?.referenceId ??
          c.var.user.id;

        const summary = await getCustomersWithPurchasesByReferenceId(referenceId);

        if (!isFeatureAvailable(summary, feature)) {
          throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
            code: "error.upgradeRequired",
          });
        }

        await next();
      });
    ```

    Use it on any route that should require a paid capability:

    ```ts title="packages/api/src/modules/organization/router.ts"
    export const organizationRouter = new Hono().get(
      "/teams",
      enforceAuth,
      enforceFeatureAvailable(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
      async (c) => c.json(/* ... */),
    );
    ```

    `402 Payment Required` signals to the client that an upgrade — not a login — is the fix. Pair it with a translated error code the UI can map to an upgrade modal.

    <Callout type="warn" title="Check the billing reference">
      For organization-scoped billing, pass the **organization id** as `referenceId` when fetching the summary. The billing router already uses `enforceAccessToReference()` so only owners with billing permissions can manage checkout — mirror that reference id in feature checks.
    </Callout>
  </Step>

  <Step>
    ## Gate React UI with the same helpers

    Fetch the billing summary once, derive the plan, and branch in components. The account switcher already follows this pattern:

    ```tsx title="apps/web/src/modules/organization/account-switcher.tsx"
    const summary = useQuery(
      billing.queries.summary.get(
        activeOrganization.data?.id ?? session.data?.user.id,
      ),
    );
    const activePlan = getActivePlan(summary.data);
    ```

    For feature-specific screens, prefer `isFeatureAvailable()` so you do not reimplement inheritance logic:

    ```tsx title="apps/web/src/modules/teams/teams-page.tsx"
    "use client";

    import { FEATURES, BillingPlan, isFeatureAvailable } from "@workspace/billing";
    import { useQuery } from "@tanstack/react-query";

    import { billing } from "~/modules/billing/lib/api";
    import { UpgradePrompt } from "~/modules/billing/upgrade-prompt";

    export const TeamsPage = ({ referenceId }: { referenceId: string }) => {
      const summary = useQuery(billing.queries.summary.get(referenceId));

      if (summary.isLoading) {
        return <TeamsSkeleton />;
      }

      const canUseTeams = isFeatureAvailable(
        summary.data,
        FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION,
      );

      if (!canUseTeams) {
        return (
          <UpgradePrompt
            feature={FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION}
            referenceId={referenceId}
          />
        );
      }

      return <TeamsWorkspace />;
    };
    ```

    ### Upgrade prompts

    Use `getHigherPlans()` to find the next tier that unlocks a feature:

    ```tsx title="apps/web/src/modules/billing/upgrade-prompt.tsx"
    import {
      BillingPlan,
      FEATURES,
      getHigherPlans,
      getActivePlan,
    } from "@workspace/billing";

    const activePlan = getActivePlan(summary.data);
    const upgradeTarget = getHigherPlans(activePlan).find((plan) =>
      plan.features.includes(FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
    );
    ```

    Link to [pricing](/docs/web/billing/configuration) or open the billing portal via `billing.mutations.portal.get` — the pricing `usePlan` hook already wraps checkout and portal flows.

    ### Conditional navigation

    Hide nav items the user cannot use, but keep server enforcement as the real gate:

    ```tsx
    {
      isFeatureAvailable(
        summary.data,
        FEATURES[BillingPlan.ENTERPRISE].API_ACCESS,
      ) ? (
        <NavLink href={pathsConfig.dashboard.api}>API</NavLink>
      ) : null;
    }
    ```
  </Step>

  <Step>
    ## Enforce usage limits

    When a feature is available but **quantity** matters — projects, seats, storage — use `checkPlanLimit()`:

    ```ts title="packages/api/src/modules/projects/mutations/create.ts"
    import { checkPlanLimit, getActivePlan } from "@workspace/billing";

    const activePlan = getActivePlan(summary);
    const projectCount = await countProjects(referenceId);

    const { allowed, remaining } = checkPlanLimit({
      id: activePlan,
      key: "projects",
      currentUsage: projectCount,
    });

    if (!allowed) {
      throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
        code: "error.limitReached",
        message: `You can create ${remaining} more projects on your current plan.`,
      });
    }
    ```

    `checkPlanLimit()` reads the `limits` object from your billing config. A value of `null` means unlimited; a missing key means no cap is configured.

    Pass `increment` when validating bulk actions (for example inviting three members at once).
  </Step>

  <Step>
    ## Gate non-HTTP surfaces (OAuth, MCP, webhooks)

    Some capabilities are not plain REST routes. You can still reuse `isFeatureAvailable()` in Better Auth plugins or background jobs.

    Example: block OAuth token issuance unless the user has the right plan (pattern from production TurboStarter apps):

    ```ts title="packages/auth/src/plugins/feature-gate.ts"
    import { APIError, createAuthMiddleware } from "better-auth/api";

    import { BillingPlan, FEATURES, isFeatureAvailable } from "@workspace/billing";
    import { getCustomersWithPurchasesByReferenceId } from "@workspace/billing/server";

    export const hasApiAccess = async (userId: string) => {
      const summary = await getCustomersWithPurchasesByReferenceId(userId);
      return isFeatureAvailable(summary, FEATURES[BillingPlan.ENTERPRISE].API_ACCESS);
    };

    export const apiAccessTokenGate = () => ({
      id: "api-access-token-gate",
      hooks: {
        before: [
          {
            matcher(ctx) {
              return ctx.path === "/oauth/token";
            },
            handler: createAuthMiddleware(async (ctx) => {
              const userId = /* resolve from token body */;

              if (userId && !(await hasApiAccess(userId))) {
                throw new APIError("FORBIDDEN", {
                  error: "access_denied",
                  error_description: "API access requires an Enterprise plan.",
                });
              }
            }),
          },
        ],
      },
    });
    ```

    Register the plugin in your Better Auth config alongside existing plugins.
  </Step>

  <Step>
    ## Test your matrix

    Add unit tests next to the billing utilities so plan changes do not silently break access:

    ```ts title="packages/billing/shared/src/utils/test/plan.test.ts"
    import { FEATURES, BillingPlan, isFeatureAvailable } from "@workspace/billing";

    it("premium users can access team collaboration", () => {
      const summary = {
        subscriptions: [
          { status: SubscriptionStatus.ACTIVE, variantId: "premium-monthly" },
        ],
      };

      expect(
        isFeatureAvailable(
          summary,
          FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION,
        ),
      ).toBe(true);
    });

    it("free users cannot access team collaboration", () => {
      expect(
        isFeatureAvailable({}, FEATURES[BillingPlan.PREMIUM].TEAM_COLLABORATION),
      ).toBe(false);
    });
    ```

    Manually verify in the app:

    1. Sign in as a Free user — gated UI shows upgrade prompt, API returns `402`.
    2. Complete checkout for Premium — feature unlocks without redeploying.
    3. Cancel subscription — access revokes after the billing provider syncs webhooks.
  </Step>
</Steps>

## Checklist for new features

When you add a monetized capability:

1. Add a constant to `features.ts` and spread it into the right plan tier.
2. Add the feature to the plan's `features` array in billing config.
3. Add a `billing:feature.*` translation string.
4. Protect the API route with `enforceFeatureAvailable()`.
5. Gate the page or component with `isFeatureAvailable()`.
6. If the feature has quotas, define `limits` and call `checkPlanLimit()` before mutations.
7. Add tests for at least one allowed and one denied plan.

<Cards>
  <Card title="Billing configuration" href="/docs/web/billing/configuration" description="Plans, variants, features, and limits in one shared schema." />

  <Card title="Protected routes" href="/docs/web/api/protected-routes" description="Auth, role, and feature middleware patterns for Hono." />

  <Card title="Mobile feature gating" href="/docs/mobile/recipes/feature-based-access" description="Entitlements from RevenueCat or Superwall plus API summary." />

  <Card title="Extension feature gating" href="/docs/extension/recipes/feature-based-access" description="Lightweight gating in the browser extension." />
</Cards>


# Multiple environments
Source: https://www.turbostarter.dev/docs/web/recipes/multiple-environments

Use separate environments when you want local development, staging, and production to talk to different databases, auth callbacks, payment accounts, analytics projects, or feature flags.

The safe pattern is:

* keep variable **names** consistent across environments
* keep **secrets** out of Git
* expose only browser-safe values with `NEXT_PUBLIC_`
* use `APP_ENV` for your own environment name instead of changing `NODE_ENV`

<Callout title="Good to know" type="info">
  Next.js treats `NEXT_PUBLIC_` variables as client-side values. Anything with that prefix is bundled into the browser build, so never use it for database URLs, API secrets, webhook secrets, or private keys.
</Callout>

<Steps>
  <Step>
    ## Choose your environments

    Most apps only need three:

    * `development` - local work with local or sandbox services
    * `staging` - production-like testing before release
    * `production` - the live customer environment

    Use the same variable names in every environment. Only the values should change.

    ```dotenv title=".env.example"
    APP_ENV="development"
    URL="http://localhost:3000"
    DATABASE_URL=""
    BETTER_AUTH_SECRET=""
    BETTER_AUTH_URL="${URL}"
    NEXT_PUBLIC_URL="${URL}"
    NEXT_PUBLIC_APP_ENV="${APP_ENV}"
    ```
  </Step>

  <Step>
    ## Create local env files

    For day-to-day development, keep local values in ignored `.env.local` files:

    ```dotenv title=".env.local"
    APP_ENV="development"
    URL="http://localhost:3000"
    DATABASE_URL="postgresql://user:password@localhost:5432/app"
    BETTER_AUTH_SECRET="local-secret"
    BETTER_AUTH_URL="${URL}"
    ```

    ```dotenv title="apps/web/.env.local"
    NEXT_PUBLIC_URL="${URL}"
    NEXT_PUBLIC_APP_ENV="${APP_ENV}"
    NEXT_PUBLIC_THEME_MODE="system"
    NEXT_PUBLIC_THEME_COLOR="orange"
    ```

    The root file is a good place for shared server-side values. The app file is a good place for web-only values.
  </Step>

  <Step>
    ## Add staging and production values

    For local staging checks, create environment-specific local files:

    ```dotenv title=".env.staging.local"
    APP_ENV="staging"
    URL="https://staging.example.com"
    DATABASE_URL="postgresql://..."
    BETTER_AUTH_SECRET="..."
    BETTER_AUTH_URL="${URL}"
    ```

    ```dotenv title="apps/web/.env.staging.local"
    NEXT_PUBLIC_URL="${URL}"
    NEXT_PUBLIC_APP_ENV="${APP_ENV}"
    NEXT_PUBLIC_THEME_MODE="system"
    NEXT_PUBLIC_THEME_COLOR="orange"
    ```

    Repeat the same shape for production:

    ```dotenv title=".env.production.local"
    APP_ENV="production"
    URL="https://example.com"
    DATABASE_URL="postgresql://..."
    BETTER_AUTH_SECRET="..."
    BETTER_AUTH_URL="${URL}"
    ```

    <Callout title="Do not commit secrets" type="warn">
      Commit `.env.example` with empty or safe placeholder values. Keep `.env.local`, `.env.staging.local`, and `.env.production.local` ignored.
    </Callout>
  </Step>

  <Step>
    ## Add environment-aware scripts

    The project already uses `dotenv-cli`, so you can load an environment by name with `dotenv -c <environment>`.

    ```json title="package.json"
    {
      "scripts": {
        "dev:web": "dotenv -c development -- turbo dev --filter=web",
        "dev:web:staging": "dotenv -c staging -- turbo dev --filter=web",
        "build:web:staging": "dotenv -c staging -- turbo build --filter=web",
        "build:web:production": "dotenv -c production -- turbo build --filter=web"
      }
    }
    ```

    Then run:

    ```bash
    pnpm dev:web:staging
    pnpm build:web:production
    ```

    This loads `.env`, `.env.<environment>`, `.env.local`, and `.env.<environment>.local` in cascade order.
  </Step>

  <Step>
    ## Configure your hosting provider

    In your hosting provider, create separate environment groups or projects for staging and production.

    Set the same variables there:

    ```dotenv title="Hosting provider variables"
    APP_ENV="production"
    URL="https://example.com"
    DATABASE_URL="postgresql://..."
    BETTER_AUTH_SECRET="..."
    BETTER_AUTH_URL="https://example.com"
    NEXT_PUBLIC_URL="https://example.com"
    NEXT_PUBLIC_APP_ENV="production"
    ```

    Before going live, verify:

    * auth callback URLs point to the same environment
    * webhooks point to the same environment
    * payment providers use test keys in staging and live keys in production
    * analytics and monitoring projects are separated or tagged by `APP_ENV`
    * `NEXT_PUBLIC_` values contain no secrets
  </Step>
</Steps>

## Useful references

* [Environment variables](/docs/web/configuration/environment-variables)
* [Next.js environment variables](https://nextjs.org/docs/app/guides/environment-variables)


# Onboarding flow
Source: https://www.turbostarter.dev/docs/web/recipes/onboarding

After purchase, most SaaS products need a short **post-signup path**: collect profile or workspace data, optionally take payment, then land in the dashboard. TurboStarter reserves empty slots for this (`apps/web/src/modules/onboarding/` and `apps/web/src/app/[locale]/dashboard/(user)/onboarding/`) and already redirects authenticated users into the dashboard layout.

This recipe fills those slots with a production pattern: **server-backed completion**, a multi-step UI, and an **optional hard paywall** before the product.

<Callout title="TL;DR">
  1. Persist `onboardingCompleted` (Better Auth additional field or a small table) - not only localStorage.
  2. Guard the dashboard layout: incomplete users → `/dashboard/onboarding`.
  3. Build steps under `modules/onboarding` + the reserved route folder.
  4. Soft monetization: show upgrade CTAs; hard paywall: incomplete billing → `/dashboard/choose-plan` until `getActivePlan()` is paid.
  5. Point post-auth `callbackURL` / `redirectTo` at onboarding for new accounts.
</Callout>

## Soft vs hard paywall

TurboStarter defaults to a **Free** plan via `getActivePlan()` when there is no subscription. That is intentional freemium.

| Mode     | What users can do after signup                                       | Implementation                     |
| -------- | -------------------------------------------------------------------- | ---------------------------------- |
| **Soft** | Finish onboarding → use Free → upgrade from settings / feature gates | Wizard only                        |
| **Hard** | Finish onboarding → must checkout → then dashboard                   | Wizard + `choose-plan` layout gate |

Use soft when Free drives activation. Use hard when the product is paid-only (or trial-only via your billing provider).

For gating **individual features** after users are inside the app, use [Feature-based access](/docs/web/recipes/feature-based-access) instead of blocking the whole dashboard.

## Flow

After signup (and email verification when enabled), send new users to `/dashboard/onboarding` via `callbackURL` / `redirectTo`. The wizard lives in the reserved module and route below. When the last step finishes, mark `onboardingCompleted` on the server.

From there:

* **Soft** - go straight to `/dashboard`. Users stay on Free until they upgrade from settings or hit a [feature gate](/docs/web/recipes/feature-based-access).
* **Hard** - go to `/dashboard/choose-plan`. The app shell layout keeps redirecting Free users back here until checkout succeeds and `getActivePlan()` is paid.

Fill these empty slots:

* `apps/web/src/modules/onboarding/` - wizard UI, steps, and API helpers
* `apps/web/src/app/[locale]/dashboard/(user)/onboarding/` - add `page.tsx` here

<Steps>
  <Step>
    ## Persist completion on the server

    Client-only flags break across browsers and devices. Pick one approach.

    ### Option A - Better Auth additional field (simplest)

    Add a boolean on the user via Better Auth `user.additionalFields`, then expose it on the session. Exact wiring follows your Better Auth version - see [Better Auth docs](https://www.better-auth.com/docs/concepts/database#extending-core-schema) and regenerate / migrate the Drizzle schema after adding the field.

    Conceptually:

    ```ts
    // packages/auth - user additionalFields
    onboardingCompleted: {
      type: "boolean",
      required: false,
      defaultValue: false,
      input: false, // only your server/API can set it
    },
    ```

    Mark complete with `auth.api.updateUser` (server) or a small Hono route that updates the user row after the last wizard step.

    ### Option B - Dedicated table (richer answers)

    If you store multi-step answers (role, company size, goals), add a table similar to other app schemas:

    ```ts title="packages/db/src/schema/onboarding.ts"
    import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";

    import { generateId } from "@workspace/shared/utils";

    import { user } from "./auth";

    export const onboarding = pgTable("onboarding", {
      id: text("id").primaryKey().$defaultFn(generateId),
      userId: text("user_id")
        .notNull()
        .unique()
        .references(() => user.id, { onDelete: "cascade" }),
      data: jsonb("data").$type<Record<string, unknown>>().default({}),
      completed: boolean("completed").default(false).notNull(),
      createdAt: timestamp("created_at").defaultNow().notNull(),
      updatedAt: timestamp("updated_at")
        .defaultNow()
        .$onUpdate(() => new Date())
        .notNull(),
    });
    ```

    Export it from the schema barrel, generate a migration (`pnpm --filter @workspace/db db:generate` / `db:migrate`), and expose `GET` / `PATCH` under a Hono module with `enforceAuth`.

    Invitees who join an existing org often should **skip** personal onboarding - set `completed: true` (or the additional field) when accepting an invitation.
  </Step>

  <Step>
    ## Register paths

    ```ts title="apps/web/src/config/paths.ts"
    dashboard: {
      user: {
        index: DASHBOARD_PREFIX,
        onboarding: `${DASHBOARD_PREFIX}/onboarding`,
        choosePlan: `${DASHBOARD_PREFIX}/choose-plan`, // hard paywall only
        ai: `${DASHBOARD_PREFIX}/ai`,
        // ...
      },
    },
    ```

    Use these constants everywhere - layout redirects, auth `callbackURL`, and wizard navigation.
  </Step>

  <Step>
    ## Guard the dashboard with route groups

    Today the user dashboard layout only checks session, then renders the sidebar shell. Split routes so onboarding never mounts that shell - this avoids pathname sniffing and redirect loops.

    ```
    dashboard/(user)/
      layout.tsx                 ← session required for everything below
      onboarding/page.tsx        ← wizard (no sidebar)
      choose-plan/page.tsx       ← hard paywall only (no sidebar)
      (app)/
        layout.tsx               ← onboarding + optional plan guards + sidebar
        page.tsx                 ← home
        ai/...
        settings/...
    ```

    Move the existing sidebar layout into `(app)/layout.tsx`. In that layout, redirect incomplete users before rendering the shell:

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/(app)/layout.tsx"
    import { redirect } from "next/navigation";

    import { BillingPlan, getActivePlan } from "@workspace/billing";

    import { pathsConfig } from "~/config/paths";
    import { getSession } from "~/lib/auth/server";
    // reuse the same billing summary prefetch the kit already uses

    const REQUIRE_PAID_PLAN = false; // flip to true for hard paywall

    export default async function AppShellLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      const { user } = await getSession();

      if (!user) {
        return redirect(pathsConfig.auth.login);
      }

      if (!user.onboardingCompleted) {
        return redirect(pathsConfig.dashboard.user.onboarding);
      }

      if (REQUIRE_PAID_PLAN) {
        const summary = /* await billing summary for user.id */;
        if (getActivePlan(summary) === BillingPlan.FREE) {
          return redirect(pathsConfig.dashboard.user.choosePlan);
        }
      }

      return /* existing SidebarProvider + children */;
    }
    ```

    Parent `(user)/layout.tsx` only needs the session check (and shared providers). Onboarding and choose-plan stay siblings of `(app)`, so they never hit the completion/plan redirects.

    <Callout type="warn" title="Invite flows">
      Mark invited users as completed (or send them through a shorter path) when they accept an org invite, otherwise the `(app)` guard will trap them in personal onboarding.
    </Callout>
  </Step>

  <Step>
    ## Build the wizard UI

    Create a small compound flow in the reserved module - one page, multiple steps, shared state:

    ```tsx title="apps/web/src/modules/onboarding/onboarding-wizard.tsx"
    "use client";

    import { useState } from "react";
    import { useRouter } from "next/navigation";

    import { pathsConfig } from "~/config/paths";

    import { ProfileStep } from "./steps/profile-step";
    import { WorkspaceStep } from "./steps/workspace-step";
    import { completeOnboarding } from "./lib/api";

    const STEPS = ["profile", "workspace"] as const;

    export const OnboardingWizard = () => {
      const router = useRouter();
      const [index, setIndex] = useState(0);
      const [answers, setAnswers] = useState<Record<string, unknown>>({});

      const finish = async () => {
        await completeOnboarding(answers);
        router.replace(pathsConfig.dashboard.user.index);
        // Hard paywall: replace with pathsConfig.dashboard.user.choosePlan
      };

      if (STEPS[index] === "profile") {
        return (
          <ProfileStep
            onNext={(data) => {
              setAnswers((prev) => ({ ...prev, ...data }));
              setIndex(1);
            }}
          />
        );
      }

      return (
        <WorkspaceStep
          onBack={() => setIndex(0)}
          onNext={async (data) => {
            setAnswers((prev) => ({ ...prev, ...data }));
            await finish();
          }}
        />
      );
    };
    ```

    ```tsx title="apps/web/src/app/[locale]/dashboard/(user)/onboarding/page.tsx"
    import { OnboardingWizard } from "~/modules/onboarding/onboarding-wizard";

    export default function OnboardingPage() {
      return (
        <div className="mx-auto flex min-h-[80vh] max-w-lg flex-col justify-center gap-8 px-4">
          <OnboardingWizard />
        </div>
      );
    }
    ```

    Wire `completeOnboarding` to your Option A/B mutation. Keep steps as named exports under `modules/onboarding/steps/` so the page stays thin.

    Add progress UI (dots or a stepper) in the module - mirror the mobile dots pattern if you want a consistent brand across platforms.
  </Step>

  <Step>
    ## Send new users into onboarding after auth

    Default post-login land is `/dashboard`. For **new** accounts, prefer onboarding.

    Where login / register pass `callbackURL` or `redirectTo` (auth forms under `apps/web/src/modules/auth/`), use onboarding path after email verification and social callbacks for first-time users. Returning users with `onboardingCompleted` should keep `pathsConfig.dashboard.user.index`.

    If the layout guard is in place, even a dashboard deep-link will bounce incomplete users to onboarding - setting `callbackURL` correctly just avoids a flash.
  </Step>

  <Step>
    ## Optional: hard paywall with choose-plan

    ### Choose-plan page

    Reuse pricing / plan cards from the marketing or billing modules. On select, call the same checkout mutation the pricing page uses (`billing.mutations` / provider checkout). Return URL:

    ```ts
    successUrl: pathsConfig.dashboard.user.index,
    cancelUrl: pathsConfig.dashboard.user.choosePlan,
    ```

    ### Layout gate

    Set `REQUIRE_PAID_PLAN = true` in the layout sketch above. Users who finish onboarding but stay on Free keep landing on choose-plan until webhooks mark a subscription or order active.

    ### Trials

    If your provider grants a trial, `getActivePlan()` should already resolve to the paid plan while status is `TRIALING` (active statuses include trial). Do not invent a second trial flag unless product needs it.

    ### Org billing

    When subscriptions hang on organizations, gate on `activeOrganizationId` + that org’s billing summary instead of the user id - same `referenceId` rules as [feature-based access](/docs/web/recipes/feature-based-access).
  </Step>

  <Step>
    ## Verify the matrix

    | Scenario                  | Expected                                                                     |
    | ------------------------- | ---------------------------------------------------------------------------- |
    | New signup                | Lands on onboarding; cannot open `/dashboard` home                           |
    | Completes wizard (soft)   | Dashboard home; Free plan OK                                                 |
    | Completes wizard (hard)   | Choose-plan until checkout succeeds                                          |
    | Returning user, completed | Dashboard; no onboarding flash                                               |
    | Invite accept             | Skip or shortened onboarding                                                 |
    | Checkout webhook delay    | Choose-plan can poll `billing.queries.summary` or show “confirming payment…” |
  </Step>
</Steps>

## Checklist

* [ ] Completion stored on the server (field or table)
* [ ] Paths for `onboarding` (+ `choosePlan` if hard)
* [ ] Layout redirects without loops
* [ ] Wizard UI under `modules/onboarding`
* [ ] Auth `callbackURL` for new users
* [ ] Hard paywall only if product requires paid access; otherwise use feature gates
* [ ] API still enforces paid capabilities

## Other platforms

Mobile already ships welcome + steps + paywall - customize that flow instead of copying this web structure 1:1.

<Cards>
  <Card title="Mobile onboarding" href="/docs/mobile/recipes/onboarding" description="Built-in welcome, steps, and RevenueCat/Superwall paywall." />

  <Card title="Extension onboarding" href="/docs/extension/recipes/onboarding" description="First-run popup and web-backed auth/billing." />

  <Card title="Feature-based access" href="/docs/web/recipes/feature-based-access" description="Gate screens and APIs by plan after users are inside the app." />

  <Card title="Billing configuration" href="/docs/web/billing/configuration" description="Plans, variants, and Free vs paid catalog." />
</Cards>


# Prisma
Source: https://www.turbostarter.dev/docs/web/recipes/prisma

[Prisma ORM](https://www.prisma.io/docs/orm) is a great fit when your team wants a schema file as the center of the database workflow, a generated client with familiar CRUD methods, and mature tooling such as [Prisma Migrate](https://www.prisma.io/docs/orm/prisma-migrate/getting-started) and Prisma Studio.

TurboStarter uses [Drizzle ORM](/docs/web/database/overview) by default because it keeps the data layer close to SQL, but the monorepo is intentionally modular. The database lives behind `@workspace/db`, auth reads that database through an adapter, and feature code imports the client from `@workspace/db/server`. That means you can switch to Prisma without rewriting the whole app at once, as long as you replace the database package carefully.

<Callout title="TL;DR">
  To switch from Drizzle to Prisma, replace the Drizzle schema and client in `packages/db`, move Better Auth from the Drizzle adapter to the Prisma adapter, regenerate migrations with Prisma Migrate, then rewrite service queries that currently use `db.select()`, `db.insert()`, `eq(...)`, `db.query.*`, and Drizzle table exports.
</Callout>

## Drizzle vs. Prisma

Prisma is a strong choice when you want:

* a single `schema.prisma` file for models, relations, and enums
* a generated client with methods like `findMany`, `create`, `update`, and `upsert`
* a migration workflow centered on `prisma migrate dev`
* Prisma Studio for browsing and editing local data
* a database layer many product engineers already know

You should usually stay on Drizzle if you want:

* the default TurboStarter path with the fewest changes
* SQL-first query composition
* direct reuse of the existing `packages/db/src/schema/*` files and migrations
* minimal churn in auth, organizations, billing, and admin queries

## Breaking changes

The Drizzle setup is concentrated in a few predictable places:

| Area            | Drizzle today                                    | Prisma replacement                                                      |
| --------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
| Database schema | `packages/db/src/schema/*`                       | `packages/db/prisma/schema.prisma`                                      |
| Database client | `packages/db/src/server.ts` exports `db`         | `packages/db/src/server.ts` exports a Prisma Client instance            |
| Migrations      | `packages/db/migrations` from Drizzle Kit        | `packages/db/prisma/migrations` from Prisma Migrate                     |
| Auth adapter    | `drizzleAdapter(db, { provider: "pg", schema })` | `prismaAdapter(db, { provider: "postgresql" })`                         |
| Query helpers   | `eq`, `and`, `sql`, `buildConflictUpdateColumns` | Prisma `where`, `select`, `include`, `upsert`, transactions, or raw SQL |
| Zod schemas     | `drizzle-zod` from table definitions             | manual Zod schemas or generated schemas from a Prisma ecosystem tool    |

<Callout type="warn" title="This is not a package swap">
  Prisma and Drizzle expose different query APIs. The safest migration is to keep the `@workspace/db/server` export name as `db`, then rewrite each service module behind the same package boundary.
</Callout>

<Steps>
  <Step>
    ## Install Prisma packages

    Add Prisma to the database package and the Better Auth Prisma adapter to the auth package:

    ```bash
    pnpm --filter @workspace/db add @prisma/client @prisma/adapter-pg
    pnpm --filter @workspace/db add -D prisma
    pnpm --filter @workspace/auth add @better-auth/prisma-adapter
    ```

    Then remove Drizzle packages once the migration is complete:

    ```bash
    pnpm --filter @workspace/db remove drizzle-orm drizzle-zod postgres
    pnpm --filter @workspace/db remove -D drizzle-kit drizzle-seed
    ```

    <Callout type="info" title="Why @prisma/adapter-pg?">
      Starting with Prisma 7, Prisma Client uses a driver adapter for PostgreSQL. The official Prisma docs show `PrismaPg` from `@prisma/adapter-pg` when creating a PostgreSQL client, and the Better Auth docs call out that Prisma 7 requires an explicit generated client `output`.
    </Callout>
  </Step>

  <Step>
    ## Create the Prisma schema

    Create `packages/db/prisma/schema.prisma`. Start by translating the models from `packages/db/src/schema/*`.

    In the kit, `packages/db/src/schema/index.ts` exports two schema groups:

    ```ts title="packages/db/src/schema/index.ts"
    export * from "./auth";
    export * from "./billing";
    ```

    That means your first Prisma schema pass should cover the Better Auth tables from `auth.ts` and the billing tables from `billing.ts`.

    ```prisma title="packages/db/prisma/schema.prisma"
    generator client {
      provider = "prisma-client"
      output   = "../src/generated/prisma"
    }

    datasource db {
      provider = "postgresql"
    }

    model User {
      id               String      @id
      name             String
      email            String      @unique
      emailVerified    Boolean     @default(false) @map("email_verified")
      image            String?
      createdAt        DateTime    @default(now()) @map("created_at")
      updatedAt        DateTime    @updatedAt @map("updated_at")
      twoFactorEnabled Boolean?    @default(false) @map("two_factor_enabled")
      isAnonymous      Boolean?    @default(false) @map("is_anonymous")
      role             String?
      banned           Boolean?    @default(false)
      banReason        String?     @map("ban_reason")
      banExpires       DateTime?   @map("ban_expires")

      sessions         Session[]
      accounts         Account[]
      passkeys         Passkey[]
      twoFactors       TwoFactor[]
      members          Member[]
      invitations      Invitation[]

      @@map("user")
    }

    model Session {
      id        String   @id
      expiresAt DateTime @map("expires_at")
      token     String   @unique
      createdAt DateTime @default(now()) @map("created_at")
      updatedAt DateTime @updatedAt @map("updated_at")
      ipAddress String?  @map("ip_address")
      userAgent String?  @map("user_agent")
      userId    String   @map("user_id")

      user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

      @@index([userId])
      @@map("session")
    }

    model Account {
      id                    String    @id
      accountId             String    @map("account_id")
      providerId            String    @map("provider_id")
      userId                String    @map("user_id")
      accessToken           String?   @map("access_token")
      refreshToken          String?   @map("refresh_token")
      idToken               String?   @map("id_token")
      accessTokenExpiresAt  DateTime? @map("access_token_expires_at")
      refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
      scope                 String?
      password              String?
      createdAt             DateTime  @default(now()) @map("created_at")
      updatedAt             DateTime  @updatedAt @map("updated_at")

      user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)

      @@index([userId])
      @@map("account")
    }

    model Verification {
      id         String   @id
      identifier String
      value      String
      expiresAt  DateTime @map("expires_at")
      createdAt  DateTime @default(now()) @map("created_at")
      updatedAt  DateTime @updatedAt @map("updated_at")

      @@index([identifier])
      @@map("verification")
    }

    model Customer {
      id            String         @id
      referenceId   String         @map("reference_id")
      externalId    String         @map("external_id")
      provider      String
      createdAt     DateTime       @default(now()) @map("created_at")
      updatedAt     DateTime       @updatedAt @map("updated_at")

      subscriptions Subscription[]
      orders        Order[]

      @@unique([referenceId, provider])
      @@unique([externalId, provider])
      @@map("customer")
    }
    ```

    Keep going until every Drizzle table has a Prisma model:

    * `packages/db/src/schema/auth.ts` -> `User`, `Session`, `Account`, `Verification`, `Passkey`, `TwoFactor`, `Organization`, `Member`, and `Invitation`
    * `packages/db/src/schema/billing.ts` -> `Customer`, `Subscription`, `Order`, `SubscriptionStatus`, and `PaymentStatus`

    <Callout title="Preserve table and column names">
      Use `@@map("table_name")` and `@map("column_name")` so Prisma can keep the same database shape that Drizzle created. This is especially useful if you are migrating an existing database instead of starting fresh.
    </Callout>

    <Callout type="warn" title="Handle generated IDs intentionally">
      Several billing tables use Drizzle's `$defaultFn(generateId)`. Prisma cannot call that TypeScript function from the schema, so either set IDs in application code before `create` / `upsert`, or choose a Prisma default such as `@default(cuid())` if changing the ID format is acceptable for your project.
    </Callout>
  </Step>

  <Step>
    ## Add Prisma config

    Create `packages/db/prisma.config.ts` so Prisma CLI commands can find the schema, migration folder, and `DATABASE_URL`.

    ```ts title="packages/db/prisma.config.ts"
    import { defineConfig, env } from "prisma/config";

    export default defineConfig({
      schema: "prisma/schema.prisma",
      migrations: {
        path: "prisma/migrations",
      },
      datasource: {
        url: env("DATABASE_URL"),
      },
    });
    ```

    This mirrors the role of `packages/db/drizzle.config.ts`, but for Prisma. Prisma also supports multi-file schemas; if your schema grows large, you can point `schema` at a folder instead of a single file.
  </Step>

  <Step>
    ## Generate Better Auth's Prisma schema

    Better Auth can generate schema for adapters based on your auth config and plugins. In the Drizzle setup, TurboStarter runs the Better Auth CLI into `packages/db/src/schema/auth.ts`.

    With Prisma, generate to a temporary Prisma file first, then merge the auth models into `packages/db/prisma/schema.prisma`. This keeps the generator from overwriting your custom models.

    ```json title="packages/auth/package.json"
    {
      "scripts": {
        "db:generate": "cross-env SKIP_ENV_VALIDATION=1 pnpm dlx auth generate --config src/server.ts --output ../db/prisma/auth.generated.prisma --y"
      }
    }
    ```

    Then run:

    ```bash
    pnpm --filter @workspace/auth db:generate
    ```

    Review the generated Prisma models before committing. The active Better Auth plugins include organizations, admin, passkeys, two-factor auth, anonymous users, one-tap, email OTP, magic links, Expo, and Next.js cookies. After merging, you can delete `auth.generated.prisma` or keep it ignored as a comparison file.
  </Step>

  <Step>
    ## Replace the database client

    Replace the Drizzle client in `packages/db/src/server.ts` with Prisma Client.

    ```ts title="packages/db/src/server.ts"
    import { PrismaPg } from "@prisma/adapter-pg";
    import { PrismaClient } from "./generated/prisma/client";

    import { env } from "./env";

    const adapter = new PrismaPg({
      connectionString: env.DATABASE_URL,
    });

    export const db = new PrismaClient({ adapter });
    ```

    The key is keeping the exported name `db` stable so downstream imports from `@workspace/db/server` do not all change at once.
  </Step>

  <Step>
    ## Update package exports

    The Drizzle version exports schema tables and Drizzle helpers. Prisma code should export the generated Prisma types instead.

    ```json title="packages/db/package.json"
    {
      "exports": {
        ".": "./src/index.ts",
        "./env": "./src/env.ts",
        "./server": "./src/server.ts",
        "./prisma": "./src/generated/prisma/client.ts"
      }
    }
    ```

    Then update `packages/db/src/index.ts`:

    ```ts title="packages/db/src/index.ts"
    export type {
      Account,
      Customer,
      Invitation,
      Member,
      Order,
      Organization,
      Passkey,
      Session,
      Subscription,
      TwoFactor,
      User,
    } from "./generated/prisma/client";
    ```

    You can also export `Prisma` from the generated client if service code needs Prisma utility types.
  </Step>

  <Step>
    ## Update Better Auth

    Switch `packages/auth/src/server.ts` from the Drizzle adapter to the Prisma adapter.

    ```ts title="packages/auth/src/server.ts"
    import { prismaAdapter } from "@better-auth/prisma-adapter";

    import { db } from "@workspace/db/server";

    export const auth = betterAuth({
      // ...
      database: prismaAdapter(db, {
        provider: "postgresql",
      }),
    });
    ```

    Remove the old Drizzle schema import:

    ```ts
    import * as schema from "@workspace/db/schema";
    ```

    Better Auth's Prisma adapter supports joins, but only enable `experimental: { joins: true }` after your Prisma schema includes the required relations.
  </Step>

  <Step>
    ## Replace Drizzle scripts with Prisma scripts

    Update `packages/db/package.json` so the commands match Prisma's workflow:

    ```json title="packages/db/package.json"
    {
      "scripts": {
        "db:generate": "prisma generate",
        "db:migrate": "prisma migrate deploy",
        "db:migrate:dev": "prisma migrate dev",
        "db:push": "prisma db push",
        "db:studio": "prisma studio",
        "db:reset": "prisma migrate reset"
      }
    }
    ```

    Use `db:migrate:dev` while developing schema changes locally. Use `db:migrate` for applying committed migrations in deployed environments.

    If your root `turbo.json` references `db:generate`, `db:migrate`, or `db:studio`, keep the task names the same so existing commands such as `pnpm with-env turbo db:generate` still work.
  </Step>

  <Step>
    ## Generate the client and create migrations

    For a new project or disposable local database, generate a fresh migration history:

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate:dev -- --name init
    ```

    For an existing database that already has Drizzle migrations applied, do not let Prisma try to recreate existing tables. Use Prisma's introspection and baselining flow instead:

    ```bash
    pnpm with-env pnpm --filter @workspace/db exec prisma db pull
    pnpm with-env pnpm --filter @workspace/db exec prisma migrate diff \
      --from-empty \
      --to-schema-datamodel prisma/schema.prisma \
      --script > packages/db/prisma/migrations/0_init/migration.sql
    pnpm with-env pnpm --filter @workspace/db exec prisma migrate resolve --applied 0_init
    ```

    Review the generated SQL before applying it to shared environments.
  </Step>

  <Step>
    ## Rewrite service queries

    This is where most of the real work happens. Search for Drizzle imports and replace each query with Prisma Client calls.

    Common rewrites:

    | Drizzle pattern                                                           | Prisma pattern                                                             |
    | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
    | `db.select().from(customer).where(eq(customer.referenceId, referenceId))` | `db.customer.findMany({ where: { referenceId } })`                         |
    | `db.select({ count: count() }).from(member)`                              | `db.member.count({ where })`                                               |
    | `.onConflictDoUpdate(...)`                                                | `db.model.upsert({ where, create, update })`                               |
    | `db.query.customer.findMany({ with: { subscriptions, orders } })`         | `db.customer.findMany({ include: { subscriptions: true, orders: true } })` |
    | `leftJoin(...)`                                                           | `include`, nested `select`, or `$queryRaw` for SQL-heavy reads             |

    For example, the customer lookup in `packages/billing/shared/src/server/customer.ts`:

    ```ts title="packages/billing/shared/src/server/customer.ts"
    import { eq } from "@workspace/db";
    import { customer } from "@workspace/db/schema";
    import { db } from "@workspace/db/server";

    export const getCustomerByExternalId = async (externalId: string) => {
      const [data] = await db
        .select()
        .from(customer)
        .where(eq(customer.externalId, externalId));

      return data ?? null;
    };
    ```

    becomes:

    ```ts title="packages/billing/shared/src/server/customer.ts"
    import { db } from "@workspace/db/server";

    export const getCustomerByExternalId = (externalId: string) => {
      return db.customer.findFirst({
        where: { externalId },
      });
    };
    ```

    The billing upserts in `packages/billing/shared/src/server/subscription.ts` and `packages/billing/shared/src/server/order.ts` also need special attention because they rely on composite uniqueness:

    ```ts title="packages/billing/shared/src/server/subscription.ts"
    export const upsertSubscription = async (data: InsertSubscription) => {
      return db
        .insert(subscription)
        .values(data)
        .onConflictDoUpdate({
          target: [subscription.externalId, subscription.store],
          set: subscriptionConflictUpdateSet,
        })
        .returning();
    };
    ```

    In Prisma, model that as a named composite unique constraint, then upsert through the generated compound selector:

    ```prisma title="packages/db/prisma/schema.prisma"
    model Subscription {
      id         String @id
      externalId String @map("external_id")
      store      String

      @@unique([externalId, store], name: "subscription_external_store")
      @@map("subscription")
    }
    ```

    ```ts title="packages/billing/shared/src/server/subscription.ts"
    import { db } from "@workspace/db/server";

    export const upsertSubscription = (data: InsertSubscription) => {
      return db.subscription.upsert({
        where: {
          subscription_external_store: {
            externalId: data.externalId,
            store: data.store,
          },
        },
        create: data,
        update: {
          variantId: data.variantId,
          status: data.status,
          periodStartsAt: data.periodStartsAt,
          periodEndsAt: data.periodEndsAt,
          trialStartsAt: data.trialStartsAt,
          trialEndsAt: data.trialEndsAt,
          updatedAt: data.updatedAt,
        },
      });
    };
    ```
  </Step>

  <Step>
    ## Replace drizzle-zod schemas

    Drizzle currently creates schemas with `drizzle-zod`, for example:

    ```ts
    export const insertSubscriptionSchema = createInsertSchema(subscription);
    ```

    Prisma does not ship a built-in Zod schema generator. The most predictable approach is to keep request validation explicit:

    ```ts title="packages/api/src/schema/billing.ts"
    import * as z from "zod";

    export const subscriptionStatusSchema = z.enum([
      "active",
      "canceled",
      "incomplete",
      "incomplete_expired",
      "past_due",
      "paused",
      "trialing",
      "unpaid",
    ]);

    export const subscriptionIdSchema = z.object({
      id: z.string().min(1),
    });
    ```

    If you prefer generated schemas, evaluate a Prisma-to-Zod generator and commit the generated output policy to your team conventions before using it broadly.
  </Step>
</Steps>

## Recommended migration order

1. Commit the current Drizzle implementation before starting.
2. Create `schema.prisma` and translate all Drizzle tables, enums, indexes, and relations.
3. Generate the Prisma Client and replace `packages/db/src/server.ts`.
4. Switch Better Auth to `prismaAdapter`.
5. Replace package exports that expose Drizzle tables or helpers.
6. Rewrite service queries one module at a time.
7. Replace `drizzle-zod` schemas with explicit Zod schemas or a chosen generator.
8. Generate or baseline Prisma migrations.
9. Smoke test auth, organizations, admin tables, billing customers, orders, subscriptions, and seed scripts.

## Smoke test checklist

After the switch, verify:

* `pnpm with-env pnpm --filter @workspace/db db:generate`
* `pnpm with-env pnpm --filter @workspace/db db:migrate:dev -- --name init`
* `pnpm with-env pnpm --filter @workspace/db db:studio`
* sign-in and session reads through Better Auth
* organization creation, invitations, role checks, and account deletion guards
* billing customer, order, subscription, and per-seat quantity sync
* admin user, account, membership, invitation, order, and subscription listings
* any code that imports `@workspace/db/schema`

## FAQ

### Can I keep the `@workspace/db/server` import?

Yes. That is the best way to reduce churn. Keep exporting `db` from `@workspace/db/server`, but change the implementation from Drizzle to Prisma Client.

### Can I reuse Drizzle migrations?

Not directly. Drizzle and Prisma store migration history differently. For a new database, generate fresh Prisma migrations. For an existing database, use Prisma introspection and baseline the existing schema so Prisma does not try to recreate tables that already exist.

### Does Prisma work with Better Auth?

Yes. Better Auth provides a Prisma adapter through `@better-auth/prisma-adapter` and documents the `prismaAdapter(db, { provider: "postgresql" })` setup.

### Do I need to rewrite every query at once?

You need the app to compile against one database client, but you can still migrate by module. Start with auth, then organizations/admin queries, then billing customers, subscriptions, and orders.

<Cards className="sm:grid-cols-3">
  <Card title="Prisma Client" description="prisma.io" href="https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/introduction" />

  <Card title="Prisma Migrate" description="prisma.io" href="https://www.prisma.io/docs/orm/prisma-migrate/getting-started" />

  <Card title="Better Auth Prisma" description="better-auth.com" href="https://better-auth.com/docs/adapters/prisma" />
</Cards>


# Subdomain multi-tenancy
Source: https://www.turbostarter.dev/docs/web/recipes/subdomain-multi-tenancy

This recipe shows how to add **subdomain-based multi-tenancy** to a TurboStarter app, so each [organization](/docs/web/organizations/overview) can be reached through a hostname like `acme.turbostarter.dev`.

The implementation has two main pieces:

* TurboStarter's organization model, where each organization already has a stable **slug**
* [Next.js Proxy](https://nextjs.org/docs/app/getting-started/proxy) that extracts the subdomain and rewrites the request to an organization-aware route

The key idea is simple: use the **subdomain as another way to select the active organization**, then keep the rest of your application working with the same organization-aware APIs and session patterns you already use elsewhere.

## Capabilities

This approach is a good fit when:

* each organization should feel like it has its own workspace or app
* you want URLs like `org.turbostarter.dev` instead of `/dashboard/org`
* you already model organizations with a unique slug
* you want one codebase and one deployment serving many tenants

If your app only needs organization scoping inside an authenticated dashboard, keeping `/dashboard/[organization]` routes may stay simpler.

## Architecture

Organization-aware pages already use a slug-based route:

```tsx title="apps/web/src/app/[locale]/dashboard/[organization]/layout.tsx"
const organizationSlug = (await params).organization;
const activeOrganization = await getOrganization({ slug: organizationSlug });
```

That is the important foundation. A subdomain setup does not replace this idea, it just changes **where the slug comes from**.

With subdomain multi-tenancy, the request flow becomes:

1. User opens `https://acme.turbostarter.dev`
2. Proxy extracts `acme` from the hostname
3. Proxy rewrites the request to an internal route such as `/app/acme`
4. Your server component resolves the organization by slug
5. The rest of the app continues using the resolved organization and membership data

<Callout title="Keep one source of truth" type="info">
  Use the organization slug as the canonical tenant identifier everywhere: URLs, lookups, cache keys, and permission checks.
</Callout>

<Steps>
  <Step>
    ## Make sure organizations have stable slugs

    TurboStarter already follows the right pattern: organizations are addressed by slug, and slug generation is checked for uniqueness.

    ```ts title="packages/api/src/modules/organization/queries/generate-slug.ts"
    export const generateSlug = async (name: string) => {
      const base = slugify(name, {
        lower: true,
        remove: /[.,'+:()]/g,
      });

      let slug = base;

      // retry with a suffix when the slug is taken
      // ...

      return { slug };
    };
    ```

    For subdomain routing, your slug rules matter more than in path-based routing because the slug becomes part of the hostname.

    Recommended constraints:

    * lowercase only
    * no spaces
    * no underscores
    * avoid very long labels
    * reserve special hostnames such as `www`, `app`, `admin`, `docs`, `api`

    It is worth validating this at organization creation time so you never issue an invalid hostname.
  </Step>

  <Step>
    ## Add a root domain env variable

    Define one environment variable that represents the base domain your tenants live under.

    ```dotenv title=".env.local"
    NEXT_PUBLIC_ROOT_DOMAIN="turbostarter.dev"
    ```

    For local development, you can still point this to localhost-style development:

    ```dotenv title=".env.local"
    NEXT_PUBLIC_ROOT_DOMAIN="localhost:3000"
    ```

    Then expose a tiny helper:

    ```ts title="src/lib/tenant.ts"
    export const protocol =
      process.env.NODE_ENV === "production" ? "https" : "http";

    export const rootDomain =
      process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";
    ```

    This gives you a single place to reason about hostnames in your Proxy logic and redirects.
  </Step>

  <Step>
    ## Add a Proxy that extracts the organization from the hostname

    This is the core of the pattern. Use [Next.js Proxy](https://nextjs.org/docs/app/getting-started/proxy) to:

    * detect subdomains in local development
    * detect subdomains in production
    * rewrite matching requests to an internal route

    In TurboStarter, rewrite a tenant hostname to a route that includes the organization slug:

    ```ts title="proxy.ts"
    import { type NextRequest, NextResponse } from "next/server";

    const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";

    const RESERVED_SUBDOMAINS = new Set(["www", "app", "admin", "docs", "api"]);

    const getOrganizationSlugFromHost = (request: NextRequest) => {
      const host = request.headers.get("host") ?? "";
      const hostname = host.split(":")[0];
      const root = rootDomain.split(":")[0];

      if (hostname === root || hostname === `www.${root}`) {
        return null;
      }

      if (
        hostname.endsWith(".localhost") ||
        request.url.includes("localhost") ||
        request.url.includes("127.0.0.1")
      ) {
        const match = request.url.match(/https?:\/\/([^.]+)\.localhost(?::\d+)?/);
        return match?.[1] ?? null;
      }

      if (!hostname.endsWith(`.${root}`)) {
        return null;
      }

      const slug = hostname.replace(`.${root}`, "");
      return RESERVED_SUBDOMAINS.has(slug) ? null : slug;
    };

    export function proxy(request: NextRequest) {
      const slug = getOrganizationSlugFromHost(request);
      const { pathname } = request.nextUrl;

      if (!slug) {
        return NextResponse.next();
      }

      if (pathname.startsWith("/api") || pathname.startsWith("/_next")) {
        return NextResponse.next();
      }

      return NextResponse.rewrite(new URL(`/app/${slug}${pathname}`, request.url));
    }

    export const config = {
      matcher: ["/((?!api|_next|[\\w-]+\\.\\w+).*)"],
    };
    ```

    This keeps hostname parsing at the edge while letting the rest of the app stay focused on loading the organization by slug.
  </Step>

  <Step>
    ## Create an internal tenant route

    Now create a route that receives the rewritten slug. A simple structure is:

    ```text
    src/app/app/[organization]/page.tsx
    src/app/app/[organization]/layout.tsx
    ```

    Then resolve the organization exactly the same way TurboStarter resolves `/dashboard/[organization]` routes:

    ```tsx title="src/app/app/[organization]/layout.tsx"
    import { notFound, redirect } from "next/navigation";

    import { getOrganization, getSession } from "~/lib/auth/server";

    export default async function TenantLayout({
      children,
      params,
    }: {
      readonly children: React.ReactNode;
      readonly params: Promise<{ organization: string }>;
    }) {
      const { user } = await getSession();

      if (!user) {
        redirect("/login");
      }

      const slug = (await params).organization;
      const organization = await getOrganization({ slug });

      if (!organization) {
        notFound();
      }

      return <>{children}</>;
    }
    ```

    Once you have the slug, the rest of the organization loading story stays familiar.
  </Step>

  <Step>
    ## Reuse the existing active-organization flow

    TurboStarter already treats the organization slug in the URL as the primary signal for organization context, and then syncs that with the authenticated session.

    For example, the client hook reads the current slug from route params:

    ```tsx title="apps/web/src/modules/organization/hooks/use-active-organization.ts"
    const params = useParams();
    const slug = params.organization?.toString();

    const activeOrganization = useQuery({
      ...organization.queries.get({ slug: slug ?? "" }),
      enabled: !!slug,
    });
    ```

    That means you do not need a totally separate tenant model for subdomains. You can keep:

    * organization lookup by slug
    * membership lookup by organization ID
    * permission checks against the resolved organization
    * session synchronization through `activeOrganizationId`

    In practice, subdomains become a new entrypoint into the same organization state model you already use for slug-based routes.
  </Step>

  <Step>
    ## Decide whether to keep path-based dashboard URLs

    You have two reasonable options:

    ### Option A: Subdomain only

    Use the rewrite target as the real application surface, for example:

    * `acme.turbostarter.dev/`
    * `acme.turbostarter.dev/settings`
    * `acme.turbostarter.dev/members`

    This feels the most native, but it means more of your app depends on hostname-based behavior.

    ### Option B: Hybrid

    Keep the existing `/dashboard/[organization]` routes for the main app, and use the subdomain only as a convenient entrypoint:

    * `acme.turbostarter.dev` rewrites or redirects to `/dashboard/acme`
    * all deeper navigation stays path-based

    This is usually the easiest migration path because you preserve almost all existing routing and layouts.

    If you want the smallest possible change set, start with the hybrid approach.
  </Step>

  <Step>
    ## Protect tenant boundaries in data access

    The hostname should help select the tenant, but it should never be the only security boundary.

    Keep all existing checks that verify:

    * the organization exists
    * the current user belongs to that organization
    * the user has permission for the requested action

    This is already how TurboStarter is structured: the slug gets you to the organization, and membership or role checks decide what the user may do inside it.

    <Callout title="Important" type="warn">
      Do not trust the subdomain by itself for authorization. Always resolve the organization, then verify membership and permissions server-side before returning tenant data.
    </Callout>
  </Step>

  <Step>
    ## Support local development

    Use local subdomains such as:

    `http://tenant.localhost:3000`

    That is the easiest development setup for this pattern as well.

    Examples:

    * `http://acme.localhost:3000`
    * `http://globex.localhost:3000`

    Make sure your Proxy handles:

    * `localhost`
    * `127.0.0.1`
    * the port being present in the `host` header

    You can keep your root app on:

    * `http://localhost:3000`

    and tenant apps on:

    * `http://org.localhost:3000`
  </Step>

  <Step>
    ## Configure production deployment

    For production, you need wildcard DNS for your application domain.

    Typical setup:

    1. Add `turbostarter.dev` to your hosting provider
    2. Add a wildcard record for `*.turbostarter.dev`
    3. Set `NEXT_PUBLIC_ROOT_DOMAIN=turbostarter.dev`
    4. Deploy the app with `proxy.ts` enabled

    Preview deployments need one extra consideration: the hostname may look different from production. If your hosting provider uses alternate preview hostnames, normalize those inside Proxy before extracting the organization slug.
  </Step>
</Steps>

## Suggested file layout

One clean structure is:

<Files>
  <Folder name="src" defaultOpen>
    <Folder name="app" defaultOpen>
      <Folder name="[organization]" defaultOpen>
        <File name="layout.tsx" />

        <File name="page.tsx" />

        <Folder name="settings" />

        <Folder name="members" />
      </Folder>
    </Folder>

    <Folder name="lib" defaultOpen>
      <File name="tenant.ts" />
    </Folder>

    <File name="proxy.ts" />
  </Folder>
</Files>

If you prefer the hybrid approach, your Proxy can instead redirect or rewrite subdomains into the existing dashboard structure.

## Common edge cases

* **Reserved subdomains:** Prevent users from creating organizations named `www`, `docs`, `api`, `admin`, and similar internal hostnames.
* **Slug changes:** If you allow organizations to rename their slug, old subdomains will break unless you store redirects or a hostname history table.
* **Cross-tenant caching:** Cache keys must include the organization slug or ID to avoid leaking data across tenants.
* **Asset URLs and cookies:** Cookies should usually be scoped to the parent domain when you want session sharing across subdomains.
* **SEO and indexing:** Private tenant workspaces should usually be `noindex`.
* **Login flows:** After sign-in, redirect the user back to the tenant hostname they started on instead of always sending them to the root domain.

## Recommended rollout

The safest rollout is:

1. keep the current organization slug routes
2. add hostname parsing in `proxy.ts`
3. rewrite `org.domain.com` into the existing organization-aware route tree
4. verify auth, membership, and caching behavior
5. only then decide whether you want fully subdomain-native routes

That way you reuse the existing organization model from TurboStarter and only add the hostname routing layer.


# Supabase
Source: https://www.turbostarter.dev/docs/web/recipes/supabase

[Supabase](https://supabase.com) is an open-source backend platform built on top of PostgreSQL that provides a managed database, storage, and other features out of the box.

You can adopt Supabase incrementally - start with just the pieces you need (for example, database only, or database + storage) and add more features over time. There's no requirement to integrate everything at once.

In this guide, we'll walk you through the process of setting up Supabase as a provider for your TurboStarter project. This could include using it as a [database](https://supabase.com/docs/guides/database), [storage](https://supabase.com/docs/guides/storage), [edge runtime for your API](https://supabase.com/docs/guides/functions) and more.

## Prerequisites

Before you start, make sure you have:

* **TurboStarter project** cloned locally with dependencies installed (you can use our [CLI](/docs/web/cli) to create a new project in seconds)
* **Supabase account** - you can create one at [supabase.com](https://supabase.com/sign-up)
* Basic familiarity with the core database docs:
  * [Database overview](/docs/web/database/overview)
  * [Migrations](/docs/web/database/migrations)
  * [Database client](/docs/web/database/client)

<Steps>
  <Step>
    ## (Optional) Use Supabase locally with Docker

    If you're on the Supabase free plan, you can only have a limited number of active hosted databases at once. A good workflow is:

    * Use **local Supabase** for day-to-day development
    * Keep **one hosted Supabase project** for staging/production (and for testing features that require a deployed project)

    Supabase provides a local development stack that runs via **Docker**, managed by the **Supabase CLI**.

    ### Install prerequisites

    * Install **Docker** (Docker Desktop is the easiest option)
    * Install the **Supabase CLI** (pick one):
      * macOS (Homebrew): `brew install supabase/tap/supabase`
      * npm (no global install): `npx supabase --version`

    ### Initialize and start Supabase locally

    From the monorepo root:

    ```bash
    supabase init
    supabase start
    ```

    Once it’s running, get the local URLs and credentials:

    ```bash
    supabase status
    ```

    You should see a local **DB URL** (Postgres), plus URLs for **Studio** and the local API.

    <Callout title="Default local DB URL" type="info">
      In most default setups, the local Postgres URL looks like:

      `postgresql://postgres:postgres@127.0.0.1:54322/postgres`

      Always prefer copying the exact value from `supabase status` to avoid port mismatches.
    </Callout>

    ### Point TurboStarter to the local database

    Update the **root** `.env.local` so TurboStarter’s `@workspace/db` uses the local Postgres:

    ```dotenv title=".env.local"
    DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
    ```

    Then run migrations (same as with hosted Supabase):

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    That’s it — TurboStarter now talks to your **local Supabase Postgres**.

    ### Useful local commands

    ```bash
    supabase stop        # stop containers
    supabase start       # start again
    supabase status      # show URLs/ports/keys
    supabase db reset    # reset local DB (drops data)
    ```
  </Step>

  <Step>
    ## Create a new Supabase project

    1. Go to the [Supabase dashboard](https://supabase.com).
    2. Create a **new project** (choose a strong database password and a region close to your users).
    3. Supabase will automatically provision a **PostgreSQL database** for you.

    ![Create a new Supabase project](/images/docs/web/recipes/supabase/create-project.png)

    Optionally, you can customize the **Security options** by choosing the **Only Connection String** option - it will opt out of autogenerating API for tables inside your database. It's not needed for TurboStarter setup, but of course you can still leverage it for your custom use-cases.

    ![Security options](/images/docs/web/recipes/supabase/security-options.png)

    Once the project is ready, you can fetch the connection string.
  </Step>

  <Step>
    ## Get the database connection string

    In the Supabase dashboard:

    1. Open your project.
    2. Click on the **Connect** button at the top.
    3. Locate the **connection string** for your chosen ORM (it will be under the **ORMs** tab).

    ![Connect application](/images/docs/web/recipes/supabase/connect-app.png)

    Copy this value - you'll use it as your `DATABASE_URL`.

    <Callout title="Replace password placeholder" type="warn">
      In your Supabase connection string, you can see a placeholder like `[YOUR-PASSWORD]`. Make sure to replace this with the actual password you set when creating your Supabase project.
    </Callout>
  </Step>

  <Step>
    ## Configure environment variables

    TurboStarter reads database connection settings from the **root** `.env.local` file and uses them inside the `@workspace/db` package.

    Create (or update) the `.env.local` file in the **monorepo root**:

    ```dotenv title=".env.local"
    DATABASE_URL="postgres://postgres.[YOUR-PROJECT-REF]:[YOUR-PASSWORD]@aws-0-[aws-region].pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
    ```

    Replace:

    * `YOUR-PROJECT-REF` with your Supabase project ref
    * `YOUR-PASSWORD` with the database password you set when creating the project
    * `aws-region` with the region shown in the Supabase connection string

    <Callout>
      These variables are validated in the `@workspace/db` package and used to create Drizzle client for your database.
    </Callout>

    For more background on how `DATABASE_URL` is used, see [Database overview](/docs/web/database/overview).
  </Step>

  <Step>
    ## Setup your Supabase database

    With `DATABASE_URL` now pointing to Supabase, you can apply the existing TurboStarter schema to your Supabase database.

    From the monorepo root, run:

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    This will:

    * Use your Supabase `DATABASE_URL` from `.env.local`
    * Run all pending SQL migrations from `packages/db/migrations`
    * Create the full TurboStarter schema (users, billing, demo tables, etc.) in Supabase

    If you're actively iterating on the schema, you can generate new migrations and apply them as described in [Migrations](/docs/web/database/migrations).

    <Callout title="Seeding your database" type="info">
      After running your migrations, you may want to seed your database with initial data (such as demo users or organizations). You can do this by running the following command:

      ```bash
      pnpm with-env pnpm turbo db:seed
      ```

      This will populate your Supabase database with some example data you can use to test your application.
    </Callout>
  </Step>

  <Step>
    ## Use Supabase Storage as S3-compatible storage

    TurboStarter's storage layer is designed to work seamlessly with **any S3-compatible provider**. In this section, we'll show how to use [Supabase Storage](/docs/web/storage/overview) as your application's file storage back-end.

    Supabase Storage provides a simple, S3-compatible API and is a great choice if you're already using Supabase for your database.

    ### Create a storage bucket

    1. In the Supabase dashboard, go to **Storage → Buckets**.
    2. Click **Create bucket** (name it whatever you want, for example `avatars` or `uploads`).
    3. Adjust settings based on your needs (e.g. limit the maximum file size, specify the allowed file types, etc.)

    ![Create a new bucket](/images/docs/web/recipes/supabase/create-bucket.png)

    You can create multiple buckets (for documents, images, videos, etc.) if needed.

    ### Generate S3 access keys in Supabase dashboard

    1. Go to **Storage → S3 → Access keys**.
    2. Click **New access key**.
    3. Give it a descriptive name and create the key.
    4. Copy the **Access key ID** and **Secret access key** to use in your application.

    ![Generate S3 access keys](/images/docs/web/recipes/supabase/s3-keys.png)

    ### Configure S3 environment variables for Supabase Storage

    In your weba application's `.env.local`, add (or update) the S3 configuration used by TurboStarter's storage layer:

    ```dotenv title=".env.local"
    S3_REGION="us-east-1"
    S3_BUCKET="avatars"
    S3_ENDPOINT="https://[YOUR-PROJECT-REF].supabase.co/storage/v1/s3"
    S3_ACCESS_KEY_ID="your-access-key-id"
    S3_SECRET_ACCESS_KEY="your-secret-access-key"
    ```

    These variables integrate directly with the storage configuration described in:

    * [Storage overview](/docs/web/storage/overview)
    * [Storage configuration](/docs/web/storage/configuration)

    Once set, existing TurboStarter file upload flows (e.g. user avatars, organization logos) will use Supabase Storage via presigned URLs.
  </Step>

  <Step>
    ## Run your API on Supabase Edge Functions

    As we're using a [Hono](https://hono.dev) as our API server, you can deploy it as a Supabase Edge Function so it runs close to your users.

    At a high level:

    1. Install the [Supabase CLI](https://supabase.com/docs/guides/cli) and initialize a Supabase project locally with `supabase init`.
    2. Create a new [Edge Function](https://supabase.com/docs/guides/functions/quickstart) (for example `hono-backend`) with `supabase functions new hono-backend`.
    3. Inside the generated function (for example `supabase/functions/hono-backend/index.ts`), set up a basic Hono app and export it via `Deno.serve(app.fetch)`:

    ```ts
    import { Hono } from "jsr:@hono/hono";

    // change this to your function name
    const functionName = "hono-backend";
    const app = new Hono().basePath(`/${functionName}`);

    app.get("/hello", (c) => c.text("Hello from hono-server!"));

    Deno.serve(app.fetch);
    ```

    4. Run the function locally with `supabase start` and `supabase functions serve --no-verify-jwt`, then call it from your TurboStarter app using the local or deployed function URL.
    5. When you're ready, deploy the function with `supabase functions deploy` (or `supabase functions deploy hono-backend`) and manage it using the Supabase dashboard, as described in the [Supabase Edge Functions docs](https://supabase.com/docs/guides/functions).

    This is entirely optional, but it's a great fit for lightweight APIs, webhooks, and other serverless logic you want to run alongside your Supabase project.
  </Step>

  <Step>
    ## Explore additional Supabase features

    Supabase is a full Postgres development platform, so beyond the database and storage pieces wired up above you can gradually add more features as your app grows ([see the Supabase homepage](https://supabase.com/) for an overview).

    Some features that fit especially well with TurboStarter's design are:

    * [Realtime](https://supabase.com/docs/guides/realtime) - built on [Postgres replication](https://www.postgresql.org/docs/current/runtime-config-replication.html), so you can stream changes from your existing TurboStarter tables (inserts, updates, deletes) into live UIs without changing how you manage schema or RLS. You still define tables and policies via `@workspace/db`, and opt into Realtime on top.
    * [Vector](https://supabase.com/docs/guides/ai) - powered by the [pgvector](https://github.com/pgvector/pgvector) extension and stored in regular Postgres tables, making it easy to integrate semantic search or AI features while keeping everything in the same migrations and Drizzle models you already use in TurboStarter. We're using it extensively in our dedicated [AI Kit](/ai/docs).
    * [Cron](https://supabase.com/modules/cron) - enables you to schedule background jobs and periodic tasks with [pg\_cron](https://github.com/citusdata/pg_cron). You can define cron jobs for things like scheduled database cleanups, sending emails, report generation, or any recurring logic, all managed alongside your TurboStarter app with full Postgres integration.

    Because these features are all layered on top of Postgres, you can introduce them incrementally and keep managing everything through your familiar workflow.
  </Step>

  <Step>
    ## Start the development server

    With the database and other services configured to use Supabase, you can start TurboStarter as usual from the monorepo root:

    ```bash
    pnpm dev
    ```

    TurboStarter will now:

    * Use **Supabase Postgres** as your database through `DATABASE_URL`
    * Use **Supabase Storage** as your file storage through the S3-compatible endpoint
    * Leverage **Supabase Edge Functions** (for example, with Hono) for your serverless backend
  </Step>
</Steps>

That's it! You can now start building your application with Supabase as your main provider. Explore the [Supabase documentation](https://supabase.com/docs) for more features and best practices.


# Versioning
Source: https://www.turbostarter.dev/docs/web/recipes/versioning

On the web you can redeploy without anyone noticing. That is the point of continuous delivery, and it is also why teams skip versioning until a support ticket asks "which build am I on?"

A product version is the shared label for everything that shipped together: the UI, the API contract your clients assume, the migration that ran, the flag defaults that flipped. Without it, analytics, error reports, changelogs, and rollbacks all become archaeology.

## Why bother at all?

Stores do not force a number on you. You still want one because:

* **Support** - screenshots and tickets that say `v1.8.2` beat "I think I refreshed yesterday".
* **Observability** - group crashes and sessions by release instead of by deploy time guesswork.
* **Communication** - changelogs, status updates, and "we rolled back to …" need a name.
* **Coordination** - marketing, mobile, and API consumers can talk about the same cut of the product.

Git SHAs are great for engineers. Users and most support tools are not. Keep a human version (`1.8.2`) even if your host also stamps every deploy with a commit.

## Bumping versions

[Semantic Versioning](https://semver.org/) is enough for most SaaS apps:

| Bump      | Use when                                                 |
| --------- | -------------------------------------------------------- |
| **Patch** | Fixes, copy, no breaking behavior                        |
| **Minor** | New capabilities that stay compatible                    |
| **Major** | Breaking API/UX, hard migrations, "everyone must notice" |

Bump when the release is worth talking about, not on every typo commit. Pair the bump with the production deploy so the live site and the label never drift.

Pre-release tags (`1.9.0-beta.1`) help for staging if you need them. Production versions stay clean so dashboards and tickets stay readable.

## Best practices

* One **product** version for the app users meet. Do not confuse it with dozens of workspace package versions in a monorepo.
* The same value everywhere you show or send "app version" (UI, support macros, error context).
* A short human note per release (changelog or release description). The number alone does not explain what changed.
* Independence from mobile and extension listings. Those follow store review cycles; your Next.js deploy does not have to match their numbers.

## In TurboStarter

The kit already exposes a single product version from `apps/web/package.json` through `appConfig.version` (shown in the marketing footer). Bump that field with the deploy you care about tracking:

```json title="apps/web/package.json"
{
  "version": "1.8.1", // [!code --]
  "version": "1.8.2" // [!code ++]
}
```

Deploy flow: [deployment checklist](/docs/web/deployment/checklist). Optional next step: pass `appConfig.version` into your monitoring provider so production issues group by release.


# Access control
Source: https://www.turbostarter.dev/docs/web/security/access-control

Access control in TurboStarter has two jobs:

1. Prove **who** the caller is (authentication)
2. Prove **what** they are allowed to do (authorization)

Both must happen on the server. Hiding a button in React is not enough.

## Authentication (sessions)

Better Auth stores sessions in cookies and validates them with `BETTER_AUTH_SECRET`. On the web app:

* **Server Components / layouts** use `getSession()` from `~/lib/auth/server`
* **API routes** use `enforceAuth`, which calls `auth.api.getSession` with the request headers
* **Client code** uses the Better Auth React client for sign-in flows only - never as the sole gate for sensitive actions

Protected dashboard layouts redirect when there is no user:

```tsx
const { user } = await getSession();

if (!user) {
  return redirect(pathsConfig.auth.login);
}
```

Organization dashboards also verify membership before rendering tenant data - if the org is missing or the user is not a member, they redirect away.

### Hardening auth

* Keep **email verification** enabled for password sign-up (`requireEmailVerification: true` in `packages/auth/src/server.ts`)
* Offer **2FA**, passkeys, and session management from the account security settings
* Review `trustedOrigins` when you add mobile deep links or browser extension origins
* Prefer production HTTPS so session cookies stay on secure transports

<Cards>
  <Card title="Authentication overview" href="/docs/web/auth/overview" description="Supported methods and flows." />

  <Card title="Two-factor authentication" href="/docs/web/auth/2fa" description="TOTP, OTP, and recovery codes." />
</Cards>

## API authorization

The Hono API in `packages/api` exposes reusable middleware in `middleware.ts`:

| Middleware                      | Purpose                                     |
| ------------------------------- | ------------------------------------------- |
| `enforceAuth`                   | Requires a valid session; sets `c.var.user` |
| `enforceAdmin`                  | Requires the global admin role              |
| `enforceUserPermission`         | Checks Better Auth permissions for the user |
| `enforceOrganizationPermission` | Checks permissions inside an organization   |
| `enforceMembership`             | Requires org membership at a minimum role   |

Typical protected route:

```ts title="packages/api/src/modules/storage/router.ts"
export const storageRouter = new Hono().get(
  "/upload",
  enforceAuth,
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
);
```

Admin routes stack auth and role checks:

```ts
.use(enforceAuth)
.use(enforceAdmin)
```

See [Protected routes](/docs/web/api/protected-routes) for feature/plan gating patterns.

## Multi-tenant isolation

Organizations are the tenancy boundary. The critical rule:

**Never authorize from a client-provided `organizationId` alone.** Resolve the session first, then verify membership or permissions for that organization.

Billing already follows this pattern: if `referenceId` is not the current user, the router requires organization billing permissions before continuing:

```ts title="packages/api/src/modules/billing/router.ts"
const enforceAccessToReference = (permissions: Permissions["billing"]) =>
  createMiddleware(async (c, next) => {
    const referenceId =
      c.req.valid("json")?.referenceId ??
      c.req.query("referenceId") ??
      c.var.user.id;

    if (referenceId === c.var.user.id) {
      return next();
    }

    return enforceOrganizationPermission({
      organizationId: referenceId,
      permissions: { billing: permissions },
    })(c, next);
  });
```

When you add your own resources (projects, documents, …):

1. Store `organizationId` (or `userId`) on the row
2. Authenticate the request
3. Confirm membership / permission for that tenant
4. Scope every query with the verified id - not the raw body field unchecked

```ts title="Pattern"
// After enforceAuth + enforceMembership({ organizationId })
const projects = await db.query.project.findMany({
  where: eq(project.organizationId, organizationId),
});
```

Client-side `hasPermission` / `checkRolePermission` checks are for UI only. Repeat the check in the API.

<Card title="Organizations RBAC" href="/docs/web/organizations/rbac" description="Roles, permissions, and how to extend access control." />

## Server Actions

If you add `"use server"` actions, treat them like public HTTP endpoints:

1. Validate input with Zod
2. Load the session inside the action
3. Authorize against the resource
4. Perform the mutation

Do not rely on “this action is only imported from a protected page” - clients can call Server Actions directly.

## Global admin vs org admin

These are different:

* **Global admin** - accesses `/admin` and admin API routes (`enforceAdmin` / Better Auth admin plugin)
* **Organization admin** - manages one tenant (`MemberRole.ADMIN` / `OWNER`)

Never conflate them when writing middleware or UI.


# Server / client boundaries
Source: https://www.turbostarter.dev/docs/web/security/boundaries

The most important [Next.js](https://nextjs.org/docs/app/guides/data-security) rule still applies in TurboStarter: **whatever you pass into a Client Component is serialized and sent to the browser**.

That includes nested objects. If a server component builds a config with an API key and hands the whole object to a `'use client'` child, the key leaks - even if the child never renders it.

## Never pass secrets into client components

```tsx title="Unsafe - config contains a server secret"
async function ServerPage() {
  const config = {
    apiKey: process.env.STRIPE_SECRET_KEY,
    storeId: process.env.NEXT_PUBLIC_URL,
  };

  const data = await loadBillingData(config);

  // Leaks STRIPE_SECRET_KEY to the client bundle / RSC payload
  return <BillingClient data={data} config={config} />;
}
```

Keep secrets inside server-only modules. Return only the fields the UI needs:

```tsx title="Safe - secrets stay in the server module"
import { getBillingSummary } from "~/modules/billing/lib/server";

async function ServerPage() {
  const summary = await getBillingSummary();

  return <BillingClient summary={summary} />;
}
```

<Callout type="warn" title="Props are a public API">
  Treat every prop on a Client Component as public. Prefer narrow DTOs over passing database rows, full session objects, or provider configs.
</Callout>

## Use the right package entry points

Shared packages expose **separate entry points** for server and client code. Import from those - do not deep-import into files that pull in secrets or Node-only SDKs.

| Package                  | Server / privileged             | Client-safe                                         |
| ------------------------ | ------------------------------- | --------------------------------------------------- |
| `@workspace/auth`        | `@workspace/auth/server`        | `@workspace/auth/client/web`                        |
| `@workspace/db`          | `@workspace/db/server`          | schema types via `@workspace/db/schema` when needed |
| `@workspace/billing-web` | `@workspace/billing-web/server` | schemas via `@workspace/billing-web/schema`         |
| `@workspace/storage`     | `@workspace/storage/server`     | - (call the API instead)                            |
| `@workspace/i18n`        | `@workspace/i18n/server`        | `@workspace/i18n`                                   |

Example from the web app: session helpers import the auth **server** entry, never the Better Auth instance into client code:

```ts title="apps/web/src/lib/auth/server.ts"
import { auth } from "@workspace/auth/server";

export const getSession = cache(async () => {
  const data = await auth.api.getSession({
    headers: await getHeaders(),
  });

  return {
    session: data?.session ?? null,
    user: data?.user ?? null,
  };
});
```

On the client, use the dedicated client builders:

```ts title="apps/web/src/lib/auth/client.ts"
import { createAuthClient } from "@workspace/auth/client/web";
```

## Do not mix server and client in one barrel

Avoid a single `index.ts` that re-exports both a server SDK and a Client Component. That pattern makes it easy for a client import to accidentally pull server code into the browser bundle.

TurboStarter already splits exports in `package.json`:

```json title="packages/auth/package.json"
{
  "exports": {
    ".": "./src/index.ts",
    "./env": "./src/env.ts",
    "./server": "./src/server.ts",
    "./client/*": "./src/client/*.ts"
  }
}
```

When you add a new shared package:

1. Put privileged code behind `./server` (or similarly named) exports
2. Put browser-safe helpers behind `./client` (or the package root if it is truly isomorphic)
3. Keep env presets in `./env` and only import them from server code
4. Never re-export server modules from a client entry

## Server Components vs Client Components

Default to Server Components. Add `'use client'` only when you need interactivity, browser APIs, or client-only libraries.

When a layout or page is a Server Component:

* Fetch with `getSession()` from `~/lib/auth/server`
* Call Hono through `~/lib/api/server` so cookies and headers stay on the server
* Pass serializable, non-sensitive props down to client islands

Dashboard layouts already follow this pattern - they resolve the session on the server and redirect before rendering protected UI:

```tsx title="apps/web/src/app/[locale]/dashboard/(user)/layout.tsx"
const { user } = await getSession();

if (!user) {
  return redirect(pathsConfig.auth.login);
}
```

UI gates are useful UX, but they are **not** security. Always enforce the same rules again in the API.


# Checklist
Source: https://www.turbostarter.dev/docs/web/security/checklist

Use this checklist before your first production deploy, and again after major features that touch auth, billing, or file uploads. Pair it with the general [deployment checklist](/docs/web/deployment/checklist).

## Secrets & configuration

* [ ] No secrets committed to git (only `.env.example` placeholders)
* [ ] Production secrets set in the host / CI - not in committed `.env` files
* [ ] No sensitive values use the `NEXT_PUBLIC_` prefix
* [ ] `BETTER_AUTH_SECRET` is a long random value unique to production
* [ ] `URL` / `NEXT_PUBLIC_URL` match the real production origin
* [ ] Billing webhook secrets match the live provider endpoint
* [ ] Seed credentials are disabled or changed in production

## Authentication

* [ ] Email verification enabled for password sign-up
* [ ] OAuth callback URLs point at production
* [ ] `trustedOrigins` includes only the apps you ship (web, mobile scheme, extension)
* [ ] 2FA / passkeys available for accounts that need them
* [ ] Dashboard and admin layouts still redirect unauthenticated users

## Authorization & tenancy

* [ ] New API routes use `enforceAuth` (and permission middleware when needed)
* [ ] Admin routes also use `enforceAdmin`
* [ ] Organization-scoped queries filter by a **verified** org id
* [ ] Client-side permission checks are mirrored on the server
* [ ] Server Actions authenticate and authorize internally

## Validation

* [ ] Mutation endpoints validate bodies with Zod via `validate`
* [ ] Schemas are allowlists (no unbounded passthrough objects)
* [ ] File upload paths and content types are constrained

## Integrations

* [ ] Billing webhooks verify signatures before updating customers
* [ ] Background task endpoints verify QStash (or equivalent) signatures
* [ ] Storage buckets are private; uploads go through presigned URLs
* [ ] Provider API keys are only imported from `./server` / server env presets
* [ ] Error monitoring scrubbers hide cookies, tokens, and secrets

## Headers & transport (recommended)

TurboStarter does not force a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) out of the box - enable one when you are ready to inventory third-party scripts (analytics, payments, fonts).

* [ ] HTTPS only in production
* [ ] Consider security headers (`Content-Security-Policy`, `Referrer-Policy`, `X-Frame-Options` / `frame-ancestors`, `Permissions-Policy`)
* [ ] If you add a strict CSP, pass nonces to any required inline scripts and allowlist payment / analytics origins deliberately

## Dependencies & process

* [ ] `pnpm` lockfile committed; installs are reproducible
* [ ] Dependencies updated for known Next.js / React advisories
* [ ] Access to production env and database is limited to the team that needs it
* [ ] Webhook and auth failures are visible in logs / monitoring

<Callout title="Ship with confidence">
  You do not need every optional hardening step on day one, but you **do** need correct secrets, server-side authz, validated input, and verified webhooks. Those four prevent the most common SaaS incidents.
</Callout>


# Integrations
Source: https://www.turbostarter.dev/docs/web/security/integrations

Most breaches in SaaS kits do not come from exotic crypto - they come from trusting an unverified webhook, a public bucket, or a leaked provider key. TurboStarter wires the common integrations to fail closed.

## Billing webhooks

Every supported web billing provider verifies the request signature before applying subscription changes.

| Provider      | Secret variable                | Verification                     |
| ------------- | ------------------------------ | -------------------------------- |
| Stripe        | `STRIPE_WEBHOOK_SECRET`        | `stripe.webhooks.constructEvent` |
| Lemon Squeezy | `LEMON_SQUEEZY_SIGNING_SECRET` | HMAC + timing-safe compare       |
| Polar         | `POLAR_WEBHOOK_SECRET`         | SDK unwrap / signature check     |
| Dodo Payments | `DODO_PAYMENTS_WEBHOOK_KEY`    | SDK `webhooks.unwrap`            |

The API mounts the active provider webhook without authentication middleware on purpose - the **signature** is the credential:

```ts title="packages/api/src/modules/billing/router.ts"
.post(`/webhook/${provider}`, (c) => webhookHandler(c.req.raw))
```

Rules when you extend webhooks:

1. Read the **raw body** for signature checks (do not re-serialize JSON first)
2. Reject missing or invalid signatures with an error - never “best effort” sync
3. Use environment-specific secrets (test vs live)
4. Keep custom side effects inside the verified handler callbacks

```ts
webhookHandler(c.req.raw, {
  onSubscriptionUpdated: async (subscriptionId) => {
    // Safe: only runs after signature verification
  },
});
```

<Cards>
  <Card title="Billing webhooks" href="/docs/web/billing/webhooks" description="Customize handlers and callbacks per provider." />

  <Card title="Billing troubleshooting" href="/docs/web/troubleshooting/billing" description="Fix signature mismatches and missed events." />
</Cards>

## Background task signatures

If you use [QStash](/docs/web/background-tasks/qstash) (or similar), verify the Upstash signature on every task route the same way you verify billing webhooks. An open cron endpoint is a free remote code trigger.

## File storage

Storage credentials stay in `@workspace/storage/server`. The browser never sees AWS keys - it receives short-lived **presigned URLs** from authenticated API routes.

| Endpoint              | Auth          | Purpose                           |
| --------------------- | ------------- | --------------------------------- |
| `GET /storage/upload` | `enforceAuth` | Presigned PUT (expires in 60s)    |
| `GET /storage/signed` | `enforceAuth` | Presigned GET (expires in 1h)     |
| `GET /storage/delete` | `enforceAuth` | Presigned DELETE (expires in 60s) |
| `GET /storage/public` | public        | Public object URL helper          |

Recommendations:

* Keep buckets **private** by default
* Prefer path prefixes scoped to `userId` / `organizationId`
* Treat a presigned URL like a temporary password - anyone with the URL can use it until it expires
* Configure CORS narrowly for your app origin
* Enable provider-side encryption when storing sensitive files

<Card title="Managing files" href="/docs/web/storage/managing-files" description="Upload flows, permissions, and signed URL usage." />

## Third-party API keys

OpenAI, Resend, Stripe, OAuth client secrets, analytics write keys that grant privileged access - all of these belong in **server** env presets.

Patterns that stay safe:

* Call providers from Hono routers, Server Components, or background workers
* Return only the UI-facing result to the client
* For browser uploads to third parties, use short-lived tokens or signed URLs issued by your API - not the master key

Patterns that leak:

* `NEXT_PUBLIC_` on a secret key
* Passing `process.env.OPENAI_API_KEY` into a Client Component
* Logging full webhook payloads or Authorization headers in production

## Email and OAuth

* OAuth **client secrets** are server-only; only client ids that providers document as public may appear in the browser
* Configure production callback URLs carefully - open redirects in auth flows are a common footgun
* Keep transactional email templates free of secrets; confirmation links should expire and be single-use (Better Auth handles this for built-in flows)

## Monitoring without leaking data

Error trackers (for example [Sentry](/docs/web/monitoring/sentry)) are valuable, but scrub:

* Cookies and `Authorization` headers
* Raw billing webhook bodies
* Password, OTP, and recovery code fields

Ship enough context to debug; never enough to impersonate a user.


# Overview
Source: https://www.turbostarter.dev/docs/web/security/overview

TurboStarter is built so the secure path is the default path. Auth runs through [Better Auth](/docs/web/auth/overview), the API is guarded with [Hono](/docs/web/api/overview) middleware, secrets stay in server-only packages, and third-party callbacks are signature-verified before they mutate data.

This section is a **security playbook** - how the pieces fit together, what you must never break, and what to double-check before production.

<Callout title="Be mindful">
  Security is not a one-time setup. Revisit these practices whenever you add endpoints, providers, or client features that touch sensitive data.
</Callout>

## Security model

Defense in TurboStarter is application-level and package-level by default:

| Layer                  | What it protects                                    | Where it lives                                    |
| ---------------------- | --------------------------------------------------- | ------------------------------------------------- |
| Package boundaries     | Secrets and DB clients never ship to the browser    | `@workspace/*/server`, `@workspace/auth/client/*` |
| Environment validation | Missing or mis-scoped secrets fail early            | `envin` presets per package                       |
| Session auth           | Only signed-in users reach protected UI and APIs    | Better Auth + `enforceAuth`                       |
| Authorization          | Roles and org membership gate mutations             | RBAC + `enforceOrganizationPermission`            |
| Input validation       | Untrusted payloads are rejected before handlers run | Zod + `validate` middleware                       |
| External integrations  | Billing and task webhooks cannot be forged          | Signature verification                            |

Unlike Supabase-first kits that lean on Row Level Security, TurboStarter treats **every API route and Server Component as responsible for authorization**. That matches the Hono + Drizzle stack: you filter by the verified session and organization, not by trusting IDs from the client alone.

Each of these topics is covered in more detail in the following guides:


# Secrets & environment
Source: https://www.turbostarter.dev/docs/web/security/secrets

Environment variables are the easiest place to accidentally publish a secret. TurboStarter validates config with [envin](https://envin.turbostarter.dev) and splits **shared**, **app**, and **secret** values so the secure default is clear.

For the full file layout, see [Environment variables](/docs/web/configuration/environment-variables). This page focuses on the security rules.

## Public vs private variables

Next.js only embeds variables prefixed with `NEXT_PUBLIC_` into client bundles.

| Prefix         | Available where        | Use for                                                                |
| -------------- | ---------------------- | ---------------------------------------------------------------------- |
| *(none)*       | Server only            | API keys, webhook secrets, database URLs, Better Auth secret           |
| `NEXT_PUBLIC_` | Server **and** browser | Product name, public URL, theme, feature flags that are safe to expose |

```dotenv title="Safe ✅"
# Server-only - never prefix these
BETTER_AUTH_SECRET="..."
DATABASE_URL="postgresql://..."
STRIPE_SECRET_KEY="..."
STRIPE_WEBHOOK_SECRET="..."
LEMON_SQUEEZY_API_KEY="..."
LEMON_SQUEEZY_SIGNING_SECRET="..."

# Public - intentionally visible to the browser
NEXT_PUBLIC_PRODUCT_NAME="TurboStarter"
NEXT_PUBLIC_URL="https://app.example.com"
NEXT_PUBLIC_DEFAULT_LOCALE="en"
```

```dotenv title="Unsafe - never do this ❌"
NEXT_PUBLIC_STRIPE_SECRET_KEY="sk_live_..."
NEXT_PUBLIC_DATABASE_URL="postgresql://..."
NEXT_PUBLIC_BETTER_AUTH_SECRET="..."
```

<Callout type="warn" title="Public means public">
  If a value has `NEXT_PUBLIC_`, assume every visitor can read it from the JS bundle. That includes “obscure” identifiers that still grant privileged access to a third-party API.
</Callout>

## Secrets architecture

| Environment       | Store secrets in                                  | Committed?      |
| ----------------- | ------------------------------------------------- | --------------- |
| Local development | Root / app `.env.local`                           | No (gitignored) |
| CI / production   | Host secrets (Vercel, Railway, GitHub Actions, …) | No              |
| Examples / docs   | `.env.example` with empty or dummy values         | Yes             |

Never put real secrets in `.env`, `.env.development`, or `.env.production` if those files are committed. Use `.env.example` only as a template.

Auth secrets are declared as **server** fields in the auth env preset - they are not available to client code by design:

```ts title="packages/auth/src/env.ts"
export const preset = {
  id: "auth",
  server: {
    BETTER_AUTH_SECRET: z.string(),
    GOOGLE_CLIENT_ID: z.string().optional().default(""),
    GOOGLE_CLIENT_SECRET: z.string().optional().default(""),
    // ...
  },
} as const satisfies Preset;
```

Billing, storage, database, and API packages follow the same pattern: privileged keys live in package `./env` presets and are imported only from server entry points.

## Generate a strong auth secret

`BETTER_AUTH_SECRET` signs sessions and related tokens. Use a long random value in every environment:

```bash
openssl rand -base64 32
```

Rotate it carefully - changing the secret invalidates existing sessions.

## Production checklist for env

1. Set every required secret in the deployment platform (not in git)
2. Confirm no `NEXT_PUBLIC_` prefix on API keys, webhook secrets, or DB URLs
3. Use separate secrets for test vs live billing providers
4. Keep `URL` / `NEXT_PUBLIC_URL` aligned with the real production origin (auth callbacks and cookies depend on it)
5. Remove seed defaults (`SEED_EMAIL` / `SEED_PASSWORD`) from production or change them immediately after first deploy

<Card title="Environment variables" href="/docs/web/configuration/environment-variables" description="Shared vs app-specific files, local overrides, and NEXT_PUBLIC_ injection." />


# Data validation
Source: https://www.turbostarter.dev/docs/web/security/validation

Authentication tells you who is calling. Validation decides whether the payload is safe to use.

TurboStarter validates API input with [Zod](https://zod.dev) through a shared Hono middleware. Invalid data never reaches business logic.

## Validate at the edge of the API

Use `validate` from `packages/api/src/middleware.ts` on query, JSON body, or path params:

```ts
import { enforceAuth, validate } from "../../middleware";
import { getObjectUrlSchema } from "@workspace/storage/server";

export const storageRouter = new Hono().get(
  "/upload",
  enforceAuth,
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
);
```

Inside the handler, read `c.req.valid("query")` / `c.req.valid("json")` - typed, already parsed, and rejected with `422` when invalid.

Schemas live next to the domain (for example `@workspace/api/schema`, `@workspace/billing-web/schema`, storage schemas) so the same rules can be reused on the client for forms when useful.

## What to validate

Validate **everything that comes from outside your process**:

* Query strings and route params
* JSON bodies
* Headers you interpret as data (not cookies Better Auth already verifies)
* Webhook payloads **after** signature verification
* File metadata (content type, size limits) before issuing upload URLs

Do not trust:

* Client-provided roles or `isAdmin` flags
* Client-provided prices or plan ids for granting entitlements
* Unbounded strings that land in SQL-like filters, emails, or redirects

Drizzle uses parameterized queries, which protects against classic SQL injection when you stick to the query builder. Validation still matters for logic bugs, mass assignment, and abuse (oversized payloads, unexpected enums).

## Prefer allowlists

Model inputs as the smallest shape you need:

```ts
const createProjectSchema = z.object({
  name: z.string().trim().min(1).max(80),
  organizationId: z.string().min(1),
});
```

Avoid “pass-through” objects like `z.record(z.unknown())` for mutation endpoints. Extra fields should not silently update privileged columns.

## i18n-aware errors

The shared `validate` middleware maps Zod issues through the i18n layer so clients get localized messages instead of raw English schema errors. Keep using it instead of ad-hoc `schema.parse` in routers unless you have a reason (for example webhooks that need a different error shape).

## Forms and Server Actions

Mirror the same schemas in UI forms (react-hook-form + Zod, or your preferred stack). Re-validate on the server anyway - client validation is UX, server validation is security.

```ts title="Server Action sketch"
"use server";

export async function createProject(input: unknown) {
  const data = createProjectSchema.parse(input);
  const { user } = await getSession();

  if (!user) {
    throw new Error("Unauthorized");
  }

  // enforce membership for data.organizationId, then insert
}
```


# Tech Stack
Source: https://www.turbostarter.dev/docs/web/stack

## Turborepo

[Turborepo](https://turborepo.dev/) is a monorepo tool that helps you manage your project's dependencies and scripts. We chose a monorepo setup to make it easier to manage the structure of different features and enable code sharing between different packages.

<Card href="https://turborepo.dev/" title="Turborepo - Make Ship Happen" description="turbo.build" icon={<Turborepo />} />

## Next.js

[Next.js](https://nextjs.org) is one of the most popular [React](https://react.dev) frameworks that enables server-side rendering, static site generation, and more. We chose Next.js for its flexibility and ease of use. We're also using it to host our serverless API.

<Cards>
  <Card href="https://react.dev" title="React" description="react.dev" icon={<React />} />

  <Card href="https://nextjs.org" title="Next.js" description="nextjs.org" icon={<Next />} />
</Cards>

## Hono & React Query

[Hono](https://hono.dev) is a small, simple, and ultrafast web framework for the edge. It provides tools to help you build APIs and web applications faster. It includes an RPC client for making type-safe function calls from the frontend. We use Hono to build our serverless API endpoints.

To make data fetching and caching from our API easy and reliable, we pair Hono with [React Query](https://tanstack.com/query/latest). It helps manage asynchronous data, caching, and state synchronization between the client and backend, delivering a fast and seamless UX.

<Cards>
  <Card href="https://hono.dev" title="Hono" description="hono.dev" icon={<Hono />} />

  <Card href="https://tanstack.com/query/latest" title="React Query" description="tanstack.com" icon={<Tanstack />} />
</Cards>

## Better Auth

[Better Auth](https://better-auth.com) is a modern authentication library for fullstack applications. It provides ready-to-use snippets for features like email/password login, magic links, OAuth providers, and more. We use Better Auth to handle all authentication flows in our application.

<Card href="https://better-auth.com" title="Better Auth" description="better-auth.com" icon={<BetterAuth />} />

## Tailwind CSS

[Tailwind CSS](https://tailwindcss.com) is a utility-first CSS framework that helps you build custom designs without writing any CSS. We also use [Base UI](https://base-ui.com) for our headless components library and [shadcn/ui](https://ui.shadcn.com), which enables you to generate pre-designed components with a single command.

<Cards className="grid-cols-2 sm:grid-cols-3">
  <Card href="https://tailwindcss.com" title="Tailwind CSS" description="tailwindcss.com" icon={<Tailwind />} />

  <Card href="https://base-ui.com" title="Base UI" description="base-ui.com" icon={<BaseUI />} />

  <Card href="https://ui.shadcn.com" title="shadcn/ui" description="ui.shadcn.com" icon={<Shadcn />} />
</Cards>

## Drizzle

[Drizzle](https://orm.drizzle.team/) is a super fast [ORM](https://orm.drizzle.team/docs/overview) (Object-Relational Mapping) tool for databases. It helps manage databases, generate TypeScript types from your schema, and run queries in a fully type-safe way.

We use [PostgreSQL](https://www.postgresql.org) as our default database, but thanks to Drizzle's flexibility, you can easily switch to MySQL, SQLite or any [other supported database](https://orm.drizzle.team/docs/connect-overview) by updating a few configuration lines.

<Cards>
  <Card href="https://orm.drizzle.team/" title="Drizzle" description="orm.drizzle.team" icon={<Drizzle />} />

  <Card href="https://www.postgresql.org" title="PostgreSQL" description="postgresql.org" icon={<Postgres />} />
</Cards>


# Configuration
Source: https://www.turbostarter.dev/docs/web/storage/configuration

Currently, TurboStarter supports all S3-compatible storage providers, including [AWS S3](https://aws.amazon.com/s3/), [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces), [Cloudflare R2](https://www.cloudflare.com/products/r2/), and [Supabase Storage](https://supabase.com/storage).

For a concrete example using Supabase Storage as an S3-compatible provider, see the [Supabase recipe](/docs/web/recipes/supabase#use-supabase-storage-as-s3-compatible-storage).

The setup process is straightforward - you just need to configure a few environment variables in both your local environment and hosting provider:

```dotenv
S3_REGION=
S3_BUCKET=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
```

Let's break down each required variable:

* `S3_REGION`: The [AWS region](https://aws.amazon.com/about-aws/global-infrastructure/regions_az/) where your storage is located - defaults to `us-east-1`
* `S3_BUCKET`: The default name of your storage bucket - you can pass different for each request
* `S3_ENDPOINT`: The S3 [endpoint URL](https://docs.aws.amazon.com/general/latest/gr/s3.html) for your storage provider - defaults to `https://s3.amazonaws.com`
* `S3_ACCESS_KEY_ID`: Your storage provider's [access key ID](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)
* `S3_SECRET_ACCESS_KEY`: Your storage provider's [secret access key](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)

You can learn more about S3 service configuration in the [official AWS documentation](https://docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html) or your specific storage provider's documentation.


# Managing files
Source: https://www.turbostarter.dev/docs/web/storage/managing-files

Before you start managing files, make sure you have [configured storage](/docs/web/storage/configuration).

## Permissions

Most S3-compatible storage providers allow you to configure bucket permissions and access policies. It's crucial to properly set these up to secure your files and control who can access them.

Here are some key security recommendations:

* Keep your bucket private by default
* Use IAM roles and policies to manage access
* Enable server-side encryption for sensitive data
* Configure CORS settings appropriately for client-side uploads
* Regularly audit bucket permissions and access logs

Making your bucket public is strongly discouraged as it can expose sensitive data and lead to unauthorized access and unexpected costs from bandwidth usage.

For detailed guidance on configuring bucket policies and permissions, refer to your storage provider's documentation:

* [AWS S3 Security Documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-policy-language-overview.html)
* [DigitalOcean Spaces Security](https://docs.digitalocean.com/products/spaces/how-to/manage-access/)
* [Cloudflare R2 Security](https://developers.cloudflare.com/r2/api/s3/tokens/)
* [Supabase Storage Security](https://supabase.com/docs/guides/storage/security/access-control)

## Uploading files

As explained in the [overview](/docs/web/storage/overview), TurboStarter uses presigned URLs to upload files to your storage provider.

We prepared a special endpoint to generate presigned URLs for your uploads to use in your client-side code.

```ts title="storage/router.ts"
export const storageRouter = new Hono().get(
  "/upload",
  enforceAuth,
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
);
```

<Callout title="Expiration time" type="warn">
  The signed URL is only valid for a limited time and will work for anyone who has access to it during that period. Make sure to handle the URL securely and avoid exposing it to unauthorized users.
</Callout>

Then, you can use it to upload files to the generated presigned URL from your frontend code:

```tsx title="upload.tsx"
const upload = useMutation({
    mutationFn: async (data: { file?: File }) => {
      const extension = data.file?.type.split("/").pop();
      const path = `files/${crypto.randomUUID()}.${extension}`;

      const { url: uploadUrl } = await handle(api.storage.upload.$get)({
        query: { path },
      });

      const response = await fetch(uploadUrl, {
        method: "PUT",
        body: data.file,
        headers: {
          "Content-Type": data.file?.type ?? "",
        },
      });

      if (!response.ok) {
        throw new Error("Failed to upload file!");
      }
    },
    onError: (error) => {
      toast.error(error.message});
    },
    onSuccess: async ({ publicUrl, oldImage }, _b, context) => {
      toast.success("File uploaded!");
    },
  });
```

The code above demonstrates how to implement file uploads in your application:

1. First, we have a server-side endpoint (`storageRouter`) that generates presigned URLs for uploads. This endpoint:
   * [Requires authentication](/docs/web/api/protected-routes) via `enforceAuth`
   * Validates the request parameters using `validate`
   * Returns a presigned URL for uploading

2. Then, in the frontend code (`upload.tsx`), we use React Query's `useMutation` hook to handle the upload process:
   * Requests a presigned URL from the server
   * Uploads the file directly to the storage provider using the presigned URL
   * Handles success and error cases with toast notifications

This approach ensures secure file uploads while avoiding server bandwidth costs and function timeout issues.

### Public uploads

Although **it's not recommended** to use public uploads in production, you can use the same endpoint to generate presigned URLs for public uploads:

```ts title="storage/router.ts"
export const storageRouter = new Hono().get(
  "/upload",
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
);
```

Just remove the `enforceAuth` middleware from the endpoint and keep rest of the logic the same.

## Displaying files

We provide dedicated endpoints for retrieving signed URLs specifically for displaying files. These URLs are time-limited to maintain security, so they cannot be used for permanent storage or long-term access:

```ts title="storage/router.ts"
export const storageRouter = new Hono().get(
  "/signed",
  enforceAuth,
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getSignedUrl(c.req.valid("query"))),
);
```

This endpoint is perfect for displaying files that should only be accessible to authorized users for a limited time.

### Public files

For displaying files publicly (without authorization and time limitations), you can use the `/public` endpoint:

```ts title="storage/router.ts"
export const storageRouter = new Hono().get(
  "/public",
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getPublicUrl(c.req.valid("query"))),
);
```

This endpoint generates a public URL for the file that you can use to display in your application. Please ensure that your bucket policy allows public access to the files and verify that you're not exposing any sensitive information.

## Deleting files

Deleting files works almost the same way as uploading files. You just need to generate a presigned URL for deletion and then use it to remove the file:

```ts title="storage/router.ts"
export const storageRouter = new Hono().get(
  "/delete",
  validate("query", getObjectUrlSchema),
  async (c) => c.json(await getDeleteUrl(c.req.valid("query"))),
);
```

Then, in the frontend code, we use React Query's `useMutation` hook to handle the deletion process:

```tsx title="delete.tsx"
const remove = useMutation({
  mutationFn: async () => {
    const path = file.split("/").pop();
    if (!path) return;

    const { url: deleteUrl } = await handle(api.storage.delete.$get)({
      query: { path: `files/${path}` },
    });

    await fetch(deleteUrl, {
      method: "DELETE",
    });
  },
  onError: (error) => {
    toast.error(error.message);
  },
  onSuccess: () => {
    toast.success("File removed!");
  },
});
```

Now that you understand how to manage files in TurboStarter, it's time to build something awesome! Try creating a file upload component, building a photo gallery, or implementing a document management system.


# Overview
Source: https://www.turbostarter.dev/docs/web/storage/overview

With TurboStarter, you can easily upload and manage files (images, videos, documents, and more) in your application.

Currently, all S3-compatible storage providers are supported, including [AWS S3](https://aws.amazon.com/s3/), [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces), [Cloudflare R2](https://www.cloudflare.com/products/r2/), [Supabase Storage](https://supabase.com/storage), and others.

If you're using Supabase, you can follow the [Supabase recipe](/docs/web/recipes/supabase#use-supabase-storage-as-s3-compatible-storage) for a concrete example of configuring Supabase Storage as your S3-compatible backend.

## Uploading files

The most common approach to uploading files is to use client-side uploads. With client-side uploads, you avoid paying ingress/egress fees for transferring file binary data through your server.

Additionally, most hosting platforms like [Vercel](https://vercel.com/docs/functions/runtimes#size-limits) or [Netlify](https://answers.netlify.com/t/what-is-the-maximum-file-size-upload-limit-in-a-netlify-form-submission/108419) have limitations on file size and maximum serverless function execution time.

That's why TurboStarter utilizes the **presigned URLs** feature of storage providers to upload files. Instead of sending files to the serverless function, the client requests a time-limited presigned URL from the serverless function and then uploads the file directly to the storage provider.

<ThemedImage alt="Client side uploads" light="/images/docs/web/storage/light.png" dark="/images/docs/web/storage/dark.png" width={1294} height={654} zoomable />

1. Client **requests** a presigned URL from the serverless function.
2. Server parses the request, validates the payload, optionally saves the metadata, and **returns the presigned URL** to the client.
3. Client **uploads the file** to the presigned URL within the expiration time.
4. (Optional) Once the file is uploaded, the serverless function is notified about the upload event, and the file metadata is saved to the database.

<Callout>
  This approach ensures that credentials remain secure, handles authorization and authentication properly, and avoids the limitations of serverless platforms.
</Callout>

The configuration and use of storage is straightforward and simple. We'll explore this in more detail in the following sections.


# E2E tests
Source: https://www.turbostarter.dev/docs/web/tests/e2e

End-to-end (E2E) tests verify that your application works correctly from a user's perspective by simulating real browser interactions against a running app and a real database. Learn more in the [Playwright intro](https://playwright.dev/docs/intro).

TurboStarter uses [Playwright](https://playwright.dev) for web E2E tests.

<Callout title="Why Playwright?">
  Playwright is fast, reliable, and built for modern web apps. It auto-waits for elements, supports Chromium/Firefox/WebKit from a single API, captures traces and videos on failure, and ships a great UI mode for debugging.
</Callout>

![Playwright UI mode](/images/docs/web/tests/e2e/playwright-ui.png)

## Why write E2E tests?

[Unit tests](/docs/web/tests/unit) are fast and focused, but they cannot catch integration issues between the UI, API, database, and third-party services. E2E tests fill that gap:

* **User flow bugs**: broken redirects, stale session state, or form validation that only fails in the browser
* **Auth regressions**: login, magic links, OTP, and persisted sessions across page loads
* **Cross-browser issues**: layout or behavior differences in Chrome, Firefox, and Safari
* **Email flows**: magic link and OTP delivery when using a local SMTP catcher

E2E tests are slower than unit tests, so use them for **critical paths** like authentication, onboarding, billing checkout, and other flows where a regression would block users.

## Prerequisites

Before running E2E tests locally, make sure your development environment is ready:

1. **Start services**: Postgres and Mailpit must be running. The fastest way is:

```bash
pnpm services:setup
```

This starts Docker services and runs database migrations. See [development setup](/docs/web/installation/development) for details.

2. **Environment files**: copy the example env files if you have not already:

```bash
cp .env.example .env
cp apps/web/.env.example apps/web/.env.local
```

3. **Install Playwright browsers** (first time only):

```bash
pnpm --filter web exec playwright install
```

For cross-browser runs, install the browsers you need:

```bash
pnpm --filter web exec playwright install chromium firefox webkit
```

<Callout title="Mailpit for email flows">
  Magic link and OTP sign-in tests read emails from [Mailpit](/docs/web/emails/overview), which is included in the Docker setup. Make sure Mailpit is reachable at `http://localhost:8025` (the default).
</Callout>

## Test structure

E2E tests live in `apps/web/e2e/`:

```
apps/web/e2e/
├── constants.ts          # Test user credentials and paths
├── env.ts                # Loads Next.js env before Playwright starts
├── helpers/
│   └── mailbox.ts        # Mailpit API helpers for email flows
├── pages/                # Page Object classes
│   ├── login.page.ts
│   ├── dashboard.page.ts
│   └── settings.page.ts
├── setup/
│   ├── global-setup.ts   # Migrates and seeds the database
│   └── auth.setup.ts     # Creates persisted auth state
├── specs/                # Test files
│   ├── auth.sign-in.spec.ts
│   └── dashboard.authenticated.spec.ts
├── .playwright/          # Generated auth state (gitignored)
├── playwright-report/    # HTML report output
└── test-results/         # Screenshots, videos, traces
```

## Configuration

Playwright is configured in `apps/web/playwright.config.ts`. Key settings:

* **Production-like server**: tests run against `next build` + `next start`, not `next dev`. This catches issues that only appear in production builds.
* **[Global setup](https://playwright.dev/docs/test-global-setup-teardown)**: migrates the database and seeds test users before any test runs.
* **[Cross-browser projects](https://playwright.dev/docs/test-projects)**: Chrome, Firefox, and Safari each get their own project.
* **Auth projects**: a setup project signs in once and saves [`storageState`](https://playwright.dev/docs/auth#reuse-signed-in-state); authenticated specs reuse it.
* **Artifacts on failure**: screenshots, videos, and [traces](https://playwright.dev/docs/trace-viewer) are retained when a test fails.

```ts title="apps/web/playwright.config.ts"
export default defineConfig({
  testDir: "./e2e",
  globalSetup: "./e2e/setup/global-setup.ts",
  retries: isCI ? 2 : 0,
  use: {
    baseURL,
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: browsers.flatMap(({ name, device }) => [
    { name: `${name}-setup`, testMatch: /setup\/.*\.setup\.ts/ },
    { name, testMatch: /specs\/.*\.spec\.ts/, testIgnore: /authenticated/ },
    {
      name: `${name}-authenticated`,
      testMatch: /specs\/.*\.authenticated\.spec\.ts/,
      dependencies: [`${name}-setup`],
      use: { storageState: authStatePath },
    },
  ]),
  webServer: {
    command: `pnpm with-env pnpm turbo build --filter=web && pnpm --filter web start`,
    reuseExistingServer: !isCI,
    url: `${baseURL}/api/status`,
    timeout: 300_000,
  },
});
```

<Callout title="Point tests at an existing server">
  Set `E2E_BASE_URL` to skip the built-in [`webServer`](https://playwright.dev/docs/test-webserver) and run against a server you already have running (for example, during debugging):

  ```bash
  E2E_BASE_URL=http://localhost:3000 pnpm --filter web test:e2e
  ```
</Callout>

## Test data

Global setup runs database migrations and seeds a test user via `pnpm -F @workspace/auth db:seed`. The default credentials are derived from your `SEED_EMAIL` and `SEED_PASSWORD` environment variables:

| Variable            | Default                    | Purpose                    |
| ------------------- | -------------------------- | -------------------------- |
| `SEED_EMAIL`        | `me@turbostarter.dev`      | Base email for seed data   |
| `SEED_PASSWORD`     | `Pa$$w0rd`                 | Password for seeded users  |
| `E2E_USER_EMAIL`    | `me+user@turbostarter.dev` | Email used by E2E tests    |
| `E2E_USER_PASSWORD` | Same as `SEED_PASSWORD`    | Password used by E2E tests |

Override any of these in your `.env` when you need different test accounts. The E2E user is a `+user` alias of the seed email so it stays predictable without colliding with other seed data.

## Page Object pattern

Tests use the [Page Object Model](https://playwright.dev/docs/pom) to keep selectors and actions in one place. Each page class wraps Playwright calls behind readable methods:

```ts title="apps/web/e2e/pages/login.page.ts"
export class LoginPage {
  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto(pathsConfig.auth.login);
  }

  async signIn(email: string, password: string) {
    await this.selectTab("password");
    await this.page.locator("#password-login-email").fill(email);
    await this.page.locator("#password-login-password").fill(password);
    await this.page
      .locator("#password-login-form button[type='submit']")
      .click();
  }
}
```

Specs stay focused on user journeys:

```ts title="apps/web/e2e/specs/auth.sign-in.spec.ts"
test("user can sign in with email and password", async ({ page }) => {
  const login = new LoginPage(page);
  const dashboard = new DashboardPage(page);

  await login.goto();
  await login.signIn(e2eUser.email, e2eUser.password);
  await dashboard.waitForReady();
});
```

## Authenticated tests

Signing in through the UI for every test is slow. TurboStarter uses Playwright [setup projects](https://playwright.dev/docs/auth#basic-shared-account-in-all-tests) to sign in once and persist the session:

1. `auth.setup.ts` signs in and saves cookies to `e2e/.playwright/auth/user.json`
2. Specs named `*.authenticated.spec.ts` run in a project that loads that `storageState`
3. Each browser (Chrome, Firefox, Safari) has its own setup project

This means authenticated tests start with a valid session and can jump straight to the feature under test.

## Email flows

Password sign-in is straightforward, but TurboStarter also tests **magic link** and **email OTP** flows end-to-end. The `mailbox.ts` helper talks to Mailpit's API:

```ts title="apps/web/e2e/helpers/mailbox.ts"
export const waitForEmailHtml = async (
  email: string,
  options?: { subject?: string },
) => {
  const messages = await searchMessages(email);
  // ...
};

export const extractMagicLink = (html: string) => {
  /* ... */
};
export const extractOtp = (html: string) => {
  /* ... */
};
```

Tests clear the mailbox before each email flow, request a magic link or OTP, then poll Mailpit with [`expect(...).toPass()`](https://playwright.dev/docs/test-assertions#expecttopass) until the email arrives:

```ts
await expect(async () => {
  const html = await waitForEmailHtml(e2eUser.email, { subject: "magic link" });
  const link = extractMagicLink(html!);
  await page.goto(link!);
}).toPass();
```

This pattern is more reliable than a fixed `sleep()` and handles slow email delivery gracefully.

## Running tests

### All browsers

```bash
pnpm --filter web test:e2e
```

The first run builds the app and starts the production server automatically. Subsequent local runs reuse the server if it is already running.

### Single browser

```bash
pnpm --filter web exec playwright test --project=chrome
pnpm --filter web exec playwright test --project=firefox
pnpm --filter web exec playwright test --project=safari
```

### Single spec

```bash
pnpm --filter web exec playwright test auth.sign-in --workers=1
```

Use `--workers=1` when debugging a single flow to avoid parallel interference.

### Interactive UI mode

```bash
pnpm --filter web exec playwright test --ui
```

![Playwright UI during a test run](/images/docs/web/tests/e2e/playwright-ui-run.png)

UI mode lets you step through tests, inspect the DOM, and watch traces. It is the fastest way to debug a failing flow. See the [UI mode docs](https://playwright.dev/docs/test-ui-mode).

### Debug mode

```bash
pnpm --filter web exec playwright test --debug
```

Opens [Playwright Inspector](https://playwright.dev/docs/debug) so you can pause, step, and inspect each action.

### Headed mode

```bash
pnpm --filter web exec playwright test --headed
```

Runs tests in a visible browser window. Useful for visual debugging.

### View the HTML report

After a test run:

```bash
pnpm --filter web exec playwright show-report e2e/playwright-report
```

![Playwright HTML report](/images/docs/web/tests/e2e/playwright-report.png)

## CI

The `CI / E2E / Web` workflow runs Playwright against Chrome, Firefox, and Safari in a matrix. On pull requests it is **label-gated**. Add the `e2e` or `e2e-web` label when you want it to run. This keeps CI fast for day-to-day changes while still giving you cross-browser verification when it matters.

The workflow uses example env files and Docker services, so no extra secrets are needed beyond your standard Turborepo cache tokens. It uploads HTML reports on completion and screenshots, videos, and traces on failure.

## Writing new tests

### Unauthenticated flow

Create a new `*.spec.ts` file in `apps/web/e2e/specs/`:

```ts title="apps/web/e2e/specs/my-feature.spec.ts"
import { expect, test } from "@playwright/test";

test("user can access the pricing page", async ({ page }) => {
  await page.goto("/pricing");
  await expect(page.getByRole("heading", { name: "Pricing" })).toBeVisible();
});
```

### Authenticated flow

Name the file `*.authenticated.spec.ts` so it runs in the authenticated project with a persisted session:

```ts title="apps/web/e2e/specs/my-feature.authenticated.spec.ts"
import { expect, test } from "@playwright/test";

test("user sees billing settings", async ({ page }) => {
  await page.goto("/dashboard/settings/billing");
  await expect(page.getByText("Billing")).toBeVisible();
});
```

### Add a Page Object

When a flow involves multiple steps or reused selectors, add a page class in `apps/web/e2e/pages/`:

```ts title="apps/web/e2e/pages/billing.page.ts"
export class BillingPage {
  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto("/dashboard/settings/billing");
  }

  async waitForReady() {
    await expect(this.page.getByText("Billing")).toBeVisible();
  }
}
```

## Best practices

### Test user flows, not implementation

Focus on what users do, not internal API calls. A good E2E test reads like a user story.

```ts
// Good: tests a user journey
test("user can sign in and open settings", async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.signIn(e2eUser.email, e2eUser.password);
  // ...
});

// Avoid: testing implementation details
test("POST /api/auth returns 200", async ({ request }) => {
  // This belongs in an API or unit test
});
```

### Use Page Objects for complex flows

Keep selectors and actions in page classes. When the UI changes, you update one file instead of every spec.

### Use `expect(...).toPass()` for async conditions

Email delivery, animations, and network requests can be slow. Polling assertions are more reliable than fixed timeouts:

```ts
await expect(async () => {
  const html = await waitForEmailHtml(email);
  expect(html).toBeTruthy();
}).toPass({ timeout: 10_000 });
```

### Keep tests independent

Each test should create or use its own data. Do not rely on test execution order. The global seed provides a known user; authenticated tests use a fresh `storageState` per browser.

### Prefer role and label selectors

Use [`getByRole`](https://playwright.dev/docs/locators#locate-by-role), `getByLabel`, and `getByText` over CSS selectors when possible. They are more resilient to markup changes and match how users interact with the page.

### Run against production builds

Always test against `next build` + `next start`. Development mode hides optimizations, middleware behavior, and bundling issues that only surface in production.

## Debugging failed tests

When a test fails, Playwright saves artifacts automatically:

| Artifact   | Location            | When           |
| ---------- | ------------------- | -------------- |
| Screenshot | `e2e/test-results/` | On failure     |
| Video      | `e2e/test-results/` | On failure     |
| Trace      | `e2e/test-results/` | On first retry |

Open the [HTML report](https://playwright.dev/docs/test-reporters#html-reporter) for a timeline of every action, network request, and console log:

```bash
pnpm --filter web exec playwright show-report e2e/playwright-report
```

For a specific trace, use the [trace viewer](https://playwright.dev/docs/trace-viewer):

```bash
pnpm --filter web exec playwright show-trace e2e/test-results/<trace-dir>/trace.zip
```

![Playwright trace viewer](/images/docs/web/tests/e2e/playwright-trace.png)

## Next steps

* [Unit tests](/docs/web/tests/unit): fast, isolated tests for functions and components
* [Authentication](/docs/web/auth/overview): auth methods tested by E2E specs
* [Emails](/docs/web/emails/overview): Mailpit setup for local email testing


# Unit tests
Source: https://www.turbostarter.dev/docs/web/tests/unit

Unit tests are a type of automated test where individual units or components are tested. The "unit" in "unit test" refers to the smallest testable parts of an application. These tests are designed to verify that each unit of code performs as expected.

TurboStarter uses [Vitest](https://vitest.dev) as the unit testing framework. It's a blazing-fast test runner built on top of [Vite](https://vite.dev), designed for modern JavaScript and TypeScript projects.

<Callout title="Why Vitest?">
  If you've used [Jest](https://jestjs.io) before, you already know Vitest - it shares the same API. But Vitest is built for speed: native TypeScript support without transpilation, parallel test execution, and a smart watch mode that only re-runs tests affected by your changes.

  It comes with everything you need out of the box - code coverage, snapshot testing, mocking, and a slick UI for debugging. Fast feedback, zero configuration.
</Callout>

## Why write unit tests?

Unit tests give you **fast, focused feedback** on small pieces of your code - individual functions, hooks, or components. Instead of debugging an entire page or flow, you can verify just the logic you care about in isolation.

They also act as **living documentation**: a good test tells you how a function is supposed to behave, which edge cases are important, and what assumptions the code makes. This makes it much easier to safely refactor or extend features later.

In TurboStarter, unit tests are designed to be **cheap and quick to run**, so you can keep Vitest running in watch mode while you code. Every change you make is immediately checked, helping you catch regressions before they ever reach integration or end‑to‑end tests.

## Configuration

TurboStarter configures Vitest to be **as simple as possible**, while still taking advantage of [Turborepo's caching](https://turborepo.com/docs/crafting-your-repository/caching) and Vitest's [Test Projects](https://vitest.dev/guide/projects).

```ts title="vitest.config.ts"
import { mergeConfig } from "vitest/config";

import baseConfig from "@workspace/vitest-config/base";

export default mergeConfig(baseConfig, {
  test: {
    /* your extended test configuration here */
  },
});
```

* **Per-package tests**: each package that has unit tests defines its own `test` script. This keeps the configuration close to the code and makes it easy to add tests to any workspace.
* **Turbo tasks for CI**: the root `test` task (`pnpm test`) uses `turbo run test` to execute all package-level test scripts with smart caching, which is ideal for CI pipelines where you want to avoid re-running unchanged tests.
* **Vitest Test Projects for local dev**: a root Vitest configuration uses [Test Projects](https://vitest.dev/guide/projects) to run all unit test suites from a single command, which is perfect for local development when you want fast feedback across the whole monorepo.

This **hybrid setup** combines Turborepo and Vitest Projects in a way that fits TurboStarter's principles: cached, package-aware runs in CI, and a single, unified Vitest entry point for local development.

You can read more about this setup in the official documentation guides listed below.

<Cards>
  <Card title="Vitest" description="turborepo.com" href="https://turborepo.com/docs/guides/tools/vitest" />

  <Card title="Test Projects" description="vitest.dev" href="https://vitest.dev/guide/projects" />
</Cards>

## Running tests

There are a few different ways to run unit tests, depending on what you're doing:

* **CI / full test run** - at the root of the repo:

```bash
pnpm test
```

This runs `turbo run test`, which executes all `test` scripts in packages that define them, with Turborepo handling caching so unchanged packages are skipped. This is what you should use in your CI/CD pipeline.

* **One-off local run with Vitest Projects**:

```bash
pnpm test:projects
```

This uses Vitest [Test Projects](https://vitest.dev/guide/projects) to run all configured unit test suites from a single command, which is great when you want to quickly validate the whole monorepo locally.

* **Watch mode during development**:

```bash
pnpm test:projects:watch
```

This starts Vitest in watch mode across all Test Projects. As you edit files, only the affected tests are re-run, giving you fast feedback while you work.

## Code coverage

Unit test coverage helps you understand **how much** of your code is being tested. While it can't guarantee bug-free code, it shines a light on untested paths that could hide issues or regressions.

To generate a code coverage report for all unit tests, run:

```bash
pnpm turbo test:coverage
```

This command runs the coverage task across all relevant packages (using Turborepo) and collects the results into a single coverage output.

To open the coverage report in your browser:

```bash
pnpm turbo test:coverage:view
```

This will build the HTML report and launch it using your default browser, so you can explore which files and branches are covered.

<Callout title="Uploading coverage as an artifact">
  You can also store the generated coverage report as a [GitHub Actions artifact](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts) during your CI/CD pipeline, just add the following steps to your workflow job:

  ```yaml title=".github/workflows/ci.yml"
  # your workflow job configuration here

  - name: 📊 Generate coverage
    run: pnpm turbo test:coverage

  - name: 🗃️ Archive coverage report
    uses: actions/upload-artifact@v5
    with:
      name: coverage-${{ github.sha }}
      path: tooling/vitest/coverage/report
  ```

  This will generate a test coverage report and upload it as an artifact, so you can access it from GitHub Actions tab for later inspection.
</Callout>

A high coverage percentage means your tests execute most lines and branches - but the quality and relevance of your tests matter more than the raw number. Use coverage reports to spot gaps and guide improvements, not as the sole metric of test health.

![Code coverage](/images/docs/code-coverage.png)

## Best practices

Unit tests should work **for you**, not the other way around. Focus on writing tests that make it easier to change code with confidence, not on satisfying arbitrary rules or reaching a magic number in a dashboard.

Code coverage is a **useful metric**, but it **SHOULD NOT** be the goal. It's better to have a smaller set of high‑value tests that cover critical paths and edge cases than a huge suite of fragile tests that are hard to maintain.

When in doubt, ask: *“Does this test give **me** confidence that I can change this code without breaking users?”* If the answer is no, refactor or remove it.

Finally, keep unit tests focused on **small, isolated pieces of logic**. More advanced flows — like multi-step user journeys, cross-service interactions, or full-page behavior — are better covered by [end-to-end (E2E) tests](/docs/web/tests/e2e), where you can verify the system as a whole.


# Billing
Source: https://www.turbostarter.dev/docs/web/troubleshooting/billing

## Checkout can't be created

This happen in the following cases:

1. The environment variables are not set correctly. Please make sure you have set the environment variables corresponding to your billing provider in `.env.local` if locally - or in your hosting provider's dashboard if in production
2. The price IDs used are incorrect. Make sure to use the exact price IDs as they are in the payment provider's dashboard.

[Read more about billing configuration](/docs/web/billing/configuration)

## Database is not updated after subscribing to a plan

This may happen if the webhook is not set up correctly. Please make sure you have set up the webhook in the payment provider's dashboard and that the URL is correct.

If working locally, make sure that:

1. If using Stripe, that the Stripe CLI or configured proxy is up and running ([see the Stripe documentation for more information](/docs/web/billing/stripe#create-a-webhook)).
2. If using Lemon Squeezy, that the webhook set in Lemon Squeezy is correct, the server is running, and the proxy is set up properly if you are testing locally ([see the Lemon Squeezy documentation for more information](/docs/web/billing/lemon-squeezy#create-a-webhook)).
3. If using Polar, ensure that you have configured the webhook URL in the Polar dashboard exactly as documented, and that your local development server is accessible (use a tool like [ngrok](https://ngrok.com) if required) ([see the Polar documentation for more information](/docs/web/billing/polar#create-a-webhook)).

## Webhook signature verification failed

If you see "Invalid signature" or "Webhook signature verification failed":

1. **Check the webhook secret** matches exactly (no extra spaces or newlines)
2. **Verify you're using the correct secret** for the environment (test vs live)
3. **Ensure the raw request body** is being passed to verification (some middleware can modify it)

## Prices not showing or wrong currency

If prices display incorrectly or don't show:

1. **Verify price IDs** in your config match the ones in your payment provider dashboard
2. **Check the price is active** (not archived) in the provider dashboard
3. **Ensure currency matches** your configuration
4. **Clear browser cache** - pricing data may be cached

```bash
# Restart dev server after config changes
pnpm dev
```


# Deployment
Source: https://www.turbostarter.dev/docs/web/troubleshooting/deployment

## Deployment build fails

This is most likely an issue related to the environment variables not being set correctly in the deployment environment. Please analyse the logs of the deployment provider to see what is the issue.

The kit is very defensive about incorrect environment variables, and will throw an error if any of the required environment variables are not set. In this way - the build will fail if the environment variables are not set correctly - instead of deploying a broken application.

Check our guides for the most popular hosting providers for more information on how to deploy your TurboStarter project correctly:

<Cards>
  <Card title="Vercel" description="Deploy your TurboStarter web app to Vercel platform." href="/docs/web/deployment/vercel" />

  <Card title="Cloudflare" description="Deploy your TurboStarter web app to Cloudflare Workers." href="/docs/web/deployment/cloudflare" />

  <Card title="Netlify" description="Deploy your TurboStarter web app to Netlify platform." href="/docs/web/deployment/netlify" />

  <Card title="Render" description="Deploy your TurboStarter web app to Render platform." href="/docs/web/deployment/render" />

  <Card title="Railway" description="Deploy your TurboStarter web app to Railway platform." href="/docs/web/deployment/railway" />

  <Card title="AWS Amplify" description="Deploy your TurboStarter web app to AWS Amplify platform." href="/docs/web/deployment/amplify" />

  <Card title="Docker" description="Containerize your TurboStarter web app using Docker." href="/docs/web/deployment/docker" />

  <Card title="VPS" description="Deploy your TurboStarter web app to your own VPS with Docker." href="/docs/web/deployment/vps" />

  <Card title="Fly.io" description="Deploy your TurboStarter web app to Fly.io platform." href="/docs/web/deployment/fly" />
</Cards>

## What should I set as a URL before my first deployment?

That's very good question! For the first deployment you can set any URL, and then, after you (or your provider) assign a domain name, you can change it to the correct one. There's nothing wrong with redeploying your project multiple times.

## Sign in with OAuth provider doesn't work

This is most likely a settings issues in the provider's settings. To troubleshoot this issue, follow these steps:

1. **Verify provider settings**: Ensure that the OAuth provider's settings are correctly configured. Check that the client ID, client secret, and redirect URI are accurate and match the values in your application.
2. **Check environment variables**: Confirm that the environment variables for the OAuth provider are set correctly in your application production environment.
3. **Validate callback URLs**: Ensure that the callback URLs for each provider are set correctly and match the URLs in your application. This is crucial for the OAuth flow to work correctly.

Please read [Better Auth documentation](https://better-auth.com/docs/concepts/oauth) for more information on how to set up third-party providers.

## Build runs out of memory

If the build fails with `JavaScript heap out of memory`:

**Increase Node.js memory limit:**

```bash
# In your build command
NODE_OPTIONS="--max-old-space-size=4096" pnpm build
```

**For Vercel**, add to `vercel.json`:

```json
{
  "build": {
    "env": {
      "NODE_OPTIONS": "--max-old-space-size=4096"
    }
  }
}
```

**For other providers**, set the `NODE_OPTIONS` environment variable in their dashboard.

## Database connection issues in production

If you see "Connection refused" or "ECONNREFUSED" errors:

1. **Check DATABASE\_URL** is set correctly in your hosting provider
2. **Verify IP allowlist** - many database providers require you to allowlist your deployment's IP addresses
3. **Check SSL requirements** - production databases often require SSL:

```
DATABASE_URL="postgresql://...?sslmode=require"
```

4. **Verify connection pooling** - serverless environments may need connection pooling (e.g., PgBouncer, Neon's pooler)

## CORS errors in production

If API requests fail with CORS errors:

1. **Verify `NEXT_PUBLIC_URL`** matches your actual domain exactly (including `https://`)
2. **Check for trailing slashes** - `https://example.com` and `https://example.com/` are different origins
3. **Verify API route** is deployed - check `/api/status` is accessible

The API automatically configures CORS based on `NEXT_PUBLIC_URL`.

## Preview deployments not working

If preview/staging deployments have issues:

1. **OAuth callbacks** won't work unless you add preview URLs to your OAuth provider's allowed redirect URIs
2. **Webhooks** need to point to the correct environment (use separate webhook endpoints for staging)
3. **Database** - ensure preview uses a separate database or the same one as staging (not production)


# Emails
Source: https://www.turbostarter.dev/docs/web/troubleshooting/emails

## I want to use a different email provider

Of course! You can use any email provider that you want. All you need to do is to implement the `EmailProviderStrategy` and export it in your `index.ts` file.

[Read more about sending emails](/docs/web/emails/sending)

## My emails are landing in the spam folder

Emails landing in spam folders is a common issue. Here are key steps to improve deliverability:

1. **Configure proper domain setup**:
   * Use a dedicated subdomain for sending emails (e.g., mail.yourdomain.com)
   * Ensure [reverse DNS (PTR) records](https://www.cloudflare.com/learning/dns/dns-records/dns-ptr-record/) are properly configured
   * Warm up your sending domain gradually

2. **Implement authentication protocols**:
   * Set up [SPF records](https://www.cloudflare.com/learning/dns/dns-records/dns-spf-record/) to specify authorized sending servers
   * Enable [DKIM signing](https://www.cloudflare.com/learning/dns/dns-records/dns-dkim-record/) to verify email authenticity
   * Configure [DMARC policies](https://www.cloudflare.com/learning/dns/dns-records/dns-dmarc-record/) to prevent spoofing

3. **Follow deliverability best practices**:
   * Include clear unsubscribe mechanisms in all marketing communications
   * Personalize content appropriately
   * Avoid excessive promotional language and spam triggers
   * Maintain consistent HTML formatting and styling
   * Only include links to verified domains
   * Keep a regular sending schedule
   * Clean your email lists regularly
   * Use double opt-in for new subscribers

4. **Monitor and optimize**:
   * Track key metrics like delivery rates, opens, and bounces
   * Monitor spam complaint rates
   * Review email authentication reports
   * Test emails across different clients and devices
   * Adjust sending practices based on performance data

## Emails not sending in development

If emails don't send locally:

1. **Check your provider credentials** are set in `.env.local`
2. **Verify the email provider is configured** in `packages/email`
3. **Check the console/logs** for error messages

**For local testing without a provider**, you can use services like:

* [Mailpit](https://github.com/axllent/mailpit) (included in Docker setup)
* [Ethereal](https://ethereal.email/) (free test accounts)

## Email templates not rendering correctly

If email templates look broken or don't render:

1. **Preview locally first:**

```bash
pnpm --filter @workspace/email dev
```

2. **Check for unsupported CSS** - email clients have limited CSS support. Avoid:
   * Flexbox/Grid (use tables)
   * External stylesheets
   * Modern CSS properties

3. **Test across clients** using tools like [Litmus](https://litmus.com/) or [Email on Acid](https://www.emailonacid.com/)


# Installation
Source: https://www.turbostarter.dev/docs/web/troubleshooting/installation

## Cannot clone the repository

Issues related to cloning the repository are usually related to a Git misconfiguration in your local machine. The commands displayed in this guide using SSH: these will work only if you have setup your SSH keys in Github.

If you run into issues, [please make sure you follow this guide to set up your SSH key in Github.](https://docs.github.com/en/authentication/connecting-to-github-with-ssh)

If this also fails, please use HTTPS instead. You will be able to see the commands in the repository's Github page under the "Clone" dropdown.

Please also make sure that the account that accepted the invite to TurboStarter, and the locally connected account are the same.

## My environment variables from `.env.local` file are not being loaded

Make sure you are running the `pnpm dev` command from the root directory of your project (where the `pnpm-workspace.yaml` file is located)

Also, ensure that the `.env.local` files are present in the apps that need them. For example, the `.env` file should be present in the `apps/web` directory for the web app.

<Callout>
  TurboStarter uses the `dotenv-cli` to load environment variables from a `.env` files. The `dotenv-cli` is automatically used when running the `pnpm dev` command from the root directory.
</Callout>

## Next.js server doesn't start

This may happen due to some issues in the packages. Try to clean the workspace using the following command:

```bash
pnpm clean
```

Then, reinstall the dependencies:

```bash
pnpm i
```

You can now retry running the dev server.

## Local database doesn't start

If you cannot run the local database container, it's likely you have not started [Docker](https://docs.docker.com/get-docker/) locally. Our local database requires Docker to be installed and running.

Please make sure you have installed Docker (or compatible software such as [Colima](https://github.com/abiosoft/colima), [Orbstack](https://github.com/orbstack/orbstack)) and that is running on your local machine.

Also, make sure that you have enough [memory and CPU allocated](https://docs.docker.com/engine/containers/resource_constraints/) to your Docker instance.

## I don't see my translations

If you don't see your translations appearing in the application, there are a few common causes:

1. Check that your translation `.json` files are properly formatted and located in the correct directory
2. Verify that the language codes in your configuration match your translation files
3. Enable debug mode (`debug: true`) in your i18next configuration to see detailed logs

[Read more about configuration for translations](/docs/web/internationalization/configuration)

## "Module not found" error

This issue is mostly related to either dependency installed in the wrong package or issues with the file system.

The most common cause is incorrect dependency installation. Here's how to fix it:

1. Clean the workspace:

   ```bash
   pnpm clean
   ```

2. Reinstall the dependencies:
   ```bash
   pnpm i
   ```

If you're adding new dependencies, make sure to install them in the correct package:

```bash
# For main app dependencies
pnpm install --filter web my-package

# For a specific package
pnpm install --filter @workspace/ui my-package
```

If the issue persists, please check the file system for any issues.

### Windows OneDrive

OneDrive can cause file system issues with Node.js projects due to its file syncing behavior. If you're using Windows with OneDrive, you have two options to resolve this:

1. Move your project to a location outside of OneDrive-synced folders (recommended)
2. Disable OneDrive sync specifically for your development folder

This prevents file watching and symlink issues that can occur when OneDrive tries to sync Node.js project files.

## Turbo cache issues

If builds behave unexpectedly or changes aren't reflected, clear the Turbo cache:

```bash
pnpm turbo clean
```

Then rebuild:

```bash
pnpm build
```

## Windows line ending issues

If you see errors related to line endings or scripts fail with `\r` characters:

**Configure Git to use LF:**

```bash
git config --global core.autocrlf input
```

**Fix existing files:**

```bash
git rm --cached -r .
git reset --hard
```

Or use a tool like `dos2unix` to convert files.


# Agents
Source: https://www.turbostarter.dev/ai/docs/agents

<Callout title="Agents are coming soon!">
  This feature is currently under development and will be
  available in a future release.

  [See roadmap](https://github.com/orgs/turbostarter/projects/1)
</Callout>

The Agents page is currently a placeholder in both the web and mobile apps. Today, it serves as a preview screen pointing users to the public roadmap while the feature is still under development.

The long-term goal is to showcase how to build intelligent, autonomous agents that can interact with users, tools, and data sources.

## Features

<Cards>
  <Card title="Cross-platform">
    Design agents once and deploy them seamlessly across multiple platforms
    including React, React Native, Expo, and Next.js through a unified
    architecture.
  </Card>

  <Card title="Memory">
    Implement sophisticated context retention that allows agents to maintain
    state and recall critical information across conversations and devices with
    perfect continuity.
  </Card>

  <Card title="Function calling">
    Enable agents to take meaningful actions by integrating with external tools,
    accessing APIs, and executing functions dynamically within secure,
    controlled environments.
  </Card>

  <Card title="MCP integration">
    Leverage the [Model Context
    Protocol](https://modelcontextprotocol.io/introduction) to standardize
    context delivery between agents and Large Language Models (LLMs). This
    enables frictionless connections to diverse data sources and tools,
    dramatically enhancing agent capabilities.
  </Card>

  <Card title="Agentic workflows">
    Orchestrate complex workflows combining Retrieval-Augmented Generation
    (RAG), tool utilization, and MCP server interactions to solve sophisticated
    tasks that previously required human intervention.
  </Card>
</Cards>

Stay tuned for the release of this exciting functionality!


# Chat
Source: https://www.turbostarter.dev/ai/docs/chat

The [Chat](https://ai.turbostarter.dev/chat) demo application showcases an advanced AI assistant capable of engaging in complex conversations, browsing the web, working with file attachments, and sharing selected conversations through public links. It integrates multiple large language models (LLMs), supports reasoning-enabled models, and streams responses in real time.

You also get light chat management features like pinning and renaming so conversations stay easy to find.

<AIAppShowcase id="chat" />

## Features

The chat app offers a variety of capabilities for an enhanced conversational experience:

<Cards>
  <Card title="Multi-model integration">
    Switch between models from providers like
    [OpenAI](/ai/docs/providers/openai),
    [Anthropic](/ai/docs/providers/anthropic), [Google
    AI](/ai/docs/providers/google), [xAI](/ai/docs/providers/xai), and
    [DeepSeek](/ai/docs/providers/deepseek) from one consistent chat interface.
  </Card>

  <Card title="Deep reasoning">
    Experience an AI that truly understands complex questions and delivers
    thoughtful, nuanced responses based on comprehensive reasoning.
  </Card>

  <Card title="Live web information">
    Access up-to-the-minute information from the web through the integrated
    search capability powered by the shared [web search provider
    layer](/ai/docs/web-search).
  </Card>

  <Card title="Shareable chats">
    Share a conversation up to a chosen point, then copy or open the public link
    from the built-in share sheet.
  </Card>

  <Card title="Instant response delivery">
    Enjoy natural, fluid conversations with responses that stream in real-time,
    eliminating waiting periods.
  </Card>

  <Card title="Chat management">
    Pin and rename chats for quick organization without adding unnecessary
    complexity.
  </Card>
</Cards>

## Setup

To implement your advanced AI assistant, you'll need several services configured. If you haven't set these up yet, start with:

<Cards>
  <Card href="/ai/docs/database" title="Database" description="Configure a PostgreSQL database to store conversation history and metadata." />

  <Card href="/ai/docs/storage" title="Storage" description="Set up S3-compatible storage for handling file attachments." />
</Cards>

### AI models

<Callout>
  Different models offer varying capabilities for tool calling, reasoning, and file processing. Consider these differences when selecting the optimal model for your specific use case.
</Callout>

The Chat app uses the AI SDK to support multiple language and vision-capable models. You can switch models based on your needs. Explore the most relevant providers here:

<Cards className="grid-cols-1 sm:grid-cols-2">
  <Card href="/ai/docs/providers/openai" title="OpenAI" description="Implement GPT and o-series models for powerful text generation." icon={<OpenAI />} />

  <Card href="/ai/docs/providers/anthropic" title="Anthropic" description="Integrate Claude models renowned for nuanced reasoning." icon={<Anthropic />} />

  <Card href="/ai/docs/providers/google" title="Google AI" description="Incorporate Gemini models for versatile AI capabilities." icon={<Google />} />

  <Card href="/ai/docs/providers/xai" title="xAI Grok" description="Leverage xAI's innovative Grok models for advanced interactions." icon={<XAI />} />

  <Card href="/ai/docs/providers/deepseek" title="DeepSeek" description="Use DeepSeek models for chat and reasoning-focused workflows." icon={<DeepSeek />} />
</Cards>

For detailed configuration of specific providers and other supported models, refer to the [AI SDK documentation](https://sdk.vercel.ai/providers/ai-sdk-providers).

### Web browsing

The chat app includes a dedicated web-search tool with provider-specific strategy adapters. The current codebase includes integrations for [Tavily](https://www.tavily.com/), [Brave Search](https://brave.com/search/api/), [Exa](https://exa.ai/), and [Firecrawl](https://www.firecrawl.dev/).

This provider layer keeps the tool contract stable while letting you switch or extend the underlying search backend. It also centralizes result normalization so the chat flow does not depend on each provider's raw response format.

<Card href="/ai/docs/web-search" title="Web search" description="See the implemented search providers, code layout, and required environment variables." />

### Tavily quick start

[Tavily](https://www.tavily.com/) remains a strong default option because it is optimized for LLM and agent workflows and returns structured, AI-friendly search results with minimal setup.

<Callout title="Free tier available">
  Tavily offers a generous free tier with [1,000 API credits per
  month](https://docs.tavily.com/documentation/api-credits) without requiring
  credit card information. A basic search consumes 1 credit, while an advanced
  search uses 2 credits. Paid plans are available for higher volume usage.
</Callout>

To enable web browsing, follow these steps:

<Steps>
  <Step>
    #### Get Tavily API Key

    Sign up or log in at the [Tavily Platform](https://app.tavily.com/sign-in) to obtain your API key from the dashboard.
  </Step>

  <Step>
    #### Add API Key to Environment

    Add your API key to your project's `.env` file (e.g., in `apps/web`):

    ```bash title=".env"
    TAVILY_API_KEY=tvly-your-api-key
    ```
  </Step>
</Steps>

With the API key properly configured, the chat app can use Tavily for searches when contextually appropriate.

## Data persistence

User interactions and chat history are persisted to ensure a continuous experience across sessions.

<Card href="/ai/docs/database" title="Database" description="Learn more about database service in TurboStarter AI." />

Conversation data is organized within a dedicated PostgreSQL schema named `chat`
to maintain clear separation from other application data.

* `chat`: stores records for each conversation session, including metadata like `userId`, `name`, and timestamps.
* `message`: stores individual messages linked to a parent chat.
* `part`: stores structured message parts, including text parts and file parts.
* `usage`: stores model/provider usage metadata for assistant responses.

<Card href="/ai/docs/storage" title="Storage" description="Learn more about cloud storage service in TurboStarter AI." />

Files shared within conversations are uploaded to [cloud storage](/ai/docs/storage) (S3-compatible), with attachment metadata stored in message parts and signed URLs generated when the files need to be read back.

## Devtools

TurboStarter AI includes a built-in devtools tool designed to help you inspect, debug, and understand all aspects of the AI chat experience. When you run the development server, it becomes available at [http://localhost:3001](http://localhost:3001).

The devtools provide a detailed view into chat request/response flows, message payloads, model invocations, and step-by-step assistant function calls as they occur.

![Devtools](/images/docs/ai/devtools.png)

You can monitor live chat events, observe intermediate reasoning traces, and troubleshoot issues - making it much easier to build, test, and optimize AI-powered conversations with full transparency.

## Structure

The Chat functionality is distributed across shared packages and platform-specific modules for web and mobile, ensuring strong code reuse and a consistent product experience.

### Core

The shared chat logic lives in `@workspace/ai-chat`, implemented in `packages/ai/chat/src`. It includes:

* Zod schemas for chat payloads and options
* Model definitions and provider strategy wiring
* Chat persistence helpers for messages, parts, attachments, and usage
* provider-backed web search tooling under `tools/web-search`
* Streamed AI responses built on the AI SDK

### API

Built with Hono, the `packages/api` package wires the chat app through `packages/api/src/modules/ai/chat.ts`.

That module validates incoming payloads, applies shared middleware like authentication and credit deduction, and then forwards the request into `@workspace/ai-chat`, where the chat stream, persistence, attachment handling, and model/tool execution actually happen.

### Web

The Next.js web application in `apps/web` implements the user-facing chat experience:

* `src/app/[locale]/(apps)/chat/**`: route entry points for the chat app
* `src/modules/chat/**`: the actual feature modules for composer, history, conversation UI, web search rendering, and attachment handling

### Mobile

The Expo/React Native mobile application in `apps/mobile` delivers a native chat experience:

* `src/app/(apps)/chat/**`: route entry points for the mobile chat app
* `src/modules/chat/**`: mobile-native chat modules for composer, history, and conversation UI
* **API interaction**: uses the same shared Hono client as the web app for consistent backend communication

This modular structure promotes separation of concerns and facilitates independent development and scaling of different parts of the application.


# Image playground
Source: https://www.turbostarter.dev/ai/docs/image

The [Image Generation](https://ai.turbostarter.dev/image) demo application allows users to create visuals from text prompts using multiple image models. It provides a clean interface for prompt entry, model selection, aspect ratio control, and browsing generation history.

<AIAppShowcase id="image" />

## Features

Explore the capabilities of the AI-powered image generation tool:

<Cards>
  <Card title="Prompt-based generation">
    Create images simply by describing what you want to see in text.
  </Card>

  <Card title="Multi-model support">
    Choose from different AI image generation models offered by various
    providers.
  </Card>

  <Card title="Aspect ratio control">
    Select the desired aspect ratio for your generated images (e.g. square,
    landscape, portrait).
  </Card>

  <Card title="Batch generation">
    Create multiple design variations from a single prompt simultaneously,
    accelerating your creative workflow.
  </Card>

  <Card title="Generation history">
    Access and reference your complete generation history, including all prompts
    and resulting images for continued iteration.
  </Card>
</Cards>

## Setup

To implement image generation in your application, you'll need to configure the necessary backend services.

<Cards>
  <Card href="/ai/docs/database" title="Database" description="Configure a PostgreSQL database to store generation history and image metadata." />

  <Card href="/ai/docs/storage" title="Storage" description="Set up S3-compatible storage to securely manage generated image assets." />
</Cards>

You'll also need API keys for the models you want to enable. Follow the provider documentation linked below for setup details.

## AI models

The Image Generation app uses the AI SDK to support several image-capable models. In the current codebase, these come from OpenAI, Google, and Replicate:

<Cards>
  <Card href="/ai/docs/providers/openai" title="OpenAI" description="Implement DALL·E models for exceptional image quality and creative fidelity." icon={<OpenAI />} />

  <Card href="/ai/docs/providers/replicate" title="Replicate" description="Access a diverse ecosystem of open-source models including Stable Diffusion variants." icon={<Replicate />} />

  <Card href="/ai/docs/providers/google" title="Google AI" description="Use Gemini image models such as Nano Banana and Nano Banana Pro." icon={<Google />} />
</Cards>

For detailed implementation guidance, refer to the [AI SDK documentation](https://sdk.vercel.ai/docs/ai-sdk-core/image-generation) covering the `generateImage` function and supported providers.

## Data persistence

Details about image generation requests and the resulting images are stored to maintain user history.

<Card href="/ai/docs/database" title="Database" description="Learn more about database services in TurboStarter AI." />

Data is organized within a dedicated PostgreSQL schema named `image`:

* `generation`: captures detailed information about each generation request, including the `prompt`, selected `model`, `aspectRatio`, requested image `count`, `userId`, and precise timestamps.
* `image`: stores metadata for each generated image, linked to its parent `generation` record via `generationId` and maintaining the `url` reference to the stored image file.

<Card href="/ai/docs/storage" title="Storage" description="Learn more about cloud storage services in TurboStarter AI." />

Generated image files are uploaded to [cloud storage](/ai/docs/storage) (S3-compatible). The public asset URL is then stored in the `image` table for later retrieval.

## Structure

The Image Generation feature is organized across the monorepo for clear separation between shared AI logic, API routes, and platform-specific UI.

### Core

The shared image generation logic lives in `@workspace/ai-image`, implemented in `packages/ai/image/src`:

* Validation schemas for prompts and generation options
* Model definitions and provider strategy wiring
* DB helpers for generations and images
* Upload flow for persisting generated assets to storage

### API

The `packages/api` package wires image generation through `packages/api/src/modules/ai/image.ts`.

That module is responsible for validating generation input, applying shared middleware like authentication, rate limiting, and credits, and then delegating to `@workspace/ai-image`, which creates generation records, calls the model provider, uploads assets to storage, and returns the results back through the API layer.

### Web

The Next.js application (`apps/web`) delivers an intuitive user interface:

* `src/app/[locale]/(apps)/image/**`: route entry points for the image app, history page, and generation detail pages
* `src/modules/image/**`: feature modules for the composer, history, generation detail views, and image gallery UI

### Mobile

The Expo/React Native application (`apps/mobile`) provides a native mobile experience:

* `src/app/(apps)/image/**`: route entry points for the mobile image app
* `src/modules/image/**`: mobile-native modules for generation, history, and viewing results
* **API integration**: uses the same shared Hono client as the web app for consistent backend communication

This architecture ensures perfect consistency across platforms while enabling tailored UI implementations optimized for each environment.


# Knowledge RAG
Source: https://www.turbostarter.dev/ai/docs/rag

The [Knowledge RAG](https://ai.turbostarter.dev/rag) demo application enables intelligent interaction with document content through a conversational AI interface. Upload a document from your device or provide a remote URL, then ask questions, request summaries, and extract information grounded in the document itself.

<AIAppShowcase id="rag" />

## Features

Transform how you interact with document content through these powerful capabilities:

<Cards>
  <Card title="File upload">
    Upload documents directly from your device or import them from a remote URL.
  </Card>

  <Card title="Contextual conversation">
    Chat with an AI that answers using content retrieved from the uploaded
    document.
  </Card>

  <Card title="Information extraction">
    Quickly find specific information, key points, or summaries within the
    document through natural language queries.
  </Card>

  <Card title="Source highlighting">
    Visualize exactly which document sections informed the AI's responses with
    precise source highlighting.
  </Card>

  <Card title="Multi-document intelligence (coming soon)">
    Conduct sophisticated conversations spanning multiple uploaded documents,
    enabling cross-document analysis and comparison.
  </Card>
</Cards>

## Setup

To implement the [Knowledge RAG](/ai/docs/rag) application in your project, configure these essential backend services:

<Cards>
  <Card href="/ai/docs/database" title="Database">
    Set up PostgreSQL with the `pgvector` extension to efficiently store
    conversation history, document metadata, and vector embeddings for semantic
    search.
  </Card>

  <Card href="/ai/docs/storage" title="Storage">
    Configure S3-compatible cloud storage for secure management of uploaded
    documents documents.
  </Card>
</Cards>

You'll also need API keys for the language and embedding models used in the RAG flow.

## AI models

This application leverages two complementary AI model types working together:

1. **Large Language Models (LLMs):** Provide sophisticated natural language understanding to interpret your questions and generate contextually appropriate responses based on document content.
2. **Embedding Models:** Convert document text segments into numerical vector representations that enable efficient semantic similarity search and [Retrieval-Augmented Generation (RAG)](https://en.wikipedia.org/wiki/Retrieval-augmented_generation).

In the current codebase, the default RAG strategy uses OpenAI for both the chat model and the embedding model:

<Cards>
  <Card href="/ai/docs/providers/openai" title="OpenAI" description="Use GPT models for responses and OpenAI embeddings for semantic retrieval." icon={<OpenAI />} />
</Cards>

If you want to expand provider support, the right place to do that is `packages/ai/rag/src/strategies.ts`.

## Data persistence

The application stores data related to chats, documents, and embeddings to provide a persistent experience.

<Card href="/ai/docs/database" title="Database" description="Learn more about database services in TurboStarter AI." />

Application data is organized within a dedicated PostgreSQL schema named `rag`:

* `chat`: stores metadata for each RAG conversation.
* `message`: stores all user and assistant messages within a chat.
* `document`: stores uploaded document metadata including `name` and storage `path`.
* `embedding`: stores extracted chunks and vector embeddings using [`pgvector`](https://github.com/pgvector/pgvector)'s `vector` type, with an HNSW index for similarity search.

<Card href="/ai/docs/storage" title="Storage" description="Learn more about cloud storage services in TurboStarter AI." />

The files uploaded by users are securely stored in your configured [cloud storage](/ai/docs/storage) bucket. The `path` field in the `document` table maintains the precise reference to each file's location.

## Devtools

TurboStarter AI provides an integrated devtools panel specifically designed to help you analyze, debug, and optimize every aspect of the RAG (Retrieval-Augmented Generation) workflow.

When you run the development server, the devtools panel becomes available at [http://localhost:3001](http://localhost:3001).

With devtools, you can trace how user queries are processed, examine the retrieval of relevant documents, and inspect each step in the response generation pipeline—including model calls, prompt construction, and semantic matching.

![Devtools](/images/docs/ai/devtools.png)

This tool allows you to observe retrieval and generation events as they happen, diagnose retrieval quality or edge cases, and fine-tune your RAG configuration for the best results. It is an essential resource for developing robust, transparent document-based AI features.

## Structure

The [Knowledge RAG](/ai/docs/rag) feature is organized across the monorepo for shared AI logic, API routes, and platform-specific UI.

### Core

The shared RAG logic lives in `@workspace/ai-rag`, implemented in `packages/ai/rag/src`:

* Validation schemas for messages and remote URLs
* Document loading, chunking, and embedding generation helpers
* Similarity search utilities for retrieving relevant content
* Streamed RAG chat logic with tool-assisted retrieval

### API

The `packages/api` package wires the RAG app through `packages/api/src/modules/ai/rag.ts`.

This module validates uploads and chat messages, applies shared middleware like authentication, rate limiting, and credits, and then delegates to `@workspace/ai-rag`, where document creation, embedding generation, retrieval, and streamed responses are handled.

### Web

The [Next.js](https://nextjs.org/) application (`apps/web`) delivers an intuitive user interface:

* `src/app/[locale]/(apps)/rag/**`: route entry points for the RAG app and chat detail pages
* `src/modules/rag/**`: feature modules for upload, chat history, conversation UI, and the built-in document previewer

### Mobile

The [Expo](https://expo.dev/)/[React Native](https://reactnative.dev/) application (`apps/mobile`) provides a native mobile experience:

* `src/app/(apps)/rag/**`: route entry points for the mobile RAG app
* `src/modules/rag/**`: mobile-native modules for upload, history, and conversation UI
* **API integration**: uses the same shared Hono client as the web app for consistent backend communication

This architecture ensures that core AI processing and data handling logic is shared across platforms, while enabling optimized UI implementations tailored to each environment.


# Text to Speech
Source: https://www.turbostarter.dev/ai/docs/tts

The [Text to Speech (TTS)](https://ai.turbostarter.dev/tts) demo application transforms written text into high-quality spoken audio. It uses ElevenLabs models to stream generated speech and gives users fine-grained control over voice settings.

<AIAppShowcase id="tts" />

## Features

Discover the powerful capabilities of this AI-powered voice synthesis solution:

<Cards>
  <Card title="Large voice library">
    Browse a large library of voices from [Eleven Labs](https://elevenlabs.io/)
    to find a style that fits your product.
  </Card>

  <Card title="Real-time audio streaming">
    Experience near-instantaneous audio generation with streaming delivery,
    providing immediate feedback as your content comes to life.
  </Card>

  <Card title="Integrated audio player">
    Enjoy a full-featured playback interface with precise controls for playback
    speed and convenient options to download generated audio files.
  </Card>

  <Card title="Voice customization">
    Fine-tune your audio output with settings like speed, stability, similarity,
    and speaker boost, depending on the selected voice and model.
  </Card>

  <Card title="Intuitive user experience">
    Benefit from a thoughtfully designed interface that makes transforming text
    to speech effortless and efficient, even for first-time users.
  </Card>
</Cards>

## AI models

This application primarily utilizes specialized text-to-speech models from [Eleven Labs](https://elevenlabs.io/).

<Cards>
  <Card href="/ai/docs/providers/eleven-labs" title="Eleven Labs" description="Integrate Eleven Labs' state-of-the-art voice synthesis technology for stunningly realistic and expressive speech generation." icon={<ElevenLabs />} />
</Cards>

For comprehensive information about available voices and advanced customization techniques, consult the [ElevenLabs SDK documentation](https://elevenlabs.io/docs/overview).

## Data flow

Unlike the chat, image, and RAG demos, the TTS demo does **not** persist generations in the database by default. The API streams back audio directly from ElevenLabs, and the UI handles playback and download on the client side.

## Structure

The Text-to-Speech feature is organized across the monorepo for maximum flexibility and maintainability:

### Core

The shared TTS logic lives in `@workspace/ai-tts`, implemented in `packages/ai/tts/src`:

* Validation schemas and constants for TTS options
* The ElevenLabs client wrapper
* Voice mapping utilities and streamed text-to-speech generation

### API

The `packages/api` package wires the TTS app through `packages/api/src/modules/ai/tts.ts`.

That module validates the text-to-speech payload, applies shared middleware like authentication, rate limiting, and credits, and then delegates to `@workspace/ai-tts`, which fetches voices and streams generated audio from ElevenLabs back to the client.

### Web

The [Next.js](https://nextjs.org/) application (`apps/web`) provides the user interface:

* `src/app/[locale]/(apps)/tts/**`: route entry points for the TTS app
* `src/modules/tts/**`: feature modules for the composer, voice selector, settings controls, playback, and visualizer UI

### Mobile

The [Expo](https://expo.dev/)/[React Native](https://reactnative.dev/) application (`apps/mobile`) provides the native mobile experience:

* `src/app/(apps)/tts/**`: route entry points for the mobile TTS app
* `src/modules/tts/**`: mobile-native modules for composing and playing speech
* **API interaction**: uses the same shared Hono client as the web app for consistent communication with the backend

This architecture ensures perfect consistency between platforms while allowing for optimized UI implementations tailored to each environment.


# Voice
Source: https://www.turbostarter.dev/ai/docs/voice

The [Voice](https://ai.turbostarter.dev/voice) app is the most real-time part of TurboStarter AI. Instead of a request-response UI, it gives users a shared audio session with an agent that can listen, reason, speak back, and stream conversation state across web and mobile.

<AIAppShowcase id="voice" />

## Capabilities

[LiveKit](https://livekit.com/) gives this app more than a microphone button. It provides the realtime transport, room lifecycle, participant state, and media controls that make the experience feel like a proper call interface instead of a chat form with audio attached.

<Callout title="Why LiveKit?">
  LiveKit is a powerful realtime platform that provides the infrastructure for the voice app. It is used to transport the audio and video streams between the client and the server.

  It is also used to store the session state and to control the media tracks. It powers millions of real-time voice and video sessions every day, including a [ChatGPT Voice](https://chatgpt.com/features/voice/) mode.
</Callout>

### Web

On web, the app leans into the full browser surface area. The desktop experience is especially good for demos, internal copilots, sales assistants, and any workflow where screen sharing matters.

<Cards>
  <Card title="Realtime conversation UI">
    Users can start a session, interrupt naturally, and follow the conversation
    through a live transcript while the agent is speaking or listening.
  </Card>

  <Card title="Camera and screen sharing">
    The web client exposes microphone, camera, and screen-share controls, which
    makes it useful for support, onboarding, and collaborative assistant flows.
  </Card>

  <Card title="Visualizer and layout controls">
    The visualizer is customizable, and the layout adapts when transcript,
    camera, or screen-share tiles are active.
  </Card>

  <Card title="Browser-native testing surface">
    Web is the fastest place to test prompts, media permissions, interruptions,
    and room behavior while you are iterating on the agent.
  </Card>
</Cards>

![Web voice session](/images/docs/ai/apps/voice/web.png)

### Mobile

On mobile, the same LiveKit room and agent stack is presented through a native-first session layout. The experience is optimized for touch controls, safe areas, and audio-session handling on real devices.

<Cards>
  <Card title="Native audio session handling">
    The mobile app manages the underlying audio session so the voice experience
    behaves like a real call instead of a fragile media demo.
  </Card>

  <Card title="Transcript plus in-session chat">
    Users can keep the conversation voice-first while still opening transcript
    and chat surfaces when they want more control or visibility.
  </Card>

  <Card title="Camera and screen sharing">
    The mobile UI also supports microphone, camera, and screen-share controls,
    with a second media tile shown when visual tracks are active.
  </Card>

  <Card title="Shared backend, native UI">
    Both apps use the same voice backend and request lifecycle, while keeping
    the interaction model natural on phones and tablets.
  </Card>
</Cards>

![Mobile voice session](/images/docs/ai/apps/voice/mobile.png)

### Shared infrastructure

Under the UI differences, the architecture stays consistent across platforms. Both apps request a room token from the shared API layer, join a LiveKit room, and then hand the live session off to a LiveKit agent worker.

<Cards>
  <Card title="Shared request lifecycle">
    The web and mobile welcome screens trigger the same shared voice route
    module in `packages/api/src/modules/ai/voice.ts`, so auth, credits, and
    token creation stay centralized.
  </Card>

  <Card title="Shared voice package">
    The LiveKit token logic, environment handling, agent entrypoint, and
    deployment assets all live in `packages/ai/voice/src`.
  </Card>

  <Card title="Live session transport">
    Session state, media tracks, transcript messages, and agent events are
    streamed over LiveKit instead of the text-streaming path used by the chat
    apps.
  </Card>
</Cards>

## Architecture

The voice app still uses the same monorepo boundaries as the rest of the [AI product](/ai/docs/architecture). The difference is that the backend work happens around room creation and agent sessions rather than around a single streamed HTTP response.

1. The user starts a [session](https://docs.livekit.io/agents/logic/sessions/) from the platform-specific voice UI in `src/modules/voice/**`.
2. The shared API route module in `packages/api/src/modules/ai/voice.ts` runs through the normal request lifecycle, including auth context and credit checks.
3. The token-creation logic in `packages/ai/voice/src/api.ts` creates a [short-lived LiveKit participant token](https://docs.livekit.io/frontends/reference/tokens-grants/) and [room](https://docs.livekit.io/reference/other/roomservice-api/) configuration.
4. The web and mobile `SessionProvider` implementations use LiveKit's token source helpers to join the room.
5. The LiveKit agent worker defined in `packages/ai/voice/src/agent/main.ts` joins that room and drives the conversation.

This keeps the app aligned with the rest of the starter while still giving voice its own realtime transport and deployment model.

## Voice architecture

The most useful mental model for this app is the classic voice pipeline: speech comes in, gets transcribed, passed to an LLM, and then rendered back to audio. LiveKit Agents supports that pattern directly, and it is the right baseline to understand before looking at realtime speech-to-speech models.

<Tabs items={["STT + LLM + TTS pipeline", "Realtime model"]}>
  <Tab>
    This is the recommended architecture to understand first because it gives
    you the most flexibility. You can mix providers for transcription,
    reasoning, and voice quality instead of accepting one provider's full stack.

    ```ts
    import { AgentSession } from "@livekit/agents";

    const session = new AgentSession({
      stt: "deepgram/nova-3:multi",
      llm: "openai/gpt-4.1-mini",
      tts: "cartesia/sonic-3:voice-id",
    });
    ```

    In the repository, this full pipeline is already scaffolded in
    `packages/ai/voice/src/agent/main.ts`, along with turn detection, VAD, and
    noise-cancellation hooks.
  </Tab>

  <Tab>
    Realtime models are a strong alternative when you want a more tightly
    coupled speech-to-speech experience with fewer moving pieces in the app
    layer.

    ```ts
    import { AgentSession } from "@livekit/agents";
    import * as openai from "@livekit/agents-plugin-openai";

    const session = new AgentSession({
      llm: new openai.realtime.RealtimeModel({ voice: "cedar" }),
    });
    ```

    This pattern is also supported by LiveKit and is useful when you want
    latency and expressiveness from a single realtime model. It is not the only
    option, though, and you can switch between the two approaches as your
    product needs evolve.
  </Tab>
</Tabs>

LiveKit's own docs present both approaches side by side, which is a helpful way to reason about tradeoffs: pipeline mode gives you finer provider control, while realtime mode gives you a more tightly integrated speech experience. See the [Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/), [turn handling guide](https://docs.livekit.io/agents/logic/turns/), and [realtime models overview](https://docs.livekit.io/agents/models/realtime/).

### STT and TTS provider options

One of the strengths of LiveKit Agents is that you are not locked into a single speech stack. You can mix and match providers based on latency, language coverage, cost, and voice quality.

| Capability                | Common choices                                                          | Notes                                                                                                                          |
| ------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| STT                       | Deepgram, AssemblyAI, OpenAI, Google Cloud, Speechmatics                | Deepgram is a common starting point for low-latency multilingual transcription, but LiveKit supports a wider plugin ecosystem. |
| TTS                       | Cartesia, ElevenLabs, Deepgram, OpenAI, Google Cloud, Rime              | Cartesia and ElevenLabs are common picks when voice quality is the main product differentiator.                                |
| Realtime speech-to-speech | OpenAI Realtime, Gemini Live, xAI Grok Voice, Amazon Nova Sonic, Phonic | Realtime models reduce app-layer composition but change how you think about control and provider mix.                          |

If you are exploring the space, start with [STT models](https://docs.livekit.io/agents/models/stt/), [TTS models](https://docs.livekit.io/agents/models/tts/), and [realtime models](https://docs.livekit.io/agents/models/realtime/). For product-specific guidance inside this docs set, see [Speech](/ai/docs/speech), [Transcription](/ai/docs/transcription), [OpenAI](/ai/docs/providers/openai), and [ElevenLabs](/ai/docs/providers/eleven-labs).

## Create a project and set environment variables

The repository already knows how to build and run the voice agent, but you still need a [LiveKit project](https://livekit.com/) to point it at. You can use [LiveKit Cloud](https://cloud.livekit.io/) for the smoothest path, or [run the LiveKit server locally](https://docs.livekit.io/transport/self-hosting/local/) when you want full control during development.

<Tabs items={["LiveKit Cloud", "Local LiveKit server"]}>
  <Tab>
    LiveKit Cloud is the easiest way to get from code to a working agent. It
    gives you hosted transport, agent deployment, observability, and the cloud
    dashboard in one place.

    <Steps>
      <Step>
        Create a project in the [LiveKit Cloud dashboard](https://cloud.livekit.io/).
      </Step>

      <Step>
        Install the LiveKit CLI and link it to your account:

        ```bash
        brew install livekit-cli
        lk cloud auth
        ```
      </Step>

      <Step>
        Add the LiveKit credentials to `apps/web/.env.local`, because the
        `@workspace/ai-voice` package scripts load that file by default:

        ```bash title="apps/web/.env.local"
        LIVEKIT_URL=wss://your-project.livekit.cloud
        LIVEKIT_API_KEY=your-livekit-api-key
        LIVEKIT_API_SECRET=your-livekit-api-secret

        # Optional: voice-model providers
        OPENAI_API_KEY=your-openai-api-key
        DEEPGRAM_API_KEY=your-deepgram-api-key
        CARTESIA_API_KEY=your-cartesia-api-key
        ELEVENLABS_API_KEY=your-elevenlabs-api-key
        ```
      </Step>
    </Steps>

    You do not need every provider key on day one. Add only the providers your
    chosen voice pipeline uses. The most important local prerequisite is simply
    having the `lk` CLI installed and available on your `PATH`, because the
    repository deploy script shells out to it directly.
  </Tab>

  <Tab>
    Local LiveKit is useful when you want to test the room transport yourself
    or develop without relying on a cloud project. It is separate from the
    repository's own `docker-compose.yml`, which only starts Postgres.

    <Steps>
      <Step>
        Install the LiveKit server locally:

        ```bash
        brew update && brew install livekit
        ```
      </Step>

      <Step>
        Start the server in dev mode:

        ```bash
        livekit-server --dev
        ```
      </Step>

      <Step>
        Point your env file at the local instance. The local dev server uses the
        default `devkey` and `secret` credentials:

        ```bash title="apps/web/.env.local"
        LIVEKIT_URL=ws://127.0.0.1:7880
        LIVEKIT_API_KEY=devkey
        LIVEKIT_API_SECRET=secret
        ```
      </Step>
    </Steps>

    If you want to connect from another device (e.g. mobile phone) on your network, use
    `livekit-server --dev --bind 0.0.0.0` and replace `127.0.0.1` with your
    machine's LAN IP in the client-facing URL.
  </Tab>
</Tabs>

<Callout title="All setup pre-built">
  The starter already includes Docker-based build assets for the agent itself,
  but it does not currently spin up a LiveKit server through Docker Compose for
  you. Treat LiveKit transport as a separate dependency from Postgres and the
  rest of the local services.
</Callout>

## Run the agent locally

Once your environment variables are in place, local development is straightforward. The easiest path is to let Turbo orchestrate the voice package task graph for you, because `packages/ai/voice/turbo.json` already declares `dev -> download-files -> build`.

<Callout title="Recommended approach">
  From the `ai` repository root, you can run the voice worker through Turbo and
  let it handle the build and pre-download steps automatically:

  ```bash
  pnpm with-env turbo dev --filter=@workspace/ai-voice
  ```

  This is the best command to document for day-to-day development. The
  step-by-step commands below are still useful when you want to understand what
  happens under the hood or run each piece manually.
</Callout>

If you are already working in the full monorepo, `pnpm dev` from the repository root is also a valid path. The root script runs `pnpm with-env turbo dev`, so the voice package can be started as part of the wider development graph alongside the web and mobile apps.

<Steps>
  <Step>
    Install dependencies for the monorepo:

    ```bash
    pnpm install
    ```
  </Step>

  <Step>
    Build the package first if you want to run the pieces manually:

    ```bash
    pnpm --filter @workspace/ai-voice build
    ```

    This runs TypeScript compilation for `@workspace/ai-voice` and produces the
    `dist` output used by the worker's production-style scripts.
  </Step>

  <Step>
    Optionally pre-download local files such as VAD-related assets:

    ```bash
    pnpm --filter @workspace/ai-voice download-files
    ```

    This boots the built agent in a special download mode so it can fetch any
    local runtime assets it needs ahead of time. In practice, this is where
    model helpers such as Silero VAD assets can be warmed up before you enter a
    live session.
  </Step>

  <Step>
    Run the agent in development mode:

    ```bash
    pnpm --filter @workspace/ai-voice dev
    ```

    This loads `apps/web/.env.local`, starts the LiveKit agent entrypoint in
    development mode, prewarms the VAD, and waits for room jobs from your
    local or cloud LiveKit project.
  </Step>

  <Step>
    In a separate terminal, run the app itself:

    ```bash
    pnpm dev
    ```

    This starts the rest of the product surface so you can actually join the
    room from the web app, mobile app, or any other connected client.
  </Step>
</Steps>

The package also includes `pnpm --filter @workspace/ai-voice connect` and `pnpm --filter @workspace/ai-voice start` for alternative LiveKit agent startup modes. If you prefer the Turbo task graph for those flows too, `start` is also wired to depend on `download-files` in `packages/ai/voice/turbo.json`. For the bigger picture on these modes, see LiveKit's [voice quickstart](https://docs.livekit.io/agents/start/voice-ai/).

## Dashboard and playground

LiveKit Cloud gives you two especially useful surfaces while building. The dashboard is your operational control plane, and the playground is your fastest browser-based testing surface when you do not want to open the full app.

### Dashboard

The [LiveKit Cloud dashboard](https://cloud.livekit.io/) is where you create projects, manage API keys, inspect agent deployments, and review operational signals after deployment.

<Cards>
  <Card title="Project and key management">
    Create projects, generate credentials, and manage the values that end up in
    your local env files or cloud secrets.
  </Card>

  <Card title="Deployments and health">
    Review deployment status, session counts, errors, limits, and other agent
    health signals from one place.
  </Card>

  <Card title="Logs and debugging">
    Use the dashboard's runtime and build logs when a deployment starts failing,
    cold starts become visible, or a model provider is misconfigured.
  </Card>
</Cards>

![LiveKit Cloud dashboard](/images/docs/ai/apps/voice/livekit-dashboard.png)

### Playground

The Agents Playground is useful when you want to verify the agent itself before you involve the full product UI. It is especially handy while tuning prompts, testing interruptions, or validating a new STT/TTS provider combination.

You can use the playground against a locally running agent in `dev` mode or a deployed agent in LiveKit Cloud. LiveKit covers this flow in the [Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/).

![LiveKit Agents Playground](/images/docs/ai/apps/voice/playground.png)

## Deployment

The repository already contains the agent build and staging flow, so deployment is less about writing Docker logic and more about understanding what the existing scripts are doing for you.

### `deploy` command

The main entrypoint is the package-level deploy script in `packages/ai/voice/package.json`. It stages a deployment workspace and then hands that staged directory off to the LiveKit CLI.

```bash
pnpm --filter @workspace/ai-voice run deploy
```

### What the staging script does

The file `packages/ai/voice/src/deployment/stage-agent-deploy.ts` prepares a clean `.lk-stage-ai-voice` workspace at the repo root. That staging step keeps the agent deployment isolated from the rest of the monorepo while still letting the Docker build reuse workspace packages.

It copies:

* the root `package.json`
* `pnpm-lock.yaml` and `pnpm-workspace.yaml`
* the `packages` and `tooling` directories
* the agent-specific deployment `Dockerfile`

### How the container build works

The Dockerfile in `packages/ai/voice/src/deployment/Dockerfile` is already set up for the agent. It installs dependencies for `@workspace/ai-voice`, builds the package, pre-downloads model files, and then starts the built worker with the production `start` script.

That means the important work has already been encoded into the repository:

* dependency installation is workspace-aware
* the build only targets `@workspace/ai-voice`
* `download-files` runs during image build
* the final container boots directly into the LiveKit agent worker

### How LiveKit Cloud sees the deployment

After staging, the script runs `lk agent deploy` against `.lk-stage-ai-voice`. LiveKit Cloud then builds the image, stores deployment metadata, and exposes the resulting agent through the dashboard and playground surfaces.

If you are using a cloud deployment, keep provider credentials such as `OPENAI_API_KEY`, `DEEPGRAM_API_KEY`, `CARTESIA_API_KEY`, or `ELEVENLABS_API_KEY` in LiveKit Cloud secrets instead of baking them into the image. LiveKit documents this flow in its [deployment overview](https://docs.livekit.io/deploy/agents/).

### LiveKit project metadata

The staged workspace also contains a `livekit.toml` file once the CLI has linked that deployment to a specific LiveKit project and agent. Think of it as deployment metadata owned by the LiveKit toolchain rather than application code you should hand-edit frequently.

## Structure

The voice feature spans the shared agent package, the API layer, and the two frontends. Keeping these boundaries clear makes it much easier to change models or deployment strategy without rebuilding the UI from scratch.

### Core

The shared voice backend logic lives in `packages/ai/voice/src`. This is where token generation, environment handling, agent instructions, the agent entrypoint, and deployment assets live.

### API

The voice route wiring lives in `packages/api/src/modules/ai/voice.ts`. It sits inside the same Hono request pipeline as the other AI apps, so voice still benefits from shared auth, validation, and credit logic before the request reaches the LiveKit-specific code.

### Web

The web route entrypoints live in `apps/web/src/app/[locale]/(apps)/voice/**`, while the actual feature implementation lives in `apps/web/src/modules/voice/**`. That module tree contains the welcome screen, controls, transcript, session provider, settings, and visualizer components.

### Mobile

The mobile route entrypoints live in `apps/mobile/src/app/(apps)/voice/**`, while the feature logic lives in `apps/mobile/src/modules/voice/**`. That is where the mobile session provider, controls, transcript, video tile, chat composer, and animations are defined.

## Related documentation

Voice sits at the intersection of speech, transcription, provider choice, and realtime product design. These pages are the best next stop if you want to go deeper into one part of the stack.

<Cards>
  <Card href="/ai/docs/speech" title="Speech" description="Learn how to think about TTS quality, latency, and voice UX more broadly." />

  <Card href="/ai/docs/transcription" title="Transcription" description="Understand the STT side of voice products, including accuracy and streaming tradeoffs." />

  <Card href="/ai/docs/providers/eleven-labs" title="ElevenLabs" description="Explore a speech-first provider that can plug into a LiveKit pipeline." />

  <Card href="/ai/docs/providers/openai" title="OpenAI" description="See where realtime and LLM layers fit when you use OpenAI models in voice products." />
</Cards>

## References

These are the best official LiveKit references to keep open while working on the voice app:

* [Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/)
* [Turn handling](https://docs.livekit.io/agents/logic/turns/)
* [STT models](https://docs.livekit.io/agents/models/stt/)
* [TTS models](https://docs.livekit.io/agents/models/tts/)
* [Realtime models](https://docs.livekit.io/agents/models/realtime/)
* [Running LiveKit locally](https://docs.livekit.io/transport/self-hosting/local/)
* [Agent deployment overview](https://docs.livekit.io/deploy/agents/)


# Embeddings
Source: https://www.turbostarter.dev/ai/docs/embeddings

Embeddings let machines represent text as vectors, which makes meaning searchable. Instead of matching exact keywords, you can compare semantic similarity: "pricing page" and "billing plan" may be close together even if they share few words.

That is why embeddings are a core building block for search, recommendations, clustering, deduplication, and especially [RAG](/ai/docs/rag).

<Callout title="If text generation is how models answer, embeddings are often how they find">
  In many modern AI systems, embeddings are the bridge between raw content and
  useful retrieval.
</Callout>

<Cards>
  <Card title="What embeddings are good for">
    Semantic search, knowledge retrieval, document chat, duplicate detection,
    recommendations, and content grouping.
  </Card>

  <Card title="Where they appear in TurboStarter AI">
    The [Knowledge RAG app](/ai/docs/rag) uses embeddings to index uploaded PDFs
    and retrieve relevant chunks before generating an answer.
  </Card>

  <Card title="Best fit">
    Use embeddings when you need "similar meaning", not just "matching words".
  </Card>
</Cards>

## Mental model

Imagine every sentence in your system gets turned into a point in a very high-dimensional space. Sentences about similar ideas land near each other. Queries can be embedded too, and then compared against stored vectors.

That gives you a simple retrieval loop:

<Steps>
  <Step>Split source content into chunks.</Step>
  <Step>Turn each chunk into an embedding vector.</Step>
  <Step>Store the vector alongside the original content.</Step>
  <Step>Embed the user's query.</Step>
  <Step>Retrieve the nearest chunks and pass them into a language model.</Step>
</Steps>

This pattern is the backbone of many retrieval-augmented systems.

## What embeddings are not

It is just as helpful to understand the boundaries of embeddings as it is to understand their strengths. That keeps teams from expecting retrieval systems to behave like answer engines on their own.

<Cards>
  <Card title="Not generation">
    Embeddings do not answer questions by themselves. They are for
    representation and retrieval, not final responses.
  </Card>

  <Card title="Not magic memory">
    Embeddings improve retrieval, but weak chunking, noisy source data, or poor
    ranking can still produce bad context.
  </Card>

  <Card title="Not only for RAG">
    RAG is the most popular use case, but embeddings are also useful for search,
    recommendations, classification pipelines, and analytics.
  </Card>
</Cards>

## Core concepts that matter

A few concepts account for most of the quality difference between a weak embeddings system and a strong one. These are the ideas worth learning first.

<Accordions>
  <Accordion title="Chunking">
    Long documents are typically split into smaller sections before embedding.
    Chunk size and overlap shape retrieval quality more than many teams expect.
  </Accordion>

  <Accordion title="Similarity search">
    Once text is converted into vectors, you compare vectors with metrics like
    cosine similarity or cosine distance to find the closest matches.
  </Accordion>

  <Accordion title="Vector database or vector index">
    You need somewhere to store embeddings and query them efficiently. That can
    be a vector database, or Postgres with `pgvector`, as used in TurboStarter
    AI.
  </Accordion>

  <Accordion title="Recall vs precision">
    Retrieving more chunks increases your chance of finding the right one, but
    also adds noise. Choosing the right threshold and top-k matters.
  </Accordion>

  <Accordion title="Grounding">
    Retrieved chunks should be passed into a text-generation model with clear
    instructions to answer from the supplied context.
  </Accordion>
</Accordions>

## Common stack

A common production-friendly embeddings stack looks like this:

* [LangChain](https://js.langchain.com/) for PDF loading and text splitting
* the AI SDK for `embed` and `embedMany`
* Postgres with [`pgvector`](https://github.com/pgvector/pgvector) or a vector database for similarity search

That is the key production pattern: embed source chunks once, then embed each user query at request time.

If you want to see how this capability is used in the starter, [Knowledge RAG](/ai/docs/rag) is the best companion page.

## AI SDK example

The AI SDK gives you simple building blocks for both query-time embedding and batch indexing. Those two modes cover most real-world embeddings workflows.

<Tabs items={["Single value", "Batch embedding"]}>
  <Tab value="Single value">
    ```ts
    import { openai } from "@ai-sdk/openai";
    import { embed } from "ai";

    const { embedding } = await embed({
      model: openai.embedding("text-embedding-3-small"),
      value: "How do I add AI chat to my SaaS app?",
    });

    console.log(embedding.length);
    ```

    Use this when embedding a single query at request time.
  </Tab>

  <Tab value="Batch embedding">
    ```ts
    import { openai } from "@ai-sdk/openai";
    import { embedMany } from "ai";

    const { embeddings } = await embedMany({
      model: openai.embedding("text-embedding-3-small"),
      values: [
        "TurboStarter supports AI chat.",
        "TurboStarter includes background jobs.",
        "TurboStarter ships with billing integrations.",
      ],
    });

    console.log(embeddings.length);
    ```

    Use this when indexing documents, help center content, or product knowledge in bulk.
  </Tab>
</Tabs>

## Similarity search in plain language

Once you have vectors, you rank documents by "how close" they are to the query vector. In many systems, that means using cosine similarity or cosine distance and then selecting the top few chunks above some quality threshold.

This is one reason `pgvector` has become such a practical choice: many teams can add semantic retrieval to an existing Postgres-backed app without introducing a separate data system on day one.

## Where teams usually go wrong

Embeddings are conceptually simple, but retrieval quality often breaks down in the implementation details. These are some of the most common failure points.

<Cards>
  <Card title="Chunks are too big">
    Large chunks blur topics together and make retrieval less precise. Smaller
    overlapping chunks are often easier to retrieve well.
  </Card>

  <Card title="Everything gets embedded blindly">
    Navigation chrome, repeated headers, or noisy boilerplate can pollute
    retrieval quality.
  </Card>

  <Card title="No retrieval threshold">
    Returning low-similarity chunks can hurt answer quality more than returning
    fewer chunks.
  </Card>

  <Card title="Retrieval is treated as the final answer">
    Retrieved context still needs a generation step that explains, compares, or
    answers in a user-friendly way.
  </Card>
</Cards>

## When to use embeddings

This quick comparison helps separate problems that benefit from semantic retrieval from problems that are better solved with plain generation or deterministic logic.

| Problem                              | Use embeddings? | Why                                                                |
| ------------------------------------ | --------------- | ------------------------------------------------------------------ |
| Find docs related to a user question | Yes             | Semantic similarity is usually better than keyword matching alone. |
| Answer questions from uploaded PDFs  | Yes             | Embeddings help retrieve relevant chunks before generation.        |
| Write a product announcement         | Probably not    | That is primarily a text generation problem.                       |
| Compute an exact invoice total       | No              | This is deterministic logic, not semantic retrieval.               |

## Useful references

These references are a good next step if you want to understand both the practical implementation side and the research ideas behind modern embeddings systems.

* [AI SDK embeddings docs](https://ai-sdk.dev/docs/ai-sdk-core/embeddings)
* [LangChain text splitters](https://js.langchain.com/docs/concepts/text_splitters/)
* [pgvector](https://github.com/pgvector/pgvector)
* [TurboStarter AI RAG docs](/ai/docs/rag)
* [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401)
* [Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks](https://arxiv.org/abs/1908.10084)


# Generating text
Source: https://www.turbostarter.dev/ai/docs/generating-text

Text generation is the foundation of most AI products. It powers chatbots, writing copilots, search assistants, structured extraction, summarization, classification, and agent-style workflows.

What changes from product to product is not whether you are "using text generation", but how you shape the input, what context you supply, how you constrain the output, and what happens after the model responds.

<Cards>
  <Card title="Common outputs">
    Chat replies, summaries, rewrites, labels, outlines, SQL, JSON, and
    multi-step tool decisions all start as text generation tasks.
  </Card>

  <Card title="Where it shows up in TurboStarter AI">
    See it in the [Chat app](/ai/docs/chat), [Knowledge RAG app](/ai/docs/rag),
    and provider guides like [OpenAI](/ai/docs/providers/openai) or
    [Anthropic](/ai/docs/providers/anthropic).
  </Card>

  <Card title="Best fit">
    Use text generation when the result should be language-first: explain,
    answer, transform, compare, classify, or draft.
  </Card>
</Cards>

## Overview

At a practical level, text generation means asking a model to continue or complete a task in natural language. The model can work from:

* a single prompt
* a chat history
* retrieved context from your database or documents
* tool results from external systems
* structured instructions that constrain the output format

That makes text generation much broader than "write me a paragraph". A production system might generate:

* a customer support answer grounded in your docs
* a product description rewritten in your brand voice
* a JSON object for downstream automation
* a step-by-step plan before invoking tools
* a streaming response that feels interactive in the UI

<Callout title="A useful mental model">
  Most AI apps are just text generation plus constraints: context, formatting,
  tools, memory, and UI.
</Callout>

## Common patterns

Most text generation features fall into a small number of recurring patterns. Picking the right one early helps you avoid overengineering or forcing every use case into a chat-shaped UI.

<Cards>
  <Card title="Prompt → response">
    The simplest pattern. Best for copywriting, rewriting, tagging, and one-off
    generation jobs.
  </Card>

  <Card title="Messages → streamed reply">
    The standard chat pattern. Best when users expect conversational
    back-and-forth and low perceived latency.
  </Card>

  <Card title="Retrieved context → grounded answer">
    Used in RAG systems. The model answers from external documents instead of
    relying only on its training data.
  </Card>

  <Card title="Prompt → structured output">
    Best when another system needs to consume the result reliably, for example
    JSON, enums, or extracted fields.
  </Card>

  <Card title="Prompt → tools → final answer">
    Best for assistants that need search, databases, calculators, or third-party
    APIs before they respond.
  </Card>

  <Card title="Prompt → long-running job">
    Useful for reports, content pipelines, and background tasks where a
    synchronous response is not the best UX.
  </Card>
</Cards>

## How to design good text generation features

Strong text features usually come from good product framing, not just better prompts. These design choices tend to matter most once you move beyond toy demos.

<Accordions>
  <Accordion title="1. Start from the job, not the model">
    Define what the user is trying to accomplish. "Answer a question from
    uploaded PDFs" leads to a very different architecture than "draft a
    marketing email" or "extract fields from invoices".
  </Accordion>

  <Accordion title="2. Decide whether you need streaming">
    Streaming improves perceived speed and feels much better for chat, drafting,
    and long answers. For tiny background transformations, a single final
    response is often enough.
  </Accordion>

  <Accordion title="3. Add context deliberately">
    Inject only the context the model needs: user input, system instructions,
    retrieved documents, tool results, or account metadata. Too little context
    hurts accuracy. Too much hurts relevance and cost.
  </Accordion>

  <Accordion title="4. Constrain the output">
    If you need reliable downstream behavior, ask for structured output or
    validate the result after generation. Free-form prose is great for UX, but
    brittle for automation.
  </Accordion>

  <Accordion title="5. Design for failure">
    Plan for rate limits, partial streaming, empty answers, hallucinations, and
    provider outages. Strong AI products handle these gracefully instead of
    pretending the model never fails.
  </Accordion>
</Accordions>

## AI SDK examples

These examples show the two most common starting points. One is best for one-shot tasks, while the other is better when you want the response to feel alive in the UI.

<Tabs items={["Generate once", "Stream in real time"]}>
  <Tab value="Generate once">
    ```ts
    import { generateText } from "ai";
    import { openai } from "@ai-sdk/openai";

    const { text } = await generateText({
      model: openai("gpt-5"),
      prompt: "Summarize this feature request in 3 concise bullet points.",
    });

    console.log(text);
    ```

    This pattern is ideal for short tasks like summarization, rewriting, extraction, and internal automations.
  </Tab>

  <Tab value="Stream in real time">
    ```ts
    import { streamText } from "ai";
    import { openai } from "@ai-sdk/openai";

    const { textStream } = streamText({
      model: openai("gpt-5"),
      prompt: "Draft a launch announcement for a new AI image editor.",
    });

    for await (const textPart of textStream) {
      process.stdout.write(textPart);
    }
    ```

    Streaming is the better fit for chat UIs, copilots, and any experience where responsiveness matters.
  </Tab>
</Tabs>

## Model selection in practice

Most real products do not treat text generation as a single-model feature. They choose different models depending on the task: a faster one for chat, a cheaper one for background jobs, or a more capable one for harder reasoning-heavy requests.

That is a good production pattern to learn from:

* keep provider wiring in one place
* keep product logic separate from provider choice
* add middleware around models for logging, billing, safety, or localization

If you want to see how that idea shows up in this docs set, start with [Chat](/ai/docs/chat), then compare the provider pages like [OpenAI](/ai/docs/providers/openai), [Anthropic](/ai/docs/providers/anthropic), and [Google AI](/ai/docs/providers/google).

## Beginner mistakes to avoid

Many early text-generation features fail for predictable reasons. These are some of the most common traps when teams move from experimentation to real product work.

<Cards>
  <Card title="Treating prompts like magic spells">
    Better prompts help, but product quality usually improves more from better
    context, better constraints, and better retrieval than from prompt tweaks
    alone.
  </Card>

  <Card title="Using one model for every task">
    The best model for fast chat is not always the best one for extraction,
    planning, or background jobs.
  </Card>

  <Card title="Skipping evaluation">
    If the task matters, compare prompts, models, and outputs against real
    examples instead of relying on intuition.
  </Card>
</Cards>

## Related documentation

This capability shows up in several parts of the AI docs because it is the base layer for many other features. These pages are the best next stop if you want to see it in more applied contexts.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="A multi-model conversational assistant with streaming responses, attachments, and web search." />

  <Card href="/ai/docs/rag" title="Knowledge RAG" description="Ground text generation in uploaded PDFs and retrieved document chunks." />

  <Card href="/ai/docs/reasoning" title="Reasoning" description="Use reasoning-capable models when the task benefits from deeper multi-step thinking." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="Extend text generation with external actions, APIs, and system integrations." />
</Cards>

## When to use it

Text generation is powerful, but it should not be stretched to solve every AI problem on its own. The most reliable products know when to pair it with retrieval, tools, or another modality.

<Cards>
  <Card title="Use plain text generation">
    Drafting, rewriting, summarizing, extracting, classifying, and answering
    from provided context are usually text-generation-first problems.
  </Card>

  <Card title="Add retrieval">
    If answers need to come from your documents, tickets, database records, or
    knowledge base, pair generation with embeddings and retrieval.
  </Card>

  <Card title="Add tools">
    If the model must search the web, create records, call APIs, or execute
    workflows, add tool calling instead of hoping the model can infer the
    answer.
  </Card>

  <Card title="Use another modality">
    If the output should be an image, audio file, or transcription, move to a
    modality-specific capability like [Image
    generation](/ai/docs/image-generation) or [Speech](/ai/docs/speech).
  </Card>
</Cards>

## Practical quality checklist

Before shipping a text feature, it helps to pressure-test the basics. A small checklist like this often catches the issues that matter most in production.

* Write instructions that are explicit about tone, scope, and success criteria.
* Give the model the minimum context needed to answer well.
* Prefer streaming for user-facing experiences that may take more than a moment.
* Validate or post-process outputs if another system depends on them.
* Log prompt inputs, model choice, latency, and failures so you can improve the feature over time.

## Learn more

If you want to go deeper, these references cover both practical implementation and the broader ideas that shaped modern text-generation workflows.

* [AI SDK text generation docs](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text)
* [AI SDK streaming guide](https://ai-sdk.dev/docs/foundations/streaming)
* [Prompt engineering guide by OpenAI](https://developers.openai.com/api/docs/guides/prompt-engineering)
* [Language Models are Few-Shot Learners](https://arxiv.org/abs/2005.14165)
* [Chain-of-Thought Prompting Elicits Reasoning in Large Language Models](https://arxiv.org/abs/2201.11903)
* [TurboStarter AI Chat docs](/ai/docs/chat)
* [TurboStarter AI RAG docs](/ai/docs/rag)


# Image generation
Source: https://www.turbostarter.dev/ai/docs/image-generation

Image generation turns natural-language prompts into visual outputs. It is one of the most visible AI capabilities today, but good product design matters just as much as model quality: prompt structure, aspect ratio, moderation, iteration loops, and asset storage all shape the final experience.

<Cards>
  <Card title="What it enables">
    Concept art, product mockups, marketing visuals, avatar creation,
    thumbnails, moodboards, and creative exploration.
  </Card>

  <Card title="Where it appears in TurboStarter AI">
    See the full flow in the [Image playground](/ai/docs/image), including
    prompting, aspect ratios, history, and stored generations.
  </Card>

  <Card title="Best fit">
    Use image generation when the user wants a new visual artifact, not just a
    text description of one.
  </Card>
</Cards>

## Overview

Modern image models can synthesize original visuals from prompts such as:

> Editorial-style portrait of a founder in a minimalist office

> Landing page illustration for a logistics startup, isometric, blue-orange palette

> Packaging mockup for a premium matcha brand, studio lighting

The output is shaped by more than the prompt alone. Common controls include:

* model choice
* aspect ratio
* image count
* style direction
* quality and latency tradeoffs
* post-processing or storage workflow

<Callout title="Image generation is iterative by nature">
  The first result is often a direction, not the final asset. Strong products
  make it easy to adjust prompts, regenerate, compare versions, and save the
  best outcome.
</Callout>

## Common product patterns

Most image-generation products reuse a few familiar interaction models. Understanding these patterns makes it easier to decide whether you are building a creative playground, a workflow tool, or something more structured.

<Cards>
  <Card title="Prompt-to-image playground">
    The classic interface: write a prompt, choose a model, generate one or more
    images, then iterate.
  </Card>

  <Card title="Template-driven generation">
    Great for internal tools. Users fill in fields like subject, brand, mood,
    and aspect ratio instead of writing a raw prompt.
  </Card>

  <Card title="Image generation inside a broader workflow">
    Generate supporting visuals as part of a CMS, campaign builder, ecommerce
    flow, or design review process.
  </Card>

  <Card title="Multi-variant generation">
    Produce several candidates at once so users can choose the best direction
    before refining.
  </Card>

  <Card title="Stored asset pipeline">
    Save generated files to object storage, keep metadata in your database, and
    expose a history UI for future reuse.
  </Card>

  <Card title="Human-in-the-loop review">
    Especially important for brand-sensitive or customer-facing content where
    style, safety, and consistency matter.
  </Card>
</Cards>

## Design considerations

Image generation looks simple on the surface, but a lot of the product quality comes from a few design choices made early. These are the places where teams usually win or lose usability.

<Accordions>
  <Accordion title="Prompt UX matters more than people expect">
    Users often need help describing composition, mood, style, subject, and
    framing. Good defaults, examples, and prompt templates usually improve
    results more than adding more settings.
  </Accordion>

  <Accordion title="Model selection is a product decision">
    Some models are better for speed, others for photorealism, branding,
    illustration, or experimentation. Let the product goal drive the default
    model.
  </Accordion>

  <Accordion title="Storage is part of the feature, not an afterthought">
    If generated assets matter after the first render, you will likely want
    object storage, metadata persistence, and a history browser.
  </Accordion>

  <Accordion title="Safety and moderation need a plan">
    Image generation can produce copyrighted, unsafe, or off-brand results.
    Decide what to block, what to review, and what to log.
  </Accordion>

  <Accordion title="Expect iteration">
    Designers and marketers rarely accept the first output. Build for fast
    retries, prompt edits, and version comparison from the start.
  </Accordion>
</Accordions>

## AI SDK example

This is the basic prompt-to-image shape used in many modern apps. In practice, you would usually wrap this in your own server flow for auth, moderation, and storage.

```ts
import { replicate } from "@ai-sdk/replicate";
import { generateImage } from "ai";
import { writeFile } from "node:fs/promises";

const { image } = await generateImage({
  model: replicate.image("black-forest-labs/flux-schnell"),
  prompt:
    "A cinematic product photo of a matte-black mechanical keyboard on a walnut desk",
  aspectRatio: "16:9",
});

await writeFile("keyboard.webp", image.uint8Array);
```

This is the core pattern behind most image features: choose a provider, pass a prompt plus image-specific options, then display or store the result.

## Choosing image models in practice

Most image products benefit from keeping the UI separate from the underlying provider choice. That makes it much easier to swap defaults, compare providers, or expose different quality and speed tiers without redesigning the experience.

As a general rule:

* pick one default model for the common path
* expose only the settings users can understand
* add more providers only when they create a clear product advantage

If you want implementation-oriented follow-up, the best companion pages are [Image playground](/ai/docs/image), [OpenAI](/ai/docs/providers/openai), [Google AI](/ai/docs/providers/google), and [Replicate](/ai/docs/providers/replicate).

## Prompt engineering

Prompt quality has an outsized effect on image results, especially for new users. A small amount of structure often produces much more usable outputs than an open-ended prompt box.

<Tabs items={["Weak prompt", "Better prompt"]}>
  <Tab value="Weak prompt">
    ```txt
    make a landing page illustration for a startup
    ```

    This leaves too much unspecified, so the model has to guess style, composition, tone, and format.
  </Tab>

  <Tab value="Better prompt">
    ```txt
    Create an isometric landing page illustration for a B2B logistics startup.
    Use a clean SaaS visual style, blue and orange accents, soft shadows,
    warehouse and route motifs, and leave negative space for headline text.
    Aspect ratio 16:9.
    ```

    This gives the model clearer constraints around subject, style, composition, color, and layout intent.
  </Tab>
</Tabs>

## Related documentation

If you want to see how image generation turns into a real product experience, these pages are the best follow-up. They connect the capability itself to concrete provider and app-level guidance.

<Cards>
  <Card href="/ai/docs/image" title="Image playground" description="See a full prompt-to-image flow with history, aspect ratios, and stored outputs." />

  <Card href="/ai/docs/providers/openai" title="OpenAI" description="Explore provider setup for image-capable models from OpenAI." />

  <Card href="/ai/docs/providers/google" title="Google AI" description="Use Google's image-capable models when they fit your product and cost profile." />

  <Card href="/ai/docs/providers/replicate" title="Replicate" description="Access a broad ecosystem of open-source and specialized image models." />
</Cards>

## A simple architecture for production use

Most production image pipelines follow a fairly predictable sequence. The details vary, but the shape below is a good baseline for designing a reliable system.

<Steps>
  <Step>
    Collect the prompt and image options from the client. Keep the UI focused on
    a few controls users actually understand.
  </Step>

  <Step>
    Route the request through your server so provider keys stay private and you
    can add validation, auth, billing, and moderation.
  </Step>

  <Step>Generate the image with your selected provider and model.</Step>

  <Step>
    Store the asset and metadata if the result should be reusable later.
  </Step>

  <Step>
    Return the image plus enough metadata for history, auditing, and future
    iteration.
  </Step>
</Steps>

## Practical quality checklist

This short checklist helps keep an image feature useful and manageable once real users start generating assets at scale.

* Offer prompt examples so users are not starting from a blank box.
* Keep the number of settings small unless your audience is highly technical.
* Store prompt, model, aspect ratio, and timestamps alongside the generated asset.
* Add moderation and error states early, not after launch.
* Make regeneration and side-by-side comparison fast and obvious.

## Research and background

If you want more context on how current image systems work and how they are evaluated, these references are a strong place to start.

* [AI SDK image generation docs](https://ai-sdk.dev/docs/ai-sdk-core/image-generation)
* [High-Resolution Image Synthesis with Latent Diffusion Models](https://arxiv.org/abs/2112.10752)
* [DALL·E 3 system card](https://cdn.openai.com/papers/DALL_E_3_System_Card.pdf)

## Learn more

These companion pages are the most useful next step if you want to move from general understanding to provider setup and app-level implementation.

* [TurboStarter AI Image playground docs](/ai/docs/image)
* [OpenAI provider docs](/ai/docs/providers/openai)
* [Google AI provider docs](/ai/docs/providers/google)
* [Replicate provider docs](/ai/docs/providers/replicate)


# Model Context Protocol (MCP)
Source: https://www.turbostarter.dev/ai/docs/mcp

Model Context Protocol, or MCP, is an emerging standard for connecting models to external tools, data sources, and execution environments. It gives assistants a common way to discover capabilities instead of relying on one-off custom integrations for every app.

If tool calling answers the question "can the model use a function?", MCP answers a broader one: "how do we expose tools and context to models in a consistent, portable way?"

## Overview

Without a standard, every AI integration tends to invent its own tool format, auth flow, transport, and discovery mechanism. That makes ecosystems fragmented and difficult to reuse.

MCP creates a shared protocol for:

* listing available tools and resources
* describing what those tools do
* validating inputs and outputs
* connecting over supported transports
* letting clients and assistants interact with those capabilities in a uniform way

## Why this matters

MCP is important because it shifts AI integration from bespoke glue code toward reusable interfaces. That makes it easier to plug assistants into IDEs, local tools, databases, internal systems, or SaaS platforms without redesigning the entire integration each time.

<Cards>
  <Card title="Standardized access">
    MCP gives models and clients a common contract for tools, resources, and
    structured interactions.
  </Card>

  <Card title="Portable integrations">
    The same MCP server can potentially be used by multiple clients instead of
    being tightly coupled to one product.
  </Card>

  <Card title="Where it connects in these docs">
    MCP fits naturally alongside [Tool calling](/ai/docs/tool-calling),
    [Generating text](/ai/docs/generating-text), and assistant-style
    [Chat](/ai/docs/chat) experiences.
  </Card>
</Cards>

## MCP vs regular tool calling

These concepts are related, but they are not identical. Tool calling is the model behavior. MCP is one way to provide tools and context in a standardized format.

| Concept      | What it focuses on                              | Typical question                                            |
| ------------ | ----------------------------------------------- | ----------------------------------------------------------- |
| Tool calling | Letting the model invoke external capabilities  | "Can the model call this function?"                         |
| MCP          | Standardizing how tools and context are exposed | "How should these capabilities be described and connected?" |

## A simple mental model

You can think of MCP as a protocol layer between AI clients and the systems they want to use. Instead of every client speaking a different dialect, MCP gives them a shared language.

That usually means three actors:

<Steps>
  <Step>An MCP server exposes tools, resources, or prompts.</Step>

  <Step>
    An MCP client connects to that server and discovers what is available.
  </Step>

  <Step>
    A model-enabled app uses those capabilities through the client during
    generation.
  </Step>
</Steps>

## AI SDK example

The AI SDK has support for working with MCP clients and feeding discovered tools into generation. This example shows the general shape without tying it to any specific internal product logic.

```ts
import { createMCPClient } from "@ai-sdk/mcp";
import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";

const transport = new Experimental_StdioMCPTransport({
  command: "node",
  args: ["./server.js"],
});

const client = await createMCPClient({ transport });
const tools = await client.tools();

const result = await generateText({
  model: openai("gpt-4o"),
  tools,
  prompt: "Find products under $100 and summarize the best options.",
  stopWhen: stepCountIs(5),
});

await client.close();
```

The important idea is that the model does not need hardcoded knowledge of each capability. It can discover and use tools through a consistent protocol.

## When MCP is a good fit

MCP is not mandatory for every project. It shines when you want interoperability, reuse, and a cleaner separation between AI clients and backend capabilities.

<Cards>
  <Card title="Use MCP when">
    You want multiple AI clients to share the same tool surface, or you want to
    expose capabilities in a more standardized way.
  </Card>

  <Card title="Maybe skip MCP when">
    You only need one or two internal tools in a single app and a direct
    tool-calling setup is simpler.
  </Card>

  <Card title="Especially useful for">
    IDE assistants, internal copilots, local tooling, multi-client ecosystems,
    and platforms that want plug-in style extensibility.
  </Card>
</Cards>

## Design considerations

Even with a protocol, good interface design still matters. MCP does not remove the need for careful tool and resource design.

<Accordions>
  <Accordion title="Keep capabilities understandable">
    Whether exposed through MCP or not, tools still need clear descriptions,
    good schemas, and predictable outputs.
  </Accordion>

  <Accordion title="Treat auth and permissions seriously">
    Standardized access does not mean unrestricted access. Different tools may
    require different auth, scoping, or approval flows.
  </Accordion>

  <Accordion title="Prefer stable contracts">
    MCP works best when clients can rely on consistent tool names, schemas, and
    behavior over time.
  </Accordion>

  <Accordion title="Separate discovery from business logic">
    Let MCP handle the interface layer, while your actual domain logic stays
    behind well-defined services.
  </Accordion>
</Accordions>

## Where MCP fits in a modern AI stack

MCP is easiest to understand when you place it in the bigger picture. It is not a replacement for models, retrieval, or prompting. It is a way to connect them to external capability surfaces.

<Cards>
  <Card title="With tool calling">
    MCP can supply the tools that the model chooses to call during generation.
  </Card>

  <Card title="With retrieval">
    An MCP server can expose resources or search interfaces that help the model
    get better context.
  </Card>

  <Card title="With assistants">
    IDE copilots, chat assistants, and agent-like systems can all benefit from a
    standardized integration layer.
  </Card>
</Cards>

## Common misconceptions

MCP is powerful, but it helps to be clear about what it does and does not solve. That keeps teams from overcomplicating their architecture too early.

| Misconception                         | Better framing                                                                                   |
| ------------------------------------- | ------------------------------------------------------------------------------------------------ |
| "MCP replaces tool calling."          | MCP is one standardized way to provide tools and context to a model.                             |
| "MCP automatically makes tools safe." | Safety still depends on auth, validation, permissions, and execution policy.                     |
| "Every AI app needs MCP."             | Many apps can start with direct tools and adopt MCP later if interoperability becomes important. |

## Related documentation

If you are learning this capability for the first time, the most useful follow-up is to pair it with tool calling. MCP becomes much easier to reason about when you already understand how models use tools in practice.

<Cards>
  <Card href="/ai/docs/tool-calling" title="Tool calling" description="Start here if you want the practical foundation for model-driven use of external tools." />

  <Card href="/ai/docs/chat" title="Chat" description="Assistant-style chat experiences are one of the most natural places to expose tool and MCP-powered capabilities." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="MCP complements generation by supplying external capabilities and context." />
</Cards>

## Learn more

These references are the best next stop if you want to understand both the protocol and how it plugs into modern AI tooling.

* [Model Context Protocol](https://modelcontextprotocol.io/)
* [AI SDK MCP tools cookbook](https://ai-sdk.dev/cookbook/node/mcp-tools)
* [AI SDK tool calling docs](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
* [TurboStarter AI Tool calling docs](/ai/docs/tool-calling)


# Anthropic
Source: https://www.turbostarter.dev/ai/docs/providers/anthropic

Anthropic is a strong choice when your product leans heavily on assistant-style interaction, nuanced writing, and deeper reasoning-heavy workflows. Claude models are especially popular in products that need thoughtful long-form output, careful tool use, and reliable conversation quality.

If OpenAI often wins on breadth, Anthropic often wins on teams that care most about the feel and quality of the assistant itself.

![Anthropic](/images/docs/ai/providers/anthropic.png)

## Why choose Anthropic

Anthropic tends to be most attractive for text-first products where answer quality, reasoning style, and assistant behavior matter more than broad multimodal coverage.

<Cards>
  <Card title="Strong assistant quality">
    Claude is a natural fit for products centered on chat, explanation,
    synthesis, and careful long-form responses.
  </Card>

  <Card title="Good fit for tool-based assistants">
    Anthropic is often used in assistants that need to reason through steps
    before choosing a tool or producing a final answer.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text),
    [Reasoning](/ai/docs/reasoning), [Tool calling](/ai/docs/tool-calling), and
    [Chat](/ai/docs/chat).
  </Card>
</Cards>

## Setup

Anthropic setup is simple in most AI SDK projects. You mainly need an API key and a clear choice about where Claude should fit in your provider mix.

<Steps>
  <Step>
    Create an API key in the [Anthropic Console](https://console.anthropic.com/).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    ANTHROPIC_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the Anthropic provider in the AI SDK and choose the Claude model that fits your task and latency budget.
  </Step>
</Steps>

## Best fit

Claude is usually most compelling in products that feel more like an assistant than a pure model backend. It is often chosen for quality-sensitive text work rather than breadth across every modality.

<Cards>
  <Card title="Reasoning-heavy chat">
    Strong fit for complex questions, analytical conversations, and
    assistant-style workflows that benefit from careful thinking.
  </Card>

  <Card title="Writing and synthesis">
    Useful for explanations, rewriting, summarization, planning, and structured
    reasoning over complex inputs.
  </Card>

  <Card title="Tool-enabled agents">
    Good fit when the model needs to reason before choosing or sequencing
    external tools.
  </Card>

  <Card title="Multimodal inputs">
    Relevant when you want text workflows that also incorporate image
    understanding or mixed-input reasoning.
  </Card>
</Cards>

## AI SDK example

This example shows the basic Anthropic integration pattern through the AI SDK. In practice, teams often compare Claude against other providers for tasks like chat quality, summarization, and planning.

```ts
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const { text } = await generateText({
  model: anthropic("claude-sonnet-4-5"),
  prompt: "Summarize the tradeoffs of adding RAG to a support assistant.",
});
```

This is a good mental model for Anthropic: it is often chosen when the product needs a strong general text-and-assistant engine more than a huge list of modalities.

## Related documentation

Anthropic is most relevant in the parts of the docs where assistant quality and structured thinking matter. These pages are the best follow-up if that is your main interest.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See where high-quality conversational behavior matters most in end-user experiences." />

  <Card href="/ai/docs/reasoning" title="Reasoning" description="A natural companion page if you are evaluating Claude for more deliberate multi-step tasks." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="See how assistant quality and tool choice interact in agent-style systems." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="Understand the broader product layer Anthropic often powers." />
</Cards>

## When to compare alternatives

Anthropic is a strong provider, but not every product needs what it is best at. Sometimes a broader or more specialized provider will create a better overall fit.

| If you care most about...                     | You may also want to compare              |
| --------------------------------------------- | ----------------------------------------- |
| Broad multimodal coverage in one ecosystem    | [OpenAI](/ai/docs/providers/openai)       |
| Google-native multimodal and Gemini workflows | [Google AI](/ai/docs/providers/google)    |
| Open-source image model access                | [Replicate](/ai/docs/providers/replicate) |

## Learn more

These resources are the best next step if you want to go from high-level provider selection to implementation.

* [Anthropic](https://www.anthropic.com)
* [Anthropic documentation](https://docs.anthropic.com)
* [AI SDK Anthropic provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic)


# DeepSeek
Source: https://www.turbostarter.dev/ai/docs/providers/deepseek

DeepSeek is most often evaluated for text-heavy and reasoning-oriented workloads, especially when teams want another serious option beyond the more commonly used default providers. It is especially relevant in products centered on chat, analysis, and tool-enabled assistants.

For many teams, DeepSeek is not the only provider in the stack. It is a provider worth comparing when quality, reasoning behavior, and cost sensitivity all matter at once.

![DeepSeek](/images/docs/ai/providers/deepseek.webp)

## Why choose DeepSeek

DeepSeek is often attractive when you want a strong text-and-reasoning option in a multi-provider product. It is less about modality breadth and more about fit for language-heavy tasks.

<Cards>
  <Card title="Reasoning-oriented evaluation">
    DeepSeek is commonly evaluated for analytical, explanation-heavy, and
    reasoning-sensitive product flows.
  </Card>

  <Card title="Good fit for text-first products">
    It is most relevant in chat, summarization, planning, coding support, and
    assistant-style workflows.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text),
    [Reasoning](/ai/docs/reasoning), [Tool calling](/ai/docs/tool-calling), and
    [Chat](/ai/docs/chat).
  </Card>
</Cards>

## Setup

DeepSeek setup is similar to most AI SDK-backed providers. The main implementation questions are usually model selection and where it belongs in your provider mix.

<Steps>
  <Step>
    Create an API key on the [DeepSeek platform](https://platform.deepseek.com/).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    DEEPSEEK_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the DeepSeek provider in the AI SDK and compare it against the other text-generation providers in your product.
  </Step>
</Steps>

## Best fit

DeepSeek is usually a text-and-reasoning decision rather than a broad multimodal-platform decision. That makes it easier to position inside the rest of the docs.

<Cards>
  <Card title="Chat and assistant workflows">
    Relevant when you want another strong text-generation provider in a
    conversational product.
  </Card>

  <Card title="Reasoning-heavy tasks">
    Worth evaluating for analysis, planning, and other tasks where model
    behavior under more difficult prompts matters.
  </Card>

  <Card title="Tool-enabled automation">
    Useful in systems where text generation and tool use work together to
    complete multi-step tasks.
  </Card>

  <Card title="Cost-conscious provider mix">
    Often compared when teams want to balance quality and operational cost
    across more than one provider.
  </Card>
</Cards>

## AI SDK example

This example shows the basic DeepSeek integration shape. In practice, teams often compare it directly against OpenAI, Anthropic, or xAI for the same product flow.

```ts
import { generateText } from "ai";
import { deepseek } from "@ai-sdk/deepseek";

const { text } = await generateText({
  model: deepseek("deepseek-chat"),
  prompt:
    "Explain how a support assistant could use RAG and tool calling together.",
});
```

This is the right way to think about DeepSeek in most products: a text- and reasoning-oriented provider you evaluate where those traits matter most.

## Related documentation

DeepSeek maps most naturally to the text-heavy and assistant-oriented parts of the docs. These pages are the best follow-up if you want to place it in a real product context.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See where DeepSeek-style provider comparisons make sense in assistant UX." />

  <Card href="/ai/docs/reasoning" title="Reasoning" description="Compare DeepSeek against other providers for more deliberate multi-step work." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="See how provider choice matters once tools and external actions are involved." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="Understand the broader text-first product layer where DeepSeek is most relevant." />
</Cards>

## When to compare alternatives

DeepSeek is strong in its lane, but if you need a wider modality surface or a more unified ecosystem, another provider may be a better starting point.

| If you care most about...                    | You may also want to compare              |
| -------------------------------------------- | ----------------------------------------- |
| Broad multimodal and audio coverage          | [OpenAI](/ai/docs/providers/openai)       |
| Assistant-style writing and Claude workflows | [Anthropic](/ai/docs/providers/anthropic) |
| Gemini and richer multimodal file workflows  | [Google AI](/ai/docs/providers/google)    |

## Learn more

These references are the best next step if you want to go deeper into DeepSeek-specific setup and implementation.

* [DeepSeek](https://www.deepseek.com/)
* [DeepSeek Platform](https://platform.deepseek.com/)
* [AI SDK DeepSeek provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/deepseek)


# ElevenLabs
Source: https://www.turbostarter.dev/ai/docs/providers/eleven-labs

ElevenLabs is best understood as a speech-first platform rather than a general-purpose text-model provider. It is especially relevant when your product needs realistic voice synthesis, transcription, voice cloning, or broader audio experiences.

That makes ElevenLabs a strong complement to the text- and multimodal-focused providers in the rest of this section. It is often added when audio quality is a product requirement rather than a nice-to-have.

![ElevenLabs](/images/docs/ai/providers/elevenlabs.jpg)

## Why choose ElevenLabs

Teams usually pick ElevenLabs when speech quality, voice control, or audio-specific product UX matters more than using one provider for every modality.

<Cards>
  <Card title="Speech-first platform">
    ElevenLabs is a natural fit when the product centers on TTS, STT, voice
    cloning, or richer audio experiences.
  </Card>

  <Card title="High-quality voice UX">
    It is especially attractive when voice realism and perceived quality are
    central to the product, not just an extra feature.
  </Card>

  <Card title="Best companion pages">
    See [Speech](/ai/docs/speech), [Transcription](/ai/docs/transcription),
    [Text to Speech](/ai/docs/tts), and [Voice](/ai/docs/voice).
  </Card>
</Cards>

## Setup

ElevenLabs is typically integrated through its own SDKs and APIs rather than through the AI SDK core. In most projects, setup is mainly about getting a key and deciding which audio capabilities belong in your product.

<Steps>
  <Step>
    Generate an API key in the [ElevenLabs dashboard](https://elevenlabs.io/).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    ELEVENLABS_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the ElevenLabs SDK or API for the speech workflow you are building, such as TTS, STT, cloning, or conversational audio.
  </Step>
</Steps>

## Best fit

ElevenLabs is the most specialized provider in this section. It is most compelling when your product has an explicit audio surface rather than treating speech as a minor extra.

<Cards>
  <Card title="Text to speech">
    A strong fit for narration, accessibility playback, spoken summaries, and
    any product where voice output quality matters.
  </Card>

  <Card title="Speech to text">
    Useful for transcription, captions, voice input, and audio pipelines that
    feed into summarization or agents.
  </Card>

  <Card title="Voice cloning and design">
    Relevant when your product needs branded voices, character voices, or more
    customized audio identity.
  </Card>

  <Card title="Real-time voice experiences">
    Worth evaluating when live or near-live conversational audio is a meaningful
    part of the user experience.
  </Card>
</Cards>

## SDK example

This example shows the basic pattern of creating a client and using it as the entry point for audio workflows. The specific method you call will depend on whether you are generating speech, transcribing, or working with another audio feature.

```ts
import { ElevenLabsClient } from "elevenlabs";

const client = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});
```

The important design takeaway is that ElevenLabs is usually introduced when audio is important enough to deserve a dedicated provider strategy.

## Related documentation

ElevenLabs maps directly to the speech- and voice-oriented parts of the AI docs. These pages are the best follow-up if you want to see how the provider turns into product features.

<Cards>
  <Card href="/ai/docs/tts" title="Text to Speech" description="See a concrete speech-synthesis product flow with playback, voice selection, and streamed audio." />

  <Card href="/ai/docs/voice" title="Voice" description="See how speech and transcript-like flows fit into real-time conversational experiences." />

  <Card href="/ai/docs/speech" title="Speech" description="Understand the broader capability and product-design side of text-to-speech." />

  <Card href="/ai/docs/transcription" title="Transcription" description="See where speech-to-text fits into audio and assistant workflows." />
</Cards>

## When to compare alternatives

ElevenLabs is excellent for audio, but that specialization also means it is usually one part of a broader stack rather than the only provider in the product.

| If you care most about...                                         | You may also want to compare                                 |
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
| One provider covering text, embeddings, speech, and transcription | [OpenAI](/ai/docs/providers/openai)                          |
| Live conversational voice sessions                                | [Voice](/ai/docs/voice) and the broader real-time stack docs |
| Open-source image or niche model experimentation                  | [Replicate](/ai/docs/providers/replicate)                    |

## Learn more

These are the best next references if you want to move from provider overview into concrete audio implementation.

* [ElevenLabs](https://elevenlabs.io/)
* [ElevenLabs docs](https://elevenlabs.io/docs)
* [ElevenLabs quickstart](https://elevenlabs.io/docs/quickstart)
* [ElevenLabs API reference](https://elevenlabs.io/docs/api-reference/introduction)


# Google AI
Source: https://www.turbostarter.dev/ai/docs/providers/google

Google AI is most compelling when your product benefits from Gemini models, multimodal inputs, embeddings, and broader Google ecosystem familiarity. It is a strong option for teams building assistants that need to work across text, files, images, and retrieval-style workflows.

Google is often worth considering when you want more than pure chat. It becomes especially interesting in products that combine reasoning, files, search grounding, and multimodal interaction.

![Google Generative AI](/images/docs/ai/providers/google.webp)

## Why choose Google AI

Google AI stands out when multimodal understanding and Gemini-specific workflows matter. It is often evaluated as a serious alternative to OpenAI for teams building richer input and grounding experiences.

<Cards>
  <Card title="Gemini ecosystem">
    A strong fit for teams that specifically want Gemini models as the core of
    their AI product.
  </Card>

  <Card title="Multimodal workflows">
    Google is especially relevant when the product needs to work across text,
    files, images, and broader input types.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text), [Image
    generation](/ai/docs/image-generation), [Embeddings](/ai/docs/embeddings),
    and [Chat](/ai/docs/chat).
  </Card>
</Cards>

## Setup

Most projects start with a Google AI Studio key, though larger teams may eventually prefer Google Cloud-style credential flows depending on their architecture.

<Steps>
  <Step>
    Create an API key in [Google AI Studio](https://aistudio.google.com/app/apikey).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    GOOGLE_GENERATIVE_AI_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the Google provider in the AI SDK and choose Gemini models that match your product's latency, reasoning, and modality needs.
  </Step>
</Steps>

## Best fit

Google tends to be most attractive in products that combine text generation with richer context or multimodal inputs. That makes it a practical option for assistants that go beyond plain conversation.

<Cards>
  <Card title="Multimodal assistants">
    Useful when the product needs to understand text, images, files, or mixed
    input sources in one workflow.
  </Card>

  <Card title="Embeddings and retrieval">
    Relevant for semantic search, retrieval, and knowledge-aware experiences.
  </Card>

  <Card title="Grounded workflows">
    Valuable when you want answers that connect to search or other grounded
    information sources.
  </Card>

  <Card title="Image-capable products">
    Worth comparing when your product needs both text and image-oriented flows
    in a shared provider ecosystem.
  </Card>
</Cards>

## AI SDK example

This example shows the core Google AI SDK pattern through Gemini. The same provider can then extend into embeddings, multimodal input, or grounding-heavy workflows.

```ts
import { generateText } from "ai";
import { google } from "@ai-sdk/google";

const { text } = await generateText({
  model: google("gemini-2.5-flash"),
  prompt: "Explain how embeddings help a support-search product.",
});
```

This is a good default mental model for Google AI: a strong provider to evaluate when the product is more multimodal or context-rich than plain text generation.

## Related documentation

Google touches several parts of the AI docs because its strengths map cleanly to multiple capabilities. These are the best follow-up pages if you want to see those patterns in context.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See where Gemini-style conversational behavior fits into assistant UX." />

  <Card href="/ai/docs/image" title="Image playground" description="See where Google image-capable models fit into a full generation experience." />

  <Card href="/ai/docs/embeddings" title="Embeddings" description="Compare Google's fit for retrieval and semantic-search workflows." />

  <Card href="/ai/docs/image-generation" title="Image generation" description="See how provider choice changes the shape of image products." />
</Cards>

## When to compare alternatives

Google is strong, but the best starting provider still depends on the product. In some cases, a provider with broader modality coverage or a more specialized ecosystem may be the better fit.

| If you care most about...                                          | You may also want to compare              |
| ------------------------------------------------------------------ | ----------------------------------------- |
| One provider for text, speech, transcription, and image generation | [OpenAI](/ai/docs/providers/openai)       |
| Assistant-style writing and reasoning quality                      | [Anthropic](/ai/docs/providers/anthropic) |
| Open-source model experimentation                                  | [Replicate](/ai/docs/providers/replicate) |

## Learn more

These references are the best next step if you want to go deeper into Google's provider surface and Gemini-specific implementation details.

* [Google AI](https://ai.google/)
* [Google AI Studio](https://aistudio.google.com/)
* [Google AI docs](https://ai.google.dev/docs)
* [AI SDK Google provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/google-generative-ai)


# AI providers
Source: https://www.turbostarter.dev/ai/docs/providers

Providers are the model backends and capability platforms that actually power your AI features. Choosing the right one affects latency, cost, multimodal support, reasoning quality, tool support, and how much flexibility you have as your product evolves.

There is rarely one perfect provider for everything. Most strong AI products choose a default provider for the common path, then add others only when they create a clear product advantage.

## Overview

This section is meant to help you choose and understand providers, not just configure environment variables. Start with the providers that match your capability needs, then go deeper into setup and app-specific docs when you are ready to implement.

<Cards>
  <Card title="For broadest capability coverage" href="/ai/docs/providers/openai">
    OpenAI is a strong starting point if you want text, vision, speech,
    transcription, embeddings, and image generation in one ecosystem.
  </Card>

  <Card title="For strong Claude-style reasoning and writing" href="/ai/docs/providers/anthropic">
    Anthropic is a natural fit for high-quality writing, deep analysis, and
    assistant-style workflows with tool use.
  </Card>

  <Card title="For Gemini and multimodal workflows" href="/ai/docs/providers/google">
    Google AI is especially relevant when you want Gemini, embeddings, file
    input, and broader multimodal experiences.
  </Card>

  <Card title="For open-source image model access" href="/ai/docs/providers/replicate">
    Replicate is useful when you want a wide range of image and niche community
    models without hosting them yourself.
  </Card>
</Cards>

## Available providers

These pages cover the providers that make the most sense for the current AI section. Each one explains where the provider fits best rather than treating setup as the only thing that matters.

<Cards>
  <Card href="/ai/docs/providers/openai" title="OpenAI" description="General-purpose models across text, vision, speech, transcription, image generation, and embeddings." />

  <Card href="/ai/docs/providers/anthropic" title="Anthropic" description="Claude models for thoughtful writing, analysis, tool use, and assistant-style experiences." />

  <Card href="/ai/docs/providers/google" title="Google AI" description="Gemini, multimodal inputs, embeddings, grounding, and broader Google AI ecosystem support." />

  <Card href="/ai/docs/providers/meta" title="Meta" description="Meta's open-weight model ecosystem, including Llama, for teams that value portability and host choice." />

  <Card href="/ai/docs/providers/xai" title="xAI" description="Grok models for chat, reasoning-oriented workflows, and selected multimodal use cases." />

  <Card href="/ai/docs/providers/deepseek" title="DeepSeek" description="DeepSeek models for cost-sensitive reasoning and text-heavy workflows." />

  <Card href="/ai/docs/providers/replicate" title="Replicate" description="Cloud access to open-source and specialized models, especially for image generation." />

  <Card href="/ai/docs/providers/eleven-labs" title="ElevenLabs" description="Speech-first platform for TTS, STT, voice cloning, and broader audio experiences." />
</Cards>

## Provider selection

Provider choice is usually easier when you anchor it in the product problem instead of the model hype cycle. This quick comparison is a good starting point.

| If you need...                               | A good starting page                         |
| -------------------------------------------- | -------------------------------------------- |
| One provider with broad modality coverage    | [OpenAI](/ai/docs/providers/openai)          |
| Strong writing and assistant-style reasoning | [Anthropic](/ai/docs/providers/anthropic)    |
| Gemini and multimodal Google workflows       | [Google AI](/ai/docs/providers/google)       |
| Open-source image models and experimentation | [Replicate](/ai/docs/providers/replicate)    |
| Speech-first product features                | [ElevenLabs](/ai/docs/providers/eleven-labs) |
| Open-weight model flexibility                | [Meta](/ai/docs/providers/meta)              |

## Related capabilities

Provider pages are most useful when read alongside the capability pages. That is where you can see how provider choice maps to actual product features.

<Cards>
  <Card href="/ai/docs/generating-text" title="Generating text" description="Understand where provider choice matters for chat, writing, and structured output." />

  <Card href="/ai/docs/image-generation" title="Image generation" description="Compare providers based on image quality, model ecosystem, and product fit." />

  <Card href="/ai/docs/embeddings" title="Embeddings" description="See which providers matter when retrieval and semantic search are involved." />

  <Card href="/ai/docs/speech" title="Speech" description="Compare provider ecosystems for TTS, voice UX, and audio generation." />
</Cards>


# Meta
Source: https://www.turbostarter.dev/ai/docs/providers/meta

Meta is different from the other providers in this section because its AI story is centered on open-weight models rather than a single hosted platform. In practice, that usually means accessing Llama through a third-party host such as DeepInfra, Fireworks, Bedrock, or another compatible provider.

That makes Meta especially interesting for teams that care about ecosystem choice, provider portability, or open-model strategy rather than a single managed API surface.

![Meta](/images/docs/ai/providers/meta.jpg)

## Why choose Meta

Teams usually choose Meta's model ecosystem when they want more flexibility around hosting, pricing, model access, or open-model experimentation. It is less about one official vendor experience and more about keeping options open.

<Cards>
  <Card title="Open-weight flexibility">
    Meta's open models are attractive when you want the option to choose from
    multiple hosts instead of depending on one provider platform.
  </Card>

  <Card title="Good for text-first workflows">
    It is commonly evaluated for chat, generation, code assistance, and
    tool-using assistant scenarios.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text), [Tool
    calling](/ai/docs/tool-calling), and [Chat](/ai/docs/chat).
  </Card>
</Cards>

## Setup

Because Llama is usually hosted by third parties, setup starts by choosing a host rather than going directly to Meta. Your environment variables and model IDs then depend on that host.

<Steps>
  <Step>
    Choose a hosting provider such as DeepInfra, Fireworks, or Amazon Bedrock.
  </Step>

  <Step>
    Add the relevant credentials to your environment. For example:

    ```bash title=".env"
    DEEPINFRA_API_KEY=your-api-key
    # or
    FIREWORKS_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use that host's AI SDK provider to access the Llama model that fits your product.
  </Step>
</Steps>

## Best fit

Meta is most interesting when you care about provider optionality, open-model ecosystems, or experimenting with different hosting paths while staying within familiar AI SDK patterns.

<Cards>
  <Card title="Chat and text generation">
    A natural fit for text-first assistants, writing flows, and internal
    productivity tools.
  </Card>

  <Card title="Code-related workflows">
    Often evaluated for coding assistants, explanation, and developer tooling
    depending on the specific hosted model.
  </Card>

  <Card title="Tool use and agents">
    Relevant when you want open-weight model options for tool-calling and
    assistant-style systems.
  </Card>

  <Card title="Provider flexibility">
    Useful when architecture or procurement constraints make host portability
    more important than using a single closed model platform.
  </Card>
</Cards>

## AI SDK example

This example shows the general idea using a hosted Meta model through a provider integration. The exact provider and model ID will vary based on the host you choose.

```ts
import { generateText } from "ai";
import { deepinfra } from "@ai-sdk/deepinfra";

const { text } = await generateText({
  model: deepinfra("meta-llama/Meta-Llama-3.1-8B-Instruct"),
  prompt: "Explain the benefits of using tool calling in a support assistant.",
});
```

The important thing to remember is that with Meta's open models, host choice is part of provider choice.

## Related documentation

Meta is mostly relevant in the text- and assistant-oriented parts of the docs. These pages are the best next stop if you want to understand where its models could fit into an end-user product.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See where a hosted Llama model could fit into a conversational assistant flow." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="Compare Llama against closed providers in text-first product scenarios." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="See how open-weight model strategies interact with external tools and workflows." />
</Cards>

## When to compare alternatives

Meta's ecosystem is flexible, but that does not automatically make it the best starting point. If you want a more unified, managed experience, another provider may get you moving faster.

| If you care most about...             | You may also want to compare                 |
| ------------------------------------- | -------------------------------------------- |
| Broad managed capability coverage     | [OpenAI](/ai/docs/providers/openai)          |
| Assistant-style writing and reasoning | [Anthropic](/ai/docs/providers/anthropic)    |
| Speech and audio workflows            | [ElevenLabs](/ai/docs/providers/eleven-labs) |

## Learn more

These references are useful if you want to evaluate Meta's models through the hosts and provider surfaces that actually make them available in practice.

* [Meta AI](https://ai.meta.com/)
* [Llama](https://ai.meta.com/llama/)
* [AI SDK providers directory](https://sdk.vercel.ai/providers)


# OpenAI
Source: https://www.turbostarter.dev/ai/docs/providers/openai

OpenAI is one of the broadest general-purpose providers in the current AI ecosystem. It is often the simplest starting point when you want one provider that can cover chat, reasoning, vision, speech, transcription, embeddings, and image generation.

That breadth makes OpenAI especially useful for teams that want to move quickly without stitching together several providers on day one.

![OpenAI](/images/docs/ai/providers/openai.png)

## Why choose OpenAI

OpenAI is usually the default pick when teams want strong coverage across multiple AI capabilities, mature tooling, and a straightforward path from prototype to production.

<Cards>
  <Card title="Broad modality support">
    OpenAI is relevant across text generation, image generation, transcription,
    speech, embeddings, tool calling, and multimodal apps.
  </Card>

  <Card title="Strong fit for product teams">
    It is a practical choice when you want fewer moving parts and a single
    provider to support many product experiments.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text), [Image
    generation](/ai/docs/image-generation), [Embeddings](/ai/docs/embeddings),
    [Speech](/ai/docs/speech), and [Transcription](/ai/docs/transcription).
  </Card>
</Cards>

## Setup

Getting started with OpenAI is straightforward. In most projects, setup is mainly about generating a key, storing it in your environment, and choosing the right model for each task.

<Steps>
  <Step>
    Create an API key in the [OpenAI API dashboard](https://platform.openai.com/api-keys).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    OPENAI_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the OpenAI provider through the AI SDK and choose the right model for the capability you are building.
  </Step>
</Steps>

## Best fit

OpenAI is most compelling when you want one provider that can support multiple product surfaces without switching ecosystems every time the feature changes.

<Cards>
  <Card title="Chat and text generation">
    Strong fit for assistants, copilots, drafting, summarization, structured
    output, and many tool-using workflows.
  </Card>

  <Card title="Embeddings and retrieval">
    A practical choice for RAG, semantic search, clustering, and relevance-based
    workflows.
  </Card>

  <Card title="Speech and transcription">
    Useful when you want text-to-speech or speech-to-text inside the same
    broader AI stack.
  </Card>

  <Card title="Image generation">
    Relevant when your product needs prompt-to-image flows in the same provider
    ecosystem as text and audio.
  </Card>
</Cards>

## AI SDK example

This example shows the simplest OpenAI text-generation shape through the AI SDK. The same provider can then extend into images, embeddings, or audio depending on the feature.

```ts
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

const { text } = await generateText({
  model: openai("gpt-5"),
  prompt: "Write a short product description for an AI meeting assistant.",
});
```

The main lesson is not the exact model name. It is that OpenAI is often chosen when a team wants one provider to support several product directions.

## Related documentation

OpenAI appears across several capability pages because it spans more than one mode of interaction. These are the best follow-up pages if you want to explore actual product patterns.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="A natural place to use OpenAI for general-purpose text generation and tool-based workflows." />

  <Card href="/ai/docs/image" title="Image playground" description="See how image generation fits into a full prompt-to-image product flow." />

  <Card href="/ai/docs/tts" title="Text to Speech" description="See where OpenAI-style speech generation sits alongside a dedicated audio provider strategy." />

  <Card href="/ai/docs/voice" title="Voice" description="See how real-time voice experiences differ from simple text-to-speech or transcription." />
</Cards>

## When to compare alternatives

OpenAI is broad, but that does not mean it is always the best fit. In some products, a more specialized or cheaper provider may be the better starting point.

| If you care most about...                   | You may also want to compare                 |
| ------------------------------------------- | -------------------------------------------- |
| Claude-style writing and assistant behavior | [Anthropic](/ai/docs/providers/anthropic)    |
| Gemini and Google multimodal workflows      | [Google AI](/ai/docs/providers/google)       |
| Open-source image ecosystem access          | [Replicate](/ai/docs/providers/replicate)    |
| Dedicated voice and audio workflows         | [ElevenLabs](/ai/docs/providers/eleven-labs) |

## Learn more

These are the most useful next references if you want to move from provider overview to implementation details.

* [OpenAI Platform](https://openai.com/)
* [OpenAI API docs](https://developers.openai.com/api/docs/overview)
* [AI SDK OpenAI provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/openai)


# Replicate
Source: https://www.turbostarter.dev/ai/docs/providers/replicate

Replicate is different from the frontier-model platforms in this section because it is primarily a model-hosting ecosystem. It is especially useful when you want access to a wide range of open-source and specialized models without managing the infrastructure yourself.

That makes Replicate one of the most practical choices for teams building image products or experimenting with niche models that are not available through the larger general-purpose providers.

![Replicate](/images/docs/ai/providers/replicate.png)

## Why choose Replicate

Replicate is usually chosen for model diversity rather than for being the single provider for an entire AI stack. It shines when experimentation, image workflows, or specialized model access matter.

<Cards>
  <Card title="Open-source model access">
    Replicate gives teams cloud access to a large catalog of community and
    specialized models without self-hosting them.
  </Card>

  <Card title="Strong image-product fit">
    It is especially useful in image-generation workflows where model variety
    matters more than staying inside one closed provider ecosystem.
  </Card>

  <Card title="Best companion pages">
    See [Image generation](/ai/docs/image-generation), [Image
    playground](/ai/docs/image), and [Speech](/ai/docs/speech) if you are
    exploring broader model experimentation.
  </Card>
</Cards>

## Setup

Replicate setup is simple and usually starts with a single API token. The bigger product decision is which models to expose and how much provider-specific configuration you want to surface in the UI.

<Steps>
  <Step>
    Generate a token in your [Replicate account settings](https://replicate.com/).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    REPLICATE_API_TOKEN=your-api-key
    ```
  </Step>

  <Step>
    Use the Replicate provider in the AI SDK and select the model that matches the job you are solving.
  </Step>
</Steps>

## Best fit

Replicate is best thought of as a gateway to model variety. It becomes attractive when a product needs more experimentation or niche capability than a single default provider offers.

<Cards>
  <Card title="Image generation">
    The clearest fit. Replicate is especially useful when your product depends
    on image models with different styles, tradeoffs, or specialties.
  </Card>

  <Card title="Specialized model experiments">
    Useful when you want to test a narrower model for a specific task instead of
    relying only on one general-purpose provider.
  </Card>

  <Card title="Provider diversity">
    A good addition when your stack already has a main text provider but you
    want broader model choice for other modes.
  </Card>

  <Card title="Fast iteration">
    Helpful when the team wants to compare several hosted models before deciding
    which one deserves a deeper integration.
  </Card>
</Cards>

## AI SDK example

This example shows the basic Replicate image-generation pattern through the AI SDK. It captures the main reason most teams add Replicate in the first place.

```ts
import { generateImage } from "ai";
import { replicate } from "@ai-sdk/replicate";

const { image } = await generateImage({
  model: replicate.image("black-forest-labs/flux-schnell"),
  prompt: "A clean SaaS dashboard hero illustration in blue and orange",
  aspectRatio: "16:9",
});
```

The main lesson here is that Replicate is often the right answer when model variety matters as much as model quality.

## Related documentation

Replicate connects most directly to the image-oriented parts of the AI docs. These pages are the best follow-up if that is the product surface you care about most.

<Cards>
  <Card href="/ai/docs/image" title="Image playground" description="See where open-source image models turn into a real prompt-to-image product flow." />

  <Card href="/ai/docs/image-generation" title="Image generation" description="Compare Replicate against the broader set of image-capable providers." />

  <Card href="/ai/docs/providers/openai" title="OpenAI" description="Compare Replicate's model variety against a more unified provider ecosystem." />

  <Card href="/ai/docs/providers/google" title="Google AI" description="Compare Replicate to a provider that approaches image generation from a broader multimodal platform angle." />
</Cards>

## When to compare alternatives

Replicate is powerful, but not every product needs a large model catalog. If a unified provider experience matters more than model breadth, another choice may be simpler.

| If you care most about...                           | You may also want to compare                 |
| --------------------------------------------------- | -------------------------------------------- |
| One provider for text, image, audio, and embeddings | [OpenAI](/ai/docs/providers/openai)          |
| Gemini and broader multimodal workflows             | [Google AI](/ai/docs/providers/google)       |
| Speech-first product surfaces                       | [ElevenLabs](/ai/docs/providers/eleven-labs) |

## Learn more

These references are the best next step if you want provider-specific setup details or want to browse the model ecosystem directly.

* [Replicate](https://replicate.com)
* [Replicate docs](https://replicate.com/docs)
* [AI SDK Replicate provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/replicate)


# xAI Grok
Source: https://www.turbostarter.dev/ai/docs/providers/xai

xAI is most relevant when you want to evaluate Grok models as part of a modern AI product stack. It is typically considered for chat, reasoning-flavored interaction, tool-enabled assistants, and selected multimodal workflows.

For most teams, xAI is not the first provider they integrate, but it can be a worthwhile comparison point when model behavior and provider diversity matter.

![xAI Grok](/images/docs/ai/providers/xai.webp)

## Why choose xAI

xAI tends to matter most when a team wants to compare Grok against other frontier-style model providers rather than committing immediately to a single default ecosystem.

<Cards>
  <Card title="Useful as a comparison provider">
    xAI is often evaluated alongside OpenAI, Anthropic, and Google for
    conversational and assistant-style product behavior.
  </Card>

  <Card title="Relevant for multimodal products">
    Depending on the product surface, xAI may also be relevant for image-related
    or richer multimodal workflows.
  </Card>

  <Card title="Best companion pages">
    See [Generating text](/ai/docs/generating-text), [Tool
    calling](/ai/docs/tool-calling), [Reasoning](/ai/docs/reasoning), and
    [Chat](/ai/docs/chat).
  </Card>
</Cards>

## Setup

xAI setup is similar to most AI SDK-backed providers: generate a key, store it securely, and choose the Grok model that fits your task.

<Steps>
  <Step>
    Create an API key from the [xAI platform](https://x.ai).
  </Step>

  <Step>
    Add it to your environment:

    ```bash title=".env"
    XAI_API_KEY=your-api-key
    ```
  </Step>

  <Step>
    Use the xAI provider in the AI SDK and compare Grok models against the other providers in your stack.
  </Step>
</Steps>

## Best fit

xAI is usually best understood as part of a provider comparison set. It becomes useful when your product needs another strong option for chat, tool use, or model diversity rather than a specialized niche capability.

<Cards>
  <Card title="Conversational interfaces">
    Relevant for chat and assistant flows where you want to evaluate Grok's
    interaction style against other providers.
  </Card>

  <Card title="Tool-enabled assistants">
    Worth comparing for assistant scenarios where external tools or system
    integrations are part of the experience.
  </Card>

  <Card title="Reasoning-oriented evaluation">
    Depending on the task, xAI can be part of the set you compare for deeper
    multi-step responses.
  </Card>

  <Card title="Image-capable workflows">
    In some products, xAI may also be relevant where text and image generation
    live in the same provider evaluation set.
  </Card>
</Cards>

## AI SDK example

This example shows the basic xAI integration pattern in the AI SDK. In practice, teams usually use it as one option inside a broader provider comparison strategy.

```ts
import { generateText } from "ai";
import { xai } from "@ai-sdk/xai";

const { text } = await generateText({
  model: xai("grok-3-mini-fast"),
  prompt: "Summarize the risks of adding too many tools to an AI assistant.",
});
```

This is the right mental model for xAI in product work: one provider in a broader frontier-model toolbox, not necessarily the only backend in the system.

## Related documentation

The most useful way to explore xAI in these docs is through the capability pages where provider tradeoffs are most visible. These pages are the best next follow-up.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See where provider personality and interaction style matter most in user-facing experiences." />

  <Card href="/ai/docs/reasoning" title="Reasoning" description="Compare xAI against other providers for deeper, more deliberate tasks." />

  <Card href="/ai/docs/image-generation" title="Image generation" description="Evaluate where xAI belongs in multimodal or image-aware product decisions." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="See how provider choice affects tool-enabled assistant design." />
</Cards>

## When to compare alternatives

xAI can be valuable, but most teams will still want to compare it against more established defaults before making it the primary provider in a product.

| If you care most about...              | You may also want to compare              |
| -------------------------------------- | ----------------------------------------- |
| Broad managed capability coverage      | [OpenAI](/ai/docs/providers/openai)       |
| Assistant-style writing quality        | [Anthropic](/ai/docs/providers/anthropic) |
| Gemini and Google multimodal ecosystem | [Google AI](/ai/docs/providers/google)    |

## Learn more

These links are the best next stop if you want provider-specific implementation details.

* [xAI](https://x.ai)
* [AI SDK xAI provider docs](https://sdk.vercel.ai/providers/ai-sdk-providers/xai)


# Reasoning
Source: https://www.turbostarter.dev/ai/docs/reasoning

Reasoning models are designed for tasks that benefit from deeper multi-step thinking: planning, comparison, synthesis, troubleshooting, tool selection, and decisions that are too brittle for a fast one-shot answer.

That does not mean you should turn reasoning on for everything. In practice, reasoning is a tradeoff between quality, latency, and cost.

<Cards>
  <Card title="Where reasoning helps most">
    Complex questions, ambiguous requests, code analysis, planning, and tasks
    that require multiple intermediate steps.
  </Card>

  <Card title="Where it appears in TurboStarter AI">
    The [Chat app](/ai/docs/chat) supports reasoning-capable models and can
    surface reasoning-related usage in the UI.
  </Card>

  <Card title="Best fit">
    Use reasoning when the task is hard enough that extra deliberation is likely
    to improve the answer.
  </Card>
</Cards>

## Overview

For most teams, reasoning is not about exposing private chain-of-thought. It is about choosing models and settings that spend more effort on:

* decomposing a problem
* checking assumptions
* comparing alternatives
* working through constraints
* deciding which tool or strategy to use next

That often leads to better outcomes for difficult tasks, but with higher latency and sometimes higher cost.

<Callout title="A product-friendly definition">
  Reasoning is extra thinking budget for hard tasks, not a feature to enable
  blindly across your whole app.
</Callout>

## Use cases

Reasoning is most valuable when the task actually benefits from extra deliberation. This quick comparison helps separate genuinely reasoning-heavy work from tasks that are better handled by faster models.

| Task                                               | Use reasoning? | Why                                                              |
| -------------------------------------------------- | -------------- | ---------------------------------------------------------------- |
| Debug a production incident from logs and symptoms | Yes            | The model needs to compare hypotheses and work through evidence. |
| Summarize a short meeting note                     | Usually no     | A fast model is often enough.                                    |
| Plan a migration with constraints and tradeoffs    | Yes            | This benefits from deeper structured thinking.                   |
| Rewrite a paragraph in a friendlier tone           | No             | This is mainly a generation task, not a reasoning-heavy one.     |

## How to think about reasoning in UX

Reasoning is not just a model setting. It also changes the experience of using the product, especially around latency, confidence, and how much internal process you expose to the user.

<Accordions>
  <Accordion title="Users do not always need to see the full thinking process">
    What users often want is confidence, not raw internal deliberation.
    Summaries, cited evidence, and clear conclusions are usually better UX than
    dumping intermediate reasoning.
  </Accordion>

  <Accordion title="Reasoning should feel intentional">
    If a response takes longer, the UI should communicate why: a thinking
    indicator, staged streaming, or a clear "analyzing" state helps set
    expectations.
  </Accordion>

  <Accordion title="Reasoning should be selective">
    Applying reasoning only to hard requests is often a better product choice
    than enabling it globally.
  </Accordion>

  <Accordion title="Reasoning still needs grounding">
    A model that thinks longer can still hallucinate. Retrieval, tools,
    validation, and citations still matter.
  </Accordion>
</Accordions>

## Product patterns

In many AI products, reasoning is primarily a chat or assistant concern. A typical implementation:

* supports reasoning-capable chat models
* passes provider-specific reasoning options when the user enables reasoning
* streams reasoning-aware responses into the chat UI
* tracks reasoning token usage separately

That is a strong pattern for production systems: if reasoning has a cost profile, you should measure it explicitly.

If you want to see where this capability shows up in this docs set, start with [Chat](/ai/docs/chat), then compare it with [Generating text](/ai/docs/generating-text) and [Tool calling](/ai/docs/tool-calling).

## AI SDK usage pattern

Provider support varies, but the core idea is to pass reasoning-related options through provider configuration when the task warrants it.

```ts
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

const result = await generateText({
  model: openai("gpt-5"),
  prompt:
    "Compare two migration strategies for moving a SaaS app to a monorepo.",
  providerOptions: {
    openai: {
      reasoningEffort: "medium",
    },
  },
});
```

The important idea is not the exact option name. It is the product decision behind it: harder tasks may justify a slower, more deliberate model run.

## Decision framework

If you are unsure whether reasoning belongs in a feature, a lightweight decision process usually helps. This keeps reasoning intentional instead of becoming the default for every request.

<Steps>
  <Step>Ask whether the task is genuinely multi-step or ambiguity-heavy.</Step>

  <Step>
    Decide whether better reasoning quality is worth extra latency and cost.
  </Step>

  <Step>
    Add retrieval or tools if the task needs outside information or actions.
  </Step>

  <Step>
    Surface the answer in a user-friendly way, with evidence or a concise
    reasoning summary when helpful.
  </Step>

  <Step>
    Track usage, latency, and success rates so you know whether reasoning is
    paying off.
  </Step>
</Steps>

## When not to use reasoning

Some tasks feel complex, but the real answer is not "add more reasoning". In many cases, speed, deterministic logic, or better context will matter more.

<Cards>
  <Card title="Fast, repeatable transformations">
    Simple rewrites, summaries, formatting tasks, and classification are often
    better served by fast models without extra reasoning overhead.
  </Card>

  <Card title="Deterministic business logic">
    Taxes, permissions, billing rules, and policy enforcement should be encoded
    in software, not delegated to model reasoning.
  </Card>

  <Card title="When the real problem is missing context">
    If the model lacks the right documents, tool results, or system state, more
    reasoning alone will not fix it.
  </Card>
</Cards>

## Related capabilities

Reasoning rarely stands alone. It is usually layered on top of other capabilities that provide context, actions, or the final user-facing output.

<Cards>
  <Card href="/ai/docs/generating-text" title="Generating text" description="Reasoning is often layered on top of text generation, not a separate product category." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="Reasoning becomes more useful when the model can decide when to search, calculate, or call external systems." />

  <Card href="/ai/docs/rag" title="Knowledge RAG" description="For many tasks, retrieval matters as much as reasoning quality." />
</Cards>

## Useful references

These resources are helpful if you want to understand both the practical product tradeoffs and the research conversation around reasoning in language models.

* [Anthropic's article on extended thinking](https://www.anthropic.com/engineering/claude-think-tool)
* [OpenAI prompting guide](https://developers.openai.com/api/docs/guides/prompt-engineering)
* [Chain-of-Thought Prompting Elicits Reasoning in Large Language Models](https://arxiv.org/abs/2201.11903)
* [Large Language Models are Zero-Shot Reasoners](https://arxiv.org/abs/2205.11916)
* [TurboStarter AI Chat docs](/ai/docs/chat)


# Speech
Source: https://www.turbostarter.dev/ai/docs/speech

Speech synthesis turns text into audio that sounds spoken rather than written. It is the capability behind narration, voice assistants, accessibility playback, character voices, and many real-time conversational experiences.

In modern products, speech is rarely just "read this text out loud". The best experiences consider voice selection, latency, emotional tone, playback controls, and whether the audio is a one-off file or part of a live conversation.

## Overview

At its core, speech synthesis means taking text as input and producing audio as output. That audio can be generated as a single file, streamed in chunks, or produced in real time as part of an interactive voice system.

Most speech products involve some combination of:

* converting text into spoken audio
* selecting a voice or speaker profile
* controlling style, pace, or delivery
* streaming or downloading the result
* playing the result inside an app or assistant

## Where speech is useful

Speech is especially useful when reading is not the best interface. It adds reach, accessibility, and a stronger sense of presence than text alone.

<Cards>
  <Card title="Good fit">
    Accessibility playback, narration, reading assistants, virtual characters,
    voice UIs, and spoken summaries are all strong speech use cases.
  </Card>

  <Card title="Where it appears in these docs">
    See [Text to Speech](/ai/docs/tts), [Voice](/ai/docs/voice), and the [Eleven
    Labs](/ai/docs/providers/eleven-labs) provider guide for more applied
    follow-up.
  </Card>

  <Card title="Not always needed">
    If the user only needs silent, skimmable output, plain text is often faster,
    cheaper, and easier to control.
  </Card>
</Cards>

## Speech vs voice vs transcription

These terms are related, but they refer to different capabilities. Keeping them separate makes it easier to design the right system.

| Capability       | Input                | Output                   | Best for                                            |
| ---------------- | -------------------- | ------------------------ | --------------------------------------------------- |
| Speech synthesis | Text                 | Audio                    | Narration, playback, spoken responses               |
| Transcription    | Audio                | Text                     | Captions, notes, search, voice input                |
| Real-time voice  | Audio and text turns | Interactive conversation | Voice assistants, live agents, low-latency sessions |

## AI SDK example

The AI SDK includes a speech generation API for provider-backed text-to-speech flows. The example below shows the basic shape: choose a speech model, send text, and receive audio.

```ts
import { experimental_generateSpeech as generateSpeech } from "ai";
import { openai } from "@ai-sdk/openai";

const { audio } = await generateSpeech({
  model: openai.speech("tts-1"),
  text: "Hello from the AI SDK!",
  voice: "alloy",
});

console.log(audio);
```

This is the foundation for many speech features. In a real app, you would usually stream or save the audio rather than just logging it.

## Common product patterns

Speech features tend to fall into a few common UX patterns. Choosing the right one depends on whether the user wants playback, interaction, or audio as a generated asset.

<Cards>
  <Card title="Text-to-speech player">
    The user enters text, chooses a voice, and plays or downloads the result.
    This is the most common TTS pattern.
  </Card>

  <Card title="Narration layer">
    The app adds optional speech playback to written content such as articles,
    summaries, or onboarding instructions.
  </Card>

  <Card title="Voice response layer">
    A text or chat system generates the answer, then speech synthesis reads it
    aloud for a more immersive interaction.
  </Card>

  <Card title="Real-time conversation">
    Speech synthesis is used as one piece of a live assistant flow alongside
    transcription, turn-taking, and session control.
  </Card>
</Cards>

## Design considerations

Speech can feel magical in demos, but product quality usually comes down to a few practical decisions. These are the areas worth thinking through up front.

<Accordions>
  <Accordion title="Latency changes the experience">
    For narration, a short wait is fine. For assistants or live responses,
    latency has a much bigger effect on whether the interaction feels natural.
  </Accordion>

  <Accordion title="Voice choice is part of the product">
    The voice communicates brand, tone, and trust. A great voice for
    accessibility may not be the right one for a playful character or a support
    assistant.
  </Accordion>

  <Accordion title="Playback matters too">
    Speech quality alone is not enough. Users often need pause, replay, speed
    control, and download options.
  </Accordion>

  <Accordion title="Text needs audio-aware formatting">
    Text written for reading is not always pleasant to hear. Long sentences,
    timestamps, code, or URLs may need pre-processing before synthesis.
  </Accordion>

  <Accordion title="Streaming changes UX expectations">
    If speech is streamed as it is generated, the product can feel much more
    alive, but error handling and buffering become more important.
  </Accordion>
</Accordions>

## Beginner mistakes to avoid

Many weak speech features fail for predictable reasons. The model may be good, but the experience still feels awkward if the surrounding design is poor.

<Cards>
  <Card title="Reading raw text without cleanup">
    Lists, links, code snippets, and long machine-written sentences often sound
    unnatural if sent directly to speech synthesis.
  </Card>

  <Card title="Ignoring playback controls">
    Even high-quality audio becomes frustrating if users cannot pause, replay,
    or adjust speed.
  </Card>

  <Card title="Choosing a voice without context">
    The same voice can feel warm, robotic, premium, or wrong depending on the
    product and audience.
  </Card>

  <Card title="Using speech where silence is better">
    Some tasks are simply easier to scan in text than to hear in audio. Speech
    should add value, not just novelty.
  </Card>
</Cards>

## Related documentation

In this docs set, speech is best understood through the app and provider pages that turn the capability into concrete product flows. These are the best places to continue once you understand the fundamentals.

<Cards>
  <Card href="/ai/docs/tts" title="Text to Speech" description="See a full speech-synthesis experience with voice selection, playback, and streaming audio." />

  <Card href="/ai/docs/voice" title="Voice" description="See how speech fits into a real-time conversational assistant alongside transcripts and session control." />

  <Card href="/ai/docs/providers/eleven-labs" title="Eleven Labs" description="Explore a provider focused on realistic voice synthesis, cloning, and broader audio workflows." />

  <Card href="/ai/docs/providers/openai" title="OpenAI" description="See provider-level speech generation support in the broader OpenAI capabilities surface." />
</Cards>

## A practical checklist

Before shipping a speech feature, it helps to test whether the output sounds good, behaves well, and actually improves the product rather than just adding novelty.

* Pick voices that match the product tone and audience.
* Clean up text before sending it to speech synthesis.
* Add playback controls early, not as an afterthought.
* Measure latency if the experience is interactive.
* Decide whether audio should be streamed, downloaded, or stored.

## Learn more

These references are useful if you want to go deeper into both implementation and product design for speech features.

* [AI SDK speech generation reference](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-speech)
* [TurboStarter AI Text to Speech docs](/ai/docs/tts)
* [TurboStarter AI Voice docs](/ai/docs/voice)
* [ElevenLabs Documentation](https://elevenlabs.io/docs)


# Tool calling
Source: https://www.turbostarter.dev/ai/docs/tool-calling

Tool calling lets a model do more than generate text. Instead of guessing, it can decide to call a weather API, search the web, look up data, run a calculation, or trigger a workflow, then use the result to continue the response.

This is one of the key shifts that turns a chatbot into an assistant. The model is no longer limited to what it remembers. It can interact with systems around it.

## Overview

At a high level, tool calling means giving the model a set of well-defined capabilities and letting it choose when to use them. Each tool has a name, a description, an input schema, and usually an execution function that runs in your app or backend.

That creates a loop like this:

<Steps>
  <Step>
    The user asks for something that may require outside information or action.
  </Step>

  <Step>The model decides whether a tool is needed.</Step>
  <Step>The selected tool runs with validated input.</Step>
  <Step>The tool result is returned to the model.</Step>
  <Step>The model uses that result to continue or complete the answer.</Step>
</Steps>

## When tool calling is useful

Tool calling is most useful when the model needs access to fresh data, private data, or real-world actions. That is why it shows up so often in assistants, agents, dashboards, support tools, and internal automation.

<Cards>
  <Card title="Good fit">
    Web search, database lookup, CRM queries, order status checks, calculations,
    scheduling, and content retrieval are all strong tool-calling use cases.
  </Card>

  <Card title="Where it appears in these docs">
    See related implementations in [Chat](/ai/docs/chat), [Knowledge
    RAG](/ai/docs/rag), and [MCP](/ai/docs/mcp).
  </Card>

  <Card title="Not always needed">
    If the answer can be produced from the prompt and context alone, plain text
    generation is usually simpler and faster.
  </Card>
</Cards>

## Tool calling vs retrieval vs reasoning

These capabilities are often discussed together, but they solve different problems. Knowing the difference helps you design simpler systems.

| Capability             | What it does                                     | Best for                                                          |
| ---------------------- | ------------------------------------------------ | ----------------------------------------------------------------- |
| Text generation        | Produces language output                         | Drafting, rewriting, summarizing, answering from provided context |
| Retrieval / embeddings | Finds relevant context                           | Search, RAG, semantic lookup                                      |
| Tool calling           | Lets the model use external functions or systems | Actions, real-time data, workflow orchestration                   |
| Reasoning              | Gives the model more thinking budget             | Multi-step planning, comparisons, hard decisions                  |

## AI SDK example

The AI SDK makes tool calling approachable by letting you define tools with a schema and an execution function. This keeps the interface model-friendly while still giving you runtime control.

```ts
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const result = await generateText({
  model: openai("gpt-5"),
  prompt: "What is the weather in Berlin today?",
  tools: {
    weather: tool({
      description: "Get the weather in a location",
      inputSchema: z.object({
        location: z.string().describe("The city or place to check"),
      }),
      execute: async ({ location }) => {
        return {
          location,
          temperature: "18C",
          conditions: "Cloudy",
        };
      },
    }),
  },
  stopWhen: stepCountIs(5),
});
```

This pattern is useful because the model gets a clear interface, and your app keeps control over validation, permissions, and execution.

## How to design good tools

The best tools are boringly clear. Models do better when tools are specific, narrow, and easy to distinguish from one another.

<Accordions>
  <Accordion title="Give each tool one clear job">
    A tool like `lookupCustomerOrder` is easier for the model to use correctly
    than a vague tool like `handleSupportTask`.
  </Accordion>

  <Accordion title="Write precise descriptions">
    Tool descriptions should explain when the tool should be used and what it
    returns. Ambiguity leads to incorrect tool selection.
  </Accordion>

  <Accordion title="Use strict schemas">
    Input validation matters. A strong schema keeps tool calls predictable and
    prevents malformed inputs from leaking into the rest of your system.
  </Accordion>

  <Accordion title="Return compact, useful results">
    Tool responses should contain what the model needs to continue, not huge
    noisy payloads with every possible field.
  </Accordion>

  <Accordion title="Make tools safe to run">
    Some tools are read-only, while others create side effects. Treat write
    actions carefully and consider confirmations, auth, and audit logging.
  </Accordion>
</Accordions>

## Common product patterns

Tool calling rarely exists by itself. In most products, it appears as part of a broader workflow or assistant experience.

<Cards>
  <Card title="Search assistant">
    The model decides when to use search, fetches results, and then summarizes
    them for the user.
  </Card>

  <Card title="Back-office copilot">
    The model looks up customer, billing, or product data across internal
    systems before answering.
  </Card>

  <Card title="Agent-style workflow">
    The model chains multiple tools together, such as search, retrieval, and
    summarization, to complete a multi-step task.
  </Card>

  <Card title="Action-taking assistant">
    The model does not just answer. It creates tickets, updates records, or
    triggers downstream automations after validation.
  </Card>
</Cards>

## Failure modes to plan for

Tool calling is powerful, but it introduces new operational risks. A good assistant is not just "smart"; it is predictable under failure.

<Cards>
  <Card title="Wrong tool selection">
    The model may choose a tool when none is needed, or choose the wrong one if
    descriptions overlap too much.
  </Card>

  <Card title="Bad input shape">
    Weak schemas or vague prompts can produce malformed parameters that break
    execution.
  </Card>

  <Card title="Noisy tool responses">
    Returning too much irrelevant data can make the final answer worse instead
    of better.
  </Card>

  <Card title="Unsafe side effects">
    Any tool that writes data, charges money, or changes system state should be
    protected with auth, policy checks, and confirmation flows where
    appropriate.
  </Card>
</Cards>

## Related documentation

In this docs set, tool calling connects naturally to several other capabilities. Those pages are the best place to see how it fits into end-user experiences.

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="The chat experience is the most natural place to expose tools like search or retrieval to end users." />

  <Card href="/ai/docs/rag" title="Knowledge RAG" description="RAG workflows often combine retrieval and tool-style orchestration before producing the final answer." />

  <Card href="/ai/docs/mcp" title="Model Context Protocol (MCP)" description="MCP is a standardized way to expose tools and context sources to models." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="Tool calling usually sits on top of text generation rather than replacing it." />
</Cards>

## A practical checklist

Before shipping tool calling, make sure the system is understandable to both the model and your team. The simpler the contract, the more reliable the behavior.

* Keep tools small and well-scoped.
* Validate all tool inputs with schemas.
* Prefer read-only tools first, then add safe write actions later.
* Log tool selections and failures so you can evaluate behavior.
* Avoid exposing tools that overlap too much in purpose.

## Learn more

If you want to go deeper, these resources are the best next step. They cover both the practical API surface and the emerging design patterns around agent-like systems.

* [AI SDK tool calling docs](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
* [AI SDK chatbot tool usage guide](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage)
* [OpenAI function calling guide](https://developers.openai.com/api/docs/guides/function-calling)
* [TurboStarter AI Chat docs](/ai/docs/chat)
* [TurboStarter AI RAG docs](/ai/docs/rag)


# Transcription
Source: https://www.turbostarter.dev/ai/docs/transcription

Transcription converts spoken audio into text. It is one of the most practical AI capabilities because it turns voice, meetings, recordings, and media into something searchable, editable, and usable by the rest of your product.

For many teams, transcription is the bridge between audio and everything else: summaries, captions, analytics, support workflows, notes, and voice-based input all start with getting the spoken words into text reliably.

## Overview

At a basic level, transcription means sending audio into a speech-to-text model and receiving text back. Some systems return only raw text, while others also provide timestamps, speaker information, segmentation, or confidence-style metadata.

That makes transcription useful well beyond simple dictation. A good transcription pipeline can support:

* meeting notes
* captions and subtitles
* search across audio and video
* voice input for assistants
* downstream summarization or extraction

## When transcription is useful

Transcription is valuable any time spoken information needs to become searchable, shareable, or actionable in a text-first workflow. It is often the first step in a larger AI pipeline.

<Cards>
  <Card title="Good fit">
    Voice notes, meeting recordings, support calls, interview analysis,
    captioning, and voice-assistant input are strong transcription use cases.
  </Card>

  <Card title="Where it connects in these docs">
    The closest companion pages are [Voice](/ai/docs/voice),
    [Speech](/ai/docs/speech), and [Generating text](/ai/docs/generating-text).
  </Card>

  <Card title="Not always enough on its own">
    A transcript captures what was said, but not always what mattered. Many
    products pair transcription with summarization, extraction, or search.
  </Card>
</Cards>

## Transcription vs speech vs voice

These capabilities are often grouped together, but they solve different parts of the audio experience. Keeping them separate helps you choose the right building blocks.

| Capability       | Input                       | Output                   | Best for                                          |
| ---------------- | --------------------------- | ------------------------ | ------------------------------------------------- |
| Transcription    | Audio                       | Text                     | Notes, captions, search, voice input              |
| Speech synthesis | Text                        | Audio                    | Narration, spoken answers, accessibility playback |
| Real-time voice  | Live audio and system turns | Interactive conversation | Voice assistants, live support, agent sessions    |

## AI SDK example

The AI SDK can also be used for transcription through provider-backed models. The shape is simple: send audio bytes in and receive text out.

```ts
import { experimental_transcribe as transcribe } from "ai";
import { openai } from "@ai-sdk/openai";
import { readFile } from "fs/promises";

const { text } = await transcribe({
  model: openai.transcription("whisper-1"),
  audio: await readFile("audio.mp3"),
});

console.log(text);
```

This is the core pattern behind many transcription features. In a real product, you would often store the transcript, index it, or pass it into another AI step right after transcription.

## Common product patterns

Transcription usually becomes more useful when it is part of a larger workflow. The text itself is valuable, but the downstream actions often create the real product value.

<Cards>
  <Card title="Voice note to text">
    A short recording becomes editable text that can be saved, searched, or
    turned into a task or reminder.
  </Card>

  <Card title="Meeting pipeline">
    Audio is transcribed first, then summarized, tagged, or turned into action
    items.
  </Card>

  <Card title="Captioning layer">
    Transcription powers subtitles or accessibility features for audio and video
    content.
  </Card>

  <Card title="Live assistant input">
    Real-time voice systems use transcription as the text layer that feeds the
    model before a spoken response is generated.
  </Card>
</Cards>

## Design considerations

Transcription is often judged by accuracy, but product usefulness depends on more than just word-level correctness. These are the design decisions that usually matter most.

<Accordions>
  <Accordion title="Audio quality affects everything">
    Background noise, overlapping speakers, poor microphones, and compression
    can hurt quality long before model choice becomes the limiting factor.
  </Accordion>

  <Accordion title="Timestamps can be more valuable than raw text">
    If users need captions, clip references, or synchronized playback, timing
    information may matter as much as the transcript itself.
  </Accordion>

  <Accordion title="Language and domain matter">
    Technical jargon, names, accents, and multilingual audio all affect
    transcription quality. Domain-aware post-processing is often worth it.
  </Accordion>

  <Accordion title="Real-time and offline are different products">
    Live transcription is a latency problem. Post-recording transcription is
    more about accuracy, formatting, and downstream processing.
  </Accordion>

  <Accordion title="Transcripts often need cleanup">
    Fillers, repetitions, and broken punctuation may be acceptable in raw
    transcripts, but not in user-facing notes or captions.
  </Accordion>
</Accordions>

## What comes after transcription

In most products, the transcript is not the end result. It becomes the input to another capability that makes the output more useful to humans.

<Cards>
  <Card title="Summarization">
    Turn long conversations into concise notes or recap emails.
  </Card>

  <Card title="Extraction">
    Pull out action items, decisions, names, dates, or structured fields.
  </Card>

  <Card title="Search">
    Make audio and video content searchable using text and embeddings.
  </Card>

  <Card title="Voice agents">
    Feed the transcribed text into a model that decides how to respond in real
    time.
  </Card>
</Cards>

## Beginner mistakes to avoid

Many transcription features feel disappointing not because speech-to-text is weak, but because the surrounding workflow is incomplete. These are some of the most common issues.

<Cards>
  <Card title="Treating raw text as final output">
    Most real users want cleaned-up notes, captions, or searchable records, not
    just an unformatted block of transcript text.
  </Card>

  <Card title="Ignoring noisy audio">
    No model can fully rescue very poor recordings. It helps to set expectations
    and improve capture quality where possible.
  </Card>

  <Card title="Skipping language hints">
    If the provider supports language hints or domain-specific options, using
    them can noticeably improve accuracy and speed.
  </Card>

  <Card title="Forgetting privacy and retention">
    Audio can be sensitive. Decide what gets stored, how long transcripts
    persist, and who can access them.
  </Card>
</Cards>

## Related documentation

While there is not a dedicated transcription demo page yet, the capability connects directly to the voice and audio parts of the stack. These pages are the best follow-up if you want to see how transcription fits into broader experiences.

<Cards>
  <Card href="/ai/docs/voice" title="Voice" description="See how transcript-like text flows fit into real-time conversational assistants." />

  <Card href="/ai/docs/speech" title="Speech" description="Compare speech-to-text with the opposite direction, text-to-speech." />

  <Card href="/ai/docs/providers/eleven-labs" title="Eleven Labs" description="Explore a provider that also offers speech-to-text as part of a broader audio platform." />

  <Card href="/ai/docs/generating-text" title="Generating text" description="See what often happens after transcription, such as summarization or structured extraction." />
</Cards>

## A practical checklist

Before shipping a transcription feature, it helps to decide whether the output is meant for raw capture, user reading, downstream AI processing, or all three.

* Test on noisy, accented, and domain-specific audio, not just clean samples.
* Decide whether you need raw transcript, cleaned text, timestamps, or speaker separation.
* Plan what happens after transcription instead of stopping at raw text.
* Think about privacy, retention, and who can access recordings or transcripts.
* Treat live and offline transcription as different UX problems.

## Learn more

These references are a strong next step if you want to explore transcription in more depth, both as a technical capability and as part of richer audio workflows.

* [AI SDK transcription guide](https://ai-sdk.dev/docs/ai-sdk-core/transcription)
* [AI SDK transcribe reference](https://ai-sdk.dev/docs/reference/ai-sdk-core/transcribe)
* [TurboStarter AI Voice docs](/ai/docs/voice)
* [ElevenLabs Documentation](https://elevenlabs.io/docs)


# Web search
Source: https://www.turbostarter.dev/ai/docs/web-search

Web search is the capability that lets the chat app pull in current or externally verified information instead of relying only on model memory. In TurboStarter AI, that capability lives behind a shared provider contract so the tool layer can stay stable while the search backend changes.

This is especially useful for:

* current events and recent announcements
* source-backed answers that need citations or fresh data
* niche lookups where static model knowledge may be incomplete
* assistant flows that benefit from explicit retrieval before generating a response

## Where it lives

The shared implementation is in `packages/ai/chat/src/tools/web-search`.

That module currently includes:

* a tool entrypoint used by the chat flow
* input schemas for multi-query web search requests
* shared normalization utilities for results, images, domains, and dates
* provider strategies for [Tavily](https://www.tavily.com/), [Brave Search](https://brave.com/search/api/), [Exa](https://exa.ai/), and [Firecrawl](https://www.firecrawl.dev/)
* provider-specific SDK wrappers under `packages/ai/chat/src/tools/sdk/*`

The overall shape follows the same pattern as the rest of the AI package: keep the chat app code focused on behavior, and isolate third-party integrations behind typed boundaries.

## Provider architecture

The web-search tool does not talk to a single provider API directly. Instead, it uses a provider strategy layer. Each provider implementation maps its own SDK response into the same result shape:

* `results`: normalized search hits
* `images`: normalized image candidates

That makes it easier to:

* swap the default provider
* compare providers during development
* keep the UI independent from provider-specific response formats
* add provider-specific options without rewriting the whole tool surface

## Implemented providers

TurboStarter AI currently includes the following web-search providers:

<Cards>
  <Card title="Tavily" href="https://www.tavily.com/" description="Search engine designed for agent and LLM workflows, with structured results that fit tool-driven assistants well." />

  <Card title="Brave Search" href="https://brave.com/search/api/" description="General web search API with broad public web coverage and a straightforward result format." />

  <Card title="Exa" href="https://exa.ai/" description="Search platform tailored for AI applications, with highlights, page text, and metadata useful in agentic workflows." />

  <Card title="Firecrawl" href="https://www.firecrawl.dev/" description="Search and crawl-oriented provider that fits well when you want richer control over web, news, and image retrieval." />
</Cards>

## Environment variables

If you want to enable these providers, add the corresponding server-side keys in `apps/web/.env.local` or your deployment environment:

```dotenv title="apps/web/.env.local"
BRAVE_SEARCH_API_KEY=""
EXA_API_KEY=""
FIRECRAWL_API_KEY=""
TAVILY_API_KEY=""
```

Only configure the providers you actually plan to use. Keeping multiple providers available can be useful during development, but it is not required.

## How it fits into chat

The [Chat](/ai/docs/chat) app uses web search as a tool rather than as a separate product surface. That means the model decides when browsing is necessary based on the request and the tool instructions.

The shared chat package handles:

* deciding when the tool is relevant
* executing the normalized web-search flow
* streaming the tool status back into the conversation UI
* passing structured search results back into the model loop

For the user, this shows up as a normal tool-driven assistant interaction rather than a separate search screen.

## Related documentation

<Cards>
  <Card href="/ai/docs/chat" title="Chat" description="See how web search appears in the end-user chat application." />

  <Card href="/ai/docs/tool-calling" title="Tool calling" description="Understand the general tool-execution model that web search plugs into." />

  <Card href="/ai/docs/architecture" title="Architecture" description="See where the web-search provider layer fits into the broader AI system." />
</Cards>


# API
Source: https://www.turbostarter.dev/ai/docs/api

The API service acts as the central hub for all backend logic within TurboStarter AI. It handles interactions with AI models, data processing, and communication between the frontend and backend systems.

## Technology

We use [Hono](https://hono.dev), a fast, TypeScript-first web framework. This ensures efficient handling of API requests, especially for AI interactions like streaming responses.

**Importantly, this single API layer serves both web and mobile applications, guaranteeing consistent business logic and data handling across all platforms.**

In the AI kit, the API is mounted under `/api` (base path), and routes are grouped by module, for example:

* `/api/ai/*` for AI features (chat, RAG, image, voice, TTS)
* `/api/auth/*` for authentication helpers
* `/api/storage/*` for upload and signed URL helpers

## AI integration

While the API package (`@workspace/api`) exposes the endpoints, most AI functionality is implemented in dedicated AI packages and imported into the API routes. In practice:

* `@workspace/ai` contains shared AI primitives like credit costs and server helpers (credit balance, deductions, etc.)
* Each demo app has its own AI package (e.g. `@workspace/ai-chat`, `@workspace/ai-rag`, `@workspace/ai-image`, `@workspace/ai-tts`, `@workspace/ai-voice`) that contains the module-specific API functions, schemas, and strategy/provider wiring

The AI packages are responsible for:

* Communicating with various AI providers and models ([OpenAI](/ai/docs/providers/openai), [Anthropic](/ai/docs/providers/anthropic), [Google AI](/ai/docs/providers/google), etc.)
* Processing and formatting data specifically for AI interactions
* Parsing responses from AI models and producing consistent outputs
* Reading/writing AI module data (chat history, RAG documents/embeddings, image generations, etc.) via `@workspace/db` and `@workspace/storage` where needed

The API layer itself focuses on registering Hono routes, applying middleware (auth, validation, credits, etc.), and exposing these AI features to web and mobile clients.

This separation ensures AI-specific logic remains modular and reusable, while the API package stays focused on request handling and routing.

<Callout>
  API keys for AI services are managed securely on the backend within these packages, ensuring they never appear client-side.
</Callout>

## Middlewares

Hono middlewares streamline request handling by tackling common tasks before the main logic runs. In TurboStarter AI, they handle:

* **Authentication:** verifying user sessions before allowing access to protected routes (the AI kit starts with anonymous sessions by default)
* **Validation:** validating query params and JSON bodies with [Zod](https://zod.dev/); validation errors can be localized using the i18n layer
* **Rate limiting:** restricting request frequency (for example, for costly operations like image generation or RAG ingestion)
* **Credits management:** checking a user's credit balance and deducting costs before running an AI operation
* **Localization:** detecting the user's locale (cookie / `Accept-Language`) so API errors and validation messages can be translated
* **Security:** CORS and CSRF protections where appropriate

These middlewares keep core route logic clean and focused, while consistently enforcing security, usage limits, and data integrity across the API.

## Core API documentation

For general information about the API setup, architecture, authentication integration, and how to add new endpoints, please refer to the [Core API documentation](/docs/web/api/overview).

<Card title="API documentation" href="/docs/web/api/overview" description="Learn about the general API setup, structure, and best practices in the core TurboStarter documentation." />

Specific configurations related to AI providers or templates can be found in their respective documentation sections.


# Authentication
Source: https://www.turbostarter.dev/ai/docs/auth

TurboStarter AI implements a streamlined authentication approach powered by [Better Auth](https://better-auth.com/). Since the primary focus is showcasing AI capabilities, we've kept the initial authentication simple, allowing you to quickly integrate and experiment with AI features.

## Anonymous sessions

When someone first visits the AI application, an **anonymous session** is automatically created. This establishes a unique user identity without requiring login credentials.

These anonymous sessions serve two critical purposes:

1. **Persistence:** links data like chat history or generated content to specific users in your database
2. **Usage control:** enables tracking for rate limiting and the credits system, ensuring fair AI resource usage even for anonymous visitors

Under the hood, this is implemented with Better Auth's anonymous plugin on the server and an anonymous client plugin on the frontend. The web app signs the user in anonymously on first load if there is no existing session.

## Extending authentication

While the default anonymous setup provides a frictionless initial experience, TurboStarter is built for growth. The authentication logic uses Better Auth in the shared `packages/auth` package, ensuring consistency between web and mobile applications.

When your project needs more sophisticated authentication features like:

* Email/Password login
* Magic links
* Social logins (OAuth)
* Multi-factor authentication

You can add these by porting the comprehensive authentication system from TurboStarter Core Kit. Follow the [Core Kit integration recipe](/ai/docs/integrate-core-kit) to merge auth and database ownership without replacing the AI routes and templates.

For detailed implementation guides, check out the core documentation:

<Cards>
  <Card title="Integrate Core Kit" href="/ai/docs/integrate-core-kit" description="Add full account flows, organizations, billing, and admin to an AI Kit project." />

  <Card title="Web authentication" href="/docs/web/auth/overview" description="Explore the full authentication capabilities for the web application." />

  <Card title="Mobile authentication" href="/docs/mobile/auth/overview" description="Learn how authentication works within the mobile application." />
</Cards>

By starting with anonymous sessions, the AI kit lets you focus on building compelling AI features first, while providing a clear path to implement advanced user management and security as your application evolves.


# Billing
Source: https://www.turbostarter.dev/ai/docs/billing

TurboStarter AI includes a straightforward middleware setup to manage user credits for AI features. This lets you control access based on available credits without complex payment integrations.

## Credit-based access

A focused middleware verifies if users have enough credits before allowing them to access specific AI-powered routes or actions.

```ts title="ai.router.ts"
export const aiRouter = new Hono().post(
  "/chat",
  rateLimiter,
  validate("json", chatMessageSchema),
  deductCredits({
    amount: 10, // [!code highlight]
  }),
  streamChat,
);
```

This example shows how the `deductCredits` middleware subtracts a specific amount (10 credits) for each request to the `/chat` endpoint.

## Extending billing

For more advanced billing scenarios or immediate needs, you can tap into the core TurboStarter billing features. The main documentation provides detailed guidance on setting up and managing billing with third-party providers.

<Cards>
  <Card href="/docs/web/billing/overview" title="Web billing documentation" description="Learn more about the comprehensive billing features in TurboStarter web application." icon={<Website />} />

  <Card href="/docs/mobile/billing/overview" title="Mobile billing documentation" description="Learn more about the comprehensive billing features in TurboStarter mobile application." icon={<Phone />} />
</Cards>

Stay tuned for updates as we enhance the AI-specific billing functionalities!


# Database
Source: https://www.turbostarter.dev/ai/docs/database

The database service, managed within the `packages/db` directory (as `@workspace/db`), stores data essential for both core application functions and AI features. It ensures that information like user profiles, conversation history, and AI-generated content is reliably preserved and efficiently accessed.

## Technology

We've chosen [PostgreSQL](https://www.postgresql.org) as our primary relational database for its exceptional reliability, extensibility (including powerful tools like `pgvector` for similarity searches), and proven track record in production environments.

Database interactions are handled through [Drizzle ORM](https://orm.drizzle.team/), a cutting-edge TypeScript ORM that offers outstanding type safety (generating types directly from your schema), high performance, and a developer-friendly API.

For detailed guidance on setup, configuration, schema management (including migrations), and general usage patterns of Drizzle and PostgreSQL in the TurboStarter ecosystem, check out our core documentation:

<Cards>
  <Card title="Overview" description="Get started with the database in the core web application." href="/docs/web/database/overview" />

  <Card title="Schema" description="Learn about the core database schema definitions." href="/docs/web/database/schema" />

  <Card title="Migrations" description="Understand how to manage database schema changes over time." href="/docs/web/database/migrations" />

  <Card title="Client" description="Learn how to interact with the database using the type-safe Drizzle client." href="/docs/web/database/client" />
</Cards>

## What is stored in the database?

Beyond standard application data (like users and accounts), the database plays a crucial role in storing AI-specific information:

* **[Chat](/ai/docs/chat) history**: stores conversations between users and AI models, including rich message parts (for example attachments, tool output parts) and token usage for billing/analytics
* **Vector embeddings**: stores numerical representations (vectors) of text data (like document chunks) that power Retrieval-Augmented Generation (RAG) techniques, allowing features like [Knowledge RAG](/ai/docs/rag) to quickly find relevant context from large document collections
* **Document references**: tracks metadata and storage identifiers (paths in [Blob Storage](/ai/docs/storage)) for user-uploaded files used in RAG
* **Image generations**: stores prompts, settings, and generated image URLs for the [Image playground](/ai/docs/image)
* **Credits**: keeps track of each user's remaining credits (used by the middleware to gate AI operations)

## Schema

The core database schema, defined in `packages/db/src/schema`, contains essential tables for the overall application (users, accounts, sessions, etc.).

To maintain clarity as AI features grow, AI module tables are grouped into dedicated [PostgreSQL schemas](https://www.postgresql.org/docs/current/ddl-schemas.html) using Drizzle's `pgSchema`, for example:

* `chat.*` for the [Chat](/ai/docs/chat) demo (chats, messages, message parts, usage)
* `rag.*` for the [Knowledge RAG](/ai/docs/rag) demo (chats, messages, documents, embeddings)
* `image.*` for the [Image playground](/ai/docs/image) demo (generations, images)

This logical separation helps manage complexity and isolates feature-specific data structures. You'll typically find AI-specific schema definitions either alongside the relevant demo app code or within the main `packages/db/src/schema` directory, clearly labeled and organized.


# Internationalization
Source: https://www.turbostarter.dev/ai/docs/internationalization

TurboStarter AI builds on the core internationalization (i18n) setup from the main TurboStarter framework. The shared `@workspace/i18n` package in `packages/i18n` handles translation management across platforms.

This gives you the benefit of a proven system using [i18next](https://www.i18next.com/) for managing translations on both web and mobile apps. Plus, the AI models and LLMs integrated within TurboStarter AI generally support multiple languages, enabling interactions beyond what's covered by UI translations alone.

By default, the AI kit ships with English (`en`) enabled. You can add more locales by extending the i18n config and providing matching translation files.

For detailed information on configuring languages, adding translations, or using the `useTranslation` hook, check out the core documentation:

<Cards>
  <Card title="Web internationalization" description="Learn about i18n setup for the Next.js web app." href="/docs/web/internationalization/overview" icon={<Website />} />

  <Card title="Mobile internationalization" description="Learn about i18n setup for the React Native (Expo) mobile app." href="/docs/mobile/internationalization" icon={<Phone />} />
</Cards>

## AI-specific translations

While most translations are shared across the platform, TurboStarter AI introduces a dedicated `ai` namespace within translation files. This namespace contains strings specifically for AI features, demo applications, and UI elements unique to the AI starter kit.

The i18n package ships with multiple namespaces (for example `common`, `ai`, and `validation`) and loads translations from `packages/i18n/src/translations`.

```json title="packages/i18n/src/translations/en/ai.json"
{
  "chat": {
    "title": "AI Chatbot",
    "description": "Engage in intelligent conversations."
  },
  "image": {
    "title": "Image Generation",
    "description": "Create stunning visuals with AI."
  }
  // ... other AI-specific translations
}
```

When adding translations for new AI features or modifying existing ones, place them within the `ai` namespace in the appropriate language files (e.g., `en/ai.json`, `es/ai.json`). This keeps AI-related text organized and separate from core application translations.


# Security
Source: https://www.turbostarter.dev/ai/docs/security

<Callout>
  Remember to regularly review your security implementations and update them as needed.
</Callout>

The starter kit incorporates several security measures to protect your application and users when interacting with AI services.

## Authenticated endpoints

All AI operation endpoints require user authentication. This is enforced through middleware that verifies the user's session before granting access to any AI features.

<Card title="Authentication" href="/ai/docs/auth" description="Learn more about the authentication setup in TurboStarter AI." />

The system creates anonymous sessions by default, but you can implement stronger authentication using the core framework's capabilities or the dedicated [authentication setup](/docs/web/auth/overview).

## Credit-based access

To prevent AI resource abuse, TurboStarter AI includes a credit-based system. Users receive a limited number of credits that are consumed when using AI features.

<Card title="Billing" href="/ai/docs/billing" description="Learn more about the billing and credits system." />

This approach avoids misuse while enabling potential monetization. Learn about the implementation details in the [Core billing documentation](/docs/web/billing/overview).

## Rate limiting

API endpoints are guarded by rate limiting to prevent abuse and ensure fair usage. This protects your application from potential denial-of-service attacks and excessive request volumes.

<Card title="API" href="/ai/docs/api" description="Learn more about the API layer and services in TurboStarter AI." />

We use [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter), which supports various storage options including [Redis](https://redis.io/), [Cloudflare KV](https://developers.cloudflare.com/workers/runtime-apis/kv/), and [Memcached](https://memcached.org/) for distributed rate limiting.

## Secure API key handling

Sensitive API keys for AI providers ([OpenAI](/ai/docs/providers/openai), [Anthropic](/ai/docs/providers/anthropic), [Google AI](/ai/docs/providers/google), etc.) are managed exclusively on the backend.

They are **NEVER** exposed to client-side code, dramatically reducing the risk of key leakage or unauthorized usage.

## AI service abuse protection

While TurboStarter AI provides application-level safeguards like credit limits and rate limiting, it's essential to implement additional protection directly within your AI providers.

<Callout type="warn" title="Set limits and alerts">
  Always configure spending limits, usage quotas, and monitoring alerts in your
  AI provider dashboards (e.g., [OpenAI](/ai/docs/providers/openai),
  [Anthropic](/ai/docs/providers/anthropic), [Google
  AI](/ai/docs/providers/google)). These serve as critical safety nets against
  unexpected costs or potential abuse that might bypass your application-level
  controls.
</Callout>

By combining application-level security with provider-level controls, you'll build truly robust and secure AI applications.


# Storage
Source: https://www.turbostarter.dev/ai/docs/storage

Blob storage in TurboStarter AI offers a scalable solution for handling the diverse file types essential to modern AI applications. It works seamlessly with S3-compatible services including [AWS S3](https://aws.amazon.com/s3/), [Cloudflare R2](https://www.cloudflare.com/products/r2/), and [MinIO](https://min.io/).

## Use cases

Blob storage powers several key AI functions:

* **Managing user uploads:** safely storing files like documents or images that users upload for AI processing, as seen in the [Knowledge RAG](/ai/docs/rag) and image analysis features
* **Preserving AI-generated content:** storing outputs from AI models, such as images from the [Image playground](/ai/docs/image) or audio files from the [Voice](/ai/docs/voice) and [Text to Speech](/ai/docs/tts)
* **Powering RAG systems:** housing documents and files that serve as knowledge sources for Retrieval-Augmented Generation, used in demos like [Knowledge RAG](/ai/docs/rag) and intelligent [Agents](/ai/docs/agents)

## Security

Properly configuring bucket permissions for your storage provider is critical. Always restrict access based on the principle of least privilege:

* Buckets containing user uploads or sensitive RAG documents should typically **not** be publicly accessible
* Set precise permissions that allow your application server (API) to read/write as needed while blocking unauthorized access

Refer to your provider's documentation ([AWS S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/security-best-practices.html), [Cloudflare R2](https://developers.cloudflare.com/security-center/security-insights/roles-and-permissions/), [MinIO](https://min.io/docs/minio/linux/administration/identity-access-management/policy-based-access-control.html)) for specific guidance on securing your storage buckets.

## Storage documentation

For detailed setup instructions, configuration options for different storage providers, and implementation best practices, check out the core storage documentation:

<Card title="Storage documentation" href="/docs/web/storage/overview" description="Learn how to configure and manage blob storage providers in the core TurboStarter documentation." />

In summary, blob storage is essential for building sophisticated AI applications - enabling you to handle user uploads, store AI-generated files, and manage RAG document collections.


# UI
Source: https://www.turbostarter.dev/ai/docs/ui

TurboStarter AI builds on the core TurboStarter UI foundation to create engaging interfaces for all AI features.

The UI architecture uses shared components and styles with platform-specific implementations:

* **`@workspace/ui`**: includes shared assets, themes, and fundamental styles
* **`@workspace/ui-web`**: contains web components built with [Tailwind CSS](https://tailwindcss.com), [Base UI](https://base-ui.com), and [shadcn/ui](https://ui.shadcn.com)
* **`@workspace/ui-mobile`**: delivers mobile components using [Uniwind](https://uniwind.dev/) and [react-native-reusables](https://reactnativereusables.com/)

This approach maximizes code reuse while optimizing for each platform's unique capabilities.

## UI in AI applications

The AI starter kit leverages this foundation to create intuitive interfaces for various features and demo apps:

<Cards>
  <Card title="Chat interfaces" className="shadow-none">
    Components for displaying conversations, user input, and streaming responses
    (used in [Chatbot](/ai/docs/chat), [Voice](/ai/docs/voice) and [Knowledge
    RAG](/ai/docs/rag) demos).
  </Card>

  <Card title="Image galleries" className="shadow-none">
    Displaying AI-generated images as masonry grids with options for interaction
    (used in [Image playground](/ai/docs/image) demo).
  </Card>

  <Card title="Input forms" className="shadow-none">
    Structured forms for configuring AI tasks (e.g., selecting models, adjusting
    parameters, modifying prompts).
  </Card>

  <Card title="Animations" className="shadow-none">
    Visual feedback during AI processing, such as loading spinners or progress
    indicators (e.g. [Voice](/ai/docs/voice) and [Text to Speech](/ai/docs/tts)
    voice avatar animation).
  </Card>

  <Card title="Feedback mechanisms" className="shadow-none">
    UI elements for users to rate or provide feedback on AI outputs. This can
    include thumbs up/down buttons or text input fields for comments.
  </Card>

  <Card title="Error handling" className="shadow-none">
    Components for displaying error messages or alerts when AI tasks fail or
    encounter issues.
  </Card>

  <Card title="Accessibility features" className="shadow-none">
    Ensuring that all UI components are usable for individuals with
    disabilities, including keyboard navigation and screen reader support.
  </Card>

  <Card title="Visualizations" className="shadow-none">
    Components for displaying data or model outputs visually, such as charts,
    graphs, or progress bars.
  </Card>
</Cards>

## Generative UI

A standout aspect of AI applications is their ability to dynamically create or modify UI elements based on AI responses. TurboStarter AI enables this through:

* **AI SDK components**: libraries like the [AI SDK](https://ai-sdk.dev/docs/introduction) provide specialized components and hooks designed to render UI based on AI actions or structured data. This creates interactive elements - buttons, forms, or visualizations - that appear dynamically within conversations or workflows.
* **Structured output**: AI models can return data in specific formats (such as JSON) that your frontend parses to render appropriate components, display information, or trigger actions. For example, an AI might return product details that automatically render as interactive cards.
* **Conditional rendering**: the platform uses standard React patterns for showing, hiding, or transforming UI components based on AI interaction states. This creates smooth transitions between loading states, results displays, and follow-up options tailored to AI suggestions.

This approach delivers truly responsive user experiences where interfaces adapt intelligently to ongoing AI processes. The [Chat demo app](/ai/docs/chat) showcases these generative UI capabilities in action.

## Customization and further details

Customizing appearance (themes, styling) or adding new UI components follows the same process as core TurboStarter applications. For complete guides on styling, theme management, and component development, see our core documentation:

<Cards>
  <Card title="Web UI customization" description="Learn how to customize styling and components for the web application." href="/docs/web/customization/styling" icon={<Website />} />

  <Card title="Mobile UI customization" description="Learn how to customize styling and components for the mobile application." href="/docs/mobile/customization/styling" icon={<Phone />} />
</Cards>

By leveraging the core UI system, TurboStarter AI ensures consistent user experiences across platforms while letting you focus on creating unique AI functionalities.


# Architecture
Source: https://www.turbostarter.dev/ai/docs/architecture

TurboStarter AI integrates several best-in-class open source libraries to power its diverse functionalities, including authentication, data persistence, text generation, and more. Here's a concise overview of the architecture that makes everything work together.

<ThemedImage alt="AI Architecture diagram" light="/images/docs/ai/architecture/light.png" dark="/images/docs/ai/architecture/dark.png" width={2526} height={1561} zoomable />

## Application framework

The project leverages a [monorepo structure](https://turborepo.dev/repo) powered by [Turborepo](https://turborepo.dev/) to enable efficient code sharing and consistent tooling across the entire application ecosystem. This approach creates a single source of truth for shared code and dramatically simplifies dependency management.

<Files>
  <Folder name="apps" defaultOpen>
    <Folder name="web - Web app (Next.js)" />

    <Folder name="mobile - Mobile app (React Native - Expo)" />
  </Folder>

  <Folder name="packages" defaultOpen>
    <Folder name="ai - AI features" defaultOpen>
      <Folder name="chat - Conversational AI & chatbots" />

      <Folder name="core - Core shared AI logic" />

      <Folder name="image - AI-powered image generation" />

      <Folder name="rag - Retrieval augmented generation" />

      <Folder name="tts - Text-to-speech synthesis" />

      <Folder name="voice - Real-time voice processing" />
    </Folder>

    <Folder name="api - API server including routes" />

    <Folder name="auth - Authentication setup" />

    <Folder name="db - Database setup" />

    <Folder name="i18n - Internationalization setup" />

    <Folder name="shared - Shared utilities and helpers" />

    <Folder name="storage - Storage setup" />

    <Folder name="ui - Atomic UI components for web and mobile">
      <Folder name="shared" />

      <Folder name="web" />

      <Folder name="mobile" />
    </Folder>
  </Folder>
</Files>

### Web

Built with [Next.js](https://nextjs.org) and [React](https://react.dev), the web application leverages server-side rendering and static site generation for optimal performance and SEO. The UI is styled with [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) components for rapid development and consistent design. API routes are handled by [Hono](https://hono.dev) for edge computing, chosen for its minimal overhead and excellent TypeScript support.

<Card title="Web | TurboStarter" href="/docs/web" description="Learn more about the core web application and its features." />

### Mobile

The mobile application uses [React Native](https://reactnative.dev) with [Expo](https://expo.dev) for cross-platform development. This combination was selected for its ability to share up to 90% of code between platforms while maintaining native performance. For UI layer, we use [Uniwind](https://uniwind.dev/) which is a Tailwind CSS-like framework for React Native and [react-native-reusables](https://reactnativereusables.com/) for headless components.

The integration with the monorepo allows seamless sharing of business logic and types with the web application.

<Card title="Mobile | TurboStarter" href="/docs/mobile" description="Learn more about the mobile application and its features." />

## API

The API is implemented as a dedicated package using [Hono](https://hono.dev), a lightweight framework optimized for edge computing. This architectural decision creates a clear separation between frontend and backend logic, enhancing maintainability and testability.

Hono's exceptional TypeScript support ensures type safety across all endpoints, while its minimal footprint and edge-first design deliver outstanding performance.

<Card title="API" href="/ai/docs/api" description="Discover API service in AI starter and demo apps." />

## Model providers

TurboStarter AI integrates with multiple AI model providers through the [AI SDK](https://sdk.vercel.ai/). In the current codebase, you’ll find built-in strategy/provider wiring for providers like [OpenAI](/ai/docs/providers/openai), [Anthropic](/ai/docs/providers/anthropic), [Google AI](/ai/docs/providers/google), [xAI](/ai/docs/providers/xai), and [DeepSeek](/ai/docs/providers/deepseek), plus additional providers for specific capabilities (for example [Replicate](/ai/docs/providers/replicate) for image models and [Eleven Labs](/ai/docs/providers/eleven-labs) for TTS).

For retrieval from the public web, the chat stack also includes a separate [web search](/ai/docs/web-search) provider layer with integrations for [Tavily](https://www.tavily.com/), [Brave Search](https://brave.com/search/api/), [Exa](https://exa.ai/), and [Firecrawl](https://www.firecrawl.dev/). That layer lives outside the model-provider abstraction because it powers tool execution rather than text generation itself.

For real-time voice experiences, the kit also integrates [LiveKit](https://livekit.io/) (via LiveKit Agents) to handle low-latency audio sessions and voice agent workflows.

The platform strategically utilizes specialized models for distinct AI tasks:

* **Text generation** models for conversational AI and content creation
* **Structured output** models for precise data extraction and formatting
* **Image generation** models for visual content creation
* **Transcription / speech / voice** models for real-time voice experiences
* **Embedding** models for semantic search and information retrieval

Switching models requires just a **one-line code change**, allowing you to rapidly adapt to emerging models or change providers based on your specific requirements. This flexibility ensures your application can leverage the latest AI advancements without extensive refactoring.

## Authentication

The applications use [Better Auth](https://better-auth.com/) for authentication, providing a secure and flexible authentication system. By default, the AI implementation creates an anonymous user session at startup, which is then used for all subsequent queries and interactions with the AI models. This approach maintains user context across sessions while minimizing friction.

For more sophisticated authentication requirements, you can easily extend the flow by leveraging the [Core implementation](/docs/web/auth/overview), which supports email/password authentication, magic links, OAuth providers, and more. This modular design lets you implement precisely the level of security your application demands.

<Card title="Authentication" href="/ai/docs/auth" description="Learn more about the authentication system in TurboStarter AI." />

## Persistence

Persistence in TurboStarter AI refers to the system's ability to store and retrieve data from a database. The application uses [PostgreSQL](https://www.postgresql.org/) as its primary database to store critical information such as:

* Chat history and conversation context
* User accounts and preference settings
* Vector embeddings for retrieval-augmented generation

To interact with the database from route handlers and server actions, TurboStarter AI leverages [Drizzle ORM](https://orm.drizzle.team/), a high-performance TypeScript ORM that provides type-safe database operations. This ensures robust data integrity and simplified query construction throughout the application.

A key advantage of Drizzle is its compatibility with multiple database providers including [Neon](https://neon.com/), [Supabase](https://supabase.com/), and [PlanetScale](https://planetscale.com/). This flexibility allows seamless switching between providers based on your specific requirements without modifying queries or schema definitions — making your application highly adaptable to evolving infrastructure needs.

<Card title="Database" href="/ai/docs/database" description="Explore the database architecture and persistence layer in TurboStarter AI." />

## Blob storage

File storage is managed through S3-compatible services, providing scalable, reliable storage for diverse file types. The system efficiently handles user-uploaded images, AI-generated content, and document files. This approach ensures optimal file management and straightforward integration with various storage providers including [AWS S3](https://aws.amazon.com/s3/), [Cloudflare R2](https://www.cloudflare.com/products/r2/), or [MinIO](https://min.io/).

<Card title="Storage" href="/ai/docs/storage" description="Learn more about the storage system in TurboStarter AI." />

## Security

Security is implemented comprehensively to protect both the application and its users. Key AI endpoints incorporate **rate limiting** to prevent abuse and ensure fair resource allocation.

The system uses a **credits-based access** control system, where each user has a limited number of credits for AI operations, preventing resource exhaustion and enabling monetization options.

All external API interactions, including those with AI model providers, occur exclusively server-side. This ensures that sensitive API keys are **never exposed** to client-side code, significantly reducing vulnerability to unauthorized access or credential theft.

Additionally, the system implements industry-standard security practices including thorough input validation, proper authentication enforcement, and regular dependency security audits.

<Card title="Security" href="/ai/docs/security" description="Explore the security measures in place for TurboStarter AI." />


# <AnalyzingImage />
Source: https://www.turbostarter.dev/ai/docs/components/analyzing-image

`<AnalyzingImage />` is a small status component for moments when the assistant is looking at an image and the user is waiting for the result. It makes that state feel intentional and product-specific instead of falling back to a neutral spinner.

![AnalyzingImage component demo](/images/docs/ai/components/analyzing-image.gif)

## Why it is useful

This component does one very specific job, and that is exactly what makes it valuable. In image-understanding flows, users usually need reassurance that the model is actively inspecting the image rather than simply “loading.”

<Cards>
  <Card title="More informative than a spinner">
    The visual language suggests image analysis, not just generic waiting.
  </Card>

  <Card title="Great for multimodal chat UIs">
    It fits naturally into assistant messages, loading rows, and image-analysis
    placeholders.
  </Card>

  <Card title="Small but expressive">
    The animated scan line and shimmering label add motion without taking over
    the interface.
  </Card>
</Cards>

## Usage

The simplest usage is just to render the component inline wherever an image-analysis state appears. It already includes its own icon animation and localized status label.

```tsx
import { AnalyzingImage } from "@workspace/ui-web/ai-elements/analyzing-image";

export function ImageAnalysisState() {
  return <AnalyzingImage />;
}
```

If you are already using the conversation primitives, this component also appears through the image loading variant in the conversation loading UI.

```tsx
import { ConversationLoading } from "@workspace/ui-web/ai-elements/conversation";

export function AssistantPendingState() {
  return <ConversationLoading variant="image" />;
}
```

## Props

The component is intentionally lightweight. It forwards standard `div` props, so you can pass `className` and any native wrapper attributes you need when placing it inside message rows, cards, or custom loading states.

## How it works

The component combines three small pieces to create a clear multimodal loading state. The result is subtle enough for production UIs while still being recognizable at a glance.

* An animated image frame creates the feeling of an active scan.
* A moving vertical bar reinforces the “analyzing” motion.
* A `<ShimmerText />` label displays the localized `analyzingImage` copy from the common translation namespace.

It does not manage image uploads, request state, or model calls on its own. It is purely a presentational component that helps your loading state communicate intent.

## Related components

`<AnalyzingImage />` works best as part of a broader conversational UI rather than as a standalone hero element. These nearby components are the most relevant companions.

<Cards>
  <Card href="/ai/docs/components/conversation" title="<Conversation />" description="The main conversation surface where image-specific loading states naturally appear." />

  <Card href="/ai/docs/components/message" title="<Message />" description="Useful when you want to understand the message-level building blocks around loading states." />

  <Card href="/ai/docs/components/shimmer" title="<ShimmerText />" description="The text effect used inside this component to make the status label feel alive." />
</Cards>


# <Attachments />
Source: https://www.turbostarter.dev/ai/docs/components/attachments

`<Attachments />` is the media and file surface used across the AI starter. It gives prompts and messages a consistent way to show uploaded files, generated assets, and source-like items without rebuilding attachment UI for every app.

## Web

The web implementation is the richer of the two. It supports the three shared layout variants and adds hover-card helpers that work especially well in prompt composers and chat messages.

![Web attachments demo](/images/docs/ai/components/attachments/web.png)

## Mobile

The mobile implementation keeps the same variants, but adapts them to native scrolling, touch targets, and image handling. It is designed to feel natural inside composer rows and conversation screens.

![Mobile attachments demo](/images/docs/ai/components/attachments/mobile.png)

## Variants

The attachment family exposes the same three layout variants on both platforms. Each one is useful in a different part of the product.

| Variant  | Best fit                               | Notes                                                              |
| -------- | -------------------------------------- | ------------------------------------------------------------------ |
| `grid`   | Message bubbles, gallery-like surfaces | Great for image-first attachments and compact previews             |
| `inline` | Prompt composers and compact rows      | Keeps attachments small and easy to remove or inspect              |
| `list`   | Document-heavy or mixed media views    | Better when filename and media type matter more than the thumbnail |

That shared variant model is what makes the component easy to reuse. The same attachment data can be rendered differently depending on where it appears in the product.

## Blocks

The API is intentionally simple. Most implementations only need a container, an item, and a preview, then optionally add metadata or removal behavior.

| Component           | Role                                               |
| ------------------- | -------------------------------------------------- |
| `Attachments`       | Container that applies the selected variant layout |
| `Attachment`        | Individual attachment item with contextual styling |
| `AttachmentPreview` | Thumbnail or icon preview based on media type      |
| `AttachmentInfo`    | Label and optional media type display              |
| `AttachmentRemove`  | Remove button for editable attachment lists        |
| `AttachmentEmpty`   | Empty state surface                                |

On web, the family also includes `AttachmentHoverCard`, `AttachmentHoverCardTrigger`, and `AttachmentHoverCardContent` for richer preview-on-hover behavior.

## Usage

The most common pattern is an `Attachments` container wrapping one or more `Attachment` items. From there, you decide whether the surface should be visual-first, compact, or metadata-heavy.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    The web version is a good fit when you want richer preview behavior and more flexible composition around each item.

    ```tsx
    import {
      Attachment,
      AttachmentInfo,
      AttachmentPreview,
      AttachmentRemove,
      Attachments,
    } from "@workspace/ui-web/ai-elements/attachments";
    import type { AttachmentData } from "@workspace/ui-web/ai-elements/attachments";

    const files = [
      {
        id: "img-1",
        type: "file" as const,
        url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80",
        mediaType: "image/jpeg",
        filename: "mountain-lake.jpg",
      },
      {
        id: "doc-1",
        type: "file" as const,
        url: "https://example.com/product-brief.pdf",
        mediaType: "application/pdf",
        filename: "product-brief.pdf",
      },
    ] satisfies AttachmentData[];

    export function AttachmentRow() {
      return (
        <Attachments variant="inline">
          {files.map((file) => (
            <Attachment key={file.id} data={file} onRemove={() => undefined}>
              <div className="relative size-5 shrink-0">
                <div className="absolute inset-0 transition-opacity group-hover:opacity-0">
                  <AttachmentPreview />
                </div>
                <AttachmentRemove className="absolute inset-0" />
              </div>
              <AttachmentInfo />
            </Attachment>
          ))}
        </Attachments>
      );
    }
    ```
  </Tab>

  <Tab>
    The mobile version follows the same structure, but the container is scroll-based and the interaction model is tuned for touch and native image rendering.

    ```tsx
    import {
      Attachment,
      AttachmentInfo,
      AttachmentPreview,
      AttachmentRemove,
      Attachments,
    } from "@workspace/ui-mobile/ai-elements/attachments";
    import type { AttachmentData } from "@workspace/ui-mobile/ai-elements/attachments";

    const files = [
      {
        id: "img-1",
        type: "file" as const,
        url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80",
        mediaType: "image/jpeg",
        filename: "mountain-lake.jpg",
      },
      {
        id: "doc-1",
        type: "file" as const,
        url: "https://example.com/product-brief.pdf",
        mediaType: "application/pdf",
        filename: "product-brief.pdf",
      },
    ] satisfies AttachmentData[];

    export function AttachmentRow() {
      return (
        <Attachments variant="list">
          {files.map((file) => (
            <Attachment key={file.id} data={file} onRemove={() => undefined}>
              <AttachmentPreview />
              <AttachmentInfo showMediaType />
              <AttachmentRemove />
            </Attachment>
          ))}
        </Attachments>
      );
    }
    ```
  </Tab>
</Tabs>

## Media handling

The preview component decides what to render based on the attachment data, so you do not need different components for every file type.

| Media type    | Preview behavior                           |
| ------------- | ------------------------------------------ |
| Images        | Thumbnail preview                          |
| Video         | Video-style media preview                  |
| Audio         | Audio icon                                 |
| Documents     | File icon plus filename where space allows |
| Unknown files | Generic attachment icon                    |

That keeps the calling code simple. You pass `data`, and the preview layer decides whether the result should look visual or icon-based.

## Platform differences

The shared design language is consistent, but the two implementations still respect the platform they live on.

| Area                | Web                                        | Mobile                                   |
| ------------------- | ------------------------------------------ | ---------------------------------------- |
| Container           | `div`-based layout                         | `ScrollView`-based layout                |
| Rich preview        | Hover-card helpers available               | Native touch flow instead of hover       |
| Image rendering     | Standard `img` / `video` elements          | `expo-image`                             |
| Inline editing feel | Great for composer chips and hover details | Great for touch removal and compact rows |

The result is a component family that feels shared, but not forced to behave identically across platforms.

## In the starter

Attachments are used in two main ways throughout the AI starter: as editable items inside prompt composers, and as rendered assets inside messages or conversation history.

That split explains the API shape. `AttachmentRemove` and compact inline layouts matter more in composers, while `grid` and `list` variants matter more in messages and history views.

## Related components

Attachments rarely appear on their own. These pages are the most relevant companions in the docs set.

<Cards>
  <Card href="/ai/docs/components/prompt-input" title="<PromptInput />" description="The prompt input often hosts inline attachments before a message is sent." />

  <Card href="/ai/docs/components/message" title="<Message />" description="A common parent surface when attachments are rendered inside a conversation." />

  <Card href="/ai/docs/components/analyzing-image" title="<AnalyzingImage />" description="Pairs naturally with image attachments in multimodal and vision-oriented flows." />
</Cards>


# <Context />
Source: https://www.turbostarter.dev/ai/docs/components/context

`<Context />` is a small compound component for answering a question users increasingly care about in AI products: how much context has been used, and what did that response cost?

It turns raw token usage into a compact, inspectable UI that feels at home inside chat interfaces.

## Web

On web, the context UI opens as a lightweight hover card and falls back to a popover on touch devices. That makes it easy to keep the interface compact while still exposing detailed token and cost information when the user asks for it.

![Context component demo](/images/docs/ai/components/context/web.png)

## Mobile

On mobile, the same information is presented through a bottom sheet. That keeps the summary trigger small while giving the detail view enough room for token breakdowns, pricing, and model metadata on a narrow screen.

![Context component demo](/images/docs/ai/components/context/mobile.png)

## What it solves

This component is less about decoration and more about trust. It gives users a simple way to inspect usage details without forcing the main message UI to carry that information all the time.

<Cards>
  <Card title="Makes token usage visible">
    The trigger gives a quick usage signal, and the expanded view shows what is
    happening in more detail.
  </Card>

  <Card title="Connects model choice to cost">
    By combining model metadata with usage numbers, it helps users understand
    why one generation may be more expensive than another.
  </Card>

  <Card title="Fits conversation UIs well">
    It works especially well in assistants, playgrounds, and message rows where
    context usage matters but should not dominate the layout.
  </Card>
</Cards>

## Compound API

Unlike a one-piece badge, `<Context />` is designed as a small composition system. You wrap the data once, then arrange the trigger and content pieces however your interface needs them.

The main parts are:

* `<Context />` for the shared model, provider, and usage data
* `<ContextTrigger />` for the compact entry point
* `<ContextContent />` for the expanded panel or sheet
* `<ContextContentHeader />`, `<ContextContentBody />`, and `<ContextContentFooter />` for structure
* `<ContextInputUsage />`, `<ContextOutputUsage />`, `<ContextReasoningUsage />`, and `<ContextCacheUsage />` for the token breakdown

## Basic composition

The most common pattern is a small trigger that opens a richer detail panel. The implementation differs slightly by platform, but the mental model stays the same.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import {
      Context,
      ContextContent,
      ContextContentBody,
      ContextContentFooter,
      ContextContentHeader,
      ContextInputUsage,
      ContextOutputUsage,
      ContextTrigger,
    } from "@workspace/ui-web/ai-elements/context";

    export function MessageContext() {
      return (
        <Context
          model="claude-sonnet-4-5"
          provider="anthropic"
          usage={{ input: 1240, output: 382, reasoning: 240, cached: 240 }}
        >
          <ContextTrigger />
          <ContextContent>
            <ContextContentHeader />
            <ContextContentBody>
              <ContextInputUsage />
              <ContextOutputUsage />
              <ContextCacheUsage />
              <ContextReasoningUsage />
            </ContextContentBody>
            <ContextContentFooter />
          </ContextContent>
        </Context>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import {
      Context,
      ContextContent,
      ContextContentBody,
      ContextContentFooter,
      ContextContentHeader,
      ContextInputUsage,
      ContextOutputUsage,
      ContextTrigger,
    } from "@workspace/ui-mobile/ai-elements/context";

    export function MessageContext() {
      return (
        <Context
          model="claude-sonnet-4-5"
          provider="anthropic"
          usage={{ input: 1240, output: 382, reasoning: 240, cached: 240 }}
        >
          <ContextTrigger />
          <ContextContent>
            <ContextContentHeader />
            <ContextContentBody>
              <ContextInputUsage />
              <ContextOutputUsage />
              <ContextCacheUsage />
              <ContextReasoningUsage />
            </ContextContentBody>
            <ContextContentFooter />
          </ContextContent>
        </Context>
      );
    }
    ```
  </Tab>
</Tabs>

## Shared inputs

At the top level, both versions take the same core data. That is what makes the component easy to reuse across different message and assistant surfaces.

| Prop       | Type                                                                       | Notes                                                                                  |
| ---------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `model`    | `string`                                                                   | The model identifier used to look up limits and pricing metadata.                      |
| `provider` | `string \| undefined`                                                      | The provider identifier. If omitted, model lookup falls back to model-only resolution. |
| `usage`    | `{ input?: number; output?: number; reasoning?: number; cached?: number }` | The token usage payload shown in the trigger and detail sections.                      |

The rest of the customization mostly comes from composition. You can replace or rearrange the trigger, header, body, footer, and usage rows instead of passing a long list of appearance props.

## Platform-specific behavior

The platform distinction matters here because interaction design changes the feel of the component quite a bit, even when the data is identical.

* Web uses a hover-card style interaction and switches to a popover on touch devices.
* Mobile uses a bottom sheet, which gives the content more breathing room and feels natural in native layouts.
* Both versions fetch model metadata through [tokenlens](https://www.tokenlens.dev/), calculate context usage, and render estimated costs from the same usage payload.

That shared logic helps the component stay consistent even though the surrounding shell is platform-native.

## What the user sees

Most users will encounter this component in two stages: a small trigger first, then a detail surface only when they want more context. That balance keeps the main conversation readable while still exposing meaningful operational detail.

The default experience typically includes:

* a percentage-style trigger based on used context
* a circular usage icon
* the current model name and provider logo
* input, output, reasoning, and cache token rows when available
* an estimated total cost footer

## Related components

`<Context />` is most useful when paired with other message-level primitives. These are the closest companion pages in the component set.

<Cards>
  <Card href="/ai/docs/components/message" title="<Message />" description="A natural place to attach context usage and model metadata in assistant conversations." />

  <Card href="/ai/docs/components/model-selector" title="<ModelSelector />" description="Closely related because context usage becomes more meaningful when users can also change models." />

  <Card href="/ai/docs/components/reasoning" title="<Reasoning />" description="Pairs well with context usage when you want to expose both model effort and token consumption." />
</Cards>


# <Conversation />
Source: https://www.turbostarter.dev/ai/docs/components/conversation

`<Conversation />` is the outer shell for the chat and transcript experience in the AI starter. It is responsible for the part around the messages: scrolling, bottom-follow behavior, loading and error surfaces, and small utilities like export and “jump to latest”.

## Web

The web implementation is built around a stick-to-bottom pattern, which makes it well suited for streaming AI interfaces where new content keeps arriving. It handles the “follow the latest message unless the user scrolls away” behavior for you.

![Web conversation demo](/images/docs/ai/components/conversation/web.png)

## Mobile

The mobile implementation uses a keyboard-friendly scroll container and a small internal context to manage scroll state. It is designed for transcript and chat surfaces that need to stay usable while the keyboard opens and closes.

![Mobile conversation demo](/images/docs/ai/components/conversation/mobile.png)

## Blocks

The conversation family is intentionally small. It gives you the shared shell around the message list without trying to own the messages themselves.

| Component                  | Role                                                        |
| -------------------------- | ----------------------------------------------------------- |
| `Conversation`             | Root container and scroll-state owner                       |
| `ConversationContent`      | Scrollable content region for messages and states           |
| `ConversationScrollButton` | Jump-to-latest button when the user is away from the bottom |
| `ConversationLoading`      | Loading surface while the assistant is still working        |
| `ConversationError`        | Error surface with retry affordance                         |
| `ConversationDownload`     | Export helper for the current conversation                  |

On web, the family also includes `ConversationContentSpacer`, which is useful when the layout needs extra breathing room below the latest message.

## Usage

The common pattern is a root conversation container, a `ConversationContent` region that holds your message list, and then optional utilities like a loading row, error state, or scroll button.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    The web version is driven by the underlying `StickToBottom` component, so its props are best understood as scroll-behavior props plus layout props. The most common pieces are the root container, content area, loader, error, and floating scroll button.

    ```tsx
    import {
      Conversation,
      ConversationContent,
      ConversationError,
      ConversationLoading,
      ConversationScrollButton,
    } from "@workspace/ui-web/ai-elements/conversation";

    export function ChatSurface() {
      return (
        <Conversation className="min-h-[32rem]">
          <ConversationContent className="pb-20">
            <div className="space-y-4">
              <div>First message</div>
              <div>Second message</div>
            </div>

            <ConversationLoading />
            <ConversationError
              error={new Error("Something went wrong")}
              onRetry={() => undefined}
            />
          </ConversationContent>

          <ConversationScrollButton />
        </Conversation>
      );
    }
    ```
  </Tab>

  <Tab>
    The mobile version follows the same structure, but the content area is backed by `KeyboardFriendlyScrollView`. That makes it a better fit for full-screen conversation surfaces that need to stay stable while the user types.

    ```tsx
    import {
      Conversation,
      ConversationContent,
      ConversationError,
      ConversationLoading,
      ConversationScrollButton,
    } from "@workspace/ui-mobile/ai-elements/conversation";

    export function ChatSurface() {
      return (
        <Conversation>
          <ConversationContent contentContainerClassName="pb-20">
            <ConversationLoading />
            <ConversationError
              error={new Error("Something went wrong")}
              onRetry={() => undefined}
            />
          </ConversationContent>

          <ConversationScrollButton />
        </Conversation>
      );
    }
    ```
  </Tab>
</Tabs>

## Scroll behavior

The most important thing this family does is manage how the conversation behaves as new content arrives. That is the difference between a usable chat surface and one that constantly fights the user.

| Behavior          | Web                                                   | Mobile                                                |
| ----------------- | ----------------------------------------------------- | ----------------------------------------------------- |
| Bottom following  | Built on `StickToBottom`                              | Managed with internal scroll state                    |
| Jump to latest    | `ConversationScrollButton` appears when not at bottom | Same idea, with native animated visibility            |
| Keyboard handling | Normal desktop scroll behavior                        | `KeyboardFriendlyScrollView` keeps input flows usable |

This is why `Conversation` matters even though it looks visually simple. It is carrying a lot of interaction behavior that would otherwise get rewritten in every chat screen.

## Loading and error states

Loading and error surfaces are part of the conversation family because they belong to the conversation flow, not to any single message. They are best treated as rows inside `ConversationContent`, not as separate overlays.

| Component             | Purpose                                                  |
| --------------------- | -------------------------------------------------------- |
| `ConversationLoading` | Shows that the assistant is still thinking or generating |
| `ConversationError`   | Shows an error message and exposes a retry action        |

On web, `ConversationLoading` also supports an image-style loading variant, which is useful in multimodal or vision flows where a plain spinner feels too generic.

## Exporting a conversation

Both platforms include `ConversationDownload`, but the behavior is platform-specific. The helper takes an array of conversation messages and turns them into markdown before exporting or sharing.

| Platform | Behavior                     |
| -------- | ---------------------------- |
| Web      | Downloads a `.md` file       |
| Mobile   | Opens the native share sheet |

That makes it a nice utility to keep near the conversation shell rather than rebuilding export logic around the app every time.

## Platform differences

The structure is shared, but the implementation still respects the platform.

| Area                 | Web                                   | Mobile                             |
| -------------------- | ------------------------------------- | ---------------------------------- |
| Root behavior        | `StickToBottom`                       | Context-managed `View` container   |
| Content region       | `StickToBottom.Content`               | `KeyboardFriendlyScrollView`       |
| Scroll button reveal | CSS transition-based visibility       | Reanimated timing-based visibility |
| Extra spacing helper | `ConversationContentSpacer` available | No spacer helper in the same form  |

That split keeps the API familiar while still letting each platform solve the scrolling problem in the way that makes the most sense.

## In the starter

The conversation shell is where `Message`, `Attachments`, `Reasoning`, `Tool`, and loading states all come together. It is the structural surface that turns those individual pieces into a working conversation.

If `PromptInput` starts the interaction and `Message` renders individual entries, `Conversation` is what gives the whole exchange its behavior as a live interface.

## Related components

The conversation shell is closely tied to the rest of the AI chat surface. These are the most useful companion pages in the docs set.

<Cards>
  <Card href="/ai/docs/components/message" title="<Message />" description="The core content rendered inside the conversation container." />

  <Card href="/ai/docs/components/prompt-input" title="<PromptInput />" description="The input surface that typically sits below the conversation." />

  <Card href="/ai/docs/components/attachments" title="<Attachments />" description="Often rendered inside conversation messages and prompt flows." />
</Cards>


# <Message />
Source: https://www.turbostarter.dev/ai/docs/components/message

`<Message />` is the foundation of the conversation UI in the AI starter. It is not only a message bubble, but a small family of components for laying out user and assistant messages, rendering rich responses, attaching actions, and handling response branches.

## Web

The web version is the more feature-rich implementation. It supports user-versus-assistant layout treatment, hover-revealed actions, rich markdown rendering through [Streamdown](https://streamdown.ai/), and a compact branch selector for alternate responses.

![Web message demo](/images/docs/ai/components/message/web.png)

## Mobile

The mobile version keeps the same overall structure, but adapts it to a native layout with touch-friendly spacing and a lighter response renderer. It still supports actions and branch navigation, but without the hover-based affordances from the web version.

![Mobile message demo](/images/docs/ai/components/message/mobile.png)

## Blocks

The message system is intentionally composable. Most conversation UIs only need a few of these pieces, but the family gives you room to build from a simple bubble up to a more advanced assistant surface.

| Component         | Role                                         |
| ----------------- | -------------------------------------------- |
| `Message`         | Root wrapper that sets role-aware layout     |
| `MessageContent`  | Main visual body of the message              |
| `MessageActions`  | Action row below or beside the message       |
| `MessageAction`   | Reusable action button primitive             |
| `MessageResponse` | Rich response renderer for assistant output  |
| `MessageBranch*`  | Components for alternate response navigation |

The most important idea is that the root `Message` sets the role context, and the rest of the family adapts to that context rather than requiring you to pass the same role props over and over again.

## Usage

The common pattern is a role-aware root message, a content section, and then optional actions. Assistant messages usually include `MessageResponse`, while user messages often render plain text or attachments inside `MessageContent`.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    The web version works especially well when assistant output includes formatted markdown, code, math, or diagrams. The most relevant props are the `from` role on `Message`, regular layout props on `MessageContent`, button props on `MessageAction`, and `Streamdown` props on `MessageResponse`.

    ```tsx
    import {
      Message,
      MessageAction,
      MessageActions,
      MessageContent,
      MessageResponse,
    } from "@workspace/ui-web/ai-elements/message";
    import { Icons } from "@workspace/ui-web/icons";

    export function AssistantMessage() {
      return (
        <Message from="assistant">
          <MessageContent>
            <MessageResponse>
              {
                "## Plan\n\nHere is a concise answer with **formatting** and `code`."
              }
            </MessageResponse>
          </MessageContent>

          <MessageActions>
            <MessageAction tooltip="Copy response">
              <Icons.Copy className="size-4" />
            </MessageAction>
          </MessageActions>
        </Message>
      );
    }
    ```
  </Tab>

  <Tab>
    The mobile version keeps the same composition pattern, but the response renderer is based on the native markdown component and actions are always touch-first. The key props are still `from` on `Message`, view props on the layout pieces, and button props on `MessageAction`.

    ```tsx
    import {
      Message,
      MessageAction,
      MessageActions,
      MessageContent,
      MessageResponse,
    } from "@workspace/ui-mobile/ai-elements/message";
    import { Icons } from "@workspace/ui-mobile/icons";

    export function AssistantMessage() {
      return (
        <Message from="assistant">
          <MessageContent>
            <MessageResponse>
              {
                "## Plan\n\nHere is a concise answer with **formatting** and `code`."
              }
            </MessageResponse>
          </MessageContent>

          <MessageActions>
            <MessageAction label="Copy response">
              <Icons.Copy size={16} />
            </MessageAction>
          </MessageActions>
        </Message>
      );
    }
    ```
  </Tab>
</Tabs>

## Assistant and user roles

The message family changes its layout depending on the role. That makes user and assistant messages feel related, but not identical.

| Role      | Treatment                              |
| --------- | -------------------------------------- |
| User      | Right-aligned, bubble-like surface     |
| Assistant | Left-aligned, more open content layout |

That difference is subtle, but important. It lets rich assistant output breathe while still making user messages feel compact and clearly authored.

## Response rendering

`MessageResponse` is one of the most useful pieces in the assistant side of the message family. It gives rich text output a dedicated renderer instead of pushing raw markdown handling into the surrounding conversation code.

| Platform | Renderer                                                                                                  | Notes                                                                      |
| -------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Web      | [Streamdown](https://streamdown.ai/)                                                                      | Supports richer formatting, including code, math, mermaid, and CJK plugins |
| Mobile   | [react-native-enriched-markdown](https://github.com/software-mansion-labs/react-native-enriched-markdown) | Better suited to compact native rendering and scrolling                    |

This is one of the reasons the message family is more than layout. It also standardizes how assistant output is actually presented.

## Message branches

Both platforms support a branch-navigation model for alternate assistant responses. That is useful when the product allows regeneration or multiple candidate answers.

| Component                                     | Purpose                        |
| --------------------------------------------- | ------------------------------ |
| `MessageBranch`                               | Holds branch state             |
| `MessageBranchContent`                        | Renders the active branch      |
| `MessageBranchSelector`                       | Wraps the navigation controls  |
| `MessageBranchPrevious` / `MessageBranchNext` | Move between branches          |
| `MessageBranchPage`                           | Shows the current branch index |

The branch API is intentionally separate from the base `Message` so you only pay for that complexity when the product actually needs it.

## Platform differences

The structure is shared, but the behavior still respects the platform.

| Area                 | Web                                            | Mobile                                         |
| -------------------- | ---------------------------------------------- | ---------------------------------------------- |
| Action visibility    | Hover and focus reveal                         | Touch-first, always available in layout        |
| Response rendering   | `Streamdown`                                   | Native markdown component                      |
| Root layout          | `div`-based with role-specific utility classes | `Animated.View` with native layout transitions |
| Branch selector feel | Compact desktop control group                  | Native button row                              |

That balance keeps the component family consistent without making either platform feel awkwardly ported.

## In the starter

The message family is where many of the other AI UI components come together. Attachments, tools, reasoning traces, context displays, and feedback actions often live inside or around a message.

That is why this page matters. If `PromptInput` starts the interaction, `Message` is where the result actually becomes visible to the user.

## Related components

The message surface is usually composed with several other AI primitives. These are the most relevant companion pages in the docs set.

<Cards>
  <Card href="/ai/docs/components/attachments" title="<Attachments />" description="Often rendered inside user messages and multimodal conversation history." />

  <Card href="/ai/docs/components/reasoning" title="<Reasoning />" description="Useful when assistant messages expose thinking or reasoning traces." />

  <Card href="/ai/docs/components/tool" title="<Tool />" description="Pairs naturally with assistant messages that call tools and show the resulting state." />
</Cards>


# <ModelSelector />
Source: https://www.turbostarter.dev/ai/docs/components/model-selector

`<ModelSelector />` is the model picker used across the AI starter. It ships in two UI variants:

* `select`: a compact dropdown
* `modal`: a richer picker for larger model catalogs (search, providers, capabilities)

The modal variant is also a great fit when your model list is fetched dynamically. In the starter it’s wired to work with remote model catalogs, and you can plug it into providers like OpenRouter, models.dev, or an AI gateway.

## Web

On web you can use either the small `select` dropdown or the larger `modal` picker. The modal adapts to screen size: it renders as a popover on desktop and a drawer on smaller screens.

![ModelSelector component demo](/images/docs/ai/components/model-selector/web.png)

## Mobile

On mobile, the `select` trigger includes the provider or model logo by default. The `modal` variant is built on a bottom sheet and works well for browsing a longer list.

![ModelSelector component demo](/images/docs/ai/components/model-selector/mobile.png)

## Why it matters

Model choice is often one of the most important controls in an AI product, but it can also become visually messy very quickly. This component helps you present that choice in a way that feels intentional instead of improvised.

<Cards>
  <Card title="Designed for AI workflows">
    The component already understands provider logos, model names, and the kind
    of compact trigger most chat products need.
  </Card>

  <Card title="Reusable beyond the dropdown">
    The logo and name helpers are useful on their own in places like usage
    panels, model badges, and message metadata.
  </Card>

  <Card title="Consistent across surfaces">
    Whether the selector appears in a composer, a settings area, or a context
    panel, it keeps the visual language of model choice consistent.
  </Card>
</Cards>

## Building blocks

`<ModelSelector />` is a small family of parts rather than a single monolithic control. Most screens only need the trigger, the content, and the shared logo/name helpers.

The exports are grouped by variant:

`select` variant:

* `ModelSelectorSelect`
* `ModelSelectorSelectTrigger`
* `ModelSelectorSelectContent`
* `ModelSelectorSelectItem`

`modal` variant:

* `ModelSelectorModal`
* `ModelSelectorModalTrigger`
* `ModelSelectorModalContent`
* `ModelSelectorModalList`

Shared helpers:

* `ModelSelectorLogo`
* `ModelSelectorName`
* `ModelSelectorDescription`
* `ModelSelectorProviders`
* `ModelSelectorCapabilities`
* `ModelSelectorSearchInput`

## Variants

Both variants solve “pick a model”, but the interaction feel is different.

| Variant  | Best fit                        | Notes                                                  |
| -------- | ------------------------------- | ------------------------------------------------------ |
| `select` | chat toolbars, compact settings | fast, minimal UI                                       |
| `modal`  | long model lists, discovery     | search + provider browsing; ideal for dynamic catalogs |

## Select variant

The `select` variant is best when the model list is short and the picker should stay out of the way.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import {
      ModelSelectorSelect,
      ModelSelectorSelectContent,
      ModelSelectorSelectItem,
      ModelSelectorSelectTrigger,
    } from "@workspace/ui-web/ai-elements/model-selector";

    export function ChatModelSelector() {
      return (
        <ModelSelectorSelect value="gpt-4.1-mini">
          <ModelSelectorSelectTrigger />
          <ModelSelectorSelectContent>
            <ModelSelectorSelectItem value="gpt-4.1-mini">
              GPT-4.1 Mini
            </ModelSelectorSelectItem>
            <ModelSelectorSelectItem value="claude-4-sonnet">
              Claude 4 Sonnet
            </ModelSelectorSelectItem>
            <ModelSelectorSelectItem value="gemini-2.5-flash">
              Gemini 2.5 Flash
            </ModelSelectorSelectItem>
          </ModelSelectorSelectContent>
        </ModelSelectorSelect>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import {
      ModelSelectorSelect,
      ModelSelectorSelectContent,
      ModelSelectorSelectItem,
      ModelSelectorSelectTrigger,
    } from "@workspace/ui-mobile/ai-elements/model-selector";

    export function ChatModelSelector() {
      return (
        <ModelSelectorSelect value="gpt-4.1-mini">
          <ModelSelectorSelectTrigger provider="openai" model="gpt-4.1-mini" />
          <ModelSelectorSelectContent>
            <ModelSelectorSelectItem value="gpt-4.1-mini">
              GPT-4.1 Mini
            </ModelSelectorSelectItem>
            <ModelSelectorSelectItem value="claude-4-sonnet">
              Claude 4 Sonnet
            </ModelSelectorSelectItem>
            <ModelSelectorSelectItem value="gemini-2.5-flash">
              Gemini 2.5 Flash
            </ModelSelectorSelectItem>
          </ModelSelectorSelectContent>
        </ModelSelectorSelect>
      );
    }
    ```
  </Tab>
</Tabs>

## Modal variant

The `modal` variant is built for browsing. It’s the one you want when you have many models, when you want provider filtering, or when the list is fetched dynamically.

You can source models from anywhere: [OpenRouter](https://openrouter.ai/), [models.dev](https://models.dev/), an [AI gateway](https://vercel.com/ai-gateway), or your own API. The picker only needs a normalized list to render.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import { useEffect, useMemo, useState } from "react";

    import {
      ModelSelectorCapabilities,
      ModelSelectorDescription,
      ModelSelectorLogo,
      ModelSelectorModal,
      ModelSelectorModalContent,
      ModelSelectorModalList,
      ModelSelectorModalTrigger,
      ModelSelectorName,
      ModelSelectorProviders,
      ModelSelectorSearchInput,
    } from "@workspace/ui-web/ai-elements/model-selector";

    type ModelItem = {
      id: string;
      name: string;
      description?: string;
      provider: string;
      attachments: boolean;
      tools: boolean;
      reasoning: boolean;
    };

    export function ChatModelSelectorModal() {
      const [value, setValue] = useState("gpt-4.1-mini");
      const [provider, setProvider] = useState("openai");
      const [query, setQuery] = useState("");
      const [models, setModels] = useState<ModelItem[]>([]);

      useEffect(() => {
        // Fetch models dynamically (OpenRouter, models.dev, AI gateway, or your API).
        setModels([
          {
            id: "gpt-4.1-mini",
            name: "GPT-4.1 Mini",
            provider: "openai",
            description: "Fast, general-purpose model.",
            attachments: true,
            tools: true,
            reasoning: false,
          },
          {
            id: "claude-4-sonnet",
            name: "Claude 4 Sonnet",
            provider: "anthropic",
            description: "Strong writing and reasoning balance.",
            attachments: true,
            tools: true,
            reasoning: true,
          },
        ]);
      }, []);

      const providers = useMemo(
        () => Array.from(new Set(models.map((m) => m.provider))),
        [models],
      );

      const filtered = useMemo(() => {
        return models
          .filter((m) => (provider ? m.provider === provider : true))
          .filter((m) => m.name.toLowerCase().includes(query.toLowerCase()));
      }, [models, provider, query]);

      return (
        <ModelSelectorModal>
          <ModelSelectorModalTrigger>
            <ModelSelectorLogo provider={provider} model={value} />
            <ModelSelectorName>
              {models.find((m) => m.id === value)?.name ?? "Select a model"}
            </ModelSelectorName>
          </ModelSelectorModalTrigger>

          <ModelSelectorModalContent
            className="w-[min(44rem,calc(100vw-2rem))] p-0"
            popover={{ align: "end" }}
          >
            <div className="flex min-h-0 min-w-0 flex-col md:flex-row">
              <ModelSelectorProviders
                providers={providers}
                value={provider}
                onValueChange={setProvider}
                className="shrink-0"
              />

              <div className="flex min-h-0 min-w-0 flex-1 flex-col">
                <ModelSelectorSearchInput
                  value={query}
                  onChange={(e) => setQuery(e.currentTarget.value)}
                  placeholder="Search models"
                />

                <ModelSelectorModalList className="h-[28rem]">
                  <div className="flex flex-col gap-0.5">
                    {filtered.map((m) => (
                      <button
                        key={m.id}
                        type="button"
                        role="option"
                        aria-selected={m.id === value}
                        className="hover:bg-accent flex min-w-0 items-start gap-3 rounded-lg px-3 py-2 text-left"
                        onClick={() => setValue(m.id)}
                      >
                        <ModelSelectorLogo provider={m.provider} model={m.id} />
                        <div className="min-w-0 flex-1">
                          <div className="flex min-w-0 items-center justify-between gap-3">
                            <ModelSelectorName>{m.name}</ModelSelectorName>
                            <ModelSelectorCapabilities
                              attachments={m.attachments}
                              tools={m.tools}
                              reasoning={m.reasoning}
                            />
                          </div>
                          {m.description && (
                            <ModelSelectorDescription>
                              {m.description}
                            </ModelSelectorDescription>
                          )}
                        </div>
                      </button>
                    ))}
                  </div>
                </ModelSelectorModalList>
              </div>
            </div>
          </ModelSelectorModalContent>
        </ModelSelectorModal>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import { useMemo, useState } from "react";
    import { View } from "react-native";

    import {
      ModelSelectorCapabilities,
      ModelSelectorDescription,
      ModelSelectorLogo,
      ModelSelectorModal,
      ModelSelectorModalContent,
      ModelSelectorModalList,
      ModelSelectorModalTrigger,
      ModelSelectorName,
      ModelSelectorProviders,
      ModelSelectorSearchInput,
    } from "@workspace/ui-mobile/ai-elements/model-selector";
    import { Button } from "@workspace/ui-mobile/button";
    import { Text } from "@workspace/ui-mobile/text";

    type ModelItem = {
      id: string;
      name: string;
      description?: string;
      provider: string;
      attachments: boolean;
      tools: boolean;
      reasoning: boolean;
    };

    const models: ModelItem[] = [
      {
        id: "gpt-4.1-mini",
        name: "GPT-4.1 Mini",
        provider: "openai",
        description: "Fast, general-purpose model.",
        attachments: true,
        tools: true,
        reasoning: false,
      },
      {
        id: "claude-4-sonnet",
        name: "Claude 4 Sonnet",
        provider: "anthropic",
        description: "Strong writing and reasoning balance.",
        attachments: true,
        tools: true,
        reasoning: true,
      },
    ];

    export function ChatModelSelectorModal() {
      const [value, setValue] = useState("gpt-4.1-mini");
      const [provider, setProvider] = useState("openai");
      const [query, setQuery] = useState("");

      const providers = useMemo(
        () => Array.from(new Set(models.map((m) => m.provider))),
        [],
      );

      const filtered = useMemo(() => {
        return models
          .filter((m) => (provider ? m.provider === provider : true))
          .filter((m) => m.name.toLowerCase().includes(query.toLowerCase()));
      }, [provider, query]);

      return (
        <ModelSelectorModal>
          <ModelSelectorModalTrigger>
            <ModelSelectorLogo provider={provider} model={value} size={20} />
            <ModelSelectorName>
              {models.find((m) => m.id === value)?.name ?? "Select a model"}
            </ModelSelectorName>
          </ModelSelectorModalTrigger>

          <ModelSelectorModalContent>
            <Text className="px-4 pb-2 font-sans-medium">Models</Text>

            <ModelSelectorSearchInput
              value={query}
              onChangeText={setQuery}
              placeholder="Search models"
            />

            <ModelSelectorProviders
              providers={providers}
              value={provider}
              onValueChange={setProvider}
            />

            <ModelSelectorModalList
              data={filtered}
              estimatedItemSize={64}
              keyExtractor={(item) => item.id}
              renderItem={({ item }) => (
                <Button
                  variant="ghost"
                  className="flex-row items-start gap-3 rounded-xl p-3"
                  onPress={() => setValue(item.id)}
                >
                  <ModelSelectorLogo
                    provider={item.provider}
                    model={item.id}
                    size={20}
                  />
                  <View className="min-w-0 flex-1">
                    <View className="flex-row items-center justify-between gap-3">
                      <ModelSelectorName>{item.name}</ModelSelectorName>
                      <ModelSelectorCapabilities
                        attachments={item.attachments}
                        tools={item.tools}
                        reasoning={item.reasoning}
                      />
                    </View>
                    {item.description && (
                      <ModelSelectorDescription>
                        {item.description}
                      </ModelSelectorDescription>
                    )}
                  </View>
                </Button>
              )}
            />
          </ModelSelectorModalContent>
        </ModelSelectorModal>
      );
    }
    ```
  </Tab>
</Tabs>

## Logo and name helpers

One of the most useful details in this component family is that the branding logic is reusable. You do not need to duplicate provider-logo matching in other parts of the interface.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import {
      ModelSelectorLogo,
      ModelSelectorName,
    } from "@workspace/ui-web/ai-elements/model-selector";

    export function ModelMeta() {
      return (
        <div className="flex items-center gap-2">
          <ModelSelectorLogo provider="anthropic" model="claude-4-sonnet" />
          <ModelSelectorName>Claude 4 Sonnet</ModelSelectorName>
        </div>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import {
      ModelSelectorLogo,
      ModelSelectorName,
    } from "@workspace/ui-mobile/ai-elements/model-selector";

    export function ModelMeta() {
      return (
        <View className="flex-row items-center gap-2">
          <ModelSelectorLogo provider="anthropic" model="claude-4-sonnet" />
          <ModelSelectorName>Claude 4 Sonnet</ModelSelectorName>
        </View>
      );
    }
    ```
  </Tab>
</Tabs>

The helpers first try to match model-specific icons for names like `claude`, `gemini`, `grok`, or `nano-banana`. If no model-specific icon matches, they fall back to the provider icon, and then finally to an external logo from [models.dev](https://models.dev/).

## Platform differences

The two versions stay close in spirit, but there are a few differences worth knowing when you design around them.

| Area             | Web                                        | Mobile                                  |
| ---------------- | ------------------------------------------ | --------------------------------------- |
| `select` trigger | Compact text-first trigger                 | Trigger includes logo by default        |
| Logo fallback    | `img` fallback from `models.dev`           | `expo-image` fallback from `models.dev` |
| Name helper      | `span`-based text helper                   | native `Text`-based helper              |
| `modal` surface  | popover (desktop) / drawer (small screens) | bottom sheet                            |
| Visual feel      | tighter desktop toolbar fit                | easier scanning in touch layouts        |

## What to customize

Most customization happens through composition and styling rather than through a long prop list. In practice, the main knobs are:

* the variant you choose (`select` vs `modal`)
* the selected value and state wiring in your app
* `className` on triggers and list rows
* the `provider` and `model` values used to resolve the right logo
* wiring the modal list to a dynamic model catalog (OpenRouter, models.dev, AI gateway, or your API)

That makes the component easy to adapt without turning it into a configuration-heavy abstraction.

## Related components

`<ModelSelector />` tends to live near other model-aware pieces of the UI. These are the most natural companion pages in this docs set.

<Cards>
  <Card href="/ai/docs/components/context" title="<Context />" description="Uses the logo and name helpers when presenting model usage and cost details." />

  <Card href="/ai/docs/components/prompt-input" title="<PromptInput />" description="A common place to embed a model switcher in a chat or generation workflow." />

  <Card href="/ai/docs/components/message" title="<Message />" description="Useful when model metadata appears alongside assistant output or generation state." />
</Cards>


# <PromptInput />
Source: https://www.turbostarter.dev/ai/docs/components/prompt-input

`<PromptInput />` is the main text-entry surface across the AI starter. It is not just a single input field, but a small component family for building [chat](/ai/docs/chat), [image](/ai/docs/image), [RAG](/ai/docs/rag), and [TTS](/ai/docs/tts) composers with the same design language.

## Web

The web version is the broader implementation. It supports provider-driven state, drag and drop, attachment actions, referenced sources, menus, selects, hover cards, and richer composition around the textarea.

![Web prompt input demo](/images/docs/ai/components/prompt-input/web.png)

## Mobile

The mobile version keeps the same overall structure, but adapts it to native interaction patterns. Instead of drag and drop and hover-based UI, it leans on bottom sheets, touch-friendly buttons, and platform pickers for camera, photos, and files.

![Mobile prompt input demo](/images/docs/ai/components/prompt-input/mobile.png)

## What makes it useful

Prompt input is where a lot of AI product complexity shows up. This component family gives that complexity a clean place to live without turning the composer into one giant custom component.

<Cards>
  <Card title="Composes well">
    You can start with a textarea and submit button, then add tools, model
    selectors, menus, attachments, and helper UI as needed.
  </Card>

  <Card title="Works across multiple apps">
    The same family is used for [chat](/ai/docs/chat), [image
    generation](/ai/docs/image), [knowledge RAG](/ai/docs/rag), and
    [text-to-speech](/ai/docs/tts) flows in the starter.
  </Card>

  <Card title="Handles real AI-input needs">
    Attachments, generation state, stop actions, and external input control are
    all first-class parts of the API.
  </Card>
</Cards>

## Blocks

The component family is intentionally broad, but most implementations only need a handful of pieces. The root container handles submission flow, while the surrounding helpers shape the final composer UI.

| Component                                                     | Role                                                                              |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `PromptInput`                                                 | Root container that owns submission flow and local state when no provider is used |
| `PromptInputProvider`                                         | Optional shared state provider for input text and attachments                     |
| `PromptInputTextarea`                                         | Main text entry area                                                              |
| `PromptInputSubmit`                                           | Send or stop button tied to generation state                                      |
| `PromptInputHeader` / `PromptInputBody` / `PromptInputFooter` | Layout regions for building the composer                                          |
| `PromptInputTools` / `PromptInputButton`                      | Tool rows and compact actions                                                     |
| `PromptInputActionMenu*`                                      | Attachment and secondary action menu primitives                                   |
| `PromptInputSelect*`                                          | Model or option selectors placed inside the composer                              |

On web, the family also includes extras like `PromptInputDropzone`, `PromptInputActionAddAttachments`, `PromptInputHoverCard*`, `PromptInputCommand*`, and referenced source helpers.

## Usage

The most common pattern is a root prompt input with a textarea and footer. From there, you can add tools and actions depending on the product surface.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    The web version is best when the prompt input needs to behave like a full composer surface. The root component supports `status`, `dropzone`, `attachments`, `onSubmit`, and regular form props, while the child pieces shape the UI around it.

    ```tsx
    import {
      PromptInput,
      PromptInputFooter,
      PromptInputSubmit,
      PromptInputTextarea,
      PromptInputTools,
    } from "@workspace/ui-web/ai-elements/prompt-input";
    import type { PromptInputMessage } from "@workspace/ui-web/ai-elements/prompt-input";

    export function ChatComposer() {
      return (
        <PromptInput
          status="ready"
          attachments={{
            maxFiles: 5,
            allowedMimeTypes: ["image/*", "application/pdf"],
          }}
          onSubmit={async (message: PromptInputMessage) => {
            console.log(message.text, message.files);
          }}
          className="w-full"
        >
          <PromptInputTextarea placeholder="Ask anything..." />

          <PromptInputFooter>
            <PromptInputTools />
            <PromptInputSubmit status="ready" />
          </PromptInputFooter>
        </PromptInput>
      );
    }
    ```
  </Tab>

  <Tab>
    The mobile version follows the same composition idea, but the root is a `View`-based container and the action flow is tuned for touch and native pickers. The key props are `status`, `attachments`, `onSubmit`, and standard view props.

    ```tsx
    import {
      PromptInput,
      PromptInputFooter,
      PromptInputSubmit,
      PromptInputTextarea,
      PromptInputTools,
    } from "@workspace/ui-mobile/ai-elements/prompt-input";
    import type { PromptInputMessage } from "@workspace/ui-mobile/ai-elements/prompt-input";

    export function ChatComposer() {
      return (
        <PromptInput
          status="ready"
          attachments={{
            maxFiles: 5,
            allowedMimeTypes: ["image/*", "application/pdf"],
          }}
          onSubmit={async (message: PromptInputMessage) => {
            console.log(message.text, message.files);
          }}
        >
          <PromptInputTextarea placeholder="Ask anything..." />

          <PromptInputFooter>
            <PromptInputTools />
            <PromptInputSubmit status="ready" />
          </PromptInputFooter>
        </PromptInput>
      );
    }
    ```
  </Tab>
</Tabs>

## Shared state

If the composer needs to be controlled from outside the prompt input itself, both platforms expose a provider and controller hook. That is useful when examples, attachment previews, or external UI need to read or update the same state.

| Piece                         | Purpose                                                    |
| ----------------------------- | ---------------------------------------------------------- |
| `PromptInputProvider`         | Lifts input and attachment state outside the root composer |
| `usePromptInputController()`  | Gives access to `textInput` and `attachments`              |
| `usePromptInputAttachments()` | Reads and manages current attachments                      |

On web, the provider also keeps track of the dropzone state so actions like “add attachments” can open the file dialog from elsewhere in the composer tree.

## Attachments and actions

Attachments are a core part of the prompt input family, but the interaction model differs between web and mobile.

| Area               | Web                                                                   | Mobile                                                                         |
| ------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| File input         | Drag and drop plus file dialog                                        | Native camera, photo library, and document pickers                             |
| Menu model         | Dropdown-based action menu                                            | Bottom-sheet action menu                                                       |
| Attachment helpers | `PromptInputDropzone`, `PromptInputActionAddAttachments`              | `PromptInputActionCamera`, `PromptInputActionPhotos`, `PromptInputActionFiles` |
| Validation         | `PromptInputAttachmentsOptions` for file count, size, and MIME checks | Same validation model, adapted to native assets                                |

That split is important: the API stays conceptually similar, but each platform uses the interaction pattern users already expect.

## References

You do not need the entire component family every time. These are the parts most apps will end up using first.

| Need                      | Component                                            |
| ------------------------- | ---------------------------------------------------- |
| Main text field           | `PromptInputTextarea`                                |
| Submit or stop button     | `PromptInputSubmit`                                  |
| Footer layout             | `PromptInputFooter`                                  |
| Inline tools row          | `PromptInputTools`                                   |
| Action menu trigger       | `PromptInputActionMenuTrigger`                       |
| Model or option selector  | `PromptInputSelect*`                                 |
| Provider-controlled state | `PromptInputProvider` + `usePromptInputController()` |

On web, the command and hover-card primitives are also worth reaching for when the composer needs richer inline UX, such as search, slash commands, or contextual help.

## In the starter

The prompt input is one of the most reused UI systems in the AI starter. It shows up in the [Chat](/ai/docs/chat), [Image](/ai/docs/image), [RAG](/ai/docs/rag), and [TTS](/ai/docs/tts) apps, with each surface composing a slightly different set of tools around the same foundation.

That reuse is the main reason the component family matters. Instead of rebuilding the composer for every app, the starter uses the same primitives and swaps in app-specific controls, selectors, and attachment behavior.

## Related components

The prompt input usually sits next to other AI UI primitives rather than standing alone. These pages are the closest companions in the docs set.

<Cards>
  <Card href="/ai/docs/components/model-selector" title="<ModelSelector />" description="Often placed inside the prompt input tools row so users can switch models before sending." />

  <Card href="/ai/docs/components/context" title="<Context />" description="Useful when the composer also surfaces token or usage details nearby." />

  <Card href="/ai/docs/components/tool" title="<Tool />" description="A natural follow-up surface once the prompt input triggers a tool-using response." />
</Cards>


# <Reasoning />
Source: https://www.turbostarter.dev/ai/docs/components/reasoning

`<Reasoning />` gives hidden model thinking a readable place to live. It helps you surface reasoning progress, completion state, and the final reasoning text without forcing that detail into the main assistant message.

## Web

The web version works especially well in chat and playground interfaces where users may want to peek into the model's thought process, but only when they choose to. It uses a compact trigger row plus an expandable content area with richer formatting support.

![Reasoning component demo](/images/docs/ai/components/reasoning/web.png)

## Mobile

The mobile version keeps the same interaction pattern, but simplifies the rendering for a native layout. It is a strong fit when you want to preserve the idea of “peek into reasoning” without overloading the small screen.

![Reasoning component demo](/images/docs/ai/components/reasoning/mobile.png)

## What it adds

Reasoning UI is most valuable when users want transparency without clutter. This component gives you that middle ground by separating the “thinking” status from the actual answer.

<Cards>
  <Card title="Makes reasoning inspectable">
    Users can expand the reasoning only when they care, instead of reading it
    inline with the assistant response.
  </Card>

  <Card title="Communicates progress clearly">
    The trigger changes its message and icon depending on whether reasoning is
    still streaming or already finished.
  </Card>

  <Card title="Feels native in AI products">
    It fits well beside message, tool, and context components in a modern chat
    interface.
  </Card>
</Cards>

## Building blocks

The API is intentionally small. You usually only need three pieces:

* `<Reasoning />` for the shared collapsible container and state logic
* `<ReasoningTrigger />` for the status row
* `<ReasoningContent />` for the reasoning body

The component also manages a few useful behaviors for you, like auto-opening when reasoning starts streaming and auto-closing shortly after it finishes, unless you explicitly control the open state yourself.

## Basic composition

The normal pattern is a trigger followed by the expandable reasoning content. Both platforms use the same idea, so you can carry the same design language across web and mobile.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import {
      Reasoning,
      ReasoningContent,
      ReasoningTrigger,
    } from "@workspace/ui-web/ai-elements/reasoning";

    export function AssistantReasoning() {
      return (
        <Reasoning isStreaming={false} duration={4}>
          <ReasoningTrigger />
          <ReasoningContent>
            {`I compared the user's request against the available options, ruled out
    the ones that violated the constraints, and selected the safest match.`}
          </ReasoningContent>
        </Reasoning>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import {
      Reasoning,
      ReasoningContent,
      ReasoningTrigger,
    } from "@workspace/ui-mobile/ai-elements/reasoning";

    export function AssistantReasoning() {
      return (
        <Reasoning isStreaming={false} duration={4}>
          <ReasoningTrigger />
          <ReasoningContent>
            {`I compared the user's request against the available options, ruled out
    the ones that violated the constraints, and selected the safest match.`}
          </ReasoningContent>
        </Reasoning>
      );
    }
    ```
  </Tab>
</Tabs>

## State behavior

The component changes its trigger behavior based on whether reasoning is actively streaming or already complete. That gives the UI a sense of motion without requiring extra wiring in the caller.

| Situation                        | Trigger behavior                                                     |
| -------------------------------- | -------------------------------------------------------------------- |
| Streaming                        | Shows a spinner and shimmer-style “in progress” message              |
| Finished, no duration yet        | Shows a completed message                                            |
| Finished, duration available     | Shows a completed message with elapsed time                          |
| Explicitly controlled open state | Respects the caller's open state instead of relying on auto behavior |

This is one of the reasons the component feels nice in practice: it handles the common “thinking -> done” rhythm for you.

## Platform differences

The core interaction is shared, but the content rendering differs between web and mobile.

| Area              | Web                                                                                        | Mobile                                                    |
| ----------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
| Content rendering | [Streamdown](https://streamdown.ai/) with support for code, math, mermaid, and CJK plugins | plain native text rendering                               |
| Trigger layout    | desktop-friendly inline row                                                                | touch-friendly inline row                                 |
| Text treatment    | shimmer for active state, richer formatted content when expanded                           | shimmer for active state, simpler text body when expanded |

The web version is a better fit if you want richly formatted reasoning content. The mobile version is better when you want the same product concept in a lighter-weight native surface.

## Useful control points

Most teams will not need to customize much, but there are a few props worth knowing about:

| Prop                 | Type                                   | Notes                                                   |
| -------------------- | -------------------------------------- | ------------------------------------------------------- |
| `isStreaming`        | `boolean`                              | Drives the active versus completed state.               |
| `open`               | `boolean`                              | Lets you fully control the open state.                  |
| `defaultOpen`        | `boolean`                              | Sets the initial state for uncontrolled usage.          |
| `onOpenChange`       | `(open: boolean) => void`              | Lets you react to user toggles.                         |
| `duration`           | `number`                               | Overrides or supplies the displayed reasoning duration. |
| `getThinkingMessage` | `(isStreaming, duration) => ReactNode` | Customizes the trigger message in `ReasoningTrigger`.   |

## Related components

`<Reasoning />` is usually part of a broader assistant response surface. These pages are the closest companions in the component set.

<Cards>
  <Card href="/ai/docs/components/shimmer" title="<ShimmerText />" description="Used by the trigger while reasoning is still in progress." />

  <Card href="/ai/docs/components/message" title="<Message />" description="A common parent surface when reasoning is attached to assistant output." />

  <Card href="/ai/docs/components/tool" title="<Tool />" description="Complements reasoning when the assistant both thinks and acts during a response." />
</Cards>


# <ShimmerText />
Source: https://www.turbostarter.dev/ai/docs/components/shimmer

`<ShimmerText />` is a small component with a big job: it makes waiting states feel alive without adding heavy UI. In TurboStarter AI, it is used when something is actively happening, like reasoning, tool execution, or image analysis, and you want a softer signal than a spinner alone.

![ShimmerText component demo](/images/docs/ai/components/shimmer.gif)

## Why it is useful

Shimmer text works best when the UI should feel active but calm. It gives users feedback that something is still in progress without making the interface feel noisy or overloaded.

<Cards>
  <Card title="More subtle than a spinner">
    It communicates progress without taking over the layout or competing with
    the actual content.
  </Card>

  <Card title="Works well in AI interfaces">
    It is especially effective for labels like “thinking”, “analyzing image”, or
    a tool name that is still running.
  </Card>

  <Card title="Consistent across platforms">
    Both implementations aim for the same product feel, even though the
    underlying rendering strategy differs between web and mobile.
  </Card>
</Cards>

## Usage

The simplest usage is to wrap a short status string. This works well for inline loading states, trigger labels, and compact assistant UI elements.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import { ShimmerText } from "@workspace/ui-web/ai-elements/shimmer";

    export function ThinkingLabel() {
      return <ShimmerText>Thinking...</ShimmerText>;
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import { ShimmerText } from "@workspace/ui-mobile/ai-elements/shimmer";

    export function ThinkingLabel() {
      return <ShimmerText>Thinking...</ShimmerText>;
    }
    ```
  </Tab>
</Tabs>

## Platform differences

The shared API is intentionally small, but the two implementations expose slightly different customization points because they are solving the effect in different environments.

### Web props

The web version expects a string child and supports a few simple tuning options. It is ideal when you want a polished shimmer effect with minimal setup.

| Prop        | Type          | Notes                                                            |
| ----------- | ------------- | ---------------------------------------------------------------- |
| `children`  | `string`      | The text content to render.                                      |
| `as`        | `ElementType` | Changes the rendered element, such as `p`, `span`, or `div`.     |
| `className` | `string`      | Adds typography or spacing classes.                              |
| `duration`  | `number`      | Controls how long one shimmer cycle takes.                       |
| `spread`    | `number`      | Controls the width of the highlight relative to the text length. |

### Mobile props

The mobile version is also lightweight, but it includes a few extra controls because the animation is built from a masked gradient rather than CSS background clipping.

| Prop             | Type             | Notes                                                       |
| ---------------- | ---------------- | ----------------------------------------------------------- |
| `children`       | `string`         | The text content to render.                                 |
| `className`      | `string`         | Applies text styling and layout classes.                    |
| `duration`       | `number`         | Controls animation speed.                                   |
| `direction`      | `"ltr" \| "rtl"` | Changes the shimmer direction.                              |
| `angle`          | `number`         | Rotates the gradient used for the highlight.                |
| `highlightWidth` | `number`         | Adjusts how wide the bright section of the shimmer appears. |

## How it works

Both versions aim for the same product outcome, but they get there differently because web and mobile do not offer the same rendering primitives.

* On web, the text becomes transparent and is filled by an animated background gradient.
* On mobile, the text is used as a mask and an animated gradient moves behind it.
* In both cases, the component stays focused on presentation only. It does not manage loading state itself; it simply makes a loading label feel better.

## Where it appears

`<ShimmerText />` is a foundational helper in the AI UI kit rather than a one-off effect. It shows up in a few different places where the interface benefits from an in-progress label with a little motion.

<Cards>
  <Card href="/ai/docs/components/analyzing-image" title="<AnalyzingImage />" description="Uses shimmer text to make the image-analysis state feel more intentional." />

  <Card href="/ai/docs/components/reasoning" title="<Reasoning />" description="Uses shimmer text while reasoning is still in progress." />

  <Card href="/ai/docs/components/tool" title="<Tool />" description="Uses shimmer text for tool names while a tool call is still running." />
</Cards>


# <Tool />
Source: https://www.turbostarter.dev/ai/docs/components/tool

`<Tool />` turns a tool call into something users can actually read. Instead of exposing raw tool events or JSON blobs in the message flow, it gives you a structured surface for showing what ran, what state it is in, and what came back.

## Web

The web version is built around a collapsible row that feels native inside a desktop conversation. It is especially good for agentic chat UIs where tool activity should be visible but not overwhelming.

![Tool component demo](/images/docs/ai/components/tool/web.png)

## Mobile

The mobile version keeps the same mental model, but adapts the spacing and content rendering to a native layout. It still behaves like a compact activity row first, with details available when expanded.

![Tool component demo](/images/docs/ai/components/tool/mobile.png)

## What it communicates

This component is useful because tool calls are rarely just “done” or “not done.” They move through approval, execution, success, denial, or error states, and the UI needs to make that progression feel understandable.

<Cards>
  <Card title="Gives tool calls a proper status surface">
    Users can tell whether a tool is pending, running, completed, denied, or
    failed without reading raw event payloads.
  </Card>

  <Card title="Keeps details out of the main message flow">
    Inputs and outputs can stay tucked away until the user wants to inspect
    them.
  </Card>

  <Card title="Works well for agentic products">
    It fits naturally into assistant interfaces where tool calls are part of the
    conversation, not a separate debug panel.
  </Card>
</Cards>

## Building blocks

`<Tool />` is a compact compound component. The outer wrapper manages the collapsible state, and the inner pieces let you decide how much of the tool call to show.

The main exports are:

* `<Tool />`
* `<ToolHeader />`
* `<ToolContent />`
* `<ToolInput />`
* `<ToolOutput />`
* `<StatusBadge />`

## Basic composition

The standard pattern is a collapsed header row for the tool call plus expandable details for the input and output. The API shape stays close across platforms, which makes it easy to keep the same mental model in both apps.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import {
      Tool,
      ToolContent,
      ToolHeader,
      ToolInput,
      ToolOutput,
    } from "@workspace/ui-web/ai-elements/tool";

    export function WeatherTool() {
      return (
        <Tool>
          <ToolHeader type="tool-weather" state="input-available" />
          <ToolContent>
            <ToolInput input={{ city: "Warsaw", unit: "celsius" }} />
            <ToolOutput
              output={{ temperature: 18, condition: "Cloudy" }}
              errorText={undefined}
            />
          </ToolContent>
        </Tool>
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import {
      Tool,
      ToolContent,
      ToolHeader,
      ToolInput,
      ToolOutput,
    } from "@workspace/ui-mobile/ai-elements/tool";

    export function WeatherTool() {
      return (
        <Tool>
          <ToolHeader type="tool-weather" state="input-available" />
          <ToolContent>
            <ToolInput input={{ city: "Warsaw", unit: "celsius" }} />
            <ToolOutput
              output={{ temperature: 18, condition: "Cloudy" }}
              errorText={undefined}
            />
          </ToolContent>
        </Tool>
      );
    }
    ```
  </Tab>
</Tabs>

## Supported states

The component family is designed around tool lifecycle states rather than around a single “loading” flag. That is why it reads much better in agent-driven UIs than a plain spinner row.

| State                | Meaning                                                                     |
| -------------------- | --------------------------------------------------------------------------- |
| `approval-requested` | The tool is waiting for explicit approval before it can run.                |
| `approval-responded` | An approval decision was made and the tool can proceed or stop accordingly. |
| `input-available`    | The tool input is ready and execution is underway.                          |
| `input-streaming`    | Input or tool activity is still streaming in.                               |
| `output-available`   | The tool completed successfully and produced a result.                      |
| `output-denied`      | The tool run was denied or blocked.                                         |
| `output-error`       | The tool failed and returned an error state.                                |

## How the pieces behave

A lot of the value in this component comes from the defaults baked into each part. You get a fairly rich tool row without having to author every little detail yourself.

* `<ToolHeader />` derives a readable tool name from the `type` when you do not pass a custom `title`.
* Non-final states use `<ShimmerText />` to make the label feel active.
* Final states switch to a static label and a status badge.
* `<ToolInput />` renders structured input as JSON.
* `<ToolOutput />` can render JSON, strings, React elements, or an error panel.

That balance is what makes the component useful in both product UI and internal agent tooling.

## Platform notes

The web and mobile versions stay aligned conceptually, but the rendering details are slightly different.

| Area                   | Web                           | Mobile                           |
| ---------------------- | ----------------------------- | -------------------------------- |
| Base shell             | DOM collapsible row           | native collapsible row           |
| Input/output rendering | code-block style surface      | native scrollable JSON block     |
| Status text            | web text + shimmer primitives | native text + shimmer primitives |
| Layout feel            | tighter desktop density       | more touch-friendly spacing      |

## Related components

`<Tool />` works best alongside the other conversation-level primitives that explain what the assistant is doing. These are the nearest companion pages in the current docs set.

<Cards>
  <Card href="/ai/docs/components/shimmer" title="<ShimmerText />" description="Used by the tool header while a tool is still pending or running." />

  <Card href="/ai/docs/components/message" title="<Message />" description="A natural parent surface when tool calls are embedded directly in assistant messages." />

  <Card href="/ai/docs/components/reasoning" title="<Reasoning />" description="Pairs well with tool execution when you want to show both thought and action in the same response." />
</Cards>


# <VoiceControlBar />
Source: https://www.turbostarter.dev/ai/docs/components/voice-control-bar

`<VoiceControlBar />` is the main interaction surface for the voice session once a user is connected. It brings the core voice actions into one place, so the session feels like a proper call experience rather than a scattered set of controls.

## Web

The web control bar is the richer of the two implementations. It supports the main media toggles, disconnect flow, and an expandable inline chat composer for sending text into the live session.

![Web control bar demo](/images/docs/ai/components/voice-control-bar/web.png)

## Mobile

The mobile control bar keeps the same control model, but presents it in a tighter native layout with larger touch targets and no inline text composer inside the bar itself.

![Mobile control bar demo](/images/docs/ai/components/voice-control-bar/mobile.png)

## What it does well

This component is useful because a voice product needs more than a mute button. Once the user is in a live session, the control surface has to coordinate media, chat, and exit actions in a way that stays readable under pressure.

<Cards>
  <Card title="Keeps session controls together">
    Microphone, camera, screen sharing, chat, and disconnect actions live in one
    predictable place.
  </Card>

  <Card title="Feels native to real-time voice UX">
    The control bar gives the session a call-like interaction pattern instead of
    a generic toolbar.
  </Card>

  <Card title="Scales from simple to full sessions">
    You can show only a few controls or enable the full bar depending on the
    product surface.
  </Card>
</Cards>

## Core controls

Both implementations revolve around the same control categories, even though the internal composition differs by platform.

| Control      | Purpose                                     |
| ------------ | ------------------------------------------- |
| Microphone   | Mute or unmute the user's audio track       |
| Camera       | Enable or disable the local camera track    |
| Screen share | Start or stop screen sharing when supported |
| Chat         | Toggle an in-session chat surface           |
| Leave        | Disconnect from the active session          |

On web, the chat control can expand into a compact inline input inside the control bar. On mobile, chat is still represented as a toggle, but the actual message entry happens in the surrounding session UI rather than inside the bar.

## Basic usage

You usually render the control bar as part of a connected voice session, passing in which controls should be visible and wiring it to the session state around it.

<Tabs items={["Web", "Mobile"]}>
  <Tab>
    ```tsx
    import { ControlBar } from "@workspace/ui-web/voice/control-bar";

    export function VoiceSessionControls() {
      return (
        <ControlBar
          isConnected
          controls={{
            microphone: true,
            camera: true,
            screenShare: true,
            chat: true,
            leave: true,
          }}
        />
      );
    }
    ```
  </Tab>

  <Tab>
    ```tsx
    import { ControlBar } from "@workspace/ui-mobile/voice/control-bar";

    export function VoiceSessionControls() {
      return (
        <ControlBar
          controls={{
            microphone: true,
            camera: true,
            screenShare: true,
            chat: true,
            leave: true,
          }}
        />
      );
    }
    ```
  </Tab>
</Tabs>

## Platform differences

The interaction model is shared, but the two implementations are not identical. That is intentional, because a voice call bar should respect the platform it lives on.

| Area          | Web                                               | Mobile                                                     |
| ------------- | ------------------------------------------------- | ---------------------------------------------------------- |
| Chat handling | Optional inline text input inside the control bar | toggle only, with chat handled elsewhere in the session UI |
| Device logic  | More browser-specific media and device handling   | simpler native voice-session control surface               |
| Variants      | `default`, `outline`, and `livekit`               | `default` and `outline`                                    |
| Layout feel   | wider desktop toolbar                             | compact touch-friendly row                                 |

## Useful props

The control bar is mostly configured through visibility flags and a few session callbacks.

| Prop                 | Type      | Notes                                                                                      |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `controls`           | object    | Chooses which controls are visible: `leave`, `microphone`, `camera`, `screenShare`, `chat` |
| `variant`            | string    | Changes the visual treatment of the bar                                                    |
| `isChatOpen`         | `boolean` | Controls whether the chat state is open                                                    |
| `onIsChatOpenChange` | function  | Called when the chat toggle changes                                                        |
| `onDisconnect`       | function  | Called when the user disconnects                                                           |
| `onDeviceError`      | function  | Useful for reacting to media-device issues                                                 |

The web version also accepts more session-oriented props like `isConnected` and media-control helpers because it owns more of the interactive logic directly.

## How it fits into the voice UI

This component works best when it is treated as the bottom control rail of a larger voice session. It is not the whole experience on its own; it is the part that keeps the user in control while the transcript, visualizer, and media tiles do the rest.

That means it pairs especially well with:

* a voice visualizer above it
* a transcript or chat panel nearby
* session state from LiveKit or a similar real-time layer

## Related components

The voice control bar is part of a small family of voice UI primitives. These are the most relevant companion pages in this docs set.

<Cards>
  <Card href="/ai/docs/components/voice-visualizer" title="<VoiceVisualizer />" description="A natural visual companion above the control bar in a live voice session." />

  <Card href="/ai/docs/components/prompt-input" title="<PromptInput />" description="Helpful context if you want to compare text-first input patterns with the voice session controls." />

  <Card href="/ai/docs/components/context" title="<Context />" description="Useful when voice sessions also expose model or usage details elsewhere in the UI." />
</Cards>


# <VoiceVisualizer />
Source: https://www.turbostarter.dev/ai/docs/components/voice-visualizer

TurboStarter AI ships with a small family of voice visualizers rather than one fixed component. On web, the voice experience can render six different styles from `packages/ui/web`, while mobile uses a focused bar visualizer from `packages/ui/mobile`.

## Web

The web side is the more flexible implementation. It includes six distinct visualizers, and the app-level voice screen selects between them based on the current visualizer settings.

![Web voice visualizer demo](/images/docs/ai/components/voice-visualizer/web.png)

The web package includes six visualizer styles. They all react to voice-session but each one gives the interface a different character.

| Visualizer | Component               | Best fit                    | Notes                                                                     |
| ---------- | ----------------------- | --------------------------- | ------------------------------------------------------------------------- |
| Orb        | `Orb`                   | Hero-style voice sessions   | A shader-driven focal point with blended colors and volume-driven motion. |
| Bar        | `AudioVisualizerBar`    | Clear, familiar voice UI    | The most direct option when you want a classic speech-bar treatment.      |
| Grid       | `AudioVisualizerGrid`   | Structured layouts          | Animates a matrix of cells and works well in more system-like interfaces. |
| Radial     | `AudioVisualizerRadial` | Circular layouts            | Wraps bars around a center point for a more ambient feel.                 |
| Wave       | `AudioVisualizerWave`   | Minimal wide layouts        | Uses a shader-based waveform that feels clean and elegant.                |
| Aura       | `AudioVisualizerAura`   | Premium, immersive surfaces | Renders a soft glowing field that feels more atmospheric than literal.    |

In the app, the selected visualizer shape is read from the voice settings store and mapped to the matching primitive from `@workspace/ui-web/voice/*`.

## Mobile

The mobile implementation is intentionally simpler. Instead of exposing a full visualizer family, it uses a single bar visualizer that stays clear and readable on a smaller screen.

![Mobile voice visualizer demo](/images/docs/ai/components/voice-visualizer/mobile.png)

The mobile package currently exposes one voice visualizer primitive, and the app-level mobile voice screen follows that same direction.

| Visualizer | Component            | Best fit                          | Notes                                                                                      |
| ---------- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
| Bar        | `AudioVisualizerBar` | Native full-screen voice sessions | A five-bar layout with animated idle and speaking states, optimized for compact mobile UI. |

That keeps the mobile experience consistent and easy to place next to transcript, controls, and the rest of the session UI.

## What it brings to the session

A good voice interface needs more than controls and transcript text. The visualizer is what makes the session feel active before the next response is read or heard.

<Cards>
  <Card title="Makes state visible">
    Listening, thinking, and speaking each feel different, so the session never
    looks idle or frozen.
  </Card>

  <Card title="Adds polish without extra clutter">
    It creates a strong visual focal point without introducing more buttons,
    labels, or status chips.
  </Card>

  <Card title="Scales across platforms">
    The idea stays consistent between web and mobile, even though each platform
    renders it differently.
  </Card>
</Cards>

## Usage

If you are building a custom voice surface, it is often better to use the primitives directly instead of relying on the app-level wrapper. Each example below stays minimal, but it uses the available props so you can see how the visualizer is meant to be configured.

<Tabs items={["Orb", "Bar", "Grid", "Radial", "Wave", "Aura", "Mobile bar"]}>
  <Tab>
    The orb is the most configurable visualizer in the set. It works best when the visualizer is the centerpiece of the screen rather than a supporting detail.

    ```tsx
    import { Orb } from "@workspace/ui-web/voice/orb";
    import { useRef } from "react";

    export function OrbVisualizer() {
      const colorsRef = useRef<["#93c5fd", "#1d4ed8"]>(["#93c5fd", "#1d4ed8"]);
      const inputVolumeRef = useRef(0.2);
      const outputVolumeRef = useRef(0.45);

      return (
        <Orb
          colors={["#93c5fd", "#1d4ed8"]}
          colorsRef={colorsRef}
          resizeDebounce={0}
          seed={7}
          agentState="thinking"
          volumeMode="manual"
          manualInput={0.2}
          manualOutput={0.45}
          inputVolumeRef={inputVolumeRef}
          outputVolumeRef={outputVolumeRef}
          getInputVolume={() => 0.2}
          getOutputVolume={() => 0.45}
        />
      );
    }
    ```

    | Prop                                 | Purpose                                                   |
    | ------------------------------------ | --------------------------------------------------------- |
    | `colors`                             | Sets the base gradient pair.                              |
    | `colorsRef`                          | Updates colors dynamically without remounting.            |
    | `resizeDebounce`                     | Controls how quickly the canvas reacts to resize changes. |
    | `seed`                               | Keeps the visual pattern deterministic.                   |
    | `agentState`                         | Drives the current animation state.                       |
    | `volumeMode`                         | Chooses automatic or manual volume control.               |
    | `manualInput` / `manualOutput`       | Pass explicit input and output levels.                    |
    | `inputVolumeRef` / `outputVolumeRef` | Provide refs for external live volume data.               |
    | `getInputVolume` / `getOutputVolume` | Pull volume from callbacks instead of refs.               |
  </Tab>

  <Tab>
    The bar visualizer is the most straightforward option. It is usually the easiest one to drop into a product UI when you want something readable and familiar.

    ```tsx
    import { AudioVisualizerBar } from "@workspace/ui-web/voice/audio-visualizer-bar";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function BarVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerBar
          size="lg"
          state="thinking"
          color="#2563eb"
          barCount={5}
          audioTrack={audioTrack}
        />
      );
    }
    ```

    | Prop         | Purpose                                |
    | ------------ | -------------------------------------- |
    | `size`       | Adjusts height and spacing.            |
    | `state`      | Changes the current animation pattern. |
    | `color`      | Sets the bar color.                    |
    | `barCount`   | Changes the number of bars.            |
    | `audioTrack` | Connects speaking mode to live audio.  |
  </Tab>

  <Tab>
    The grid visualizer is better when you want a more structured or technical feel. It is also the easiest option to restyle because you can replace the default cell markup.

    ```tsx
    import { AudioVisualizerGrid } from "@workspace/ui-web/voice/audio-visualizer-grid";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function GridVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerGrid
          size="lg"
          state="thinking"
          color="#2563eb"
          audioTrack={audioTrack}
          radius={3}
          interval={120}
          rowCount={7}
          columnCount={7}
        />
      );
    }
    ```

    | Prop                       | Purpose                                 |
    | -------------------------- | --------------------------------------- |
    | `size`                     | Changes the gap scale.                  |
    | `state`                    | Controls the current animation state.   |
    | `color`                    | Sets the active cell color.             |
    | `audioTrack`               | Connects the grid to live audio data.   |
    | `radius`                   | Controls how far the highlight spreads. |
    | `interval`                 | Adjusts non-speaking animation timing.  |
    | `rowCount` / `columnCount` | Define the grid dimensions.             |
  </Tab>

  <Tab>
    The radial visualizer is useful when the voice UI is built around a center point. It feels more ambient than bars while still staying readable.

    ```tsx
    import { AudioVisualizerRadial } from "@workspace/ui-web/voice/audio-visualizer-radial";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function RadialVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerRadial
          size="lg"
          state="thinking"
          color="#2563eb"
          radius={72}
          barCount={24}
          audioTrack={audioTrack}
        />
      );
    }
    ```

    | Prop         | Purpose                                    |
    | ------------ | ------------------------------------------ |
    | `size`       | Changes the overall scale.                 |
    | `state`      | Drives the current animation behavior.     |
    | `color`      | Sets the bar color.                        |
    | `radius`     | Changes the distance from the center.      |
    | `barCount`   | Defines how many radial bars are rendered. |
    | `audioTrack` | Connects the visualizer to live audio.     |
  </Tab>

  <Tab>
    The wave visualizer is a strong default when you want something polished but understated. It works especially well in wider layouts and hero-like voice stages.

    ```tsx
    import { AudioVisualizerWave } from "@workspace/ui-web/voice/audio-visualizer-wave";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function WaveVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerWave
          size="lg"
          state="thinking"
          color="#2563eb"
          colorShift={0.12}
          lineWidth={3}
          blur={2}
          audioTrack={audioTrack}
        />
      );
    }
    ```

    | Prop         | Purpose                               |
    | ------------ | ------------------------------------- |
    | `size`       | Changes the default height scale.     |
    | `state`      | Drives the motion profile.            |
    | `color`      | Sets the wave color.                  |
    | `colorShift` | Adds hue variation toward the edges.  |
    | `lineWidth`  | Changes the visible stroke thickness. |
    | `blur`       | Softens the wave edge.                |
    | `audioTrack` | Connects the wave to live audio.      |
  </Tab>

  <Tab>
    The aura visualizer is the softest option in the set. It is a good fit when you want the session to feel atmospheric rather than explicitly meter-driven.

    ```tsx
    import { AudioVisualizerAura } from "@workspace/ui-web/voice/audio-visualizer-aura";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function AuraVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerAura
          size="lg"
          state="thinking"
          color="#2563eb"
          colorShift={0.18}
          themeMode="light"
          audioTrack={audioTrack}
        />
      );
    }
    ```

    | Prop         | Purpose                                         |
    | ------------ | ----------------------------------------------- |
    | `size`       | Changes the visual scale.                       |
    | `state`      | Drives the animation state.                     |
    | `color`      | Sets the base aura color.                       |
    | `colorShift` | Adds variation across the effect.               |
    | `themeMode`  | Tunes the effect for light or dark backgrounds. |
    | `audioTrack` | Connects speaking mode to live audio.           |
  </Tab>

  <Tab>
    The mobile bar visualizer keeps the API compact, but the `options` object still gives you room to tune the motion and visual balance.

    ```tsx
    import { AudioVisualizerBar } from "@workspace/ui-mobile/voice/audio-visualizer-bar";
    import { useVoiceAssistant } from "@livekit/components-react";

    export function MobileVisualizer() {
      const { audioTrack } = useVoiceAssistant();

      return (
        <AudioVisualizerBar
          state="thinking"
          barCount={5}
          audioTrack={audioTrack}
          options={{
            maxHeight: 1,
            minHeight: 0.24,
            speakingCurve: 0.65,
            speakingGain: 1.35,
            idleHeights: [0.24, 0.36, 0.6, 0.36, 0.24],
            barColor: "#2563eb",
            barWidth: 44,
            barBorderRadius: 999,
            barGap: 10,
            activeOpacity: 1,
            inactiveOpacity: 0.2,
          }}
        />
      );
    }
    ```

    | Prop       | Purpose                                                                    |
    | ---------- | -------------------------------------------------------------------------- |
    | `state`    | Drives the current animation state.                                        |
    | `barCount` | Sets how many bars are rendered.                                           |
    | `trackRef` | Connects the visualizer to live audio.                                     |
    | `options`  | Controls bar size, spacing, color, opacity, and idle or speaking behavior. |
  </Tab>
</Tabs>

## Under the hood

Although the public API is intentionally small, the visualizer system is doing real session work for you. It ties motion to actual voice state instead of treating animation as decoration.

* On web, the wrapper chooses a visualizer style from the active voice settings and adapts it to the current theme, session and live input and output volume.
* On mobile, the implementation stays closer to a single native pattern and focuses on keeping the visualization readable and stable in a compact layout.
* Both versions react to the assistant lifecycle, so connecting, listening, thinking, and speaking can each look distinct.

## Related components

`<VoiceVisualizer />` usually lives at the center of a broader voice session. These pages are the most useful companions when you are building out the rest of that surface.

<Cards>
  <Card href="/ai/docs/components/voice-control-bar" title="<VoiceControlBar />" description="Pairs naturally with the visualizer as the session's main control surface." />

  <Card href="/ai/docs/components/context" title="<Context />" description="Helpful when the voice experience also exposes model or token details nearby." />

  <Card href="/ai/docs/voice" title="Voice app" description="See how the visualizer fits into the complete voice experience in the AI starter." />
</Cards>


# Get started
Source: https://www.turbostarter.dev/ai/docs

TurboStarter AI is a **starter kit with 10+ ready-to-use templates** across web and mobile that helps you quickly build powerful AI applications without starting from scratch.

Whether you're launching a small side project or a full-scale product, it gives you the structure you need to start building immediately.

<AppsShowcase className="pt-2 [&_a]:no-underline [&_img]:my-0" />

## Features

TurboStarter AI comes packed with features designed to accelerate your development process:

### Core framework

<Cards>
  <Card title="Monorepo setup" description="Powered by Turborepo for efficient code sharing and dependency management across web and mobile applications." href="/ai/docs/architecture" />

  <Card title="Next.js web app" description="Built with Next.js and the App Router (RSC by default), plus an opinionated structure for AI templates." href="/ai/docs/architecture#web" />

  <Card title="Hono API" description="Fast, TypeScript-first API layer shared by the web and mobile apps." href="/ai/docs/api" />

  <Card title="React Native + Expo" description="Foundation for cross-platform mobile apps that share business logic with your web application." href="/ai/docs/architecture#mobile" />
</Cards>

### AI

<Cards>
  <Card title="AI SDK" description="Complete toolkit for implementing advanced AI features like streaming responses and interactive chat interfaces." href="/ai/docs/generating-text" />

  <Card title="LangChain" description="Utilities for building RAG workflows like document loading, chunking, and retrieval." href="/ai/docs/rag" />

  <Card title="Multiple AI providers" description="Seamless integration with OpenAI, Anthropic, Google AI, xAI, DeepSeek, Replicate, Fireworks, Eleven Labs, and more through a unified strategy." href="/ai/docs/providers" />

  <Card title="Specialized models" description="Full support for text generation, structured output, image generation, embeddings (RAG), transcription, and voice synthesis." href="/ai/docs/architecture#model-providers" />

  <Card title="One-line model switching" description="Effortlessly switch between AI models or providers with minimal code changes." href="/ai/docs/architecture#model-providers" />

  <Card title="LiveKit" description="Real-time audio, video, and data streaming capabilities for collaborative AI and communication features." href="/ai/docs/voice" />
</Cards>

### Data storage

<Cards>
  <Card title="Drizzle ORM" description="Type-safe ORM for efficient interaction with PostgreSQL (default) or other supported databases (MySQL, SQLite)." href="/ai/docs/database" />

  <Card title="PostgreSQL database" description="Reliable storage for chat history, user data, and vector embeddings with optimized performance." href="/ai/docs/database" />

  <Card title="Vector embeddings" description="Built-in support for storing and retrieving vector embeddings for advanced retrieval-augmented generation." href="/ai/docs/embeddings" />

  <Card title="Blob storage" description="Integrated S3-compatible storage for managing user uploads, AI-generated content, and documents." href="/ai/docs/storage" />
</Cards>

### Authentication

<Cards>
  <Card title="Better Auth integration" description="Secure authentication system starting with anonymous sessions, extensible to email/password, magic links, and OAuth providers." href="/ai/docs/auth" />

  <Card title="Rate limiting" description="Intelligent protection for API endpoints against abuse and overuse." href="/ai/docs/security#rate-limiting" />

  <Card title="Credits-based access" description="Flexible system to manage and control AI feature usage with customizable credit allocation." href="/ai/docs/billing" />

  <Card title="Backend API key management" description="Security-first approach ensuring sensitive API keys remain protected on the server side." href="/ai/docs/security#secure-api-key-handling" />
</Cards>

### User interface

<Cards>
  <Card title="Tailwind CSS & shadcn/ui" description="Utility-first CSS framework and pre-designed components for rapid UI development." href="/ai/docs/ui" />

  <Card title="Base UI" description="Accessible, unstyled components that provide the foundation for beautiful, functional interfaces." href="/ai/docs/ui" />

  <Card title="Shared UI package" description="Centralized UI component library ensuring consistency across all applications in the monorepo." href="/ai/docs/ui" />
</Cards>

## Templates

TurboStarter AI includes several production-ready template applications that showcase diverse AI capabilities. Use these examples to understand implementation patterns and jumpstart your own projects.

<Cards>
  <Card title="Chat" description="Build intelligent conversations with an AI chatbot featuring contextual reasoning, web search, and shareable chats." href="/ai/docs/chat" icon={<Chatting01Icon />} />

  <Card title="Voice" description="Build real-time voice experiences, including streaming audio, transcription, and voice agents." href="/ai/docs/voice" icon={<AudioWaves />} />

  <Card title="Image playground" description="Create visuals with a versatile AI image generator for multiple models, styles, and resolutions." href="/ai/docs/image" icon={<Image02Icon />} />

  <Card title="Retrieval-augmented generation" description="Extract insights from documents by having conversations with your files using AI." href="/ai/docs/rag" icon={<File01Icon />} />

  <Card title="Text to speech" description="Convert text into lifelike speech with thousands of voices and languages." href="/ai/docs/tts" icon={<SpeechIcon />} />

  <Card title="Agents" description="Develop autonomous agents to execute complex tasks via multiple AI models." href="/ai/docs/agents" icon={<WorkflowCircle01Icon />} />
</Cards>

## Scope of this documentation

This documentation focuses specifically on the AI features, architecture, and demo applications included in the **TurboStarter AI** kit. While we provide comprehensive coverage of AI integrations, for information about core framework elements (authentication, billing, etc.), please refer to the [Core documentation](/docs/web).

Our goal is to guide you through setting up, customizing, and deploying the AI starter kit efficiently. Where relevant, we include links to official documentation for the integrated AI providers and libraries.

## Setup

Getting started with TurboStarter AI requires configuring the core applications first. For detailed setup instructions, refer to:

<Cards>
  <Card title="Combine AI Kit with Core Kit" description="Choose the repository that owns your app, then port features without replacing shared packages." href="/ai/docs/integrate-core-kit" icon={<WorkflowCircle01Icon />} />

  <Card title="Web app setup" description="Follow our step-by-step guide in the Core web documentation to set up your web application." href="/docs/web/installation/development" icon={<Website />} />

  <Card title="Mobile app setup" description="Use our detailed guide in the Core mobile documentation to configure your mobile application." href="/docs/mobile/installation/development" icon={<Phone />} />
</Cards>

After establishing the core applications, you can configure specific AI providers and demo applications using the dedicated sections in this documentation (see sidebar). For a quick start, you might also want to check our [TurboStarter CLI guide](/blog/the-only-turbo-cli-you-need-to-start-your-next-project-in-seconds) to bootstrap your project in seconds.

<Callout>
  When AI Kit is your application base, use the `ai` repository for Git commands. If Core Kit is already your base, keep working in `core` and port the selected AI feature by following the [Core-first recipe](/docs/web/recipes/ai-kit).
</Callout>

## Deployment

Deploying TurboStarter AI follows the same process as deploying the core web application. Ensure you configure all necessary environment variables, including those for your selected AI providers (like [OpenAI](/ai/docs/providers/openai), [Anthropic](/ai/docs/providers/anthropic), etc.), in your deployment environment.

For comprehensive deployment instructions across various platforms, consult our core deployment guides:

<Cards>
  <Card title="Deployment checklist" description="General checklist before deploying the web app." href="/docs/web/deployment/checklist" />

  <Card title="Vercel" description="Streamlined deployment process for Vercel hosting." href="/docs/web/deployment/vercel" />

  <Card title="Railway" description="Step-by-step guide for deploying to Railway." href="/docs/web/deployment/railway" />

  <Card title="Docker" description="Container-based deployment using Docker." href="/docs/web/deployment/docker" />

  <Card title="Other Providers" description="Additional guides for Netlify, Render, AWS Amplify, Fly.io and more." href="/docs/web/deployment/checklist" />
</Cards>

For mobile app store deployment, refer to our mobile publishing guides:

<Cards>
  <Card title="Publishing checklist" description="Comprehensive pre-publishing verification for mobile applications." href="/docs/mobile/publishing/checklist" />

  <Card title="iOS App Store" description="Publish your iOS app to the Apple App Store." href="/docs/mobile/publishing/ios" />

  <Card title="Google Play Store" description="Publish your Android app to the Google Play Store." href="/docs/mobile/publishing/android" />

  <Card title="Updates" description="Best practices for managing updates to published mobile apps." href="/docs/mobile/publishing/updates" />
</Cards>

Each AI demo app may have specific deployment considerations, so check their dedicated documentation sections for additional guidance.

## AI-assisted development

TurboStarter comes with built-in rules, skills, subagents, and commands designed specifically to make AI-enhanced development easier. These project-specific AI helpers guide large language models (LLMs) to understand your codebase, enforce best practices, and maintain consistency throughout your project.

Major AI coding assistants - such as [Cursor](https://cursor.com), [Claude](https://claude.ai), [ChatGPT Codex](https://openai.com/codex), [Antigravity](https://antigravity.dev), and others - work seamlessly with this setup. Simply open the TurboStarter AI project in your preferred AI tool to get intelligent code assistance right away.

Additionally, you'll find a [/llms.txt](/llms.txt) file containing up-to-date, LLM-optimized documentation, which allows you to query the latest details about TurboStarter directly from your AI assistant.

If you'd like a step-by-step walkthrough, check out our [AI-assisted development guide](/docs/web/installation/ai-development).

## Let's build amazing AI SaaS!

We're excited to help you create innovative AI-powered applications quickly and efficiently. If you have questions, encounter issues, or want to showcase your creations, connect with our community:

* [Follow updates on X](https://x.com/turbostarter_)
* [Join our Discord](https://discord.com/invite/KjpK2uk3JP)
* [Report issues on GitHub](https://github.com/turbostarter)
* [Contact us via email](mailto:hello@turbostarter.dev)

Happy building!


# Integrate Core Kit
Source: https://www.turbostarter.dev/ai/docs/integrate-core-kit

AI Kit is optimized for AI product flows and starts with lightweight anonymous authentication. [Core Kit](/docs/web) provides the broader SaaS foundation: account flows, organizations, billing, admin, email, analytics, monitoring, flags, CMS, mobile purchases, and a browser extension.

There are two valid starting points:

| Your situation                             | Recommended base                                                             |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| New product or little AI Kit customization | Start from Core Kit and follow the [AI Kit recipe](/docs/web/recipes/ai-kit) |
| AI Kit is already your application         | Keep AI Kit and port the Core features described here                        |

Do not merge both repository roots. Their shared packages have the same names but different responsibilities.

## Keep a single owner per package

When AI Kit is the base, merge by concern:

| Package or app area                             | Result after integration                                            |
| ----------------------------------------------- | ------------------------------------------------------------------- |
| `packages/ai/**`                                | Keep from AI Kit                                                    |
| AI schemas and AI routes                        | Keep from AI Kit                                                    |
| `packages/auth`                                 | Replace with Core's implementation, then preserve anonymous support |
| `packages/db`                                   | Merge Core auth and billing tables into AI's existing AI schemas    |
| `packages/api`                                  | Keep AI routes and add Core routers                                 |
| `packages/storage`                              | Keep one implementation and adapt imports                           |
| `packages/i18n`, `packages/shared`, UI packages | Merge exports and components, never duplicate package names         |
| App routes and modules                          | Keep AI templates and add selected Core product surfaces            |

<Callout type="warn" title="Use a working branch and an empty test database first">
  Auth and billing change the most sensitive schema and request paths in the application. Prove the combined migrations and sign-in flow on a disposable database before applying them to existing users.
</Callout>

The examples assume sibling clones named `ai` and `core`.

<Steps>
  <Step>
    ## Choose the Core capabilities

    Port only the product infrastructure you need. Follow dependencies outward from the selected feature:

    | Capability                   | Core packages and modules to inspect                                         |
    | ---------------------------- | ---------------------------------------------------------------------------- |
    | Full account flows           | `packages/auth`, `packages/email`, auth app routes and modules               |
    | Organizations                | Auth organization plugin, organization API router, schema, dashboard modules |
    | Web billing                  | `packages/billing/shared`, `packages/billing/web`, billing API and schema    |
    | Mobile purchases             | `packages/billing/mobile`, mobile billing screens, server webhooks           |
    | Admin                        | Admin API router and `apps/web` admin routes                                 |
    | Analytics, monitoring, flags | Matching shared and platform packages plus app providers                     |
    | Browser extension            | `apps/extension` and its platform packages                                   |

    Account flows are the usual first slice because organizations, billing, and admin all depend on Core's user and session model.
  </Step>

  <Step>
    ## Merge workspace configuration

    Copy packages that do not exist in AI Kit, then merge the overlapping packages file by file. Add the required catalog versions and `allowBuilds` entries from Core's `pnpm-workspace.yaml`.

    Do not copy either lockfile over the other. Keep AI Kit's root scripts unless a selected Core package requires an additional task, then regenerate the dependency graph:

    ```bash
    pnpm install
    ```

    Resolve shared dependency versions once at the workspace catalog. In particular, align Next.js, Expo, React Native, Tailwind CSS, Turborepo, Better Auth, Drizzle, and the AI SDK before debugging application code.
  </Step>

  <Step>
    ## Adopt Core authentication

    Replace AI Kit's minimal `packages/auth` implementation with Core's package and its dependencies. Core auth already supports the anonymous plugin, so AI templates can keep frictionless sessions while you add email and password, magic links, email OTP, passkeys, OAuth, two-factor authentication, admin, and organizations.

    Preserve AI Kit's product behavior deliberately:

    * Keep anonymous auth enabled if guests may generate content.
    * Decide when an anonymous account must upgrade before storing or sharing data.
    * Keep the cookie prefix stable for existing users, or plan a forced sign-in migration.
    * Add the AI mobile scheme to trusted origins if you retain it.
    * Use Core's auth client in both AI apps instead of maintaining parallel clients.

    Merge Core's auth environment definitions and app feature flags into the AI app env configuration. Never infer enabled server plugins only from a client-side flag.
  </Step>

  <Step>
    ## Combine the database schemas

    Keep AI Kit's feature schemas:

    ```text
    packages/db/src/schema/chat.ts
    packages/db/src/schema/rag.ts
    packages/db/src/schema/image.ts
    ```

    Port Core's auth and selected billing or organization tables. Replace AI Kit's minimal `auth.ts` only after checking that the resulting Better Auth schema contains every field and table required by the enabled Core plugins.

    Both kits define a different `customer` table. AI Kit uses it for a simple credit balance, while Core billing uses it for payment-provider customers. They cannot coexist under the same table or export name. Rename the AI model to a dedicated `ai_credit` or ledger table, or remove it and enforce Core plan entitlements instead.

    AI Kit's `schema` object prefixes relations from feature-specific PostgreSQL schemas. Preserve that object and add the Core modules to it rather than converting the index to Core's simple barrel:

    ```ts title="packages/db/src/schema/index.ts"
    import * as auth from "./auth";
    import * as billing from "./billing";
    import * as chat from "./chat";
    import * as image from "./image";
    import * as rag from "./rag";

    export const schema = {
      ...auth,
      ...billing,
      ...prefix(chat, "chat"),
      ...prefix(rag, "rag"),
      ...prefix(image, "image"),
    };
    ```

    The exact modules depend on the Core features you selected. Add your renamed credit schema only if you keep that model. Keep the existing `prefix` helper and exports required by `@workspace/db/schema/*`.

    If you keep RAG, the target PostgreSQL instance must support pgvector. Preserve AI Kit's `CREATE EXTENSION vector` migration and use a compatible local image or hosted database. Other templates do not require pgvector.

    Generate a migration from the final TypeScript schema:

    ```bash
    pnpm with-env pnpm --filter @workspace/db db:generate
    pnpm with-env pnpm --filter @workspace/db db:migrate
    ```

    For an existing AI database, review user, account, session, and cookie compatibility before deployment. Do not run Core's historical migrations and AI Kit's historical migrations independently against the same database.
  </Step>

  <Step>
    ## Add Core routers to the AI API

    Keep AI Kit's `aiRouter`, storage routes, streaming response handling, AI middleware, and error mapping. Port the selected Core routers into `packages/api/src/modules`, then register them in AI Kit's existing Hono chain:

    ```ts title="packages/api/src/index.ts"
    const appRouter = new Hono()
      .basePath("/api")
      // existing middleware
      .route("/ai", aiRouter)
      .route("/auth", authRouter)
      .route("/billing", billingRouter)
      .route("/organizations", organizationRouter)
      .route("/admin", adminRouter)
      .route("/storage", storageRouter)
      .onError(onError);
    ```

    Only register routers you ported. Merge middleware by responsibility:

    * Use Core's session and authorization checks for account, organization, admin, and billing routes.
    * Keep AI rate limiting and usage deduction around paid model calls.
    * Keep validation and localization behavior consistent across all routes.
    * Preserve the request abort signal when streaming AI responses.
  </Step>

  <Step>
    ## Reconcile billing and AI usage

    AI Kit includes a demonstration credit balance. Core Kit includes subscription and purchase providers. Decide on one server-side policy before exposing AI features to paid users:

    1. **Plan entitlement:** a plan enables a feature, with rate limits enforced separately.
    2. **Included allowance:** a plan grants a recurring AI allowance.
    3. **Purchased credits:** users buy a balance consumed per operation.
    4. **Hybrid:** plans include usage and allow top-ups.

    The API must check the policy before invoking a model. Client-side credit displays are informative only.

    Keep provider-reported usage and the product's billable units separate. Provider tokens, generated images, audio seconds, and product credits are not interchangeable accounting records.
  </Step>

  <Step>
    ## Port Core application surfaces

    Bring Core routes and modules into the AI apps without replacing AI Kit's route groups or root providers.

    For web, typical additions are:

    ```text
    apps/web/src/app/[locale]/auth
    apps/web/src/app/[locale]/dashboard
    apps/web/src/app/[locale]/admin
    apps/web/src/modules/auth
    apps/web/src/modules/billing
    apps/web/src/modules/organization
    apps/web/src/modules/user
    ```

    Merge the root layout providers so there is one auth client, query client, theme, i18n instance, analytics provider, and monitoring provider.

    Add each imported Core workspace package to `apps/web/next.config.ts`, and merge its validated variables into `apps/web/env.config.ts`, `.env.example`, and `apps/web/turbo.json`.

    For mobile, port the matching Core setup, auth, dashboard, settings, and billing routes. Keep AI Kit's `(apps)` screens and move or link them into the authenticated Core navigation according to your product.

    If you add Core's browser extension, treat it as a client of the existing web API. The [extension AI recipe](/docs/extension/recipes/ai-kit) covers the platform-specific boundary.
  </Step>

  <Step>
    ## Verify identities, access, and cost controls

    Run workspace checks, then test the combined behavior rather than each kit in isolation:

    ```bash
    pnpm lint
    pnpm --filter web build
    ```

    Verify:

    1. Anonymous users retain their own AI history.
    2. Account upgrade preserves or intentionally migrates anonymous data.
    3. Organization switching cannot expose another tenant's AI records.
    4. Billing and usage checks run before every paid provider call.
    5. Admin permissions do not imply ownership of user AI data unless your policy says so.
    6. Web and mobile resolve the same session and API types.
    7. Deleting a user applies the intended cascade or retention policy to chats, files, images, and usage records.
  </Step>
</Steps>

## Related documentation

Use the guides below to learn more about the integration process and how each part of both kits work together.

<Cards>
  <Card title="Core-first integration" description="Use Core Kit as the base and port selected AI templates into it." href="/docs/web/recipes/ai-kit" />

  <Card title="AI authentication" description="Understand the anonymous session behavior you are extending." href="/ai/docs/auth" />

  <Card title="AI database" description="Review the feature-specific PostgreSQL schemas that must be preserved." href="/ai/docs/database" />

  <Card title="AI API" description="Review the routers, middleware, credits, and streaming boundaries." href="/ai/docs/api" />
</Cards>


# Tech stack
Source: https://www.turbostarter.dev/ai/docs/stack

## Turborepo

[Turborepo](https://turborepo.dev/) is a high-performance monorepo tool that optimizes dependency management and script execution across your project. We chose this monorepo setup to simplify feature management and enable seamless code sharing between packages.

<Card href="https://turborepo.dev/" title="Turborepo - Make Ship Happen" description="turbo.build" icon={<Turborepo />} />

## Next.js

[Next.js](https://nextjs.org) is a powerful [React](https://react.dev) framework that delivers server-side rendering, static site generation, and more. We selected Next.js for its exceptional flexibility and developer experience. It also serves as the foundation for our serverless API.

<Cards>
  <Card href="https://react.dev" title="React" description="react.dev" icon={<React />} />

  <Card href="https://nextjs.org" title="Next.js" description="nextjs.org" icon={<Next />} />
</Cards>

## React Native + Expo

[React Native](https://reactnative.dev/) is a leading open-source framework created by Facebook that enables building native mobile applications using [React](https://react.dev). It provides access to native platform capabilities while maintaining the development efficiency of React.

[Expo](https://expo.dev/) extends React Native with a comprehensive toolkit that streamlines development, building, and deployment of iOS, Android, and web apps from a single codebase.

<Cards className="grid-cols-2">
  <Card href="https://reactnative.dev/" title="React Native" description="reactnative.dev" icon={<React />} />

  <Card href="https://expo.dev/" title="Expo" description="expo.dev" icon={<Expo />} />
</Cards>

## AI

As a foundation, we use [AI SDK](https://ai-sdk.dev/) which provides a robust toolkit for building AI-powered applications. It offers essential utilities and components for integrating advanced AI features, including streaming responses, interactive chat interfaces, and more.

For building complex AI systems, including prompt management, memory systems, and agent architectures, the starter leverages [LangChain](https://js.langchain.com/), a sophisticated framework designed for language model-powered applications.

For collaborative AI and communication features, we use [LiveKit](https://livekit.io/) which enables real-time audio, video, and data streaming capabilities, specifically designed for autonomous voice agents.

<Cards className="grid-cols-2 sm:grid-cols-3">
  <Card href="https://ai-sdk.dev/" title="AI SDK" description="ai-sdk.dev" icon={<AISDK />} />

  <Card href="https://js.langchain.com/" title="LangChain" description="js.langchain.com" icon={<Langchain />} />

  <Card href="https://livekit.com/" title="LiveKit" description="livekit.com" icon={<LiveKit />} />
</Cards>

## Hono

[Hono](https://hono.dev) is an ultrafast, lightweight web framework optimized for edge computing. It includes a type-safe RPC client for secure function calls from the frontend. We leverage Hono to create efficient serverless API endpoints.

<Card href="https://hono.dev" title="Hono" description="hono.dev" icon={<Hono />} />

## Tailwind CSS

[Tailwind CSS](https://tailwindcss.com) is a utility-first CSS framework that accelerates UI development without writing custom CSS. We complement it with [Base UI](https://base-ui.com), a collection of accessible headless components, and [shadcn/ui](https://ui.shadcn.com), which lets you generate beautifully designed components with a single command.

<Cards className="grid-cols-2 sm:grid-cols-3">
  <Card href="https://tailwindcss.com" title="Tailwind CSS" description="tailwindcss.com" icon={<Tailwind />} />

  <Card href="https://base-ui.com" title="Base UI" description="base-ui.com" icon={<BaseUI />} />

  <Card href="https://ui.shadcn.com" title="shadcn/ui" description="ui.shadcn.com" icon={<Shadcn />} />
</Cards>

## Drizzle

[Drizzle](https://orm.drizzle.team/) is a type-safe, high-performance [ORM](https://orm.drizzle.team/docs/overview) (Object-Relational Mapping) for modern database management. It generates TypeScript types from your schema and enables fully type-safe queries.

We use [PostgreSQL](https://www.postgresql.org) as our default database, but Drizzle's flexibility allows you to easily switch to MySQL, SQLite, or any [other supported database](https://orm.drizzle.team/docs/connect-overview) by updating a few configuration lines.

<Cards>
  <Card href="https://orm.drizzle.team/" title="Drizzle" description="orm.drizzle.team" icon={<Drizzle />} />

  <Card href="https://www.postgresql.org" title="PostgreSQL" description="postgresql.org" icon={<Postgres />} />
</Cards>
