Custom app
Build an app that uidu shows inside a Space, acting as the signed-in member — from the template to your own data in Models.
A custom app is a web app you host yourself that uidu shows inside a Space (or the workspace) in a sandboxed iframe. uidu hands it a short-lived session for the member looking at it, so the app reads and writes as that person — no login screen, no token to configure, nothing secret in the browser.
By the end of this path you'll have an app that connects to uidu, knows who is looking, reads the workspace's goals, and keeps its own records in a Model.
Scaffold from the template
npm create uidu-app@latest my-app -- -t custom-appYou get a Next.js app with <UiduAppProvider> already wired, a screen for when it's opened outside
uidu, the page's theme and accent colour applied, public/uidu.app.json to declare the app's Models,
and a Content-Security-Policy: frame-ancestors header so only uidu can frame it. See
create-uidu-app.
Connect to the page framing you
Everything that touches member data sits under <UiduAppProvider>, in client components:
// src/components/app-provider.tsx — rendered by app/layout.tsx around {children}
'use client';
import { UiduAppProvider } from '@uidu/react';
import { DEFAULT_HOST_ORIGINS } from '@uidu/app-bridge';
export function AppProvider({ children }: { children: React.ReactNode }) {
// https://*.uidu.org by default; spread it to add a local uidu or a custom domain
return <UiduAppProvider hostOrigins={[...DEFAULT_HOST_ORIGINS]}>{children}</UiduAppProvider>;
}The session only exists in the browser, after the page loads: a Server Component or a server action
never has it, so pages that show member data are 'use client'. Why, and the other rules of the
session: Authentication.
Know who is looking
useUiduApp() gives you the connection state, a session-backed client, and the context uidu sent:
'use client';
import { useUiduApp } from '@uidu/react';
export function Hello() {
const app = useUiduApp(); // 'connecting' | 'ready' | 'error'
if (app.status === 'connecting') return <p>Connecting…</p>;
if (app.status === 'error') return <p>Open this app from uidu ({app.error.code})</p>;
const { user, space, locale, theme } = app.context;
return <p>Hi {user?.name} — {space?.name ?? 'whole workspace'}</p>;
}context also carries workspaceApp (the installed app — the owner of its Models) and accent, the
page's colour to set as --primary. Every field and state: App bridge.
Read what the workspace already has
A workspace isn't empty, and the session can read some of it. Today that's the workspace's goals — those of the workspace and of the Space the app sits in:
import { listGoals, isGoalBehind } from '@uidu/client';
const goals = await listGoals(app.client);
const behind = goals.filter((g) => !g.parentId && isGoalBehind(g, { tolerance: 0.1 }));Everything else — contacts, deals, people, events, tasks — is refused to a session for now. When the data exists in uidu but the app can't reach it yet, say so to the person rather than copying it into a Model: the copy drifts from the real records.
Keep your own data in a Model
For data uidu has no place for — a room booking, a checklist — define a Model and store items in it. They stay searchable and permissioned inside uidu.
import { createModelItem, ensureModel, listModelItems, toFieldValuesAttributes } from '@uidu/client';
const model = await ensureModel(app.client, {
workspaceAppId: app.context.workspaceApp.id,
name: 'Booking',
fields: [{ shortname: 'room', name: 'Room', kind: 'string' }],
});
const created = await createModelItem(app.client, {
input: {
attributes: {
modelId: model.id,
fieldValuesAttributes: toFieldValuesAttributes(model, { room: 'Blu' }),
},
},
});
const items = await listModelItems(app.client, { modelId: model.id }); // item.fieldValuesByShortname.roomTwo things that bite
Call ensureModel once per app instance (memoize the promise): two concurrent calls each create
the model. And listModelItems reads uidu's search index, which catches up a moment after a write —
after createModelItem / deleteModelItem, update your list from the mutation payload instead of
listing again.
Handle being opened outside uidu
Opened directly in a tab, the app has no host to connect to: useUiduApp() ends in 'error' with
NOT_EMBEDDED. The template shows a "open this from uidu" screen; the
demo falls back to
localStorage. Don't use cookies or localStorage for shared state inside uidu — in a cross-origin
iframe cookies are third-party (Safari drops them) and localStorage is one browser only.
Ship it
Deploy the app anywhere that serves HTTPS, then add it to a Space in uidu. Keep two lists in step: the
hostOrigins you give the bridge and the frame-ancestors header — the template builds both from
https://*.uidu.org plus NEXT_PUBLIC_UIDU_HOST_ORIGINS. If uidu runs on your own domain, add it to
that variable.
Where to go next
- App bridge —
connect(), error codes, origins, the protocol,fromBridge. - Models — fields, field options, updating and deleting items.
- Goals — values stored ×100, progress helpers.
- Authentication — how the session differs from the other two tokens.