content surface · pds + constellation

<atproto-post>

The canonical post embed. Author, text, facets, media, quote embeds, engagement counts — all fetched straight from the protocol.

<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>
NameTypeDefaultDescription
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.

External CSS can target these via atproto-post::part(<name>) { ... }.

PartWhat it is
articleOuter card container.
avatar-linkAnchor wrapping the avatar that links to bsky source.
avatar / avatar-imageAvatar circle and inner img.
authorHeader row with display-name, handle, time.
display-name / handle / timeIndividual author-row pieces.
bodyContent column (text + embed + counts).
textRendered post text with facets applied.
images / imageImage grid container and each img inside.
external / external-thumb / external-title / external-desc / external-hostExternal-link card and its pieces.
videoVideo embed container.
quoteQuote embed wrapper (contains a nested compact atproto-post).
counts / countEngagement counts row and each count span.

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.

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.

  1. The post record from the author's PDS via com.atproto.repo.getRecord
  2. The author's DID document (cached via plc.directory for did:plc: or /.well-known/did.json for did:web:) to discover the PDS endpoint
  3. The author's app.bsky.actor.profile/self record for the avatar + display name
  4. Four parallel Constellation getBacklinksCount queries: 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.

<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.

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>

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>

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; }

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.