React blog kit backed by AT Protocol. Provides filtering UI, pagination, Bluesky comments/embeds, subscribe widget, and markdown rendering.
0

Configure Feed

Select the types of activity you want to include in your feed.

TypeScript 93.7%
JavaScript 2.6%
CSS 2.3%
Shell 1.3%
Other 0.1%
63 1 0

Clone this repository

https://tangled.org/russ.fugl.dev/atproto-presskit https://tangled.org/did:plc:ps5bhw5xjszad4nkvhwnbxaa
git@tangled.org:russ.fugl.dev/atproto-presskit git@tangled.org:did:plc:ps5bhw5xjszad4nkvhwnbxaa

For self-hosted knots, clone URLs may differ based on your setup.



README.md

atproto-presskit#

React blog kit backed by AT Protocol, distributed as a git submodule. Lifts the blog UX chain from a host site so multiple sibling blogs can share one implementation while keeping their own posts, categories, authors, and telemetry.

What it ships#

  • createAtprotoFeed — Sequoia-standard AT Protocol feed (PLC resolution, listRecords, in-flight memoization, static fallback)
  • Filtering primitivesfilterSortAndPaginatePosts, match-score sorting, OR-logic category filter
  • BlogProvider + useBlog — URL-state-driven context (categories, page, sort) with optional SSR-supplied initial posts
  • ComponentsPostList, CategoryFilter, SortButton, BlogPagination, AuthorCard, ReadMoreContent, MarkdownContent, BlueskyEmbed, BlueskyComments, Subscribe, CollapsibleComments, ScrollLogger
  • Layout componentsBlogListLayout, BlogPostLayout with slot-based composition and sensible defaults; BlogPostMeta, BlogPostCover, BlogPostBody (composable regions); FeaturedSidebar, FeaturedPostCard (related posts sidebar)
  • RSC route helpersBlogListPage, BlogPostPage (async; exported from @blog/rsc; framework-agnostic React Server Components)
  • Server utilitiesgetFeaturedPosts, getPaginatedPosts, getPublishedPosts, getPostsByCategories (filters); generateBlogPostStaticParams, generateBlogListStaticParams (static-params helpers) — all from @blog/server; plain Node async functions, callable from any server runtime
  • Next.js bindingsNextBlogProvider, nextImageAdapter (exported from @blog/next; bundles useRouter/useSearchParams/next/image into one wrapper)
  • Image platform adapterBlogImageContext + useBlogImage + BlogImage wrapper; default is plain <img>, Next consumers swap in nextImageAdapter via BlogConfig.imageComponent
  • Bluesky avatar resolverAuthorAvatar + useBlueskyAvatar(did); client-side fetches app.bsky.actor.profile and renders the live avatar on top of Author.imageUrl fallback
  • BlogTelemetry adapter — pluggable interface; ships with consoleTelemetry as the default and noopTelemetry as an opt-out
  • Next.js metadata helpersgenerateBlogPostMetadata (builds Next.js Metadata with OG tags for a post); BlogPostPage (wraps BlogPostLayout, auto-emits blog.post.view telemetry via after())
  • Standard Site discovery links — article pages emit site.standard.document and site.standard.publication links; publication home pages can emit site.standard.publication
  • Next.js OG image factorycreateBlogOpenGraphImage (builds opengraph-image.tsx routes with optional cover fallback)

Each consumer site provides its own DID, categories, authors, cover images, and (optional) telemetry backend.

Integration#

# 1. Add as a submodule (Next.js shown; adapt path aliases for other frameworks)
git submodule add https://tangled.org/russ.fugl.dev/atproto-presskit atproto-presskit

# 2. Sync deps + patchedDependencies into the consumer's package.json, then install
node atproto-presskit/scripts/sync-deps.mjs
bun install

# 3. Install required shadcn primitives (skip any already present)
bunx shadcn@latest add button toggle pagination

