---
title: "Embed the portal"
description: "The whole customer portal inside your app in one component: token handling, theming, and viewport-scale drawers and dialogs."
---

The embed is the zero-custom-UI path. You render one component, your backend mints
a token, and your customers get the full portal inside your own app:
deliveries, endpoints, event catalog, replay.

```tsx
import { RepostPortal } from "@repost/portal-react";

<RepostPortal url={portalUrl} getPortalToken={getToken} />;
```

`url` comes from `POST /v1/customers/{id}/portal-access`, the same
[portal-access call](/docs/send/portal) that powers hosted links. `getPortalToken`
returns a fresh token from your backend whenever the portal needs one. See
[token endpoint](/docs/ui/portal-react#token-endpoint). Provide it and the token never
touches the URL: it travels over a private `MessageChannel` and is refreshed
silently, ahead of expiry and again on any auth failure.

Not using React? [`createPortalEmbed`](/docs/ui/portal-js#embedding) in
`@repost/portal-js` is the same thing without the wrapper.

Want one portal area instead of the whole portal, with your navigation around
our surfaces? That is [bare surfaces](/docs/send/portal-surfaces), and it is the
same component with one more prop.

## Props

| Prop | Type | Notes |
| --- | --- | --- |
| `url` | `string` | The portal session url. Required. |
| `getPortalToken` | `() => Promise<string>` | Enables tokenless embedding and silent refresh. Strongly recommended. |
| `surface` | `PortalSurface` | Renders one portal area with no header and no tabs. See [bare surfaces](/docs/send/portal-surfaces). Omit to embed the whole portal. |
| `darkMode` | `boolean` | Pins the portal's color scheme to your app's. Omit to follow the viewer's OS preference. Seeded pre-paint so the first render never flashes the wrong theme, then applied live: toggling your theme does not reload the portal or lose the customer's place. |
| `overlays` | `boolean` | Lets drawers and dialogs cover your viewport. Default `true`. |
| `overlayZIndex` | `number` | Stacking order for the overlay frame. Default `2147483000`. |
| `onNavigationIntent` | `(intent) => void` | Called when a [`surface`](/docs/send/portal-surfaces) frame suppresses a navigation that would have left it, with the surface it was headed for. |
| `className` / `style` | — | Applied to the container. It fills its parent, so give that parent a height. |
| `loadingFallback` | `ReactNode` | Rendered until the portal frame loads. |

## Theming

Pass `darkMode` and the portal matches your app. Omit it and the portal follows
the viewer's OS preference, tracking changes to it live.

```tsx
<RepostPortal url={portalUrl} getPortalToken={getToken} darkMode={theme === "dark"} />
```

The scheme is seeded into the frame URL for the first paint, so the portal never
renders light for a frame and then flips. After that it travels over the private
port: toggling your theme repaints the portal in place and does **not** reload
it, so a customer reading a delivery keeps their scroll position, filters and
open drawer.

The frame survives a theme change. It does not survive a reload of your page,
but the customer's place does: the portal returns them to the tab and filters
they were on, for the rest of the browser session. Closing the tab forgets it,
deliberately. Bare surfaces never take part, since your own page decides where
they land.

## Drawers and dialogs

Anything inside an iframe is clipped to it, including `position: fixed`. A
delivery drawer would be a panel inside a box rather than a drawer over your
app.

So the SDK mounts a second, hidden frame on `document.body` and the portal
renders its modal surfaces there: full viewport, with a backdrop over your
page, focus moved in and returned on close, and your page's scroll locked while
it is open. Nothing is required of you: it is on by default, there is no second
component to render and no container to provide.

The frame lives on `document.body` on purpose. Any ancestor with a `transform`,
`filter`, `backdrop-filter`, `perspective`, `will-change`, `contain` or
`content-visibility` becomes the containing block for `position: fixed`
descendants, which would clip a full-viewport overlay to that element. Hanging
off `body` means there are no such ancestors.

Turn it off if you would rather keep everything inside the inline frame:

```tsx
<RepostPortal url={portalUrl} getPortalToken={getToken} overlays={false} />
```

Raise `overlayZIndex` if your app has fixed furniture that would otherwise paint
over the portal's modals.

<Note>
  Overlays need `getPortalToken`: the overlay frame authenticates its own
  session. A url-only embed keeps its modals inline.
</Note>

## Older SDK versions

The embed protocol negotiates on connect. An SDK that predates overlays
advertises nothing, and the portal keeps rendering modals inside the inline
frame, indefinitely, with no error and no degraded behavior. Upgrading is a
version bump with no code change.

The same negotiation means new portal surfaces never require an SDK release: the
host is told *where* to render, never *what*, so it needs no knowledge of the
portal's screens.

## Origins and Content-Security-Policy

The portal answers with `frame-ancestors` built from the allow-listed origins in
**Settings → Portal** for that environment (up to 20). With no origins listed,
the embed renders anywhere; once you list any, your app's origin must be among
them. Pair an embedded portal with short-lived or read-only links minted per
page view.

On your side, if your page sets `frame-src` (or `child-src`), it must allow the
portal origin, `https://portal.repost.sh` by default. The overlay frame is the
same origin as the inline one, so a policy that already works needs no change.

## Troubleshooting

**The overlay doesn't cover my page.** Something in your layout is establishing a
containing block for fixed positioning. On `<body>` itself the SDK warns in
development with the property it found; move that style onto a child of `body`.

**My own modal paints over the portal's.** Raise `overlayZIndex` above it.

**Modals still open inside the frame.** Either `overlays` is `false`,
`getPortalToken` is missing, or the installed `@repost/portal-react` predates
overlay support. All three are the documented fallback, not a failure.

**The frame doesn't load at all.** Check your `frame-src` policy and the
allow-listed origins in Settings → Portal.

## Continue

<CardGroup cols={2}>
  <Card title="Bare surfaces" icon="layout-panel-top" href="/docs/send/portal-surfaces">
    One portal area per page, no portal chrome.
  </Card>
  <Card title="Portal links" icon="link" href="/docs/send/portal">
    Minting access, revoking it, and what customers can do.
  </Card>
</CardGroup>
