{
"$type": "site.standard.document",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreifz5mqowe2bwrmii5ur67wosn454hkk2kckhzyffcmkqfjmgvcpru"
},
"mimeType": "image/png",
"size": 55277
},
"description": "A walkthrough for posting your Elixir/Phoenix blog to Bluesky and the standard.site network, using a small mix task and an atproto client.",
"path": "/posts/publishing-your-blog",
"publishedAt": "2026-06-05T00:00:00Z",
"site": "at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.publication/self",
"tags": [
"atproto",
"elixir",
"phoenix",
"bluesky",
"nimblepublisher"
],
"textContent": "[standard.site](https://standard.site) is an AT Protocol schema for long-form publishing that lets blogs expose their posts as records, so readers, indexers, and Bluesky can find and render them across the network. There’s a lot of stuff being built on top of this right now, but you can already start publishing things. Publishing your blog posts has the cute little upside of your Bluesky link previews getting a special CTA, because Bluesky automatically pulls the information from standard.site.\n\n<img src=\"/images/bluesky-standard-site-screenshot.png\" alt=\"A screenshot showing the special CTA footer in Bluesky for page previews from standard.site\" width=\"400\" height=\"324\" loading=\"lazy\" decoding=\"async\" style=\"margin:auto;padding-bottom:16px;padding-top:16px\" />\n\nOn top of that, once you’ve published your records, you can view them in [atproto explorer](https://atproto.at/uri/at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.document), in [pdsls.dev](https://pdsls.dev/at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.document/dropping-cloudflare), and they’ll automatically be aggregated in several places like [docs.surf](https://docs.surf/). To read more about why federated content is cool, Mat Marquis goes into more detail [here](https://wil.to/posts/standard-site/).\n\nYou can use [pdsls.dev](https://pdsls.dev/), signing in to your account, to manually create records. But it'd be more fun to do it in Elixir and start working towards automation\\! I wanted to get something up and running without too much complexity, so I went for an approach where I manually publish my posts using a `mix` command. So the end result we’re aiming for is being able to run something like `mix atproto.publish <slug>`, and have that record point at the live post on this website.\n\nOkay, let’s look at some code\\!\n\n## A very basic atproto client\n\nI spent a day exploring this and I ended up with a basic `atproto` client and some simple niceties. I made a deliberate effort not to DRY things up too much, I’m very much in the camp of “most abstractions are premature”. I’d rather feel the pain a little bit before I get too clever.\n\nTo be able to publish our records we need a PDS, a Personal Data Server. You’re free to set up your own, it’s basically your home in the `atproto` universe, where your data lives. You’ll also need an account. I’ll be using my [main Bluesky account](https://bsky.app/profile/jola.dev), `jola.dev`, and the main Bluesky API endpoint `https://bsky.social/xrpc`. Armed with an identifier and a password, we’re ready to go.\n\nWe’re going to be implementing an `atproto` client with 4 different operations: `login`, `resolve_handle`, `create_publication`, and `publish_document`.\n\nLet’s start with `resolve_handle`. We only need to run this once really, it will turn our handle, like `jola.dev`, into a `did`, a permanent unique identifier. If you already know your `did`, you can skip this.\n\n```elixir\ndef resolve_handle(handle) do\n result =\n Req.get(\"https://bsky.social/xrpc/com.atproto.identity.resolveHandle\",\n params: [handle: handle]\n )\n\n case result do\n {:ok, %Req.Response{status: 200, body: %{\"did\" => did}}} -> {:ok, did}\n {:ok, %Req.Response{status: status, body: body}} -> {:error, {:atproto_error, status, body}}\n {:error, reason} -> {:error, reason}\n end\nend\n```\n\nRun that in `IEx` and you should get your `did` back. Make a note of it and let’s continue with `login`. Oh, and if you’re self-hosting or for some reason you’re not sure where your PDS is, you can use the `did` to discover it using `plc.directory`. If you know where to direct your requests, you can actually skip this step because `login` also returns the `did`. But nice to know\\! Back to the client operations.\n\n```elixir\ndef login(identifier, password) do\n result =\n Req.post(\"https://bsky.social/xrpc/com.atproto.server.createSession\",\n body: JSON.encode!(%{identifier: identifier, password: password}),\n headers: [{\"Content-Type\", \"application/json\"}]\n )\n\n case result do\n {:ok,\n %Req.Response{\n status: 200,\n body: %{\"did\" => did, \"accessJwt\" => access_token, \"refreshJwt\" => refresh_token}\n }} ->\n {:ok, %{did: did, access_token: access_token, refresh_token: refresh_token}}\n\n {:ok, %Req.Response{status: status, body: body}} ->\n {:error, {:atproto_error, status, body}}\n\n {:error, reason} ->\n {:error, reason}\n end\nend\n```\n\nWe actually only need `access_token` here, but I imagine I’ll use the other fields too in the future so I’m “documenting” them for myself here.\n\nWe can take a little break here and try it out in `IEx`.\n\n```elixir\niex(1)> {:ok, session} = Client.login(\"jola.dev\", password)\n{:ok,\n %{\n refresh_token: \"eyJ0eX...\",\n access_token: \"eyJ0eX...\",\n did: \"did:plc:bvraa6gajy4tfr3eh2sisdkr\"\n }}\n```\n\nExcellent\\! Now we have an `access_token` and we’re basically unstoppable. Let’s sketch out the remaining operations before we implement the helpers we need.\n\n```elixir\ndef create_publication(session, %Publication{} = publication) do\n with {:ok, icon} <- upload_blob(session, publication.icon) do\n record = publication_record(publication, icon)\n put_record(session, \"site.standard.publication\", \"self\", record)\n end\nend\n\ndef publish_document(session, %Document{} = document) do\n with {:ok, cover_image} <- upload_blob(session, document.cover_image) do\n record = document_record(document, cover_image)\n put_record(session, \"site.standard.document\", document.rkey, record)\n end\nend\n```\n\n`create_publication` we’ll use to create our… well, publication. This would be your website\\! And then `publish_document` is for publishing each blog post. Excellent. Let’s continue. In the interest of time (?) I’m going to drop a big chunk of code on you here, but it’s just plumbing. We need to shape the request payloads for the `atproto` API, we need a helper for uploading our blog post preview images, and then some tidying up date times.\n\n```elixir\ndefp publication_record(%Publication{} = publication, icon) do\n %{\n \"$type\" => \"site.standard.publication\",\n \"name\" => publication.name,\n \"url\" => publication.url,\n \"description\" => publication.description,\n \"icon\" => icon\n }\nend\n\ndefp document_record(%Document{} = document, cover_image) do\n %{\n \"$type\" => \"site.standard.document\",\n \"site\" => document.site,\n \"title\" => document.title,\n \"path\" => document.path,\n \"publishedAt\" => to_rfc3339(document.published_at),\n \"updatedAt\" => to_rfc3339(document.updated_at),\n \"description\" => document.description,\n \"tags\" => document.tags,\n \"coverImage\" => cover_image\n }\nend\n\ndefp put_record(session, collection, rkey, record) do\n headers = [\n {\"Authorization\", \"Bearer #{session.access_token}\"},\n {\"Content-Type\", \"application/json\"}\n ]\n\n body = JSON.encode!(%{repo: session.did, collection: collection, rkey: rkey, record: record})\n result = Req.post(\"https://bsky.social/xrpc/com.atproto.repo.putRecord\", body: body, headers: headers)\n\n case result do\n {:ok, %Req.Response{status: 200, body: body}} -> {:ok, body}\n {:ok, %Req.Response{status: status, body: body}} -> {:error, {:atproto_error, status, body}}\n {:error, reason} -> {:error, reason}\n end\nend\n\ndefp upload_blob(_session, nil), do: {:ok, nil}\n\ndefp upload_blob(session, {bytes, content_type}) do\n headers = [\n {\"Authorization\", \"Bearer #{session.access_token}\"},\n {\"Content-Type\", content_type}\n ]\n\n case Req.post(\"https://bsky.social/xrpc/com.atproto.repo.uploadBlob\", headers: headers, body: bytes) do\n {:ok, %Req.Response{status: 200, body: %{\"blob\" => blob}}} -> {:ok, blob}\n {:ok, %Req.Response{status: status, body: body}} -> {:error, {:atproto_error, status, body}}\n {:error, reason} -> {:error, reason}\n end\nend\n\n# atproto expects `rfc3339`, Elixir has `iso8601` which is compatible\ndefp to_rfc3339(%Date{} = date) do\n date\n |> DateTime.new!(~T[00:00:00], \"Etc/UTC\")\n |> DateTime.to_iso8601()\nend\n```\n\nThat’s it for the client\\! Let’s take a closer look at the data we’re sending.\n\n## The shape of the records\n\nStructs are a great way to bring some compile time hints and support to your developer experience, so let’s add some\\!\n\n```elixir\ndefmodule JolaDev.Atproto.Publication do\n @enforce_keys [:name, :url]\n defstruct @enforce_keys ++ [:description, :icon]\nend\n\ndefmodule JolaDev.Atproto.Document do\n @enforce_keys [:rkey, :site, :title, :path, :published_at]\n defstruct @enforce_keys ++ [:updated_at, :description, :tags, :cover_image]\nend\n```\n\nAnd the main piece of glue, I’ve chosen to organize like this.\n\n```elixir\ndefmodule JolaDev.Atproto do\n alias JolaDev.Atproto.Document\n alias JolaDev.Atproto.Publication\n alias JolaDev.Blog.Post\n\n @did \"did:plc:bvraa6gajy4tfr3eh2sisdkr\"\n @url \"https://jola.dev\"\n\n def publication_uri, do: \"at://#{@did}/site.standard.publication/self\"\n def document_uri(rkey), do: \"at://#{@did}/site.standard.document/#{rkey}\"\n\n def publication do\n %Publication{\n name: \"jola.dev\",\n url: @url,\n description: \"Johanna Larsson's blog\",\n icon:\n {File.read!(Application.app_dir(:jola_dev, \"priv/static/images/logo.png\")), \"image/png\"}\n }\n end\n\n def document(%Post{} = post) do\n {:ok, cover_image} = JolaDev.OGImage.image_for(\"posts/#{post.id}\")\n\n %Document{\n rkey: post.id,\n site: publication_uri(),\n title: post.title,\n path: \"/posts/#{post.id}\",\n published_at: post.date,\n updated_at: post.last_modified,\n description: post.description,\n tags: post.tags,\n cover_image: {cover_image, \"image/png\"}\n }\n end\nend\n```\n\nEverything is coming together\\! We can now turn a `NimblePublisher` post into a `Document` for our `atproto` client, and we’ve got our site definition, aka `Publication`.\n\n## Proving you’re you\n\nTime for a little interlude. We’ve been looking at creating publications and documents in the standard.site schema, but how does the `atproto` ecosystem prevent just anyone from publishing things in your name? After all, it’s just accepting the `url` and other fields that you’re providing.\n\nThe first piece of the puzzle is `/.well-known/site.standard.publication`. You put this record on your site to prove that your publication is *legit*. Let’s set up a little route and controller for it.\n\n```elixir\ndefmodule JolaDevWeb.WellKnownController do\n use JolaDevWeb, :controller\n\n def publication(conn, _params) do\n conn\n |> put_resp_content_type(\"text/plain\")\n |> text(JolaDev.Atproto.publication_uri())\n end\nend\n```\n\nAnd the router needs something like `get \"/.well-known/site.standard.publication\", WellKnownController, :publication`. That’s the publication covered, but what about the documents?\n\nJust like Mastodon, we can use a `link` tag in the head tag of our blog post to prove ourselves. Let’s add a little section to our `root.html.heex` in the head section.\n\n```html\n<%= if post = @conn.assigns[:post] do %>\n\t<link rel=\"site.standard.document\" href={JolaDev.Atproto.document_uri(post.id)} />\n<% end %>\n```\n\nSo we’re using the `document_uri` function we just defined to build the full URI of the post, as `atproto` would expect to find it. You’ll only want to do this on pages that have posts, which is why I’ve put an if statement around it.\n\nWe’re finally ready to start publishing\\!\n\n## Actually publishing documents\n\nAs mentioned I went for a simple `mix` task. It makes it a manual process, but I can live with having to manually publish these after posting to my blog. For now anyway, I’m sure I’ll end up doing something *clever* eventually. But first, let’s tackle the publication itself, since it’s just a one off and it needs to exist first. I ended up just executing this in `IEx`.\n\n```elixir\niex(1)> {:ok, session} = Client.login(\"jola.dev\", password)\n{:ok,\n %{\n refresh_token: \"eyJ0eX...\",\n access_token: \"eyJ0eX...\",\n did: \"did:plc:bvraa6gajy4tfr3eh2sisdkr\"\n }}\niex(2)> {:ok, result} = Client.create_publication(session, Atproto.publication())\n{:ok,\n %{\n \"cid\" => \"bafyreiftkrgpmyyjts6gkkcnzsjqgvocz6rtqy4uwf2xmqigu53ij5mclu\",\n \"commit\" => %{\n \"cid\" => \"bafyreiaewmxamg4w6ofjpukpg5pemyxvi3klw5tq3tgzic3xualdcffk4i\",\n \"rev\" => \"3mnfzhzzpmp2j\"\n },\n \"uri\" => \"at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.publication/self\",\n \"validationStatus\" => \"unknown\"\n }}\n```\n\nThat’s it. You can see the [record live in the atproto explorer](https://atproto.at/uri/at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.publication/self).\n\nBut documents is more a repetitive task, so this is where the `mix` task comes in.\n\n```elixir\ndefmodule Mix.Tasks.Atproto.Publish do\n @shortdoc \"Publishes a blog post as a standard.site record.\"\n\n use Mix.Task\n\n alias JolaDev.Atproto\n alias JolaDev.Atproto.Client\n\n def run([slug]) do\n Application.ensure_all_started(:req)\n\n password = System.fetch_env!(\"PASSWORD\")\n post = JolaDev.Blog.find_by_id(slug)\n\n {:ok, session} = Client.login(\"jola.dev\", password)\n {:ok, result} = Client.publish_document(session, Atproto.document(post))\n\n Mix.shell().info(\"Published #{slug} as #{result[\"uri\"]}\")\n end\nend\n```\n\nLet’s try it out.\n\n```elixir\n➜ jola.dev git:(main) PASSWORD=<password> mix atproto.publish generating-og-images\nPublished generating-og-images as at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.document/generating-og-images\n```\n\n[And the record is live\\!](https://atproto.at/uri/at://did:plc:bvraa6gajy4tfr3eh2sisdkr/site.standard.document/generating-og-images#tree)\n\n## Do it automatically?\n\nI hope that’s been useful and at least vaguely interesting. I’m very curious to see where all this will lead. I doubt you’ll be able to just copy paste everything that I have here and that it’ll just work for you, although I guess if you’ve set up `NimblePublisher` the same way I have, it might\\! But it should provide the blueprint for you to set things up for yourself.\n\nIn the post I’ve cut some corners, it’s already a lot of code. One of those corners is that `atproto` documents support `textContent`, so you can publish the content of your post, meaning the whole thing lives fully on the PDS, which is cool. The full version is available on [Github](https://github.com/joladev/jola.dev/blob/main/lib/jola_dev/atproto.ex).\n\nI know I said I don’t want to get clever about this, but I have been thinking about approaches to automatically publishing new blog posts. I might want to do something where, at deploy time I compare what’s published and what’s not, and then do some reconciliation? We’ll see where I end up\\! Thanks for reading\\!",
"title": "Publishing your blog to standard.site in Elixir",
"updatedAt": "2026-06-05T00:00:00Z"
}