Inlay#
Inlay lets you build social UIs from components that live on the AT Protocol.
For example, here's a Bluesky post rendered as an Inlay component:
<mov.danabra.Post uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3lkqvm" />
The host resolves mov.danabra.Post and expands its template into a tree of smaller components:
<org.atsui.Stack>
<org.atsui.Row align="center">
<mov.danabra.AviHandle uri="did:plc:ragtjsm2j2vknwkz3zp4oxrd" />
<org.atsui.Caption>
<org.atsui.Timestamp value="2026-02-17T02:11:13.240Z" />
</org.atsui.Caption>
</org.atsui.Row>
<org.atsui.Link uri="at://...">
<org.atsui.Text>hey check this out</org.atsui.Text>
</org.atsui.Link>
<at.inlay.Maybe>
<mov.danabra.PostEmbed uri="at://..." />
</at.inlay.Maybe>
</org.atsui.Stack>
Then AviHandle and PostEmbed expand further, and so on, until everything bottoms out in org.atsui.* primitives that the host renders directly into HTML, React, SwiftUI, or whatever it wants.
An Inlay component like Post or AviHandle is just a name (NSID) — anyone can publish an implementation for it, and a host (i.e. a browser for Inlay) picks which implementation to use, potentially taking both developer and user preferences into account.
Packages#
| Package | Description |
|---|---|
@inlay/core |
Element trees, JSX runtime, and serialization |
@inlay/render |
Server-side component resolution |
@inlay/cache |
Cache policy for XRPC component handlers |
How it works#
Each component is an at.inlay.component record with one of three body kinds:
- No body — a primitive. The host renders it directly.
- Template — a stored element tree with Binding placeholders that get filled in with props.
- External — an XRPC endpoint that receives props and returns an element tree.
Each component also imports an ordered list of DIDs. When the host needs to resolve a child NSID, it walks the import list and looks up at://{did}/at.inlay.component/{nsid} — first match wins.
Atsui (org.atsui) is the first design system for Inlay — including <Text>, <Avatar>, <List>, and others. In a sense, it's like Inlay's HTML. Atsui is not a traditional component library because it solely defines lexicons (i.e. interfaces). Each host (an Inlay browser like inlay.at) may choose which components are built-in, and how they work. A host could even decide to not implement Atsui, and instead to implement another set of primitives.
A host chooses its own tech stack. For example, you could write a host with Hono and htmx, or with React Server Components, or even with SwiftUI and a custom server-side JSON endpoint. The resolution algorithm is shared, but each host may interpret the final tree as it wishes.
Declaring components#
There is no visual editor yet. Components are created by writing records to your PDS.
Pick an NSID#
An NSID describes what a component does, not how. If someone already defined one that fits (e.g. com.pfrazee.BskyPost with a uri prop), you can implement it yourself. Multiple implementations of the same NSID can coexist — the importer's DID list controls which one gets used.
If you're creating a new NSID, pick one under a domain you control so you can publish a Lexicon for it later.
Component record#
This is the record you write to your PDS.
Template#
The element tree lives inside the record. Bindings are placeholders that get filled in with props at render time.
{
"$type": "at.inlay.component",
"body": {
"$type": "at.inlay.component#bodyTemplate",
"node": {
"$": "$", "type": "org.atsui.Stack", "props": {
"gap": "small",
"children": [
{
"$": "$",
"type": "org.atsui.Text",
"props": { "children": ["Hello, "] },
"key": "0"
},
{
"$": "$",
"type": "org.atsui.Text",
"props": { "children": [ { "$": "$", "type": "at.inlay.Binding", "props": { "path": ["name"] } } ] },
"key": "1"
}
]
}
}
},
"imports": [
"did:plc:mdg3w2kpadcyxy33pizokzf3",
"did:plc:e4fjueijznwqm2yxvt7q4mba"
]
}
The imports array lists the DIDs your component needs for resolution — typically the Atsui DID so you can use org.atsui.* primitives. The record's rkey is the NSID it implements (e.g. mov.danabra.Greeting), so the full URI is at://{your-did}/at.inlay.component/mov.danabra.Greeting.
When rendered as <mov.danabra.Greeting name="world" />, this component resolves to:
<org.atsui.Stack gap="small">
<org.atsui.Text>Hello</org.atsui.Text>
<org.atsui.Text>world</org.atsui.Text>
</org.atsui.Stack>
External#
As an alternative to templates (which are very limited), you can declare components as XRPC server endpoints. Then the Inlay host will hit your endpoint to resolve the component tree.
Here's a real external component record for mov.danabra.Greeting. Notice its body points to a service did instead of an inline node:
{
"$type": "at.inlay.component",
"body": {
"$type": "at.inlay.component#bodyExternal",
"did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run"
},
"imports": [
"did:plc:mdg3w2kpadcyxy33pizokzf3",
"did:plc:e4fjueijznwqm2yxvt7q4mba"
]
}
The did is a did:web, so the host resolves it by fetching /.well-known/did.json to find the service endpoint, then POSTs props to /xrpc/mov.danabra.Greeting and gets back { node, cache }.
This particular handler runs on Val Town:
/* @jsxImportSource npm:@inlay/core@0.0.13 */
import { component, serve } from "https://esm.town/v/gaearon/inlay/main.ts";
import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts";
component("mov.danabra.Greeting", ({ name }) => (
<Stack gap="small">
<Text>Hello,</Text>
<Text>{name || "stranger"}</Text>
</Stack>
));
export default serve;
It's recommended to tag XRPC return values as cacheable; see @inlay/cache for how to do this. In the calling code, it's recommended to wrap XRPC components into <at.inlay.Loading fallback=...> so that they don't block the entire page.
Lexicon (optional)#
A Lexicon defines the prop schema for an NSID. You don't need one to get started, but publishing one enables type checking, validation, and codegen with @atproto/lex:
{
"lexicon": 1,
"id": "mov.danabra.Greeting",
"defs": {
"main": {
"type": "procedure",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "maxLength": 100 }
}
}
},
"output": {
"encoding": "application/json",
"schema": { "type": "ref", "ref": "at.inlay.defs#response" }
}
}
}
}
See @inlay/core for JSX support and more ways to build element trees.
Rendering as a host#
A host walks an element tree, resolves each component through its import stack (DIDs), and maps the resulting primitives to output (HTML, React, etc).
@inlay/render does the resolution step — call render() on an element, get back the expanded tree or a primitive. The host calls it in a loop until everything is resolved. The render README has a minimal working example that turns Inlay elements into HTML.
If you're writing XRPC component handlers, @inlay/cache lets you declare cache lifetime and invalidation tags (cacheLife(), cacheTagRecord(), cacheTagLink()). The host uses these to know when to re-render.
proto/ is a prototype host built with Hono and htmx.
Development#
npm install
npm run dev # start the Next.js dev server (Turbopack)
npm run build # production build (Next.js)
npm run typecheck # type-check
npm run lint # lint
Project structure#
app/ — Next.js app (host UI, routes, pages)
packages/@inlay/core/ — element primitives and JSX
packages/@inlay/render/ — rendering engine
packages/@inlay/cache/ — cache declarations
db/ — database schema (Drizzle)
ingester/ — AT Protocol firehose ingester
invalidator/ — cache invalidation service
proto/ — prototype host server (Hono)
lexicons/ — AT Protocol lexicon definitions
generated/ — generated TypeScript from lexicons
License#
MIT