✦ Directorium ✦

✦ Settings ✦

Max Media Rating
Enable Grawlix (Profanity filter)
Disable Shaders
Blog Settings
Blog Theme
Background Color
Text Color
Accent Color

Tips, Tricks & Tutorials

Adding a Bluesky Feed to Your SvelteKit App

It's a "two-step" process, but very straight-forward!

Note about the use of the term "Agent".

This in no way refers to Agentic AI. I am vehemently against the use of AI, especially in creative fields. "Agent" in this case is just a term that refers to code that interacts with an API on your behalf. An authorized connection to the official servers, if that makes sense.

Disclaimer:

This code was pulled and re-worked from Skeleton UI's GitHub repo. I found the implementation to be extremely clean and straight-forward. Probably one of the easiest times I've had working with a social media SDK. All code in the repo, as of writing, is MIT licensed, so feel free to use it!

While I'm focusing on Svelte/SvelteKit, most of this can apply to any JS framework you use.

The Quick Version:

Step 1:

Install Bluesky's official @atproto/api npm package and create a bluesky.js file in your src/lib directory (or where ever you personally store your API files).

src/lib/bluesky.js
import { AtpAgent } from '@atproto/api';

const blueSkyAgent = new AtpAgent({
  service: 'https://api.bsky.app'
});

export { blueSkyAgent };

Step 2:

Create a page or component to fetch and display the data.

src/lib/components/BlueskyFeed.svelte or src/routes/feed/+page.svelte
<script>
  import { blueSkyAgent } from '$lib/bluesky';

  const postLimit = 15;

  const postsPromise = blueSkyAgent
    .getAuthorFeed({
      actor: 'grawlich.art',
      limit: postLimit,
      filter: 'posts_with_media',
      includePins: true
    })
    .then((result) =>
      result.data.feed
        .filter((item) => {
          return item.post.author.handle === 'grawlich.art';
        })
        .map((item) => item.post)
    );
</script>