4. Add path aliases in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"], // already present — consumer's existing alias
      "@blog": ["./atproto-presskit/src/index"], // barrel: import { x } from "@blog"
      "@blog/*": ["./atproto-presskit/src/*"], // deep reaches into submodule files
    },
  },
}

5. Extend Tailwind content so submodule classes are emitted:

content: ["./src/**/*.{ts,tsx}", "./atproto-presskit/src/**/*.{ts,tsx}"];

6. Wire the publish script in package.json:

{
  "scripts": {
    "publish": "BLOG_REVALIDATE_URL=https://www.example.com/api/revalidate bash atproto-presskit/scripts/publish.sh",
  },
}

REVALIDATE_SECRET continues to come from the consumer's .env.

7. Create config files in the consumer:

  • src/lib/blog-config.ts — composes createBlogConfig({ ... }) from consumer-owned data
  • src/lib/blog-telemetry.ts (optional) — implements BlogTelemetry, wrapping whatever logging backend the site uses (Axiom, Convex, etc.). If omitted, consoleTelemetry emits structured wide events to stdout.

After updating the submodule (git submodule update --remote), re-run node atproto-presskit/scripts/sync-deps.mjs && bun install so any version bumps in atproto-presskit/package.json flow into the consumer's package.json.

Public API#

