stormy gray clouds

Getting started with #atdev

17 October 2023 - by

/Blog/Getting started with #atdev

So, you want to make a Bluesky client?

Bluesky Social is an upstart social network in the style of Twitter. However, "Bluesky" and the Bluesky client is just a proof of concept for what the Bluesky Team is really building - the AT Protocol. The AT Protocol, or "ATProto" for short, is an attempt to create a decentralized social network protocol that solves some of the problems that competitors like ActivityPub have - namely, account portability and a "big world" architecture that allows global aggregation.

AT Protocol is meant to stand for "Authenticated Transfer Protocol", but we all know that's just a backronym that Paul came up with.

What makes this so interesting it that the APIs used to build Bluesky are completely open! Anyone can build a Bluesky client, and the official client uses exactly the same APIs that Graysky or any other client uses. Even better, there's an NPM package called @atproto/api that provides a simple typesafe interface to the ATProto APIs.

In this blog post, we're going to go through the process of building a super simple Bluesky client using the @atproto/api package. We'll be using Next.js for this tutorial, but you can use any framework you like - the @atproto/api package is framework agnostic. This tutorial will assume you have some familiarity with JavaScript.

While you need an invite code to make a Bluesky account, you do not need an account to use a fair number of the ATProto APIs. Rather than connecting to a Personal Data Server, or PDS, you can connect directly to the "AppView", which does not need an account to view.

For a better understanding of these terms, check out the Federation Architecture Overview.

Getting started

Let's make a new Next.js app:

1pnpm create next-app --tailwind --eslint --src-dir --ts --app --import-alias="~/*" my-bsky-app

I'm using pnpm here, but you can use npm or yarn if you prefer.

Open up the project in your favorite editor, and let's get started!

Setting up the API client

The first thing we need to do is install the @atproto/api package.

1pnpm add @atproto/api

Before we do anything else, quickly remove all the garbage CSS that comes with the Next.js template by setting src/app/globals.css to the following:

1/* src/app/globals.css */
2@tailwind base;
3@tailwind components;
4@tailwind utilities;

Next, let's make a new file called src/lib/api.ts and add the following code:

1// src/lib/api.ts
2import { BskyAgent } from "@atproto/api";
4export const agent = new BskyAgent({
5 // This is the AppView URL
6 service: "",
7 // If you were making an authenticated client, you would
8 // use the PDS URL here instead - the main one is
9 // service: "",

This is our API client we can use to make requests to the AppView. Let's make the homepage (src/app/page.tsx) list the top 10 custom feeds.

1// src/app/page.tsx
2import { agent } from "~/lib/api";
4export default async function Homepage() {
5 const feeds = await{
6 limit: 10,
7 });
9 return (
10 <div className="container mx-auto">
11 <h1 className="font-bold text-xl my-4">Top Feeds</h1>
12 <ul>
13 { => (
14 <li key={feed.displayName}>{feed.displayName}</li>
15 ))}
16 </ul>
17 </div>
18 );

If you run pnpm dev and go to http://localhost:3000, you should see a list of the top 10 feeds on Bluesky!

If it worked, congrats! You've made your first Bluesky client! If not, make sure you followed all the steps correctly.

Viewing a post

Let's make a new page that shows a post. For the time being, let's just hard-code the post URI. The post we will use is this one:

2 "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/";

This is an AT URI, composed of a DID (which are how users are uniquely identified), the type of record (in this case, a post), and the rkey of the post. We could also use the user's handle to identify the post, but that risks breaking things if the user changes their handle. DIDs are permanent, so they're a better choice.

We'll use app.bsky.feed.getPostThread to get the post and its replies. Let's just display the post author's name.

1// src/app/page.tsx
2import { agent } from "~/lib/api";
5 "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/";
7export default function Homepage() {
8 const thread = await{
10 })
12 return (
13 <div className="container mx-auto">
14 <p>{}</p>
15 </div>
16 );

Oh no! Type error! That's because the thread's main post might be deleted, or blocked. Let's fix this by checking what kind of view we're getting back, and error if it's been deleted or blocked. @atproto/api provides a handy set of type guards for this purpose.

1// src/app/page.tsx
2import { AppBskyFeedDefs } from "@atproto/api";
4import { agent } from "~/lib/api";
7 "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/";
9export default async function Homepage() {
10 const thread = await{
12 });
14 if (!AppBskyFeedDefs.isThreadViewPost(
15 throw new Error("Expected a thread view post");
17 return (
18 <div className="container mx-auto">
19 <p>{}</p>
20 </div>
21 );

Now we can see the post author's display name! Let's also display the post content. We'll need to use another type guard for the post content. While we're at it, let's also display author's handle, and avatar.

1// src/app/page.tsx
2import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
4import { agent } from "~/lib/api";
7 "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/";
9export default async function Homepage() {
10 const thread = await{
12 });
14 if (!AppBskyFeedDefs.isThreadViewPost(
15 throw new Error("Expected a thread view post");
17 const post =;
19 if (!AppBskyFeedPost.isRecord(post.record))
20 throw new Error("Expected a post with a record");
22 return (
23 <div className="grid min-h-screen place-items-center">
24 <div className="w-full max-w-sm">
25 <div className="flex flex-row items-center">
26 <img
27 src={}
28 alt={}
29 className="h-12 w-12 rounded-full"
30 />
31 <div className="ml-4">
32 <p className="text-lg font-medium">{}</p>
33 <p>@{}</p>
34 </div>
35 </div>
36 <div className="mt-4">
37 <p>{post.record.text}</p>
38 </div>
39 </div>
40 </div>
41 );

You should now be able to see's post!

Viewing any post

Let's now set up routing, so that we can view any post via the URL. The URL will be in the format /profile/:handle/post/:rkey. We'll take the two dynamic sections to construct the AT URI, and then use that to fetch the post thread like we did befire. Since we're using the Next.js App Routing, we'll need to make a folder structure like this:

2├── layout.tsx
3├── page.tsx
4└── profile
5 └── [handle]
6 └── post
7 └── [rkey]
8 └── page.tsx

Let's then get these dynamic sections and use it to construct the AT URI. Make sure to decode params.handle - a DID contains : characters, which get URL encoded automatically.

1// src/app/profile/[handle]/post/[rkey]/page.tsx
3interface Props {
4 params: {
5 handle: string;
6 rkey: string;
7 };
10export default function PostView({ params }: Props) {
11 const uri = `at://${decodeURIComponent(params.handle)}/${
12 params.rkey
13 }`;
15 return <p>{uri}</p>;

Now, we can use this URI to get the post and display it. Copy paste the code from src/app/page.tsx into src/app/profile/[handle]/post/[rkey]/page.tsx, and replace EXAMPLE_POST with uri.

1// src/app/profile/[handle]/post/[rkey]/page.tsx
2import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
4import { agent } from "~/lib/api";
6interface Props {
7 params: {
8 handle: string;
9 rkey: string;
10 };
13export default async function PostView({ params }: Props) {
14 const uri = `at://${decodeURIComponent(params.handle)}/${
15 params.rkey
16 }`;
18 const thread = await{ uri });
20 if (!AppBskyFeedDefs.isThreadViewPost(
21 throw new Error("Expected a thread view post");
23 const post =;
25 if (!AppBskyFeedPost.isRecord(post.record))
26 throw new Error("Expected a post with a record");
28 return (
29 <div className="grid min-h-screen place-items-center">
30 <div className="w-full max-w-sm">
31 <div className="flex flex-row items-center">
32 <img
33 src={}
34 alt={}
35 className="h-12 w-12 rounded-full"
36 />
37 <div className="ml-4">
38 <p className="text-lg font-medium">{}</p>
39 <p>@{}</p>
40 </div>
41 </div>
42 <div className="mt-4">
43 <p>{post.record.text}</p>
44 </div>
45 </div>
46 </div>
47 );

Now, if you go to http://localhost:3000/profile/did:plc:vwzwgnygau7ed7b7wt5ux7y2/post/3karfx5vrvv23, you should see the same post as before!

Here are some other posts you can look at:

If you want to be able to use a handle as well as a DID, as the official app does, check to see if param.handle starts with did:, and if not, use agent.resolveHandle({ handle: param.handle }) to get the DID of the user.

Next steps

Now that you've got a basic client up and running, you can start to build out more features. Here are some ideas:

Make a <Post /> component

There are loads of things in the post that we're not displaying, such as the number of likes, reposts, and replies, the timestamp, and any embeds the post might have. Take what we have already and make a reusable post component! It should take a post prop with the type AppBskyFeedDefs.PostView.


There are 4 types of embeds - images, external link, record (a.k.a a quote post), or a record + the other two types. Use type guards to differentiate between them, and display them accordingly.

Rich text

Posts can have mentions, hyperlinks, and hashtags, using "facets" to indicate what part of the source text has extra information. Use the RichText helper to help display these. Here's a rough guide on how you'd use it:

1import { RichText } from "@atproto/api";
3const rt = new RichText({
4 text: post.record.text,
5 facets: post.record.facets,
8const text = [];
10for (const segment of rt.segments()) {
11 if (segment.isMention()) {
12 text.push(
13 <a className="text-blue-500" href={`/profile/${segment.mention?.did}`}>
14 {segment.text}
15 </a>,
16 );
17 } else if (segment.isLink()) {
18 text.push(
19 <a className="text-blue-500" href={}>
20 {segment.text}
21 </a>,
22 );
23 } else if (segment.isTag()) {
24 text.push(<span className="text-blue-500">{segment.text}</span>);
25 } else {
26 text.push(segment.text);
27 }

Display replies

A little tricker, but agent.getPostThread() returns all the post's replies in a recursive data structure. You can use this to display the replies to a post. Same applies to a post's parents, if it itself is a reply. Link them all together to let you browse through a thread!

View a user's profile

You can view a user's profile using agent.getProfile(), and see a user's posts using agent.getActorFeed().

View a custom feed

Using the list of feeds we made in the beginning, you could could link to each respective feed and display the posts, using{ feed: feed.uri }). You can add infinite scrolling to this by using the cursor.

Pro tip: @tanstack/react-query makes this 100x easier to manage!

Logging in

Use when making the session agent, and use agent.login() to log in. This unlocks the rest of the APIs - you can like posts and follow people, for example. Then then sky's the limit - you can do anything the official app can do, and more!


I hope this tutorial has been helpful! If you have any questions, feel free to ask on Bluesky. If you want to see the full code, you can find it on GitHub. You can also see this app live here.

If you want to see how a full-featured third-party client works, check out Graysky on GitHub!

Happy hacking!

- Samuel (

81 likes - 24 reposts


Comments are fetched directly from this post - reply there to add your own comment.