content surface · pds + constellation
<atproto-post>
The canonical post embed. Author, text, facets, media, quote embeds, engagement counts — all fetched straight from the protocol.
Live
<atproto-post src=at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3mjgmacnx2s2f></atproto-post>
<!-- compact variant for nesting -->
<atproto-post compact src=at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3mjgmacnx2s2f></atproto-post>
<!-- skip the 4 Constellation count calls -->
<atproto-post no-counts src=at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3mjgmacnx2s2f></atproto-post> Attributes
| Name | Type | Default | Description |
|---|---|---|---|
src * | string | — | AT-URI or bsky.app post URL. Handle resolves via HTTPS well-known. |
compact | boolean | — | Trimmed chrome variant for nesting (quote embeds, thread replies). |
no-counts | boolean | — | Skip the engagement counts row (4 Constellation calls saved per post). |
constellation | string | — | Override the Constellation endpoint for this element. |
Parts
External CSS can target these via atproto-post::part(<name>) { ... }.
| Part | What it is |
|---|---|
article | Outer card container. |
avatar-link | Anchor wrapping the avatar that links to bsky source. |
avatar / avatar-image | Avatar circle and inner img. |
author | Header row with display-name, handle, time. |
display-name / handle / time | Individual author-row pieces. |
body | Content column (text + embed + counts). |
text | Rendered post text with facets applied. |
images / image | Image grid container and each img inside. |
external / external-thumb / external-title / external-desc / external-host | External-link card and its pieces. |
video | Video embed container. |
quote | Quote embed wrapper (contains a nested compact atproto-post). |
counts / count | Engagement counts row and each count span. |
What it does
Drops a single ATProto post into your page. You give it an AT-URI or a bsky.app permalink; it handles DID resolution, PDS discovery, record fetching, profile lookup, and four parallel engagement counts. The result is a post card that looks familiar but doesn't touch the AppView — every byte of content comes from the author's own PDS.
Compact variant
The compact attribute trims the chrome: smaller avatar,
reduced padding, no counts row, subtle background. Used internally for
quote embeds and reply threads — but you can use it anywhere you want
a denser footprint.
What it fetches
- The post record from the author's PDS via
com.atproto.repo.getRecord - The author's DID document (cached via plc.directory
for
did:plc:or/.well-known/did.jsonfordid:web:) to discover the PDS endpoint - The author's
app.bsky.actor.profile/selfrecord for the avatar + display name - Four parallel Constellation
getBacklinksCountqueries: likes, reposts, quotes, replies
Everything runs in parallel where it can. The cache layer dedupes by URL, so rendering 20 posts from the same author on one page resolves the profile and DID doc exactly once.
When to use this vs alternatives
<atproto-post>
One post. You know the URI. You want it to look like a post. Default choice.
<atproto-thread>
You want to show the full conversation around a post, not just the post.
<atproto-feed>
You want a list of posts from one author, not a single post.
<atproto-lexicon-viewer>
You want to see the raw record (debugging, custom lexicons).
Atomics (<atproto-avatar> + friends)
You want a custom layout that the standard post card doesn't fit. Compose yourself.
Common patterns
Embed a single post in a blog
<script type="module" src="https://unpkg.com/atproto-wc/browser"></script>
<article>
<h1>My blog post</h1>
<p>Here's something I said on Bluesky:</p>
<atproto-post src="https://bsky.app/profile/you.handle/post/..."></atproto-post>
</article> Quote without the engagement counts
<atproto-post no-counts src="at://..."></atproto-post> Framework integration
Next.js (App Router)
Custom elements are client-only. Mark the wrapper as a client component and dynamic-import the registration.
// app/post-embed.tsx
"use client";
import { useEffect } from "react";
export default function PostEmbed({ src }: { src: string }) {
useEffect(() => { import("atproto-wc/browser"); }, []);
return <atproto-post src={src}></atproto-post>;
} Astro
<atproto-post src="at://..." />
<script>
import "atproto-wc/browser";
</script> Plain HTML / CDN
<script type="module" src="https://unpkg.com/atproto-wc/browser"></script>
<atproto-post src="at://..."></atproto-post> Styling
The post uses the shared token system (--atproto-accent,
--atproto-radius, etc.) and exposes ::part() hooks
on every meaningful internal element. See the styling guide
for the full three-layer approach. Quick examples:
/* change the accent color */
atproto-post { --atproto-accent: #ea580c; }
/* hide the counts row on a specific instance */
.quiet-post::part(counts) { display: none; }
/* square avatars */
atproto-post::part(avatar) { border-radius: 4px; } Troubleshooting
Shows "Input error: unrecognized post source"
The src isn't a valid AT-URI or bsky.app URL. AT-URIs look like
at://did:plc:.../app.bsky.feed.post/<rkey>. bsky.app URLs
look like https://bsky.app/profile/<handle-or-did>/post/<rkey>.
Shows "Connection issue: ..." with a Retry button
A transient network failure on the PDS, plc.directory, or Constellation. The Retry button re-fires the whole fetch. If it persists, the author's PDS is probably down — try again later.
Post renders but engagement counts are all zero
Constellation is a global backlink index, but it only sees what comes through the firehose. A post authored on a self-hosted PDS that doesn't federate will have zero Constellation counts even if it has real engagement on Bluesky. Verified with well-connected authors (pfrazee.com etc.) counts match bsky.app.
CORS error in console when post references a mention
Mentions render as plain styled spans, not links — because linking to a handle requires resolving that handle via HTTPS well-known, which many handle domains don't serve. Known limitation.