Four barrels, layered so non-React-Server-Component frameworks can pick up just the pieces they need:

  • @blog — universal/client-safe: config + types, createAtprotoFeed, BlogProvider + useBlog, all rendering components, layout components (BlogListLayout, BlogPostLayout), telemetry adapters, filter primitives, image-context, Bluesky avatar resolver. Safe to import from any context, any framework.
  • @blog/server — plain Node async utilities: server post filters (getFeaturedPosts, getPaginatedPosts, getPublishedPosts, getPostsByCategories), static-params helpers (generateBlogPostStaticParams, generateBlogListStaticParams). No framework directives — call from any server runtime (Next.js RSCs, Hono routes, SvelteKit endpoints, Express handlers). The markdown-cache loader (loadMarkdownContent / getMarkdownFromCache / clearMarkdownCache) still ships but is not re-exported from this barrel — import it directly from @blog/markdown-cache. It is kept out because its process.cwd() fs read makes bundlers trace the whole consuming project into the server bundle (same reasoning as the barrel split below).
  • @blog/rsc — framework-agnostic React Server Components: BlogListPage, BlogPostPage (async wrappers that fetch then render the layouts). Works with any RSC runtime (Next.js App Router, Waku, future Vite RSC). Non-RSC SPAs should compose the layouts from @blog directly.
  • @blog/next — client-safe Next.js bindings: NextBlogProvider (wraps BlogProvider with useRouter/useSearchParams/nextImageAdapter), nextImageAdapter, generateBlogPostMetadata (builds Metadata with OG tags). Safe to import from client components.
  • @blog/next-rsc — async RSC route helpers: BlogListPage, BlogPostPage (slot-aware; auto-emits blog.post.view telemetry via next/server's after()). Server-only; import from server route files.
  • @blog/next-ogcreateBlogOpenGraphImage (factory for opengraph-image.tsx routes). Server-only; pulls in next/og@vercel/ogsharp. Import from opengraph-image.tsx route files.

Why three entries instead of one barrel: when a client component imports a barrel, Next statically traces every re-exported module into the client bundle. Mixing "use client" exports with modules that pull server-only Next APIs (next/server, next/og) causes the client build to fail on fs/child_process/after. Keeping server-only surface in dedicated entries avoids the trace.

Prefer the barrels over @blog/... subpath imports — except markdown-cache, which is intentionally subpath-only (@blog/markdown-cache) for the bundler-tracing reason noted above.

Layout composition#

BlogPostLayout ships with a slot-based composition system. Each visible region — meta, cover, title, body, sidebar, comments — has a slot prop with a sensible default. Consumers replace what they want; defaults handle the rest. Pass null to omit a region entirely; omit the prop to use the default.

Slot props:

export interface BlogPostLayoutProps {
  post: Post | null | undefined;
  slug: string;
  config?: BlogConfig; // Required for the default sidebar; pass null if your sidebar slot doesn't need it

  // Slots — each defaults to the corresponding region's default component.
  // Pass `null` to omit, `undefined` (or omit) to use default.
  meta?: ReactNode | null; // default: <BlogPostMeta post={post} />
  cover?: ReactNode | null; // default: <BlogPostCover post={post} slug={slug} />
  title?: ReactNode | null; // default: <h1>{post.title}</h1>
  body?: ReactNode | null; // default: <BlogPostBody post={post} slug={slug} />
  sidebar?: ReactNode | null; // default: <FeaturedSidebar config={config} ... />
  comments?: ReactNode | null; // default: post.atUri ? <CollapsibleComments atUri={post.atUri} /> : null
  beforeArticle?: ReactNode; // unstyled slot above the <article>
  afterArticle?: ReactNode; // unstyled slot below the article + sidebar grid

  className?: string;
  classNames?: {
    article?: string;
    grid?: string; // 2-col grid when sidebar + comments both present
    sidebarColumn?: string;
    commentsColumn?: string;
  };
}

Default region components (exported from @blog):

  • <BlogPostMeta> — renders date + <AuthorCard>
  • <BlogPostCover> — renders cover image with PDS-first + coverImageMap fallback; returns null if neither available
  • <BlogPostBody> — wraps <MarkdownContent> in <ReadMoreContent>
  • <FeaturedSidebar> — async server component; renders sidebar of related posts
  • <FeaturedPostCard> — individual related-post card with date, categories, author

Consumers can import and wrap these to extend their styling without reimplementing:

import { BlogPostLayout, BlogPostBody, FeaturedSidebar, cn } from "@blog";

// Default: all regions populated from config defaults
<BlogPostLayout config={config} slug={slug} post={post} />

// Omit cover, customize sidebar
<BlogPostLayout
  config={config}
  slug={slug}
  post={post}
  cover={null}
  sidebar={<MyCustomSidebar posts={related} />}
  classNames={{ sidebarColumn: "lg:col-span-2" }}
/>

// Wrap the default body with custom styling
<BlogPostLayout
  config={config}
  slug={slug}
  post={post}
  body={<BlogPostBody post={post} slug={slug} className="prose-lg" />}
/>

Config#

import { createBlogConfig, createAtprotoFeed, consoleTelemetry } from "@blog";

export const blogConfig = createBlogConfig({
  basePath: "/blog",
  siteOrigin: "https://example.com", // optional; used by generateBlogPostMetadata for canonical URLs
  postsPerPage: 5,
  feed: createAtprotoFeed({
    did: "did:plc:...",
    defaultAuthor,
    categories,
    tagToCategoryMap,
    staticFallback: postsData,
  }),
  categories,
  defaultAuthor,
  sequoia: {
    publicationUri: "at://did:plc:.../site.standard.publication/...",
    commentTheme: { "--sequoia-accent-color": "#4f46e5" }, // optional
    subscribeTheme: { "--sequoia-accent-color": "#4f46e5" }, // optional
  },
  coverImageMap, // optional Record<slug, BlogImageSource>
  imageComponent, // optional ComponentType<BlogImageProps>; default <img>
  telemetry: blogTelemetry, // optional; defaults to consoleTelemetry
  notFound: NotFoundPage, // optional override for missing-post page
});

Author accepts either imageUrl, did, or both. When did is supplied, <AuthorAvatar> resolves the live app.bsky.actor.profile avatar client-side (with module-level caching across the page) and falls back to imageUrl until it loads. Even on SSR'd pages the server-rendered HTML uses the static fallback; the live avatar hydrates after mount, so there's no SSR/PDS coupling.

Pages#

BlogListPage is a Server Component that renders hero + featured posts and leaves a children slot for the consumer's interactive list. BlogConfig contains non-serializable values (feed adapter, telemetry) and cannot cross the RSC boundary, so the interactive list lives in a thin "use client" wrapper that constructs <BlogProvider> itself.

// src/app/blog/page.tsx (Server Component)
import { BlogListPage } from "@blog/next-rsc";
import { blogConfig } from "@/lib/blog-config";
import Header from "@/components/header";
import Footer from "@/components/footer";
import { BlogList } from "./blog-list";

export default async function Page() {
  const initialPosts = await blogConfig.feed.fetchAllDocuments();
  return (
    <BlogListPage config={blogConfig} header={<Header />} footer={<Footer />}>
      <BlogList initialPosts={initialPosts} />
    </BlogListPage>
  );
}
// src/app/blog/blog-list.tsx (Client wrapper)
"use client";
import { PostList, CategoryFilter, SortButton, BlogPagination, useBlog, type Post } from "@blog";
import { NextBlogProvider } from "@blog/next";
import { blogConfig } from "@/lib/blog-config";

export function BlogList({ initialPosts }: { initialPosts: Post[] }) {
  return (
    <NextBlogProvider config={blogConfig} initialPosts={initialPosts}>
      <BlogListBody />
    </NextBlogProvider>
  );
}

function BlogListBody() {
  const { state, actions, meta } = useBlog();
  return (
    <>
      <SortButton sortDateAsc={state.sortDateAsc} onSortToggle={actions.toggleSort} />
      <CategoryFilter
        availableCategories={meta.availableCategories}
        selectedCategories={state.selectedCategories}
        onCategoryToggle={actions.setCategories}
      />
      <PostList
        posts={state.posts}
        onCategoryClick={actions.toggleCategory}
        selectedCategories={state.selectedCategories}
      />
      <BlogPagination
        currentPage={state.currentPage}
        totalPages={state.totalPages}
        totalPosts={state.totalPosts}
        postsPerPage={meta.config.postsPerPage}
        onPageChange={actions.changePage}
      />
    </>
  );
}
// src/app/blog/[slug]/page.tsx
import { BlogPostPage } from "@blog/next-rsc";
import { generateBlogPostStaticParams } from "@blog/server";
import { blogConfig } from "@/lib/blog-config";

export const revalidate = 604800;

export function generateStaticParams() {
  return generateBlogPostStaticParams(blogConfig);
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // BlogPostPage: auto-emits blog.post.view telemetry via after()
  // All BlogPostLayout slot props are supported for customization
  return <BlogPostPage config={blogConfig} slug={slug} />;
}

Next.js metadata and OG images#

generateBlogPostMetadata builds a Next.js Metadata object with title, description, and OG tags. Wire it into generateMetadata in your post route:

// src/app/blog/[slug]/page.tsx
import { generateBlogPostMetadata } from "@blog/next";
import { blogConfig } from "@/lib/blog-config";

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return generateBlogPostMetadata(blogConfig, slug, {
    // Optional base metadata merged with post-specific fields
    title: { template: "%s | Blog" },
  });
}

