> ## 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 Astro

> An endpoint and a React island: the two files that put a Courier Inbox in an Astro 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 Astro: an endpoint that mints a token, and a React island that renders the feed.

The endpoint exists because your API key signs the token and must never reach the browser. Everything else is standard Astro work.

<Card title="courier-astro-quickstart" icon="github" href="https://github.com/trycourier/courier-astro-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.
* An Astro app with the React integration, and Node 20.9 or newer
* Somewhere to read the signed-in user, such as Auth.js, Clerk, or your own session cookie

## 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-astro-quickstart.git
cd courier-astro-quickstart
npm install
cp .env.example .env   # paste your API key
npm run dev
```

Open `localhost:4321`, 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}
    npx astro add react node
    npm install @trycourier/courier-react @trycourier/courier
    ```

    `node` is the adapter. The token endpoint runs per request, so the site needs a server.

    Put your key in `.env`, which Astro loads in `dev` and `build`.

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

  <Step title="Declare the key as a server secret">
    Astro reads a **secret** from the environment at runtime. Everything else it inlines at build time.

    ```js astro.config.mjs lines highlight={13} theme={null}
    import { defineConfig, envField } from "astro/config";
    import node from "@astrojs/node";
    import react from "@astrojs/react";

    export default defineConfig({
      integrations: [react()],
      adapter: node({ mode: "standalone" }),

      env: {
        schema: {
          COURIER_API_KEY: envField.string({
            context: "server",
            access: "secret",
            optional: true,
          }),
        },
      },
    });
    ```

    `access: "secret"` is the line that matters. Reading `import.meta.env.COURIER_API_KEY` instead compiles to the key's build-time value as a string literal, which ships your key inside the bundle you deploy.

    `optional: true` lets the endpoint return its own message for a missing key. Without it Astro fails the schema before the server starts.
  </Step>

  <Step title="Add an endpoint that mints the token">
    Your API key signs the JWT, so this runs on the server and never in the browser.

    ```ts src/pages/api/courier/token.ts lines highlight={2} theme={null}
    import type { APIRoute } from "astro";
    import { COURIER_API_KEY } from "astro:env/server";
    import Courier from "@trycourier/courier";

    export const prerender = false;

    export const GET: APIRoute = async () => {
      if (!COURIER_API_KEY) return new Response("COURIER_API_KEY is not set", { status: 500 });

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

      // Read the user from your own session. Never from the request.
      const session = await auth();
      if (!session) return new Response("Unauthorized", { status: 401 });

      const userId = session.user.id;

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

      return Response.json({ userId, token });
    };
    ```

    `export const prerender = false` is required. Astro prerenders by default, and a token minted at build time is the same token for every visitor.

    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 endpoint 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="Render the inbox in a React island">
    Fetch the token, call `signIn`, then mount `CourierInbox`.

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

    export default 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 />;
    }
    ```

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

  <Step title="Hydrate it with client:only">
    The inbox renders as a custom element, so it has nothing to draw on the server.

    ```astro src/pages/index.astro highlight={6} theme={null}
    ---
    import Inbox from "../components/CourierInbox";
    ---

    <main style="height: 32rem">
      <Inbox client:only="react" />
    </main>
    ```

    `CourierInbox` fills its container's width and takes its height from the parent, so size the parent.

    <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>

## Deploy

`astro dev` and `astro build` read `.env`. The built server does not, so set the key wherever you host it.

```bash theme={null}
COURIER_API_KEY=YOUR_COURIER_API_KEY node ./dist/server/entry.mjs
```

On Vercel, Netlify, or Cloudflare, swap `@astrojs/node` for that host's adapter and set `COURIER_API_KEY` in its dashboard. Nothing in the two files changes.

## 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 mint the token in the page's frontmatter instead?">
    Minting it in frontmatter works, and passing it to the island as a prop saves a round trip. The page then needs `prerender = false` too. An endpoint is shown here because it is also what you call again when the token expires.
  </Accordion>

  <Accordion title="Do I need an adapter?">
    An adapter is needed for the endpoint. A fully static Astro site has nowhere to sign a JWT, and your API key cannot go in the browser to do it there.
  </Accordion>

  <Accordion title="Can the rest of my site stay static?">
    `prerender = false` is per route, so only the token endpoint runs on demand. The page holding the inbox can prerender.
  </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>
