If you're building a Bluesky client or creating posts programmatically, you need to understand facets. Unlike HTML or Markdown, Bluesky doesn't embed formatting in the text itself. Instead, facets point at locations in the text and apply features like links, mentions, and hashtags.
This guide explains how facets work, the critical importance of byte indexing, and how to implement them correctly in your application.
What Are Facets?
Facets are Bluesky's rich text system. Instead of using markup like [link text](url) or @mention, you:
- Write plain text
- Create facets that identify specific character ranges
- Attach features (link, mention, or tag) to those ranges
For example, to make "this site" a link in "Go to this site":
{
"text": "Go to this site",
"facets": [
{
"index": {
"byteStart": 6,
"byteEnd": 15
},
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com"
}
]
}
]
}
The facet says: "Bytes 6-15 (the words 'this site') should be rendered as a link to https://example.com."
The Three Facet Types
Bluesky supports three types of facet features:
1. Link (app.bsky.richtext.facet#link)
Makes text clickable, pointing to a URL.
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com"
}
Use cases: External links, shortened URLs, embedding web content
2. Mention (app.bsky.richtext.facet#mention)
Creates an @-mention that links to a user's profile and triggers a notification.
{
"$type": "app.bsky.richtext.facet#mention",
"did": "did:plc:abc123..."
}
Important: Mentions require the user's DID, not their handle. You must resolve the handle to a DID before creating the facet.
3. Tag (app.bsky.richtext.facet#tag)
Creates a hashtag that links to search results for that tag.
{
"$type": "app.bsky.richtext.facet#tag",
"tag": "bluesky"
}
Note: The tag value should not include the # symbol.
Critical: UTF-8 Byte Indexing
This is the most important concept to understand about facets: indices are UTF-8 byte offsets, not character positions.
In ASCII text, bytes and characters are the same. But Unicode characters—especially emojis—can span multiple bytes:
- ASCII characters: 1 byte each
- Most accented characters: 2 bytes
- Most CJK characters: 3 bytes
- Most emojis: 4 bytes
- Complex emojis (with modifiers): 8+ bytes
Why This Matters
Consider this text: "Hello 👋 world"
- Character count: 13 characters
- Byte count: 16 bytes (the emoji is 4 bytes)
If you want to create a facet for "world":
- Wrong (character index): start: 9, end: 14
- Correct (byte index): start: 11, end: 16
The official documentation warns: "It's important to pay attention to this when working with facets. Incorrect indexing will produce bad data."
JavaScript Warning
JavaScript's native string methods like .slice() use character indices, not byte indices. You cannot use them directly with facet indices.
// WRONG - uses character indices
const start = text.slice(0, facet.index.byteStart);
// CORRECT - use a library that handles UTF-8 bytes
import { RichText } from '@atproto/api';
const rt = new RichText({ text });
// Library handles byte conversion
Facet Index Rules
Facet indices follow these rules:
- byteStart: Inclusive (includes this byte)
- byteEnd: Exclusive (does not include this byte)
- Both values must be non-negative integers
- byteEnd must be greater than byteStart
- Both must be valid positions within the text
Complete Post Example
Here's a complete post with all three facet types:
{
"$type": "app.bsky.feed.post",
"text": "Check out @alice.bsky.social's post about #bluesky at docs.bsky.app",
"createdAt": "2025-12-22T12:00:00.000Z",
"facets": [
{
"index": {
"byteStart": 10,
"byteEnd": 28
},
"features": [
{
"$type": "app.bsky.richtext.facet#mention",
"did": "did:plc:alice123"
}
]
},
{
"index": {
"byteStart": 41,
"byteEnd": 49
},
"features": [
{
"$type": "app.bsky.richtext.facet#tag",
"tag": "bluesky"
}
]
},
{
"index": {
"byteStart": 53,
"byteEnd": 66
},
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://docs.bsky.app"
}
]
}
]
}
Using Libraries (Recommended)
Rather than calculating byte indices manually, use the official ATProtocol libraries:
TypeScript/JavaScript
import { RichText } from '@atproto/api';
const rt = new RichText({
text: 'Check out @alice.bsky.social for #bluesky tips!'
});
// Automatically detects and creates facets
await rt.detectFacets(agent);
// Access the processed data
console.log(rt.text); // The text
console.log(rt.facets); // Array of facets with correct byte indices
Python
from atproto import Client, models
client = Client()
client.login('handle', 'password')
# The SDK handles facet detection
text = models.AppBskyFeedPost.TextBuilder()
text.text('Check out ')
text.mention('alice.bsky.social', 'did:plc:alice123')
text.text(' for ')
text.tag('bluesky')
text.text(' tips!')
post = text.build()
client.send_post(post)
Parsing Facets (Reading Posts)
When displaying posts, you need to parse facets and render them appropriately:
function renderPostWithFacets(post) {
const { text, facets } = post;
if (!facets || facets.length === 0) {
return escapeHtml(text);
}
// Sort facets by start position
const sortedFacets = [...facets].sort(
(a, b) => a.index.byteStart - b.index.byteStart
);
// Convert text to bytes for accurate slicing
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = encoder.encode(text);
let result = '';
let lastEnd = 0;
for (const facet of sortedFacets) {
// Add text before this facet
result += escapeHtml(
decoder.decode(bytes.slice(lastEnd, facet.index.byteStart))
);
// Get the faceted text
const facetText = decoder.decode(
bytes.slice(facet.index.byteStart, facet.index.byteEnd)
);
// Render based on facet type
const feature = facet.features[0];
if (feature.$type === 'app.bsky.richtext.facet#link') {
result += `${escapeHtml(facetText)}`;
} else if (feature.$type === 'app.bsky.richtext.facet#mention') {
result += `${escapeHtml(facetText)}`;
} else if (feature.$type === 'app.bsky.richtext.facet#tag') {
result += `${escapeHtml(facetText)}`;
}
lastEnd = facet.index.byteEnd;
}
// Add remaining text
result += escapeHtml(decoder.decode(bytes.slice(lastEnd)));
return result;
}
Common Mistakes
1. Using Character Indices
// WRONG
const start = text.indexOf('@alice');
const end = start + '@alice'.length;
// CORRECT
const encoder = new TextEncoder();
const textBefore = text.substring(0, text.indexOf('@alice'));
const start = encoder.encode(textBefore).length;
const end = start + encoder.encode('@alice').length;
2. Including # in Tag Value
// WRONG
{ "tag": "#bluesky" }
// CORRECT
{ "tag": "bluesky" }
3. Using Handle Instead of DID
// WRONG
{ "did": "alice.bsky.social" }
// CORRECT - resolve handle first
const did = await resolveHandle('alice.bsky.social');
{ "did": did } // "did:plc:abc123..."
4. Overlapping Facets
Facets should not overlap. If they do, rendering behavior is undefined.
Thinking About Facets as a Developer
Here's how to think about facets when building your app:
Creating Posts
- Let users type naturally with @mentions and #hashtags
- Detect mentions and tags as they type (for autocomplete)
- Before posting, resolve all @handles to DIDs
- Calculate byte indices for each facet
- Use a library if possible to avoid byte math errors
Displaying Posts
- Parse the facets array from the post
- Sort facets by byteStart
- Render plain text between facets
- Render each facet as the appropriate element (link, mention link, tag link)
- Always escape HTML to prevent XSS
Frequently Asked Questions
What are facets in Bluesky?
Facets are Bluesky's rich text system. Instead of embedding markup in text, facets point to byte ranges and apply features like links, mentions, and hashtags.
Why does Bluesky use byte indexing?
Byte indexing ensures consistent behavior across all programming languages and platforms when handling Unicode text, especially multi-byte characters like emojis.
What facet types does Bluesky support?
Three types: Link (for URLs), Mention (for @-mentions with notification), and Tag (for hashtags).
How do I create a mention?
You need the user's DID (not handle). Resolve the handle to a DID, then create a facet with type app.bsky.richtext.facet#mention and the did attribute.