TanStack Start
Publish to social platforms from TanStack Start server functions and API routes with Social SDK, keeping credentials in server-only code.
To post to social media from TanStack Start, create the Social SDK client in a server-only module and call social.posts.publish from a server function made with createServerFn. Tokens and account lookups never reach the browser bundle, and the draft ID is the idempotency key, so with a durable idempotency store a retried call does not post twice. Publish from a server function shows the code. For scheduled posts, have a durable job runner such as Trigger.dev or Inngest run the same publish call at the publish time.
TanStack Start runs its server functions and server routes on Node, so Social SDK slots in directly: construct the client in server-only code, call it from server functions, and keep webhook verification on the raw request.
Install
npm install @opencoredev/social-sdkpnpm add @opencoredev/social-sdkyarn add @opencoredev/social-sdkbun add @opencoredev/social-sdknub add @opencoredev/social-sdkaube add @opencoredev/social-sdkCreate the client in server code
An idempotencyKey only stops a retried call from posting twice when the client has an idempotencyStore. Give it a durable store that every server instance shares. The Neon guide includes a Postgres implementation.
import { createSocial } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { idempotencyStore } from "./stores"; // your durable IdempotencyStore
export const social = createSocial({
backend: bluesky({
auth: {
service: process.env.BLUESKY_SERVICE!,
did: process.env.BLUESKY_DID!,
accessJwt: process.env.BLUESKY_ACCESS_JWT!,
},
}),
idempotencyStore,
});
This client holds one Bluesky session, so it can only publish as BLUESKY_DID. The adapter rejects a reference to any other account. To publish for several accounts, load each account’s credentials from your encrypted credential store and build a client for that account. See authentication.
Publish from a server function
Server functions execute only on the server, so credentials and account references never reach the client bundle:
import { createServerFn } from "@tanstack/react-start";
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { social } from "../server/social";
export const publishPost = createServerFn({ method: "POST" })
.validator((data: { text: string; draftId: string }) => data)
.handler(async ({ data }) => {
const tenant = await requireTenant(); // your auth
const accountId = await resolveAuthorizedAccount(tenant); // your tenant check
const result = await social.posts.publish(
{
targets: [
{
account: connectedAccountRef({
backend: "default",
platform: "bluesky",
accountId,
}),
},
],
content: { text: data.text },
idempotencyKey: data.draftId,
},
// Scopes the idempotency key to this tenant, so tenants can't collide on a draft ID.
{ authorization: { tenantId: tenant.id } },
);
return {
status: result.status,
outcomes: result.outcomes.map((o) => ({ state: o.state })),
};
});
Resolve the account from your own tenant data inside the handler. Client-supplied account IDs must never select the publishing account.
If a retry returns unknown, the first attempt may have posted. Stop there and check the account before you publish again, even under a new draft ID. See idempotency.
Webhooks through a server route
This route receives Zernio webhooks, so it only applies when you publish through the Zernio managed backend. Bluesky itself sends no webhooks; its events arrive over WebSocket streams, as the events guide explains.
Server route handlers receive a web-standard Request, so verification reads the raw bytes and headers directly. The route reads the body before anything is authenticated, so cap it while reading: request.arrayBuffer() buffers the whole body first, and a Content-Length check alone misses chunked requests. Use the readLimited helper from the Next.js guide, which stops at the verifier’s 1 MiB default:
import { createFileRoute } from "@tanstack/react-router";
import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";
import { readLimited } from "../../server/read-limited";
export const Route = createFileRoute("/api/events")({
server: {
handlers: {
POST: async ({ request }) => {
const body = await readLimited(request, 1024 * 1024);
if (!body) return new Response("payload too large", { status: 413 });
try {
await verifyZernioWebhook({
secret: process.env.ZERNIO_WEBHOOK_SECRET!,
headers: request.headers,
body,
});
} catch (error) {
if (error instanceof SocialError && error.code === "unauthorized") {
return new Response("unauthorized", { status: 401 });
}
throw error;
}
// decode, resolve tenants, accept into your durable inbox
return new Response("accepted", { status: 202 });
},
},
},
});
Follow the events guide for decoding, tenant quarantine, and the durable inbox worker.
Notes
- Deploy on a Node 22.12+ or Node 24 target; the SDK is ESM-only. Edge presets are not tested SDK runtimes.
- Keep the client module out of route components; only server functions and server routes may import it.
- Scheduled publishing belongs to a durable job runner such as Trigger.dev or Inngest.