> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Add an inbox to React

> A token route and a component: the two files that put a Courier Inbox in a React app.

export const Tags = ({items}) => {
  const routes = {
    Email: "/integrations/email/overview",
    SMS: "/integrations/sms/overview",
    Push: "/integrations/push/overview",
    Inbox: "/in-app/overview",
    Chat: "/integrations/direct-message/overview",
    Templates: "/design/templates/overview",
    Variables: "/design/templates/variables",
    Elemental: "/design/elemental/overview",
    Brands: "/design/brands",
    Translations: "/design/elemental/locales",
    Routing: "/send/routing",
    Preferences: "/recipients/preferences/overview",
    Journeys: "/journeys/overview",
    Broadcasts: "/broadcasts/overview",
    Tenants: "/tenants/overview",
    Logs: "/monitor/overview",
    Webhooks: "/monitor/webhooks/outbound",
    Lists: "/recipients/lists-and-audiences/overview",
    Users: "/recipients/overview",
    Digests: "/journeys/nodes/digest",
    Environments: "/workspaces/overview",
    MCP: "/resources/mcp"
  };
  const icons = {
    Email: "envelope",
    SMS: "comment",
    Push: "mobile",
    Inbox: "inbox",
    Chat: "comments",
    Templates: "pen-ruler",
    Variables: "pen-ruler",
    Elemental: "pen-ruler",
    Brands: "pen-ruler",
    Translations: "pen-ruler",
    Routing: "paper-plane",
    Preferences: "users",
    Journeys: "route",
    Broadcasts: "bullhorn",
    Tenants: "building",
    Logs: "chart-simple",
    Webhooks: "chart-simple",
    Lists: "users",
    Users: "users",
    Digests: "route",
    Environments: "briefcase",
    MCP: "toolbox"
  };
  const base = "https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/";
  const names = String(items || "").split(",").map(entry => entry.trim()).filter(Boolean);
  return <div className="cx-tags">
      {names.map(name => {
    const href = routes[name];
    const icon = icons[name];
    const url = icon ? "url(" + base + icon + ".svg)" : null;
    const style = url ? {
      "--cx-tag-icon": url
    } : null;
    if (!href) {
      return <span className="cx-tag" data-icon={icon} style={style} key={name}>
              {name}
            </span>;
    }
    return <a className="cx-tag" data-icon={icon} style={style} href={href} key={name}>
            {name}
          </a>;
  })}
    </div>;
};

