A frontend-only Bluesky (atproto) client. React 18 + TypeScript + Vite, talking directly to the user's PDS over OAuth — no backend of our own.
npm install && npm run devThe dev server binds to http://127.0.0.1:5173 (not localhost, and the port
is fixed). This matters for OAuth — see below.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server on 127.0.0.1:5173 |
npm run build |
Type-check then production bundle |
npm run preview |
Serve the built bundle |
npm run typecheck |
tsc --noEmit |
Auth uses @atproto/oauth-client-browser (PKCE + PAR + DPoP, sessions persisted
in IndexedDB). There are two modes, selected automatically by import.meta.env.DEV.
atproto defines a special "loopback" client for local development: the
client_id is literally http://localhost with the scope and redirect_uri
encoded as query params. No hosted metadata file is needed. This is why:
- the dev server is pinned to 127.0.0.1:5173 (
vite.config.ts), and src/lib/oauth/client.tssynthesizes the loopbackclient_idand theredirect_uri(<origin>/oauth/callback) fromwindow.location.origin.
Just run npm run dev, open http://127.0.0.1:5173, and sign in with a handle.
In a production build you must:
- Host
public/client-metadata.jsonat a stable public HTTPS URL. - Set the env vars (see
.env.example):VITE_CLIENT_ID— the URL of thatclient-metadata.json(this is the OAuthclient_id).VITE_REDIRECT_URI— must also be listed in the metadata'sredirect_urisand must match your deployed/oauth/callbackroute.
The requested scope is atproto transition:generic transition:chat.bsky; the
chat scope plus the atproto-proxy header (see src/lib/api/proxy.ts) are
required for DMs.
This is an SPA using react-router-dom 7's data router with the history API.
Any host must rewrite unknown paths to index.html (history-API fallback) so
deep links like /profile/alice.bsky.social/post/abc resolve. Vite's dev server
already does this.
- All network access goes through
useAgent()/getAgent()(src/lib/api/agent.tsx). Never construct anAgentin a feature. - All cache keys come from
qk.*(src/lib/query-keys.ts). Never inline a key array. React Query is the sole server-state cache. - Optimistic like/repost/bookmark patch the cached
InfiniteDataviapatchPostInAllFeeds(src/lib/optimistic.ts); do not invalidate the patched feed on settle (avoids scroll jitter). - Rich text renders via
src/lib/rich-text.tsxsegments; never slice raw text by byte offsets. Build facets withbuildFacetsbefore posting. - Image upload only via
src/lib/blob.ts(uploadImage, compresses < 1MB). - Preferences writes only via
src/lib/prefs.ts(read-modify-write the whole array). - Theming via
<html data-theme="light|dim|dark">; colors are CSS vars insrc/styles/tokens.css. Components read vars only — never hardcode hex. Theme is applied pre-paint by an inline script inindex.htmland persisted by the ui store.
src/
main.tsx, App.tsx # root wiring (QueryClient + Auth + Router)
styles/ # tokens.css + globals.css
lib/ # oauth, agent, query client/keys, rich-text, etc.
components/ # shared presentational UI (+ layout/ shell)
features/ # one folder per product area (route entries)
routes/ # router, RootLayout, RequireAuth, NotFound
store/ # zustand ui store (ephemeral UI only)
Feature folders under src/features/* are filled in separately; the router
references their entry files by path.