social components
inlay.at
atproto
components
sdui
1# Inlay
2
3[Inlay](https://inlay.at/) lets you build social UIs from components that live on the AT Protocol.
4
5For example, here's a Bluesky post rendered as an Inlay component:
6
7```jsx
8<mov.danabra.Post uri="at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3lkqvm" />
9```
10
11The host resolves `mov.danabra.Post` and expands its template into a tree of smaller components:
12
13```jsx
14<org.atsui.Stack>
15 <org.atsui.Row align="center">
16 <mov.danabra.AviHandle uri="did:plc:ragtjsm2j2vknwkz3zp4oxrd" />
17 <org.atsui.Caption>
18 <org.atsui.Timestamp value="2026-02-17T02:11:13.240Z" />
19 </org.atsui.Caption>
20 </org.atsui.Row>
21 <org.atsui.Link uri="at://...">
22 <org.atsui.Text>hey check this out</org.atsui.Text>
23 </org.atsui.Link>
24 <at.inlay.Maybe>
25 <mov.danabra.PostEmbed uri="at://..." />
26 </at.inlay.Maybe>
27</org.atsui.Stack>
28```
29
30Then `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.
31
32An Inlay component like `Post` or `AviHandle` is just a name ([NSID](https://atproto.com/specs/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.
33
34## Packages
35
36| Package | Description |
37|---------|-------------|
38| [`@inlay/core`](packages/@inlay/core) | Element trees, JSX runtime, and serialization |
39| [`@inlay/render`](packages/@inlay/render) | Server-side component resolution |
40| [`@inlay/cache`](packages/@inlay/cache) | Cache policy for XRPC component handlers |
41
42## How it works
43
44Each component is an [`at.inlay.component`](https://pdsls.dev/at://did:plc:mdg3w2kpadcyxy33pizokzf3/com.atproto.lexicon.schema/at.inlay.component#schema) record with one of three body kinds:
45
46- **No body** — a primitive. The host renders it directly.
47- **Template** — a stored element tree with Binding placeholders that get filled in with props.
48- **External** — an XRPC endpoint that receives props and returns an element tree.
49
50Each 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.
51
52[Atsui (`org.atsui`)](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema) 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](https://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.
53
54A host chooses its own tech stack. For example, you could write a host [with Hono and htmx](./proto), or [with React Server Components](./app), 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.
55
56## Declaring components
57
58There is no visual editor yet. Components are created by writing records to your PDS.
59
60### Pick an NSID
61
62An 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.
63
64If you're creating a new NSID, pick one under a domain you control so you can [publish a Lexicon](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) for it later.
65
66### Component record
67
68This is the record you write to your PDS.
69
70#### Template
71
72The element tree lives inside the record. Bindings are placeholders that get filled in with props at render time.
73
74```json
75{
76 "$type": "at.inlay.component",
77 "body": {
78 "$type": "at.inlay.component#bodyTemplate",
79 "node": {
80 "$": "$", "type": "org.atsui.Stack", "props": {
81 "gap": "small",
82 "children": [
83 {
84 "$": "$",
85 "type": "org.atsui.Text",
86 "props": { "children": ["Hello, "] },
87 "key": "0"
88 },
89 {
90 "$": "$",
91 "type": "org.atsui.Text",
92 "props": { "children": [ { "$": "$", "type": "at.inlay.Binding", "props": { "path": ["name"] } } ] },
93 "key": "1"
94 }
95 ]
96 }
97 }
98 },
99 "imports": [
100 "did:plc:mdg3w2kpadcyxy33pizokzf3",
101 "did:plc:e4fjueijznwqm2yxvt7q4mba"
102 ]
103}
104```
105
106The `imports` array lists the DIDs your component needs for resolution — typically the [Atsui DID](https://pdsls.dev/at/did:plc:e4fjueijznwqm2yxvt7q4mba) so you can use [`org.atsui.*` primitives](https://pdsls.dev/at://did:plc:e4fjueijznwqm2yxvt7q4mba/com.atproto.lexicon.schema). 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`.
107
108When [rendered](packages/@inlay/render) as `<mov.danabra.Greeting name="world" />`, this component resolves to:
109
110```jsx
111<org.atsui.Stack gap="small">
112 <org.atsui.Text>Hello</org.atsui.Text>
113 <org.atsui.Text>world</org.atsui.Text>
114</org.atsui.Stack>
115```
116
117#### External
118
119As an alternative to templates (which are very limited), you can declare components as [XRPC](https://atproto.com/guides/glossary#xrpc) server endpoints. Then the Inlay host will hit your endpoint to resolve the component tree.
120
121Here's a [real external component record](https://pdsls.dev/at://did:plc:fpruhuo22xkm5o7ttr2ktxdo/at.inlay.component/mov.danabra.Greeting) for `mov.danabra.Greeting`. Notice its `body` points to a service `did` instead of an inline `node`:
122
123```json
124{
125 "$type": "at.inlay.component",
126 "body": {
127 "$type": "at.inlay.component#bodyExternal",
128 "did": "did:web:gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run"
129 },
130 "imports": [
131 "did:plc:mdg3w2kpadcyxy33pizokzf3",
132 "did:plc:e4fjueijznwqm2yxvt7q4mba"
133 ]
134}
135```
136
137The `did` is a `did:web`, so the host resolves it by fetching [`/.well-known/did.json`](https://gaearon--019c954e8a7877ea8d8d19feb1d4bbbb.web.val.run/.well-known/did.json) to find the service endpoint, then POSTs props to `/xrpc/mov.danabra.Greeting` and gets back `{ node, cache }`.
138
139This particular handler runs on [Val Town](https://www.val.town/x/gaearon/greeting-jsx/code/main.tsx):
140
141```tsx
142/* @jsxImportSource npm:@inlay/core@0.0.13 */
143import { component, serve } from "https://esm.town/v/gaearon/inlay/main.ts";
144import { Stack, Text } from "https://lex.val.run/org.atsui.*.ts";
145
146component("mov.danabra.Greeting", ({ name }) => (
147 <Stack gap="small">
148 <Text>Hello,</Text>
149 <Text>{name || "stranger"}</Text>
150 </Stack>
151));
152
153export default serve;
154```
155
156It's recommended to tag XRPC return values as cacheable; see [`@inlay/cache`](packages/@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.
157
158### Lexicon (optional)
159
160A [Lexicon](https://atproto.com/specs/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`](https://www.npmjs.com/package/@atproto/lex):
161
162```json
163{
164 "lexicon": 1,
165 "id": "mov.danabra.Greeting",
166 "defs": {
167 "main": {
168 "type": "procedure",
169 "input": {
170 "encoding": "application/json",
171 "schema": {
172 "type": "object",
173 "required": ["name"],
174 "properties": {
175 "name": { "type": "string", "maxLength": 100 }
176 }
177 }
178 },
179 "output": {
180 "encoding": "application/json",
181 "schema": { "type": "ref", "ref": "at.inlay.defs#response" }
182 }
183 }
184 }
185}
186```
187
188See [`@inlay/core`](packages/@inlay/core) for JSX support and more ways to build element trees.
189
190## Rendering as a host
191
192A host walks an element tree, resolves each component through its import stack (DIDs), and maps the resulting primitives to output (HTML, React, etc).
193
194[`@inlay/render`](packages/@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](packages/@inlay/render) has a minimal working example that turns Inlay elements into HTML.
195
196If you're writing XRPC component handlers, [`@inlay/cache`](packages/@inlay/cache) lets you declare cache lifetime and invalidation tags (`cacheLife()`, `cacheTagRecord()`, `cacheTagLink()`). The host uses these to know when to re-render.
197
198[`proto/`](proto/) is a prototype host built with Hono and htmx.
199
200## Development
201
202```bash
203npm install
204npm run dev # start the Next.js dev server (Turbopack)
205npm run build # production build (Next.js)
206npm run typecheck # type-check
207npm run lint # lint
208```
209
210## Project structure
211
212```
213app/ — Next.js app (host UI, routes, pages)
214packages/@inlay/core/ — element primitives and JSX
215packages/@inlay/render/ — rendering engine
216packages/@inlay/cache/ — cache declarations
217db/ — database schema (Drizzle)
218ingester/ — AT Protocol firehose ingester
219invalidator/ — cache invalidation service
220proto/ — prototype host server (Hono)
221lexicons/ — AT Protocol lexicon definitions
222generated/ — generated TypeScript from lexicons
223```
224
225## License
226
227MIT