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:

  1. Write plain text
  2. Create facets that identify specific character ranges
  3. 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

  1. Let users type naturally with @mentions and #hashtags
  2. Detect mentions and tags as they type (for autocomplete)
  3. Before posting, resolve all @handles to DIDs
  4. Calculate byte indices for each facet
  5. Use a library if possible to avoid byte math errors

Displaying Posts

  1. Parse the facets array from the post
  2. Sort facets by byteStart
  3. Render plain text between facets
  4. Render each facet as the appropriate element (link, mention link, tag link)
  5. 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.