SDK guides
Social interactions
@scribe-atp/social exports LikeButton, ShareButton, and SubscribeButton — React components that let readers interact with published content on the AT Protocol using their Bluesky account. No server-side setup is required; the components handle the OAuth flow via a popup.
Install
npm install @scribe-atp/social
Requires React 18 or later.
Getting the AT URIs
The components need AT URIs — stable identifiers that point to specific AT Protocol records:
LikeButtonandShareButtonboth need the document URI: the AT URI of thesite.standard.documentarticle record.LikeButton,ShareButton, andSubscribeButtonall need the publication URI: the AT URI of thesite.standard.publicationsite record.
Your article page loader should fetch both. The exact approach depends on your framework:
createArticleRouteLoader returns documentUri alongside the article. Fetch the site separately to get the publication URI:
// app/routes/blog.$articleSlug.tsx
import { createArticleRouteLoader } from '@scribe-atp/react-router-framework';
import { fetchSite } from '@scribe-atp/core';
import type { LoaderFunctionArgs } from 'react-router';
export async function loader({ request, params }: LoaderFunctionArgs) {
const [articleData, site] = await Promise.all([
createArticleRouteLoader('alice.bsky.social', 'https://alice.bsky.social')({ request, params }),
fetchSite('alice.bsky.social', 'https://alice.bsky.social', request.signal),
]);
return { ...articleData, publicationUri: site.uri };
}
import { fetchArticleBySlug, fetchSite } from '@scribe-atp/core';
const [{ article, uri: documentUri }, site] = await Promise.all([
fetchArticleBySlug('alice.bsky.social', 'https://alice.bsky.social', params.slug),
fetchSite('alice.bsky.social', 'https://alice.bsky.social'),
]);
// documentUri → LikeButton, ShareButton
// site.uri → LikeButton, ShareButton, SubscribeButton
import { fetchArticleBySlug, fetchSite } from '@scribe-atp/core';
const route = useRoute();
const [{ article, uri: documentUri }, site] = await Promise.all([
fetchArticleBySlug('alice.bsky.social', 'https://alice.bsky.social', route.params.slug as string),
fetchSite('alice.bsky.social', 'https://alice.bsky.social'),
]);
Rendering the buttons
Once you have the URIs, render the components in your article template:
import { LikeButton, ShareButton, SubscribeButton } from '@scribe-atp/social';
{documentUri && (
<LikeButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
/>
)}
{documentUri && (
<ShareButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
/>
)}
{publicationUri && (
<SubscribeButton publicationUri={publicationUri} title="My Site" />
)}
Note
Render the buttons conditionally. documentUri is only available once the article is fetched, and some routes (e.g. 404 pages) may not have one.
What happens when clicked
- A
480×640popup opens atsocial.scribe-atp.appshowing the article title (or site name for Subscribe). - The reader authenticates with their Bluesky account via AT Protocol OAuth.
- On success, the service writes the relevant AT Protocol record to their repository, then notifies the opener page via
postMessage. - The button updates to its confirmed state and — for Like and Subscribe — the state is saved to
localStorage.
Tip
The postMessage path is the primary notification mechanism. If a browser blocks cross-origin postMessage after the popup closes, the button falls back to polling /status/:token for up to 30 seconds.
Note
ShareButton does not persist state to localStorage. After a successful share the button resets after 3 seconds, allowing the reader to share again.
Customising the label
All three buttons accept a children prop to replace the default label text. Pass a render prop to access the button's internal state — useful when you want different copy before and after the action:
<LikeButton documentUri={documentUri} publicationUri={publicationUri} title={article.title}>
{(isLiked) => (isLiked ? "Loved it ✓" : "Did you enjoy this?")}
</LikeButton>
<ShareButton documentUri={documentUri} publicationUri={publicationUri} title={article.title}>
{(isShared) => (isShared ? "Shared! ✓" : "Share on Bluesky")}
</ShareButton>
<SubscribeButton publicationUri={publicationUri} title="My Site">
{(isSubscribed) => (isSubscribed ? "Following ✓" : "Follow this site")}
</SubscribeButton>
Or pass a static node if the label doesn't need to change:
<LikeButton documentUri={documentUri} publicationUri={publicationUri} title={article.title}>
♥ Recommend
</LikeButton>
Note
LikeButton and SubscribeButton use aria-pressed to communicate confirmed state — the button stays focusable and in the tab order after being activated. ShareButton uses disabled during its brief 3-second confirmed window since that state is transient rather than a toggle.
Styling
Each button renders a plain <button> element with a base CSS class (scribe-atp-like-button, scribe-atp-share-button, scribe-atp-subscribe-button). Pass a className prop to append your own class alongside the base:
<LikeButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
className="btn btn-outline"
/>
The base class is always present, so you can also target the components globally in your stylesheet:
.scribe-atp-like-button,
.scribe-atp-share-button,
.scribe-atp-subscribe-button {
/* shared button styles */
}
.scribe-atp-like-button[aria-pressed="true"],
.scribe-atp-subscribe-button[aria-pressed="true"] {
/* confirmed / already-actioned state */
}
.scribe-atp-share-button:disabled {
/* briefly disabled during the 3-second confirmed window */
}
See the @scribe-atp/styles package for a ready-made stylesheet if you don't want to write your own.
Reacting to success
All three buttons accept an onSuccess callback fired once the action completes. Use it to show a toast, fire an analytics event, or update surrounding UI:
<LikeButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
onSuccess={() => toast("Thanks for the like!")}
/>
<SubscribeButton
publicationUri={publicationUri}
title="My Site"
onSuccess={() => analytics.track("subscribe")}
/>
<ShareButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
onSuccess={() => toast("Article shared to Bluesky!")}
/>
SSR / avoiding flash of unconfirmed state
On SSR frameworks, LikeButton and SubscribeButton initialise in their unconfirmed state because localStorage is unavailable at render time. The useEffect that reads localStorage runs after hydration, which can produce a brief flash of the wrong state for returning readers.
Pass defaultLiked / defaultSubscribed to set the initial state at render time and skip the localStorage read on mount:
// app/routes/blog.$slug.tsx — React Router v7/v8 loader
export async function loader({ request, params }: LoaderFunctionArgs) {
const cookies = parseCookies(request.headers.get("Cookie") ?? "");
return {
...articleData,
defaultLiked: cookies[`scribe:recommended:${documentUri}`] === "1",
defaultSubscribed: cookies[`scribe:subscribed:${publicationUri}`] === "1",
};
}
// In your article component:
<LikeButton
documentUri={documentUri}
publicationUri={publicationUri}
title={article.title}
defaultLiked={defaultLiked}
/>
<SubscribeButton
publicationUri={publicationUri}
title="My Site"
defaultSubscribed={defaultSubscribed}
/>
Tip
The components write liked/subscribed state to localStorage under scribe:recommended:{documentUri} and scribe:subscribed:{publicationUri}. Mirror these to a cookie (e.g. in onSuccess) so your SSR loader can read them on the next request.
Unsubscribing
SubscribeButton is a toggle. When the reader is already subscribed, clicking the button opens an unsubscribe confirmation popup rather than the subscribe flow. The popup asks "Are you sure you want to unsubscribe from Site?" and requires the reader to confirm before deleting their subscription record.
After a confirmed unsubscribe:
- The subscription AT Protocol record is deleted from the reader's repository.
localStorageis cleared for the publication.- The button returns to its unsubscribed state.
No extra props are required. The button detects subscribed state via localStorage and routes the click to the appropriate popup automatically.
Liked / subscribed state
State is persisted in localStorage under:
scribe:recommended:{documentUri}—"1"when likedscribe:subscribed:{publicationUri}—"1"when subscribed
The components read this on mount, so a returning reader will see the confirmed state immediately without needing to re-authenticate.
The storage utilities are also exported directly if you need to read or set state programmatically:
import { isRecommended, isSubscribed, clearSubscribed } from '@scribe-atp/social';
if (isRecommended(documentUri)) {
// already liked
}
// Remove a subscription from localStorage (e.g. after a server-side unsubscribe)
clearSubscribed(publicationUri);
See the API reference for the full function signatures.