createBlogOpenGraphImage is a factory that builds a route handler for opengraph-image.tsx:

// src/app/blog/[slug]/opengraph-image.tsx
import { createBlogOpenGraphImage } from "@blog/next-og";
import { blogConfig } from "@/lib/blog-config";

export const alt = "Post cover image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default createBlogOpenGraphImage({
  config: blogConfig,
  siteName: "My Blog",
  styles: {
    gradientFallback: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
    accentColor: "#c7d2fe",
    titleColor: "#ffffff",
  },
});

BlogPostPage / BlogPostLayout emit these article-page links when the post has atUri and the config has sequoia.publicationUri:

<link rel="site.standard.document" href="at://.../site.standard.document/..." />
<link rel="site.standard.publication" href="at://.../site.standard.publication/..." />

BlogListPage passes config.sequoia.publicationUri through to BlogListLayout, so publication home pages emit:

<link rel="site.standard.publication" href="at://.../site.standard.publication/..." />

If composing BlogListLayout directly, pass publicationUri yourself.

Composing components directly#

Route helpers are conveniences. For custom layouts, compose the parts yourself — everything is exported from @blog:

List page:

import { BlogProvider, PostList, CategoryFilter, SortButton, BlogPagination, useBlog } from "@blog";

Post page with custom layout:

