A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1# atproto-ui
2
3atproto-ui is a component library and set of hooks for rendering records from the AT Protocol (Bluesky, Leaflet, and friends) in React applications. It handles DID resolution, PDS endpoint discovery, and record fetching so you can focus on UI. [Live demo](https://atproto-ui.wisp.place).
4
5## Screenshots
6
7
8
9
10## Features
11
12- Drop-in components for common record types (`BlueskyPost`, `BlueskyProfile`, `TangledString`, etc.).
13- Pass prefetched data directly to components to skip API calls—perfect for server-side rendering, caching, or when you already have the data.
14- Hooks and helpers for composing your own renderers for your own applications, (PRs welcome!)
15- Built on the lightweight [`@atcute/*`](https://github.com/atcute) clients.
16
17## Installation
18
19```bash
20npm install atproto-ui
21```
22
23## Quick start
24
251. Wrap your app (once) with the `AtProtoProvider`.
262. Drop any of the ready-made components inside that provider.
273. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
28
29```tsx
30import { AtProtoProvider, BlueskyPost } from "atproto-ui";
31
32export function App() {
33 return (
34 <AtProtoProvider>
35 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
36 {/* you can use handles in the components as well. */}
37 <LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
38 </AtProtoProvider>
39 );
40}
41```
42
43## Passing prefetched data to skip API calls
44
45All components accept a `record` prop. When provided, the component uses your data immediately without making network requests for that record. This is perfect for SSR, caching strategies, or when you've already fetched data through other means.
46
47```tsx
48import { BlueskyPost, useLatestRecord } from "atproto-ui";
49import type { FeedPostRecord } from "atproto-ui";
50
51const MyComponent: React.FC<{ did: string }> = ({ did }) => {
52 // Fetch the latest post using the hook
53 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
54 did,
55 "app.bsky.feed.post"
56 );
57
58 if (loading) return <p>Loading…</p>;
59 if (!record || !rkey) return <p>No posts found.</p>;
60
61 // Pass the fetched record directly—BlueskyPost won't re-fetch it
62 return <BlueskyPost did={did} rkey={rkey} record={record} />;
63};
64```
65
66The same pattern works for all components:
67
68```tsx
69// BlueskyProfile with prefetched data
70<BlueskyProfile did={did} record={profileRecord} />
71
72// TangledString with prefetched data
73<TangledString did={did} rkey={rkey} record={stringRecord} />
74
75// LeafletDocument with prefetched data
76<LeafletDocument did={did} rkey={rkey} record={documentRecord} />
77```
78
79### Available building blocks
80
81| Component / Hook | What it does |
82| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
83| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
84| `AtProtoRecord` | Core component that fetches and renders any AT Protocol record. **Accepts a `record` prop to use prefetched data and skip API calls.** |
85| `BlueskyProfile` | Renders a profile card for a DID/handle. **Accepts a `record` prop to skip fetching.** Also supports `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
86| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post with quotation support. **Accepts a `record` prop to skip fetching.** Custom renderer overrides and loading/fallback knobs available. |
87| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
88| `TangledString` | Renders a Tangled string (gist-like record). **Accepts a `record` prop to skip fetching.** Optional renderer overrides available. |
89| `LeafletDocument` | Displays long-form Leaflet documents with blocks and theme support. **Accepts a `record` prop to skip fetching.** Renderer overrides available. |
90| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records. `useLatestRecord` returns both the `record` and `rkey` so you can pass them directly to components. |
91
92All components accept a `colorScheme` of `'light' | 'dark' | 'system'` so they can blend into your design. They also accept `fallback` and `loadingIndicator` props to control what renders before or during network work, and most expose a `renderer` override when you need total control of the final markup.
93
94### Using hooks to fetch data once
95
96`useLatestRecord` gives you the most recent record for any collection along with its `rkey`. You can pass both to components to skip the fetch:
97
98```tsx
99import { useLatestRecord, BlueskyPost } from "atproto-ui";
100import type { FeedPostRecord } from "atproto-ui";
101
102const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
103 const { record, rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(
104 did,
105 "app.bsky.feed.post",
106 );
107
108 if (loading) return <p>Fetching latest post…</p>;
109 if (error) return <p>Could not load: {error.message}</p>;
110 if (empty || !record || !rkey) return <p>No posts yet.</p>;
111
112 // Pass both record and rkey—no additional API call needed
113 return <BlueskyPost did={did} rkey={rkey} record={record} colorScheme="system" />;
114};
115```
116
117The same pattern works for other components. Just swap the collection NSID and component:
118
119```tsx
120const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
121 const { record, rkey } = useLatestRecord(did, "pub.leaflet.document");
122 return record && rkey ? (
123 <LeafletDocument did={did} rkey={rkey} record={record} colorScheme="light" />
124 ) : null;
125};
126```
127
128## Compose your own component
129
130The helpers let you stitch together custom experiences without reimplementing protocol plumbing. The example below pulls a creator's latest post and renders a minimal summary:
131
132```tsx
133import { useLatestRecord, useColorScheme, AtProtoRecord } from "atproto-ui";
134import type { FeedPostRecord } from "atproto-ui";
135
136const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
137 const scheme = useColorScheme("system");
138 const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(
139 did,
140 "app.bsky.feed.post",
141 );
142
143 if (loading) return <span>Loading…</span>;
144 if (error || !rkey) return <span>No post yet.</span>;
145
146 return (
147 <AtProtoRecord<FeedPostRecord>
148 did={did}
149 collection="app.bsky.feed.post"
150 rkey={rkey}
151 renderer={({ record }) => (
152 <article data-color-scheme={scheme}>
153 <strong>{record?.text ?? "Empty post"}</strong>
154 </article>
155 )}
156 />
157 );
158};
159```
160
161There is a [demo](https://atproto-ui.wisp.place/) where you can see the components in live action.
162
163## Running the demo locally
164
165```bash
166npm install
167npm run dev
168```
169
170Then open the printed Vite URL and try entering a Bluesky handle to see the components in action.
171
172## Next steps
173
174- Expand renderer coverage (e.g., Grain.social photos).
175- Expand documentation with TypeScript API references and theming guidelines.
176
177Contributions and ideas are welcome—feel free to open an issue or PR!