Bluesky

Post to Bluesky from Nuxt with an app password: images, link cards, and facets computed for you, so links and hashtags are clickable instead of dead text.

Bluesky has no private posting: everything goes to your feed. It also has three rules that exist nowhere else, and two of them will bite anyone writing against the API by hand.

Copy this

Create an app password

In Bluesky: Settings → Privacy and security → App passwords → Add App Password.

Never your account password. With two factor authentication on it would fail anyway, and an app password can be revoked on its own.

Configure it

.env
PIGEON_BLUESKY_IDENTIFIER=you.bsky.social
PIGEON_BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      bluesky: true,
    },
  },
})

Post

server/api/hello.get.ts
export default defineEventHandler(async () => {
  const post = await bluesky.post('Release 1.0 is out: https://nuxt.com #nuxt')

  return { url: post.url }
})

The link and the hashtag are clickable, and you did nothing for that. Keep reading, because that is not free.

A url in the text of a Bluesky post is dead text. To make it a link, the post has to carry a facet: a byte range plus what it points at.

{
  "index": { "byteStart": 20, "byteEnd": 36 },
  "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://nuxt.com" }]
}

Every client computes this before posting, so the module does too, for links, hashtags and mentions. facets: false turns it off and your text goes out flat.

Byte ranges, not character positions. One umlaut earlier in the line shifts every offset after it. This is the single most common bug in hand written Bluesky clients, and the reason the playground draws the ranges live.

Two limits, and length is neither

LimitValue
Graphemes300
Bytes3000

'👨‍👩‍👧'.length is 8. Its grapheme count is 1. It weighs 18 bytes. Both real limits are checked before anything is sent.

One embed per post

A post carries one embed, so images and a link card compete for the same slot.

// A link card. Nothing here is read from the linked page, Bluesky unfurls nothing.
await bluesky.post('Have a look', {
  external: {
    uri: 'https://nuxt.com',
    title: 'Nuxt',
    description: 'The Intuitive Vue Framework',
  },
})

Give both media and external and the module decides rather than failing, and says so in your server log:

[nuxt-pigeon] bluesky: a post carries one embed, so the link card wins.
Your first image became its thumbnail.

Refusing the whole post would be worse. Doing it silently would be worst.

Every option

OptionWhat it does
mediaUp to four images, with alt. Uploaded as blobs
externalA link card: uri, title, description, optional thumb
facetsComputed by default. false turns linking off, or pass your own
langsBCP-47, ['en']
createdAtISO 8601, defaults to now

Receiving

nuxt.config.ts
nuxtPigeon: {
  channels: {
    bluesky: { receive: true, intervalMs: 60000 },
  },
}

Polled, there is no webhook. Needs a long running process, so not serverless.

What will cost you an hour

  • There is no edit, and that is not an omission. putRecord on a post answers 200 and the appview ignores the change, so an edit would look like it worked and do nothing. See atproto#3038. Delete and post again.
  • A blob is limited to about 1 MB. A normal screenshot is three to five times that. Official clients resize first, this module does not, and tells you the real number instead.
  • An app password with DM scope is a separate switch. Without "Allow access to your direct messages" you get Bad token scope from the chat endpoints.
  • Deleting is idempotent. Twice is not an error, unlike Discord where it is a 404.
  • A mention only becomes a facet if the handle resolves. A typo stays plain text rather than failing the post.

Read more