import {
  BlogPostLayout,
  BlogPostMeta,
  BlogPostCover,
  BlogPostBody,
  FeaturedSidebar,
  FeaturedPostCard,
} from "@blog";

// Use the composable defaults as building blocks
<BlogPostLayout
  config={config}
  slug={slug}
  post={post}
  cover={<BlogPostCover post={post} slug={slug} className="rounded-lg shadow-xl" />}
  sidebar={<FeaturedSidebar config={config} postCategories={post.categories} count={5} />}
/>;

Non-Next.js frameworks (Vite SPA, bhvr, etc.)#

@blog is React-only; only @blog/next and @blog/rsc touch framework-specific machinery. A Vite SPA pattern:

// 1. Fetch posts wherever your data layer runs — Hono route, RSC, or
//    client-side via config.feed.fetchAllDocuments().
import { getPaginatedPosts, getFeaturedPosts } from "@blog/server";

// 2. Render the layouts from @blog. No async, no server-only deps.
import { BlogProvider, BlogListLayout, PostList, ... } from "@blog";

function BlogPage({ initialPosts, featured }) {
  return (
    <BlogListLayout
      featured={featured}
      header={<Header />}
      publicationUri={blogConfig.sequoia.publicationUri}
    >
      <BlogProvider
        config={blogConfig}
        initialPosts={initialPosts}
        searchParams={window.location.search.slice(1)}
        navigate={(url) => history.pushState({}, "", url)}
      >
        <PostList ... />
      </BlogProvider>
    </BlogListLayout>
  );
}

The image adapter and Bluesky avatar resolver work the same way — no Next.js dependency.

Telemetry#

import { useBlogTelemetry, consoleTelemetry, noopTelemetry } from "@blog";
import type { BlogTelemetry } from "@blog";

// In a client component inside the BlogProvider tree:
const telemetry = useBlogTelemetry();
telemetry.logClientEvent("blog.post.read_more", { post_slug, post_title });

Telemetry Model#

consoleTelemetry writes one structured wide event per call to stdout — a JSON-serialized record with eventName, timestamp, source, and an arbitrary data payload. This matches the wide-events / canonical log line pattern: one context-rich entry per operation, not many narrow ones, so production debugging can answer questions like "which posts are getting read_more clicks?" without grepping.

Consumers swap in their own adapter via BlogConfig.telemetry:

import type { BlogTelemetry } from "@blog";
import { logClientEvent, logServerEvent } from "@/lib/axiom"; // example

export const blogTelemetry: BlogTelemetry = {
  logClientEvent: (name, data) => logClientEvent(name, data),
  logServerEvent: async (name, data) => {
    await logServerEvent(name, data);
  },
};

Never log PII. Use stable identifiers (post_slug, not author email) and hash sensitive content. Server-side adapters are responsible for flushing (e.g. await axiomLogger.flush() inside logServerEvent) since server functions can't depend on React context teardown.

Theming Model#

Three layers, each addressing a different concern:

  1. Tailwind theme tokens — every submodule component is styled with semantic tokens (bg-primary, text-foreground, border-border, etc.). Retuning the consumer's CSS variables re-themes the whole blog without touching the submodule.
  2. Sequoia CSS variablesconfig.sequoia.commentTheme and subscribeTheme flow inline to <sequoia-comments> and <sequoia-subscribe> as --sequoia-* overrides on top of sensible defaults.
  3. className overrides — most shipped components accept className for last-mile tweaks; the submodule merges them with cn from the consumer's @/lib/utils.

What stays in each consumer#

