Build Your First App
This walkthrough builds a minimal bext PRISM app from scratch: scaffold it, look at the two config files, add a root layout and a page whose loader reads the session and calls the platform SDK, then run it locally with hot reload. When you are ready to put it on a domain, continue to Deploy a PRISM Site.
Scaffold
Create a new app from the PRISM starter template:
bext new my-app --template @bext/starter-prism
cd my-app
bun install
The my-app directory must not exist yet. This lays down a small project: bext.config.toml,
tsconfig.json, package.json, .gitignore, README.md, and a src/app/ route tree with a
layout, a home page and an /api/hello route. The command also initializes a git repository,
then prints the next steps (bun install, then bext dev); --no-post-install skips the git
step and only prints the next steps. Other starters (@bext/starter-blog, -docs, -saas, -api…) exist: see the
CLI Reference.
The config files
bext.config.toml
The starter ships a minimal config. Its core (excerpt below; the generated file also has
[build.css] and [compile]) declares a PRISM app that watches its source
dirs for live reload and renders with ISR:
[server]
app_dir = "."
[framework]
type = "prism"
[build]
watch_dirs = ["src/app", "src/components"]
live_reload = true
[rendering]
mode = "isr"
revalidate = 60
See the Config Reference for every section.
tsconfig.json
PRISM compiles JSX straight to strings, so the TypeScript config points JSX at the framework (excerpt):
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@bext-stack/framework",
"moduleResolution": "bundler",
"paths": { "@/*": ["./src/*"] }
}
}
The root layout
The root layout.tsx receives the page as children. It can own the <html> document, as
below, or pass children through and let each page render its own document (the starter
does the latter). The tsconfig.json above already points JSX at the framework; you can also
state it per file with the pragma:
/** @jsxImportSource @bext-stack/framework */
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>My App</title>
</head>
<body>{children}</body>
</html>
);
}
A component is just a function that returns HTML as a string (or a promise of one); no JavaScript ships to the browser unless you add an island.
A page with a loader
A page.tsx renders the route. Put data loading in a loader: the page component never
sees request, so cookies and headers are read in the loader. Here the loader reads the
session and increments a counter in the KV store through the platform SDK.
The session comes from createSession (the @bext-stack/framework/auth entry point), a signed
cookie you declare once in src/lib/session.ts: see Loaders & Actions
for how to create it and sign users in.
/** @jsxImportSource @bext-stack/framework */
import { createSdk } from "@bext-stack/framework/sdk";
import { session } from "@/lib/session"; // createSession<{ name: string }>(…)
const sdk = createSdk("my-app");
export async function loader({ request }) {
const user = session.read(request); // null when absent, tampered or expired
const visits = Number((await sdk.kv.get("visits")) ?? 0) + 1;
await sdk.kv.set("visits", visits, 3600); // value + TTL (seconds)
return { user, visits };
}
export default function Page({ data }) {
return (
<main>
<h1>Hello{data.user ? `, ${data.user.name}` : ""}</h1>
<p>Page views: {data.visits}</p>
</main>
);
}
The loader's return value arrives on the page as props.data. See
Loaders & Actions for the full lifecycle, including action for
mutations, which runs before the loader on POST/PUT/PATCH/DELETE.
createSdk("my-app") connects to the platform SDK for your app. The SDK authenticates your
app automatically and scopes all data to its app id; you do not pass a token.
Calling the platform API from a loader
A loader can also call the tRPC Platform API. Queries are GET
(with a superjson input envelope when the procedure takes input), and the result you want is
nested at result.data.json. Your app's cookies are not sent to manage.inklura.fr, so
authenticate with a Bearer token kept in your app's secrets.
auth.getSession takes no input and requires the auth:read permission:
export async function loader() {
const token = await sdk.secrets.get("INKLURA_TOKEN");
const res = await fetch("https://manage.inklura.fr/api/trpc/auth.getSession", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return { me: null }; // for example 401 when the token is invalid or expired
const body = await res.json();
return { me: body.result.data.json };
}
The token has an expiry. An invalid or expired token counts as no credential: the call then
runs as an anonymous visitor, which usually fails with UNAUTHORIZED (401). Renew the secret
when that happens.
For a full standalone client (typed @trpc/client, pagination, and error handling), see
Call the API from a Script.
Run it
Start the dev server with hot reload:
bext dev
It listens on loopback, on the port of [server] listen (3061 when not set); if that port
is busy it picks a nearby free one and says so. The generated file also has port = 3000
under [server], but the server does not read that key: to serve on 3000, set
listen = "127.0.0.1:3000" or run bext dev --port 3000. Useful flags: --port N,
--listen host:port (for example 0.0.0.0:3000 to expose it on your network), --open,
--no-hmr. Edit a file under watch_dirs and the change is
picked up within a second or two. To see the routes it discovered:
bext routes
A note on npm packages
Pages render in an isolate, not in Node: built-ins such as node:crypto are missing or only
partly stubbed, so a library that depends on them will not work reliably at render time. The dependable approach is to
vendor a pure-JS ESM build as .js files under src/ and import it by relative path: see the
SDK Overview.
Next steps
| Page | What it covers |
|---|---|
| SDK Overview | The framework and the platform SDK |
| Loaders & Actions | Data loading, mutations, session access |
| Deploy a PRISM Site | Put the app on a domain with automatic TLS |