export const Endpoint = ({method, path, name, href, children, bare}) => {
  const verb = String(method || "").toUpperCase();
  const title = verb + " " + path;
  const label = children || name || path;
  if (bare) {
    return href ? <a href={href}><code>{title}</code></a> : <code>{title}</code>;
  }
  if (!href) {
    return <span className="cx-endpoint" data-method={verb} title={title}>
        <span className="cx-endpoint-label">{label}</span>
        <span className="cx-endpoint-method">{verb}</span>
      </span>;
  }
  return <a className="cx-endpoint" data-method={verb} href={href} title={title}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">{verb}</span>
    </a>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

<Tags items="Inbox" />

Two files get a Courier Inbox working in React: a server route that mints a token, and a component that renders the feed.

A Vite app is all browser, and your API key cannot go there. That is the only reason this needs a server at all.

<Card title="courier-react-quickstart" icon="github" href="https://github.com/trycourier/courier-react-quickstart" horizontal>
  The finished app from this guide, ready to clone and run.
</Card>

## Prerequisites

* <AppLink href="https://app.courier.com/signup">A Courier account</AppLink>
* <AppLink href="https://app.courier.com/settings/api-keys">A Courier API key</AppLink>, from any environment. Every one ships with the Courier Inbox provider already configured.
* A React app on Vite, and Node 20.9 or newer
* A backend you can add one route to

## Clone the finished app

Skip the steps below if you would rather read working code. This repo is this guide, already assembled.

```bash theme={null}
git clone https://github.com/trycourier/courier-react-quickstart.git
cd courier-react-quickstart
npm install
cp .env.example .env   # paste your API key
npm run dev
```

Open `localhost:5173`, then run `npm run send` in a second terminal. The message arrives in the open page without a refresh.

## Add an inbox to your app

<Steps>
  <Step title="Install the packages">
    The React package renders the inbox. The server package mints tokens.

    ```bash theme={null}
    npm install @trycourier/courier-react @trycourier/courier
    ```

    Put your key in `.env`. Your server reads it, and the browser never does.

    ```bash .env theme={null}
    COURIER_API_KEY=YOUR_COURIER_API_KEY
    ```
  </Step>

  <Step title="Add a route that mints the token">
    Your API key signs the JWT, so this runs on your backend and never in the browser.

    ```js server.js lines theme={null}
    import "dotenv/config";
    import Courier from "@trycourier/courier";

    app.get("/api/courier/token", async (request, response) => {
      const client = new Courier({ apiKey: process.env.COURIER_API_KEY });

      // Read the user from your own session. Never from the request.
      const session = await auth(request);
      if (!session) return response.status(401).end();

      const { token } = await client.auth.issueToken({
        scope: `user_id:${session.user.id} inbox:read:messages inbox:write:events`,
        expires_in: "1 day",
      });

      response.json({ userId: session.user.id, token });
    });
    ```

    Take the user id from your session, never from the request body or a query parameter. A caller who can name any user can read that user's inbox.

    The route calls <Endpoint method="POST" path="/auth/issue-token" name="Create a JWT" href="/docs/api-reference/authentication/create-a-jwt" />, and the two `inbox:` scopes are the minimum for a working feed. <Doc href="/docs/in-app/authenticate-users#scopes">Scopes</Doc> lists what to add for preferences and push.
  </Step>

  <Step title="Point Vite at your server">
    Proxying keeps the fetch same-origin, so there is no CORS to configure.

    ```ts vite.config.ts highlight={7} theme={null}
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";

    export default defineConfig({
      plugins: [react()],
      server: {
        proxy: { "/api": "http://localhost:3001" },
      },
    });
    ```
  </Step>

  <Step title="Render the inbox">
    Fetch the token, call `signIn`, then mount `CourierInbox`.

    ```tsx src/CourierInbox.tsx lines theme={null}
    import { useEffect, useRef } from "react";
    import { CourierInbox, useCourier } from "@trycourier/courier-react";

    export function Inbox() {
      const courier = useCourier();
      const signedIn = useRef(false);

      useEffect(() => {
        if (signedIn.current) return;
        signedIn.current = true;

        fetch("/api/courier/token")
          .then((response) => response.json())
          .then(({ userId, token }) => courier.shared.signIn({ userId, jwt: token }));
      }, [courier]);

      return <CourierInbox />;
    }
    ```

    StrictMode mounts every component twice in development. A second `signIn` signs the first user out again, so guard it with a ref.
  </Step>

  <Step title="Give the inbox a height">
    `CourierInbox` fills its container's width and takes its height from the parent, so size the parent.

    ```tsx src/App.tsx theme={null}
    import { Inbox } from "./CourierInbox";

    export default function App() {
      return (
        <main style={{ height: "32rem" }}>
          <Inbox />
        </main>
      );
    }
    ```

    <Note>
      On React 17, install `@trycourier/courier-react-17` instead. The API is identical.
    </Note>
  </Step>

  <Step title="Send a message to the inbox">
    `inbox` is a channel like `email` or `sms`. Including it in `routing.channels` is what puts the message in the feed.

    ```ts scripts/send.ts lines theme={null}
    import Courier from "@trycourier/courier";

    const client = new Courier({ apiKey: process.env.COURIER_API_KEY });

    await client.send.message({
      message: {
        to: { user_id: "sarah-bennett" },
        routing: { method: "single", channels: ["inbox"] },
        content: { title: "Your report is ready", body: "The export has finished." },
      },
    });
    ```

    `content` here is the shorthand form, which takes a `title` and a `body`. For multi-element layouts and per-channel variants, use a <Doc href="/docs/design/templates/overview">template</Doc> instead and reference it by id.
  </Step>
</Steps>

## Where the token route belongs

The sample ships a small `server.js` so it runs on its own. In your app, that route belongs on the backend you already have, next to the endpoint that returns the current user.

Any framework works, because the route does one thing: read the session, mint a JWT, return it. Express, Fastify, Hono, a Lambda, or a Rails controller are all fine.

## Keep the session alive

The SDKs do not refresh tokens. Before the current one expires, mint a new JWT and call `signIn` again with it.

Match `expires_in` to your own session length. A short-lived token can expire while a tab stays open, which quietly empties the inbox.

<Warning>
  `expires_in` is optional, and omitting it mints a token that **never expires**. Always set it. Rotating the API key that signed it is the only way to revoke one.
</Warning>

## Verify

<Steps>
  <Step title="Load the page">
    The inbox renders, signed in and empty.
  </Step>

  <Step title="Send a message">
    Run the send against the same `user_id` your token was scoped to.
  </Step>

  <Step title="Watch it arrive">
    The message appears in the open page in real time, and the unread count moves.
  </Step>
</Steps>

If the inbox renders but stays empty, suspect the token before anything else. An expired or mis-scoped JWT signs in silently and returns no messages. <Doc href="/docs/in-app/authenticate-users#troubleshooting">Troubleshooting</Doc> walks the four causes in order.

## FAQ

<AccordionGroup>
  <Accordion title="Can I skip the server and use the API key in the browser?">
    The key can send to anyone and read any user's data. It is a server credential, and a JWT scoped to one user is what the browser is meant to hold.
  </Accordion>

  <Accordion title="My backend is on another origin. What changes?">
    Drop the Vite proxy, fetch the absolute URL, and allow that origin with CORS. The token route itself does not change.
  </Accordion>

  <Accordion title="Is the JWT safe to expose to the browser?">
    The JWT is built for the browser. It is scoped to one user and it expires. Your API key, which signs it, stays on the server.
  </Accordion>

  <Accordion title="Why is the inbox empty when the send reported success?">
    The feed is filtered by tenant. Signing in with a `tenantId` hides messages sent without one. See <Doc href="/docs/in-app/send-to-the-inbox#scope-the-inbox-to-a-tenant">scope the inbox to a tenant</Doc>.
  </Accordion>
</AccordionGroup>
