An API you can curl, or open in a browser, to receive Bluesky data as markdown!
10
fork

Configure Feed

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

Add /links endpoint and bump posts default limit to 50

- GET /links?url=:url — find all Bluesky posts linking to a URL or domain
(uses app.bsky.feed.searchPosts url param, native Bluesky feature)
- Bump default posts limit from 20 → 50 across getFeed and the route
- Document /links in README, llms.txt, docs, skill.md, cli, homepage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

jack c01b1944 bb86b697

+125 -2
+1
README.md
··· 17 17 | `GET /profile/:handle/followers` | Follower list | 18 18 | `GET /profile/:handle/following` | Following list | 19 19 | `GET /search?q=:query` | Full-text post search | 20 + | `GET /links?url=:url` | Posts linking to a URL or domain | 20 21 | `GET /trending` | Trending topics right now | 21 22 | `GET /llms.txt` | Machine-readable API guide for agents | 22 23
+1
app/cli/route.ts
··· 39 39 /profile/:handle/followers Follower list 40 40 /profile/:handle/following Following list 41 41 /search?q=:query Full-text post search 42 + /links?url=:url Posts linking to a URL or domain 42 43 /trending Trending topics right now 43 44 /llms.txt Machine-readable API guide (for agents) 44 45 /skill.md Agent skill file (Claude, Cursor, Windsurf…)
+14
app/docs/route.ts
··· 117 117 118 118 --- 119 119 120 + ### Links 121 + 122 + \`\`\` 123 + GET ${base}/links?url=<url-or-domain> 124 + GET ${base}/links?url=<url-or-domain>&cursor=<cursor>&limit=<1-100> 125 + \`\`\` 126 + 127 + Find all public Bluesky posts that link to a given URL or domain. 128 + 129 + **Example:** [${base}/links?url=theverge.com](${base}/links?url=theverge.com) 130 + **Example:** [${base}/links?url=https://example.com/article](${base}/links?url=https%3A%2F%2Fexample.com%2Farticle) 131 + 132 + --- 133 + 120 134 ## Embed types 121 135 122 136 | Type | Rendered as |
+26
app/links/route.ts
··· 1 + import { type NextRequest } from 'next/server' 2 + import { searchPostsByUrl } from '@/lib/bsky' 3 + import { renderLinks } from '@/lib/markdown' 4 + import { markdownResponse, errorResponse, optionsResponse, baseUrl, handleRoute } from '@/lib/respond' 5 + 6 + export async function GET(req: NextRequest) { 7 + return handleRoute(async () => { 8 + const reqUrl = new URL(req.url) 9 + const url = reqUrl.searchParams.get('url') 10 + 11 + if (!url?.trim()) { 12 + return errorResponse('Missing required query parameter: url', 400) 13 + } 14 + 15 + const cursor = reqUrl.searchParams.get('cursor') ?? undefined 16 + const limit = Math.min(parseInt(reqUrl.searchParams.get('limit') ?? '50', 10), 100) 17 + 18 + const page = await searchPostsByUrl(url, cursor, limit) 19 + const md = renderLinks(url, page, baseUrl(req)) 20 + return markdownResponse(md) 21 + }) 22 + } 23 + 24 + export async function OPTIONS() { 25 + return optionsResponse() 26 + }
+16
app/llms.txt/route.ts
··· 129 129 130 130 --- 131 131 132 + ### GET /links?url=:url 133 + Find all public Bluesky posts that link to a given URL or domain. 134 + 135 + Query parameters: 136 + - url — full URL (e.g. https://example.com/article) or bare domain (e.g. example.com) (required) 137 + - cursor — pagination cursor (optional) 138 + - limit — 1–100, default 50 139 + 140 + Response: matching posts with author, text, embeds, engagement stats, and pagination. 141 + 142 + Example: ${base}/links?url=theverge.com 143 + Example: ${base}/links?url=https://example.com/some-article 144 + 145 + --- 146 + 132 147 ## Embed types returned in posts 133 148 134 149 - Images — inline Markdown image syntax with alt text + raw CDN URL ··· 169 184 - Fetch ${base}/profile/:handle/posts to read recent posts; follow "Next page →" for more. 170 185 - Fetch ${base}/profile/:handle/post/:rkey/thread to get a complete multi-post thread. 171 186 - Fetch ${base}/search?q=TOPIC to discover posts about a topic without knowing any handles. 187 + - Fetch ${base}/links?url=DOMAIN to find all posts linking to a website or specific URL. 172 188 - All responses are plain Markdown — strip formatting or feed directly into context windows. 173 189 - No rate limiting is imposed by this API, but Bluesky's public API may throttle heavy use. 174 190 `
+1
app/page.tsx
··· 80 80 { path: '/profile/:handle/followers', desc: 'Follower list', example: '/profile/bsky.app/followers' }, 81 81 { path: '/profile/:handle/following', desc: 'Following list', example: '/profile/bsky.app/following' }, 82 82 { path: '/search?q=:query', desc: 'Full-text post search', example: '/search?q=atproto' }, 83 + { path: '/links?url=:url', desc: 'Posts linking to a URL/domain', example: '/links?url=theverge.com' }, 83 84 { path: '/trending', desc: 'Trending topics right now', example: '/trending' }, 84 85 { path: '/llms.txt', desc: 'Machine-readable API guide', example: '/llms.txt' }, 85 86 ]
+1 -1
app/profile/[handle]/posts/route.ts
··· 11 11 const { handle } = await params 12 12 const url = new URL(req.url) 13 13 const cursor = url.searchParams.get('cursor') ?? undefined 14 - const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '20', 10), 100) 14 + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10), 100) 15 15 16 16 const page = await getFeed(handle, cursor, limit) 17 17 const md = renderFeed(handle, page, baseUrl(req), cursor)
+6
app/skill.md/route.ts
··· 93 93 \`\`\` 94 94 Live list of trending topics with links to search each one. 95 95 96 + ### Posts linking to a URL or domain 97 + \`\`\` 98 + GET ${base}/links?url=:url 99 + \`\`\` 100 + All public posts that link to a given URL or domain (e.g. \`?url=theverge.com\` or \`?url=https://example.com/article\`). 101 + 96 102 ## Parsing bsky.app URLs 97 103 98 104 When the user pastes a bsky.app URL, convert it:
+20 -1
lib/bsky.ts
··· 170 170 export async function getFeed( 171 171 handle: string, 172 172 cursor?: string, 173 - limit = 20, 173 + limit = 50, 174 174 ): Promise<FeedPage> { 175 175 const res = await agent.getAuthorFeed({ 176 176 actor: handle, ··· 252 252 ): Promise<SearchPage> { 253 253 const res = await searchAgent.app.bsky.feed.searchPosts({ 254 254 q: query, 255 + cursor, 256 + limit: Math.min(limit, 100), 257 + }) 258 + 259 + return { 260 + posts: res.data.posts.map((post) => postViewToPostData(post)), 261 + cursor: res.data.cursor, 262 + hitsTotal: res.data.hitsTotal, 263 + } 264 + } 265 + 266 + export async function searchPostsByUrl( 267 + url: string, 268 + cursor?: string, 269 + limit = 50, 270 + ): Promise<SearchPage> { 271 + const res = await searchAgent.app.bsky.feed.searchPosts({ 272 + q: '*', 273 + url, 255 274 cursor, 256 275 limit: Math.min(limit, 100), 257 276 })
+39
lib/markdown.ts
··· 510 510 return lines.join('\n') 511 511 } 512 512 513 + // ─── Links / URL search ─────────────────────────────────────────────────────── 514 + 515 + export function renderLinks(url: string, page: SearchPage, baseUrl: string): string { 516 + const lines: string[] = [] 517 + 518 + lines.push(`# Posts linking to ${escapeMarkdown(url)}`) 519 + lines.push('') 520 + if (page.hitsTotal !== undefined) { 521 + lines.push(`*${formatNumber(page.hitsTotal)} total results*`) 522 + lines.push('') 523 + } 524 + 525 + if (page.posts.length === 0) { 526 + lines.push('*No posts found.*') 527 + return lines.join('\n') 528 + } 529 + 530 + for (const post of page.posts) { 531 + lines.push(hr()) 532 + lines.push(renderPostBlock(post)) 533 + lines.push('') 534 + lines.push( 535 + `[View post](${baseUrl}${apiPostUrl(post.author.handle, post.rkey)}) · [View thread](${baseUrl}${apiThreadUrl(post.author.handle, post.rkey)}) · [View on Bluesky](${bskyPostUrl(post.author.handle, post.rkey)})`, 536 + ) 537 + } 538 + 539 + lines.push(hr()) 540 + 541 + if (page.cursor) { 542 + lines.push( 543 + `[Next page →](${baseUrl}/links?url=${encodeURIComponent(url)}&cursor=${encodeURIComponent(page.cursor)})`, 544 + ) 545 + } else { 546 + lines.push('*End of results.*') 547 + } 548 + 549 + return lines.join('\n') 550 + } 551 + 513 552 // ─── Custom Feed ────────────────────────────────────────────────────────────── 514 553 515 554 export function renderCustomFeed(