Path Role
src/content/blog/posts.ts Static postsData passed to createAtprotoFeed as staticFallback
src/content/blog/categories.ts Site categories + tagToCategoryMap for AT-protocol tag routing
src/content/blog/authors.ts defaultAuthor (and any others)
src/content/blog/*.md Local markdown bodies for offline fallback
src/lib/cover-images.ts StaticImageData map passed as coverImageMap
src/lib/blog-config.ts (new) Single createBlogConfig call wiring the above
src/lib/blog-telemetry.ts (optional) BlogTelemetry adapter on top of the site's logging backend
src/components/header.tsx, footer.tsx Site chrome
src/components/sequoia-comments.ts sequoia add sequoia-comments-generated custom-element registration
src/components/sequoia-subscribe.ts sequoia add sequoia-subscribe-generated custom-element registration
src/app/blog/page.tsx Server route — <BlogListPage> wrapping the client list
src/app/blog/blog-list.tsx (new) "use client" wrapper — <BlogProvider> + interactive list parts
src/app/blog/[slug]/page.tsx ~10 lines — <BlogPostPage> + generateBlogPostStaticParams
src/app/api/revalidate/route.ts Sequoia webhook handler — untouched
tailwind.config.ts content glob extended to include ./atproto-presskit/src/**/*.{ts,tsx}
tsconfig.json Path aliases @blog and @blog/* added (see step 4)
package.json dependencies / patchedDependencies managed by sync-deps.mjs
next.config.ts Untouched (site-specific redirects stay)

Updating#

git submodule update --remote
node atproto-presskit/scripts/sync-deps.mjs
bun install

atproto-presskit/package.json is the source of truth for react-markdown, remark-gfm, rehype-pretty-code, shiki, @heroicons/react, lucide-react, and sequoia-cli (with its patch). Consumers don't need to manage these directly — sync-deps.mjs merges them into the consumer's package.json.

Patching sequoia-cli#

sequoia-cli@0.5.6 is pinned via patchedDependencies to add three fields to every site.standard.document record published by bunx sequoia publish:

  • content — the raw markdown body wrapped in the at.markpub.markdown lexicon (markpub.at) so renderers can format the post directly instead of working from the stripped textContent fallback. Reads flavor / renderingRules from a new markpub block in sequoia.json (defaults: commonmark / markdown-it).
  • contributors — single-entry array carrying the publishing agent's DID, optionally with role and displayName from a contributor block in sequoia.json.
  • Smart textContent excerpt — replaces upstream's hard 10,000-char slice with a truncation that cuts before the first --- line within the first 5,000 chars, else at the midpoint of the third paragraph; then appends Continue reading at [{display}]({canonicalUrl}). Opt in with "smartExcerpt": true in sequoia.json.

Each feature has a self-contained design doc in patch.md/:

  • patch.md/sequoia-add-markpub-content.md
  • patch.md/sequoia-add-contributors.md
  • patch.md/sequoia-add-smart-excerpt.md

All three slices compile to a single patches/sequoia-cli@0.5.6.patch, applied automatically by bun install via the patchedDependencies entry in package.json. The .patch file is the unit bun consumes — slices in patch.md/ are docs, not separately applicable.

Iterating on the patch#

bun patch sequoia-cli
# edit node_modules/sequoia-cli/dist/index.js
bun patch --commit node_modules/sequoia-cli

bun patch copies the package out of the global cache so node_modules/sequoia-cli is writable; --commit diffs the writable copy against the cached original and regenerates patches/sequoia-cli@0.5.6.patch.

To remove the patch entirely: delete the patchedDependencies entry from package.json and the patches/sequoia-cli@*.patch file, then bun install.

Development inside the submodule#

The example/ workspace is a minimal real consumer (Next.js, mirroring the Integration snippet above) that lets the submodule's own tsc, ESLint, and tests resolve @/components/ui/* and @/lib/utils. bun install at the submodule root installs both workspaces.

bun install
bun run check    # eslint + tsc + prettier --check + vitest
bun run format   # prettier --write
bun run test     # vitest run

Caches live in node_modules/.cache/ (ESLint, Prettier, tsc, Vitest).