Pages
Abstra Pages are Python-powered custom HTML interfaces. Unlike Forms (which use a widget-based UI), Pages give you full control over the HTML, CSS, and JavaScript while keeping the backend logic in Python.
Pages are the recommended choice for dashboards, data visualizations, interactive tools, and any interface that benefits from custom styling. New projects are built with the Abstra Design System: ab-* Web Components (layout, tables, filters, forms, dialogs, charts) themed with --ab-* CSS tokens. Projects that already use Tailwind or their own CSS keep it, and can add ab-* components next to it.

Abstra Design System
Every example in this documentation is built with the Abstra Design System. Two tags load it, once, in the base template:
It gives a Page what it would otherwise build by hand:
| Need | Components |
|---|---|
| App shell, navigation, signed-in user, page title | ab-app, ab-page-header, ab-menu |
| Layout and spacing | ab-stack, ab-grid, ab-cluster, ab-split, ab-card |
| Data | ab-table (server pagination, sorting, CSV export), ab-kpi, ab-chart, ab-badge |
| Forms and filters | ab-input, ab-select, ab-date-range, ab-filter-bar, ab-upload |
| Feedback | ab-alert, ab-empty, ab-skeleton, ab-progress, ab-result, abToast(), abConfirm() |
- Theme with tokens, not CSS.
--ab-accentalone themes every component; the other--ab-*tokens are in Theming. New projects don't need Tailwind or page CSS. - Data goes in as properties or JSON, never as HTML. From JavaScript,
table.rows = await get_orders(); from a Jinja fragment,rows='{{ rows | tojson }}'. Components render values as text and formatcurrency,dateandpercentthemselves, so Python returns plain numbers and ISO dates. - htmx works out of the box.
ab-*fields are native form fields, component events (ab-page-change,ab-sort-change,ab-filter-change) are validhx-triggers that send their values, the triggeringab-buttonshows a loading state,hx-confirmopens the design system dialog, and HTTP errors become a toast. - Start from a recipe. Page recipes are complete pages (template + Python) for an overview, a work queue, a report, a form, an admin table and a record page.
- Existing projects keep their styling. A project that already uses Tailwind or its own CSS can add
ab-*components next to it: load onlyabstra-ds.js(the CSS file restyles plain HTML) and map its accent color to--ab-accent.
AI agents building Pages can read the whole API from design.abstra.io/llms.txt.
How it works
A Page is a Python script that:
- Registers functions using
@register_function— call them over plain HTTP with htmx, or through the generated async JavaScript functions - Defines a
__render__function — a special function that returns the HTML string served to the browser
Use htmx for HTML fragments (CRUD, tables, forms and progress) and JavaScript RPC for structured data (charts, browser state, downloads and media). Both call the same registered Python functions; no library is injected automatically.
For htmx, set up the shared base template once. A page then needs only a function returning a fragment and markup selecting where it goes:
When the browser needs a JSON object instead of markup, keep the generated JS function:
JavaScript RPC request flow
- GET requests call
__render__()and return the HTML with auto-generated JS function stubs - POST requests call the specified registered function and return JSON
- Generator functions are streamed as NDJSON — the JS stub becomes an
async function*(async generator)
The __render__ function is not exposed as a JavaScript function — it can only be called by the server on GET requests.
Plain HTTP function protocol
Call /_page/<path>/_fn/<name> or /_page-home/_fn/<name>. Nested pages work too (/_page/a/b/_fn/rows). Use _fn/rows in htmx attributes and resolve it against the document's <base href> with the base-template hook below. The public navigation URL is not the function endpoint.
| HTTP method | Registered Python function for _fn/rows |
|---|---|
| GET, HEAD | get_rows |
| POST | post_rows |
| PUT | put_rows |
| PATCH | patch_rows |
| DELETE | delete_rows |
Do not repeat the verb in the URL: _fn/get_rows and _fn/post_rows return 404 in every method, with a hint when there is a valid suffix. Names starting with _ are also rejected. Missing registered functions return 404; unsupported methods return 405 with Allow. Keep get_* functions read-only: link previews and crawlers may issue GET or HEAD. There is no Python methods= option.
The protocol is HTTP, not htmx-specific. fetch and other frameworks can use it too. Accept selects the format, not HX-Request:
Accept | Response |
|---|---|
text/html | String → 200 HTML fragment; None → 204, no swap; other types → 406 |
text/event-stream | Generator → SSE, one message per yield |
| Other / absent | Existing SDK JSON response ({"result": ...}), or NDJSON for a generator |
For an HTML caller, SDK exceptions keep their status (usually 500) and become plain text. Authentication is still enforced: a protected page without a token returns 401. The base-template hook sends the existing abstra-auth-token meta as Authorization: Bearer … and abstra-execution-id as X-Page-Execution-Id. Fragments do not receive a document prelude or a traceparent meta. Responses vary on Accept.
Parameters and v1 limits
- Query parameters plus JSON/urlencoded body become keyword arguments; the body wins collisions. Only POST, PUT and PATCH read a body. GET, HEAD and DELETE use the query.
- Query/urlencoded values are text. Convert inside Python (
int(page),Decimal(amount)); type annotations do not perform conversion. JSON preserves its supplied types. - Use
name[]for lists, even with one value. Plaintags=ais a string. An unchecked checkbox is absent; optional parameters need defaults (notify=""). - Multipart files arrive as dictionaries of bytes; ordinary multipart fields are decoded with
json.loadswhen possible.id=7can become an integer, and numeric postal codes can lose leading zeroes. Convert deliberately; use JSON/RPC for structured values. Multipart query values are available throughget_query_params(), not merged into function arguments. - v1 has no custom status/header helpers or
HX-Trigger. Return HTML validation messages with 200, and usehx-swap-oobto update other parts of the page. Do not turn invalid user input into an exception just to display a field error. - htmx 4 attributes are not inherited unless they have the
:inheritedsuffix. Puthx-target,hx-disable, etc. on each request element, or explicitly opt in. hx-valsaccepts scalar values only. Arrays stringify to"1,2"; objects to"[object Object]". Use named form inputs for lists, or JS RPC for structured data.@register_function(cache=N)caches the generated JS result only, not/_fnrequests.
SSE with htmx
Load htmx 4.0.0 and its dist/ext/hx-sse.js extension explicitly (both scripts and SRI hashes are in the base template). Prefer a finite hx-get or hx-post request: each unnamed message swaps its HTML, and the connection ends with the generator without reconnecting.
A yielded string becomes SSE data: (multiple lines are preserved). A dict containing only event, data, id and/or retry, with event or data, is reserved for SSE fields; other values become JSON in data:. Named events dispatch DOM events rather than swapping HTML. A generator exception becomes event: error; read its message with event.detail.data. Transport failures use the separate htmx:sse:error event.
Only use hx-sse:connect for an intentional persistent GET connection. It reconnects when the stream ends. To stop normally, finish get_progress with yield {"event": "done", "data": ""} and use hx-sse:close="done":
Streaming structured data with JavaScript
If a registered function is a Python generator (uses yield), it will be streamed to the browser chunk by chunk. You can consume the stream in three ways:
You can also use for await...of inside a <script type="module"> for top-level await:
Or collect everything at once with await:
This is useful for:
- Large files — stream data without loading it entirely into memory
- Progress updates — yield status messages as a long task progresses
- Real-time logs — stream log lines to the browser as they happen
Creating a Page
Drag the Pages icon from the workflow toolbar onto the canvas, or press the U keyboard shortcut.
The page appears in the workflow canvas alongside forms, hooks, and other stages.

