Implementing Standard.site with Sanity CMS and Netlify
How I adapted my site to publish AT protocol records automatically with Sanity document actions and Netlify Functions.
Bluesky and the AT protocol are ushering in a new era of interoperability on the web. The teams behind pckt.blog, Offprint, and Leaflet came together to define common parts of their long form post schemas, and thus were the Standard.site lexicons born.
Now the Bluesky team is getting into the community spirit and have rolled out support for these lexicons, with fancy embeds in posts. Since a site.standard.document record is readable on your PDS just like a app.bsky.feed.post record, it’s easy for Bluesky — or indeed, any other AT protocol app — to thread these different post types together.
Because the lexicon is generic (although centered on long-form posts) you’re not even tied to one of the mentioned blogging platforms. If you can configure your site to add a record to your PDS when publishing a post, you can take advantage of these kind of integrations. There’s a few tools out there already that can help automate this, but the focus seems to be on sites built from Markdown files.
If you don’t already have a site.standard.publication record, it’s simple to do manually, and tools like PDSls make it easier to get your head around this whole PDS thing. It sure did for me.

Once you’ve got the publication record added, copy the AT-URI and paste that into a file that will end up on the root of your site with the path .well-known/site.standard.publication.
at://did:plc:mexvtvwyptcdlbwbj3osq2tg/site.standard.publication/3mnxjj2ex6a2lThat’s basically it for getting the publication stuff in place. Now we need a way to create document records with details from the post — like title, slug, description — and then store the AT-URI with the post somehow, so it can be associated with the document record when your site builds. With content in Markdown files it’s easy to just add it to the front matter, but with a CMS like Sanity it’s a bit more involved (only a little).
defineField({
name: 'atUri',
title: 'AT-URI',
type: 'string',
readOnly: true,
}),Here I’ve added an atUri field to my post type schema in Sanity. It’s read-only to make sure it doesn’t get edited accidentally.
{#if atUri}
<link rel="site.standard.document" href={atUri}>
{/if}Then in my Svelte template, I grab the atUri field along with the rest of the post data, and add a link tag in the head. If you’re not using Svelte to build your site, this step will be a little different but the idea is the same: the final HTML for the blog post should have link tag associating it with the document record.
Now comes the tricky bit. I wanted to have the document record created when I publish a post in Sanity (or updated when a post is updated) but the Sanity CMS front end can be run locally, or accessed remotely, and we need to authenticate with the AT protocol in order to update the PDS.
My solution so far is twofold (this is where Netlify comes in):
- Add a document action in the Sanity config that runs when a post document is published
- That action calls a Netlify function that authenticates (with secrets stored in Netlify) and creates the PDS record
The Sanity action gets the AT URI back, adds it to the post, and completes the publish step. Here’s how it looks:
function createPublishAction(original: DocumentActionComponent, context: DocumentActionsContext) {
let publishAction: DocumentActionComponent = (properties) => {
let { patch } = useDocumentOperation(properties.id, properties.type)
let originalResult = original(properties)
return {
...originalResult,
label: originalResult?.label ?? '',
async onHandle() {
if (properties.type === 'post' && properties.draft) {
try {
const { draft } = properties
let response = await fetch(ATPROTO_FN_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: draft.title,
slug: (draft.slug as { current?: string } | undefined)?.current,
published: draft.published,
edited: draft.edited,
description: serializePortableText(draft.lede as never),
atUri: draft.atUri,
}),
})
if (response.ok) {
let { atUri } = (await response.json()) as { atUri: string }
patch.execute([{ set: { atUri } }])
} else {
console.error('AT proto sync failed', await response.text())
}
} catch (error) {
// Publish continues even if the AT proto sync fails.
console.error('AT proto sync error', error)
}
}
originalResult?.onHandle?.()
},
}
}
return publishAction
}ATPROTO_FN_URL is the URL to the Netlify Function. Then in my website I have a netlify/functions folder which has this function:
async function atProtoDocument(request, _context) {
const origin = request.headers.get('origin')
const headers = corsHeaders(origin)
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers })
}
if (request.method !== 'POST') {
return Response.json(
{ error: 'Method not allowed' },
{ status: 405, headers },
)
}
try {
let { title, slug, published, edited, description, atUri } = await request.json()
let agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({
identifier: process.env.ATPROTO_IDENTIFIER,
password: process.env.ATPROTO_APP_PASSWORD,
})
let record = {
$type: COLLECTION,
site: PUBLICATION_URI,
title,
publishedAt: toDateTime(published),
}
if (slug) record.path = `/writing/${slug}`
if (description) record.description = description
if (edited) record.updatedAt = toDateTime(edited)
let result
if (atUri) {
result = await agent.com.atproto.repo.putRecord({
repo: agent.did,
collection: COLLECTION,
rkey: atUri.split('/').pop(),
record,
})
} else {
result = await agent.com.atproto.repo.createRecord({
repo: agent.did,
collection: COLLECTION,
record,
})
}
return Response.json({ atUri: result.data.uri }, { headers })
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500, headers },
)
}
}
export default atProtoDocumentCOLLECTION here is the name of the record, site.standard.document and PUBLICATION_URI is the site.standard.publication I set up earlier. The record.path is defined based on my blog URL — yours may differ.
Once those are deployed, the automation is complete! Now all that’s left is to tweak the embed theme to your liking. I haven’t implemented it yet, but we could also have this trigger a build to get the content live, and post the link to Bluesky too. I can’t wait to see what other kinds of integrations the AT protocol will unlock!