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 primitives —
filterSortAndPaginatePosts, match-score sorting, OR-logic category filter BlogProvider+useBlog— URL-state-driven context (categories, page, sort) with optional SSR-supplied initial posts- Components —
PostList,CategoryFilter,SortButton,BlogPagination,AuthorCard,ReadMoreContent,MarkdownContent,BlueskyEmbed,BlueskyComments,Subscribe,CollapsibleComments,ScrollLogger - Layout components —
BlogListLayout,BlogPostLayoutwith slot-based composition and sensible defaults;BlogPostMeta,BlogPostCover,BlogPostBody(composable regions);FeaturedSidebar,FeaturedPostCard(related posts sidebar) - RSC route helpers —
BlogListPage,BlogPostPage(async; exported from@blog/rsc; framework-agnostic React Server Components) - Server utilities —
getFeaturedPosts,getPaginatedPosts,getPublishedPosts,getPostsByCategories(filters);generateBlogPostStaticParams,generateBlogListStaticParams(static-params helpers) — all from@blog/server; plain Node async functions, callable from any server runtime - Next.js bindings —
NextBlogProvider,nextImageAdapter(exported from@blog/next; bundlesuseRouter/useSearchParams/next/imageinto one wrapper) - Image platform adapter —
BlogImageContext+useBlogImage+BlogImagewrapper; default is plain<img>, Next consumers swap innextImageAdapterviaBlogConfig.imageComponent - Bluesky avatar resolver —
AuthorAvatar+useBlueskyAvatar(did); client-side fetchesapp.bsky.actor.profileand renders the live avatar on top ofAuthor.imageUrlfallback BlogTelemetryadapter — pluggable interface; ships withconsoleTelemetryas the default andnoopTelemetryas an opt-out- Next.js metadata helpers —
generateBlogPostMetadata(builds Next.jsMetadatawith OG tags for a post);BlogPostPage(wrapsBlogPostLayout, auto-emitsblog.post.viewtelemetry viaafter()) - Standard Site discovery links — article pages emit
site.standard.documentandsite.standard.publicationlinks; publication home pages can emitsite.standard.publication - Next.js OG image factory —
createBlogOpenGraphImage(buildsopengraph-image.tsxroutes 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— composescreateBlogConfig({ ... })from consumer-owned datasrc/lib/blog-telemetry.ts(optional) — implementsBlogTelemetry, wrapping whatever logging backend the site uses (Axiom, Convex, etc.). If omitted,consoleTelemetryemits 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). Themarkdown-cacheloader (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 itsprocess.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@blogdirectly.@blog/next— client-safe Next.js bindings:NextBlogProvider(wrapsBlogProviderwithuseRouter/useSearchParams/nextImageAdapter),nextImageAdapter,generateBlogPostMetadata(buildsMetadatawith OG tags). Safe to import from client components.@blog/next-rsc— async RSC route helpers:BlogListPage,BlogPostPage(slot-aware; auto-emitsblog.post.viewtelemetry vianext/server'safter()). Server-only; import from server route files.@blog/next-og—createBlogOpenGraphImage(factory foropengraph-image.tsxroutes). Server-only; pulls innext/og→@vercel/og→sharp. Import fromopengraph-image.tsxroute 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 +coverImageMapfallback; 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",
},
});
Standard Site discovery links#
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:
- 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. - Sequoia CSS variables —
config.sequoia.commentThemeandsubscribeThemeflow inline to<sequoia-comments>and<sequoia-subscribe>as--sequoia-*overrides on top of sensible defaults. classNameoverrides — most shipped components acceptclassNamefor last-mile tweaks; the submodule merges them withcnfrom 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 theat.markpub.markdownlexicon (markpub.at) so renderers can format the post directly instead of working from the strippedtextContentfallback. Readsflavor/renderingRulesfrom a newmarkpubblock insequoia.json(defaults:commonmark/markdown-it).contributors— single-entry array carrying the publishing agent's DID, optionally withroleanddisplayNamefrom acontributorblock insequoia.json.- Smart
textContentexcerpt — 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 appendsContinue reading at [{display}]({canonicalUrl}). Opt in with"smartExcerpt": trueinsequoia.json.
Each feature has a self-contained design doc in patch.md/:
patch.md/sequoia-add-markpub-content.mdpatch.md/sequoia-add-contributors.mdpatch.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).