Running and testing
Click on a page node and select Run to preview it in the editor. The PageTester shows the rendered HTML in an iframe with a toolbar for URL preview, query params, fullscreen, and tasks.

Pages vs Forms
| Feature | Pages | Forms |
|---|---|---|
| UI control | Full HTML/CSS/JS | Widget-based (pre-built components) |
| Styling | Abstra Design System (recommended) or any CSS | Abstra theme system |
| Best for | Dashboards, custom UIs, data viz | Step-by-step data collection |
| Backend calls | @register_function → HTTP/htmx or async JS | Widget events → Python state |
| Validation | Custom (in JS or Python) | Built-in widget validation |
| File uploads | Recommended — file bytes arrive in-memory | Built-in FileInput widget; avoid reading files across steps |
| Multi-step flows | Custom (SPA-style) | Built-in with run() steps |
Rule of thumb: Prefer Pages for new interfaces — data display, custom UIs, and any flow that uploads files. Use Forms when a widget-based wizard for structured input is all you need; for flows that upload and process files, prefer Pages.
Static files
Use register_static to serve local files (JS, CSS, images, etc.) from your page. It returns a signed URL, and each call returns a fresh one, so browsers always get the latest version without any manual cache busting.
register_static accepts str or any os.PathLike (e.g., pathlib.Path). The returned URL is a subpath of the page itself and carries a signed token query parameter, valid for 24 hours (e.g., /_page/my-page/code.js?token=eyJ...). Call it inside __render__ so every page load gets a fresh URL.
The URL already has a query string. Don't append a second ? (e.g., f"{register_static('styles.css')}?v=2"): it corrupts the token, and the file loads in the editor preview but fails once published. You don't need a version parameter; if you need other query parameters, append them with &.
This is useful for:
- Custom JavaScript — keep JS in
.jsfiles with proper syntax highlighting - CSS stylesheets — serve local CSS without CDN dependencies (with the design system, only
:root { --ab-*: … }tokens) - Images and assets — reference local images directly from your HTML
Project structure best practices
For anything beyond a simple page, keep each concern in its own file: HTML templates, CSS styles, JavaScript logic, and Python backend — each separate. Extract shared UI patterns into reusable helper functions.
Recommended file layout
Jinja2 rendering helper
Create a small helper to load and render templates:
Base template
The base template links shared CSS and JS files, and defines blocks for page-specific assets:
The first hook above is the shared Page endpoint/auth integration: install it once, not once per button. It rewrites only _fn/ actions, so credentials are not attached to unrelated requests. Keep the platform-provided <base href>; do not override it with your public navigation path. The error handler uses htmx 4's response status and cancels htmx:after:request on HTTP errors, keeping plain-text errors out of HTML swaps (htmx 4 swaps 4xx/5xx by default unless you cancel or configure it). The design system shows an error toast for HTTP and network failures, turns the triggering ab-button into a loading state, and opens its own dialog for hx-confirm. --ab-accent is the whole theme: see Tema for the other tokens.
Pass base_css_url=register_static("static/css/base.css") and common_js_url=register_static("static/js/common.js") from __render__ when using those optional assets. Their tags are omitted when the URLs are not supplied. Fragments must render standalone templates without {% extends "base.html" %}; otherwise every swap would duplicate scripts and the shell. Enable Jinja autoescape and do not mark user input safe.
Page template — HTML only
Templates contain only markup — no inline <script> or <style> blocks. JS and CSS live in their own files.
JavaScript — own file
Shared JS utilities
Toasts, confirmations and error messages don't need helpers: the design system provides abToast("Salvo"), await abConfirm({ title, message, tone: "danger" }) and <ab-alert>.
Page Python file — logic only
Shared UI
The header, the signed-in user, navigation, page titles, toasts and dialogs are design system components, so there is no HTML to build in Python: ab-app in base.html is the shell, each template starts with <ab-page-header heading="…">, and JS calls abToast(...). Extract a Jinja macro or include only for markup that repeats across your own Pages. Keep Python helpers out of markup: return data, and let templates render it.
This approach keeps each language in its own file: Python for logic, HTML for structure, JS for behavior. It makes templates easy to edit visually, enables syntax highlighting everywhere, and lets you share patterns across pages without duplicating code.
Sharing layout across multiple pages
A common pattern is an app where several Pages share a navbar or sidebar — for example, a financial dashboard with Indicators, Cash Flow, and Default rate sections, each one its own Page.
It can be tempting to build a "main" Page that holds the sidebar and embeds the active section in an <iframe>. Avoid this: every Page route already renders inside the project's own chrome, so nesting one Page inside another shows the chrome twice — duplicated navbars and breadcrumbs, broken in-page navigation, and a split auth/scroll context.
The same base.html from Project structure best practices can host the shared sidebar. Each Page extends it and renders its own content block, so navigation between sections becomes plain links to each Page's clean public path (e.g. <a href="/cash-flow">) and the sidebar appears exactly once. Never link to /_page/...: that is an internal RPC route that serves the bare page without the app shell, breaking navigation and auth.
Sidebar in the base template
ab-app shows its navigation as a sidebar with the sidebar attribute; one ab-menu holds the sections and marks the current one from active:
Cash Flow page template
Each section's template extends the base and fills the body block — same pattern as dashboard.html above:
Cash Flow page Python file
Render the template marking this section as active:
Repeat the template + Python file pair for each section (page_indicators.py, page_default_rate.py), passing the matching active value. The sidebar lives in one place; each Page is its own route.
Keeping page state in the URL
A Page runs inside an isolated frame, so history.pushState() and history.replaceState() called from the page never reach the address bar. There are two ways to change the URL from a Page:
- A link to the same Page with a new query (
<a href="?status=pending">, as in the orders table example) re-renders the Page:__render__runs again and reads the new values withget_query_params(). abstra.replaceUrl(), from the samewindow.abstrabrowser API asabstra.login(), only rewrites the query string and hash in the address bar. The Page is not re-rendered, keeps its JavaScript state, and no history entry is added. Use it to reflect what the user is looking at (an open item, a selected filter), so that reloading or sharing the URL brings them back to the same place.
abstra.replaceUrl() takes an object with:
search— the new query string, such as"?run=42". It replaces the whole query;""or leaving it out removes it.hash— the new fragment, such as"#logs".""or leaving it out removes it.
The path always stays the one of the current Page.
Registered functions called after abstra.replaceUrl() still see, in get_query_params(), the query the Page was rendered with. Pass the current state to them as arguments instead.