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

# Embedding a Data App

> Render a TextQL Data App inside your own web app with @textql/sdk.

<Note>
  For a runnable version of everything below, see the
  [embed-app example](https://github.com/TextQLLabs/textql-ts-sdk/tree/main/examples/embed-app).
</Note>

Embedding is available in the **TypeScript SDK** (`@textql/sdk`). The React
component described here needs **v1.4.0+**; the server handler works from
v1.3.8. On-prem, `TEXTQL_SERVER_URL` is read from **v1.4.1+**.

```bash theme={null}
npm install @textql/sdk
```

Two environment variables on your server:

```bash theme={null}
TEXTQL_API_KEY=...   # Settings → Developers → API Keys (admin only)
TEXTQL_APP_ID=...    # from the app's URL in TextQL: /app/<id>
```

## Quickstart

**Server** — one catch-all route. This is the only place your API key lives.

```ts theme={null}
// app/api/textql/[...path]/route.ts
import { createEmbedHandler } from "@textql/sdk/embed";

export const { GET, POST } = createEmbedHandler();
```

**Browser** — in React, one import and one tag:

```tsx theme={null}
import { TextqlApp } from "@textql/sdk/embed/react";

export default function Page() {
  return <TextqlApp style={{ height: "80vh" }} />;
}
```

That is the whole integration. Both halves default to `/api/textql`, so nothing
needs configuring on the happy path.

## Your routes are the access control

<Warning>
  The API key is org-wide, so it stays on your server and the browser talks only
  to your routes — it is never told which app it renders and cannot ask for a
  different one. But nothing in the handler knows **who the caller is**. Without
  an `authorize` hook, the app is visible to anyone who can reach the route.
</Warning>

```ts theme={null}
export const { GET, POST } = createEmbedHandler({
  authorize: async (request) => (await getSession(request)) !== null,
});
```

Return `false` or throw to reject.

## The React component

`@textql/sdk/embed/react` is a thin wrapper over the same custom element.
Prefer it to the raw tag: JSX has no type for a custom element, and React
cannot bind the events below because they are `CustomEvent`s.

```tsx theme={null}
<TextqlApp
  apiBase="/api/textql"
  style={{ height: "80vh" }}
  onMeta={(meta) => setTitle(meta.name)}
  onReady={(meta) => console.log("bridge up", meta)}
  onError={(error) => console.error(error.message)}
/>
```

<ResponseField name="apiBase" type="string" default="/api/textql">
  Where `createEmbedHandler` is mounted. Must match the handler's `basePath`.
</ResponseField>

<ResponseField name="style" type="CSSProperties">
  The element has no intrinsic size, like an iframe. Give it a real height.
</ResponseField>

<ResponseField name="className" type="string">
  Applied to the host element.
</ResponseField>

<ResponseField name="onMeta" type="(meta: TextqlAppMeta) => void">
  Metadata arrived: `name`, `screenshotUrl`, and the app's compute `functions`.
  Depends only on your server, so it fires even if the app's bridge never
  connects — use it to title your own chrome.
</ResponseField>

<ResponseField name="onReady" type="(meta: TextqlAppMeta | null) => void">
  The app's runtime completed its handshake.
</ResponseField>

<ResponseField name="onError" type="(error) => void">
  The app reported a runtime error. Already logged to the console by the
  element.
</ResponseField>

### Server-side rendering

`<TextqlApp />` carries the `"use client"` directive and loads the custom
element in an effect, so it server-renders to an inert tag and upgrades on the
client. That also code-splits the element out of your initial bundle.

<Warning>
  Do **not** import `@textql/sdk/embed/element` directly in a Next.js app. It
  defines a class extending `HTMLElement` at module scope, so importing it on a
  server throws `HTMLElement is not defined` — including inside a `"use client"`
  component, which Next.js still renders on the server.
</Warning>

`react` is an optional peer dependency (`^18 || ^19`). Nothing else in the SDK
needs it.

## Other frameworks

It is a custom element, so there is no framework binding to install.

```svelte theme={null}
<script>import "@textql/sdk/embed/element";</script>
<textql-app style="height: 80vh" />
```

Without a bundler, serve the element from your own origin. It is one
self-contained file — it imports nothing, so serving it is a copy:

```bash theme={null}
npm install @textql/sdk@^1.4.1
cp node_modules/@textql/sdk/esm/embed/element.js public/vendor/textql-element.js
```

```html theme={null}
<script type="module" src="/vendor/textql-element.js"></script>
<textql-app></textql-app>
```

Copying it keeps the version pinned in your lockfile, where your existing
review and scanning already look. A CDN saves the copy for a prototype:

```html theme={null}
<script type="module" src="https://cdn.jsdelivr.net/npm/@textql/sdk@1.4.1/esm/embed/element.js"></script>
```

<Warning>
  Pin the version if you do. Don't ship the CDN form to a restricted network:
  it puts a third-party host in your `script-src`, adds an outbound request on
  every page load, and does not resolve air-gapped.
</Warning>

Express, Fastify, and bare `node:http` predate the Web `Request` object, so
wrap the handler:

```ts theme={null}
import { createEmbedHandler, toNodeHandler } from "@textql/sdk/embed";

const embed = toNodeHandler(createEmbedHandler());

app.use(async (req, res, next) => {
  if (!(await embed(req, res))) next();
});
```

Outside React, the same three events are `CustomEvent`s on the element:

```ts theme={null}
element.addEventListener("app-meta", (event) => setTitle(event.detail.name));
```

`element.meta` holds the same value for a listener that attached late.

## Sizing

Data Apps lay out against the full viewport — the same region they get inside
TextQL — so a narrow content column breaks the app's own layout, not the
element. Full-bleed width and a real height are the safe defaults.

## Compute functions

Apps that compute call back through your server. The handler relays them to
`POST {basePath}/compute` and **refuses any function name the app does not
declare**, so the route cannot become a generic runner for anything your API
key can reach.

The declared names arrive as `functions` on `onMeta`. An app with none renders
exactly the same but never calls back, leaving the route unused.

<Note>
  TextQL rate-limits compute server-side and returns `resource_exhausted`. A
  production host should retry that with backoff.
</Note>

## Options

| Option           | Default                     |                                                                                                     |
| ---------------- | --------------------------- | --------------------------------------------------------------------------------------------------- |
| `appId`          | `TEXTQL_APP_ID`             | A function `(request) => string` picks per request — from a session, tenant header, or route param. |
| `client`         | built from `TEXTQL_API_KEY` | Pass your own `Textql` instance.                                                                    |
| `basePath`       | `/api/textql`               | Where the handler is mounted. Must match `apiBase`.                                                 |
| `authorize`      | none                        | Return `false` or throw to reject.                                                                  |
| `rehostDocument` | `true`                      | See below.                                                                                          |

The handler serves three routes under `basePath` and returns `null` for
anything else, so it composes with your own routing:

| Route                     |                                                   |
| ------------------------- | ------------------------------------------------- |
| `GET {basePath}/app`      | the app's name, screenshot, and compute functions |
| `GET {basePath}/document` | the app's HTML, served from your origin           |
| `POST {basePath}/compute` | runs one declared compute function                |

On-prem: set `TEXTQL_SERVER_URL` to the plain host. The SDK appends
`/rpc/public` itself.

## Why the document is served from your origin

TextQL pins a published app to the one origin it was published for, twice over:

| Pin                                                       | Effect elsewhere                                                       |
| --------------------------------------------------------- | ---------------------------------------------------------------------- |
| `frame-ancestors` CSP header                              | the browser refuses to frame the document at all                       |
| `ANA_RUNTIME_CONFIG.hostOrigin`, baked in at publish time | the only origin the app's runtime will message, so the bridge is inert |

So `GET {basePath}/document` fetches the app's HTML server-side and re-serves it
from your origin: your response carries no `frame-ancestors`, and `hostOrigin`
is rewritten to your origin. A `<base href>` keeps every subresource loading
from the CDN, which already serves them with `Access-Control-Allow-Origin: *`.

This is a workaround, with costs worth knowing:

* Every document load proxies through your server as `no-store`, so the entry
  document is not CDN-cached. Subresources still are.
* The rewrite is pinned to a string emitted by TextQL's app shell. If that shape
  changes, the handler returns a 502 telling you to upgrade rather than serving
  a document whose bridge will never connect.
* The response carries `content-security-policy: sandbox allow-scripts`.
  Untrusted third-party HTML is being served from *your* origin, and that header
  is what keeps it out of your cookies and `localStorage`. If you reimplement
  this route, keep it.

Set `rehostDocument: false` to point the iframe straight at the signed CDN URL
instead. That is the better path, and it works once your origin is allowed
platform-side.

## On-premise and restricted networks

Nothing in the integration needs a TextQL-operated origin at runtime, so long as
your instance also stores rendered apps somewhere you operate. Point the handler
at your instance and serve the element yourself, and every host the browser
reaches is one you run:

```bash theme={null}
TEXTQL_SERVER_URL=https://textql.internal.example.com
```

<Note>
  Every client reads `TEXTQL_SERVER_URL` from **v1.4.1+** — the embed handler,
  `new Textql()`, and `createStreamingClient`. An explicit `serverURL` still
  wins. On earlier versions the variable is ignored and the SDK silently
  defaults to `app.textql.com`; pass the host at each construction site
  instead — `createEmbedHandler({ client: new Textql({ serverURL }) })`.
</Note>

| What the browser loads                      | From                                                                          |
| ------------------------------------------- | ----------------------------------------------------------------------------- |
| the element script                          | your origin, once copied out of `node_modules`                                |
| `{basePath}/app`, `/document`, `/compute`   | your origin — the handler's three routes                                      |
| the app document's subresources             | the asset origin your instance signs URLs for, via the injected `<base href>` |
| the poster screenshot, before the app loads | the same asset origin                                                         |

Check that last pair before assuming air-gapped. `rehostDocument` re-serves the
entry document from your origin, but the scripts and styles inside it still load
from wherever your instance stores rendered apps — your own object storage
on-prem, TextQL's CDN against cloud.

With the element self-hosted and `rehostDocument` on, the host page needs
`script-src 'self'` and `frame-src 'self'` — the iframe's `src` is your own
`{basePath}/document` — plus `style-src 'unsafe-inline'`, because the element
writes its shadow-DOM stylesheet as an inline `<style>`. Add the asset origin to
`img-src` for the poster.

## What the bridge leaves out

The full host inside TextQL also carries per-member state, activity logging,
presence, deep-link routing, realtime sockets, and asking the agent. The
embedded element declares all of them off, which is a supported configuration —
apps that render data and call compute functions work unchanged, while apps
built around member state render but lose those features.
