Mastro 👨‍🍳 Docs

Search results

Blog Community GitHub   Stoat Chat   Discord   Bluesky

How to easily add Standard.site support to your website

Mauro Bieg on June 05, 2026

(Updated on June 15, 2026)

Since Bluesky added support for Standard.site, everyone with a blog seems to be racing to roll out an implementation. That’s awesome, and yet another example of how ATproto feels like one of the more exciting things happening in tech right now.

What ATmosphere?

For a bit of background, see e.g. Steve Klabnik’s How Does BlueSky Work? and Declan Chidlow’s Publishing on the Atmosphere with Standard.site.

The TL;DR is that you can think of the ATmosphere as a big distributed database, which is partially mirrored a few times via relays. Anybody with an account on a PDS (Personal Data Server – Bluesky being the biggest example) can make HTTP requests to it and read and write records in the database. A set of evolving, shared lexicons make sure your data remains portable and you’re not being locked in. The latest such lexicon to reach critical mass is Standard.site – it’s a standardized way to express metadata about publications and documents (e.g. blogs and their posts).

One interesting way that some people use this is to use the ATmosphere as the backend database for their website.

Uploading blog posts

But most people already have their posts in a traditional database (e.g. via a CMS), or have a static site with Markdown files. How can you ensure that whenever you create a new blog post (or change an existing one), the corresponding records in the Atmosphere are created (or updated)?

An ATproto record in the site.standard.document collection has a URI like this:

"at://" DID "/site.standard.document/" rkey
Copied!

The DID (Decentralized Identifier) uniquely identifies your user, and the rkey (record key) uniquely identifies the specific document in the collection.

When creating a new document, the obvious approach (that most people currently seem to be following) is to push the data and let the PDS server auto-generate an rkey.

But in order for things to be verified, you need to add your record’s AT-URI to the HTML of your blog post’s web page with:

<link rel="site.standard.document"
  href={`at://${did}/site.standard.document/${rkey}`}>
Copied!

Which means that if your rkey was generated by the PDS, you need to store it somewhere after it’s generated – e.g. in your CMS’s database, or in the YAML frontmatter of your markdown files.

While this is fine, it can be a bit annoying. Especially for a website without a database, it makes things a bit hard to automate. If you set up your CI/CD pipeline to push to the ATmosphere, do you then have it create a new commit with the modified markdown frontmatter?

Deriving the rkey from the URL path

An alternative approach, which I first saw proposed by Kuba Suder, is to derive the rkey from the URL path of the web page. This is what I just implemented in the new @mastrojs/atproto package.

It exports a rkeyFromUrl function that deterministically derives an rkey from the path of a URL. Then, in the code generating the HTML, we simply use it like:

<link rel="site.standard.document"
  href={`at://${agent.did}/site.standard.document/${rkeyFromUrl(doc.url)}`}>
Copied!

This does mean however, that you cannot change the URL of your post after you’ve published it. But I suppose you shouldn’t be doing that anyway.

Running the script

Finally, we package everything up in a nice declarative way. You simply add a script to your codebase with something like the following:

import { createOrUpdateStandardSite, type Publication } from "@mastrojs/atproto";
import { readMarkdownFiles } from "@mastrojs/markdown";

const identifier = "your.bsky.social";
const password = process.env.ATPROTO_PASSWORD;
const pubUrl = new URL("https://example.com/news/");

const publication: Publication = {
  url: pubUrl,
  name: "Peter's News",
};

const posts = await readMarkdownFiles("data/posts/*.md");
const docs = posts.map((p) => ({
  title: p.meta.title,
  publishedAt: new Date(p.meta.date),
  url: new URL(p.slug, pubUrl),
}));

await createOrUpdateStandardSite({ identifier, password }, publication, docs);
Copied!

Whenever you run your script, it will fetch the existing records from your PDS, diff them against your current input, update the existing ones, and upload any new records. Regardless of whether your run it manually, or in your CI/CD pipeline.

Does it work?

Try posting a link to this blog post on Bluesky, and you should see the shiny “View publication” button appear!

If you want the same for your blog, go ahead and use the @mastrojs/atproto package. Bug reports and contributions welcome. Happy publishing to the ATmosphere!

Version 0.2

The first version of this blog post (and first version of the @mastrojs/atproto library) used the simplest approach possible to derive an rkey from a URL path: strip all characters that may not appear in any rkey (e.g. slashes and other special characters).

While this approach works on Bluesky (and the validator didn’t complain about it at the time), I was made aware that the Standard.site schema doesn’t specify an rkey of type any, but requires one of type TID (Timestamp Identifier). There is an ongoing discussion whether this requirement can be relaxed, as it would simplify things a bit. But there is no discernible sign that the schema might be changed anytime soon.

That’s why in version 0.2 of @mastrojs/atproto, you can specify whether you want the rkey of type TID (which is now the default), any, or supply your own rkey. If a date in format YYYY-MM-DD or YYYY/MM/DD is detected anywhere in the URL path, that will be used as the timestamp of the TID. The rest of the path is used for the remaining bits of the TID. Either way, the rkey is derived deterministically from the document’s URL path – which is what this library set out to solve.

I already put a lot of thought and care into version 0.1. The idea that you declaratively provide all information as part of a function call instead of a pure CLI like Sequoia was novel AFAIK. (In v0.2, this API is even safer since it’s using URLs instead of string paths.) Then there is the flow that when you run the script for the first time, it will show you the URLs and rkeys, and once confirmed, create a .well-known/site.standard.publication file. And only on subsequent runs, it will publish and/or update documents in the Atmosphere. You can even do that in your CI/CD pipeline, which I find pretty neat.

That’s why I had mixed feelings when I discovered somebody had copied the source code of @mastrojs/atproto, removed the types and tests, and made a few relatively minor changes (source -> copy). Yet it proved I was onto something. I approached them and asked whether they were interested in a collaboration, even offering to switch to their project name and repo, but the replies were evasive. Yet the next day, they published an announcement blog post that doesn’t even mention the original project (the README now says “Credit to @mastrojs/atproto for the inspiration”). Oh well, community building is hard.

Sadly, this also means they and their users are losing out on all the improvements of v0.2 outlined above. (Their version derives the rkey from the document’s publishedAt instead of the URL path, which has the potential for clashes if your granularity is dates, and the timestamp calculation is off by a factor of 1000.)

It seems that integrating existing websites with the Atmosphere is a problem worth solving once, and worth solving well. If you’re working on this as well, I would be more than happy to collaborate! Do you have better ideas? Is there anything that doesn’t work for you yet? Should we export more functions for you to integrate it in your existing stack? Just open a GitHub issue or talk to us on Stoat or Bluesky.


Stay updated 👨‍🍳

Follow us on Bluesky, or add our blog to your RSS reader (feed link).