{#await postsPromise}
    <!-- Generate placeholder elements that match the max post fetch limit -->
    {#each { length: postLimit } as _}
        <div></div>
    {/each}
{:then posts}
    <!-- When we get posts back, render those instead. -->
    {#each posts as post}
      <a
        href="https://bsky.app/profile/{post.author.handle}/post/{post.uri.split('/').at(-1)}"
        target="_blank"
        style="display: block;"
      >
      <article>
        <header>
          <img src="{post.author.avatar}" alt="avatar" />
          <small>{new Date(post.record.createdAt).toLocaleString()}</small>
        </header>
        <p>{post.record.text}</p>
        <footer>
          {#if post.embed}
            {#each post.embed.images as image}
              <img src={image.thumb} alt={image.alt} />
            {/each}
          {/if}
        </footer>
      </article>
    </a>
  {/each}
{:catch error}
  <!-- No posts? -->
  <p>{error.message}</p>
{/await}

The Long Version

Here, I'll explain what exactly is going on here and why. Note that I am not including a pagination strategy, that'll be up to your preferences and if you want to fetch the posts client/server-side. I'm also not including styles, since that will also heavily depend on your personal preferences.

I'll be taking this step-by-step so even beginners can follow along.

While I'm focuing on Svelte/SvelteKit, Javascript is Javascript, and most of this can apply to any JS framework you use.


Firstly, as mentioned in the quick version, we need to install the @atproto/api package (using package manager you installed SvelteKit with), as it's the official way to interact with Bluesky's APIs.

Bash
npm install @atproto/api
# or
pnpm add @atproto/api
# or
yarn add @atproto/api

Next, we can create a bluesky.js file that will house the main code for creating a connection to the Bluesky API URL. I place these types of files under src/lib or src/lib/api.

Within this file, import the AtpAgent from the @atproto/api package.

src/lib/bluesky.js
// Import the `AtpAgent` from the `@atproto/api` package.
import { AtpAgent } from '@atproto/api';

Then, create a variable we can export to reuse elsewhere in the app. This is essentially just making it so we don't have to rewrite this multiple times if we need to get more data from Bluesky. We can call this agent where ever we need it to fetch data.

src/lib/bluesky.js
import { AtpAgent } from '@atproto/api';

// This will be the name you use to call the Agent in your code.
const blueSkyAgent = new AtpAgent({
  service: 'https://api.bsky.app'
});

You can also export that constant variable directly by doing export const blueSkyAgent instead. In this case, we'll be exporting it separately, just in case we want to add more functionality and AtpAgents if you want feeds from different feed APIs.

src/lib/bluesky.js
import { AtpAgent } from '@atproto/api';

const blueSkyAgent = new AtpAgent({
  service: 'https://api.bsky.app'
});

// You can add more variables and functions, separated by commas:
// export { blueSkyAgent, anotherAgent, myCustomFunction }
export { blueSkyAgent };

With that done, we can now create a page or component to load and display the data.

I recommend creating a component so you can reuse it in multiple places if you needed to. It's perfectly fine to just build a single page if that's the only place it's gonna show up.

We'll first want to create a src/lib/components/BlueskyFeed.svelte and add a script tag.

src/lib/components/BlueskyFeed.svelte or src/routes/feed/+page.svelte
<script>
  // Import the AtpAgent we just created.
  import { blueSkyAgent } from '$lib/bluesky';
</script>

There are many ways to do this next part, but the Skeleton UI site is using Svelte's {#await} syntax.

With this, we only need to create a promise to return data and allow the UI to handle its own state. To create a promise, in this case, we just need to invoke the AptAgent to fetch the posts and assign the result to a constant.

Here, we're using getAuthorFeed to fetch posts only from a specific account handle. There are numerous other methods of fetching posts, you can read about it here or under the http-reference link near the top.

src/lib/components/BlueskyFeed.svelte or src/routes/feed/+page.svelte
<script>
  // Import the AptAgent we just created.
  import { blueSkyAgent } from '$lib/bluesky';
  
  const postsPromise = blueSkyAgent.getAuthorFeed({}); // Will fetch posts from a specific account handle.
</script>

We'll also need to pass in an object of options that the AtpAgent can use to get more specific results. For the handle, I'm passing in my own handle grawlich.art, which I assigned as my personal domain name. Yours might be in this pattern: user.bsky.social.

Javascript
  const postsPromise = blueSkyAgent
    .getAuthorFeed({
      actor: 'grawlich.art', // Specify the account handle to get posts from.
      limit: 15, // Specify a default number of posts to return.
      filter: 'posts_with_media', // If you'd like to get posts that include media, like images/video.
      includePins: true // Include pinned posts, if they exist.
    });

To run additional processing, or in this case an additional filter to only show media posts I made, we'll need to use .then() to run it once we get the request result back.

src/lib/components/BlueskyFeed.svelte or src/routes/+page.svelte
<script>
  // Import the AptAgent we just created.
  import { blueSkyAgent } from '$lib/bluesky';
  
  const postsPromise = blueSkyAgent
    .getAuthorFeed({
      actor: 'grawlich.art',
      limit: 15,
      filter: 'posts_with_media',
      includePins: true
    })
    .then((result) => // Assign the `result` as a property in an anonymous function.
      result.data.feed // Follow the data path to the posts.
        .filter((item) => { // Filter each post (as `item`).
          return item.post.author.handle === 'grawlich.art'; // Return only the posts with `grawlich.art` as the `author.handle`.
        })
        .map((item) => item.post) // Map the posts to a new array.
    );
</script>

The last essential thing about his method is using the {#await} syntax. You don't need to import anything:

src/lib/components/BlueskyFeed.svelte or src/routes/+page.svelte
<script>
  // Your post fetch code
</script>

{#await postsPromise}
    <!-- Placeholder that exists here until the promise is resolved. -->
{:then posts}
    <!-- When we get posts back, render those instead. -->
{:catch error}
  <!-- No posts? Display the `error`. -->
{/await}

This last part is entirely up to how you want to structure your post cards. Here is a simple, but effective layout.

We must set the anchors/links to display: block; to prevent them from trying to flow horizontally, like text.

src/lib/components/BlueskyFeed.svelte or src/routes/+page.svelte
<script>
  // Your post fetch code
</script>

{#await postsPromise}

    <!-- Generate placeholder elements that match the max post fetch limit. -->
    {#each { length: 15 } as _}
        <div></div>
    {/each}

{:then posts}

    <!-- When we get posts back, render those instead. -->
    {#each posts as post}
      <a
        href="https://bsky.app/profile/{post.author.handle}/post/{post.uri.split('/').at(-1)}"
        target="_blank"
        style="display: block;"
      >
      <article>
        <header>
          <img src="{post.author.avatar}" alt="avatar" />
          <small>{new Date(post.record.createdAt).toLocaleString()}</small>
        </header>
        <p>{post.record.text}</p>
        <footer>
          {#if post.embed}
            {#each post.embed.images as image}
              <img src={image.thumb} alt={image.alt} />
            {/each}
          {/if}
        </footer>
      </article>
    </a>
  {/each}

{:catch error}

  <!-- No posts? -->
  <p>{error.message}</p>

{/await}

That's pretty much it! Style it to your liking and share it with the world!


Grawlich.art © Kevin Grawlich 2024 - 2026
App Version: 1.3.0-beta
Coded from scratch with by Grawlich.