· 6 min read
How to build a Buffer clone in a weekend (with Hapi as the backend)
Build your own Buffer-style queue scheduler: posting slots, a queue that fills itself, and publishing to X, LinkedIn and Bluesky. About 120 lines of Node, with Hapi as the social infrastructure.
Buffer's big idea is the queue: you set posting slots once ("weekdays at 9:00, 12:30 and 17:00"), then just write. Each new post drops into the next free slot.
That idea is about 50 lines of code. The hard part of a scheduler is everything underneath: OAuth for every network, token refresh, media rules, publishing on time, retries. That's what Hapi does. You build the product; hapi.tools is the infrastructure.
By the end you'll have:
- A composer that posts to X, LinkedIn and Bluesky (plus Discord, Slack and Telegram) at once
- Add to queue, which puts each post in the next free slot, in your timezone, daylight saving included
- A queue view with cancel buttons
- Password protection so you can share it with your team or clients
It's one file, Node 22 or later, no dependencies.
1. Set up Hapi
- Start a Hapi trial (15 days for $5).
- Connect your accounts under Accounts: X and LinkedIn with one click (OAuth), Bluesky with an app password.
- Create an API key under API keys and keep it handy.
2. The queue logic
Your posting schedule is just data:
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // Mon–FriTo place a post you need two things: the upcoming slot times in your timezone, and which of them are already taken. Taken slots come straight from Hapi, since every scheduled post has a scheduledAt. So the clone doesn't need its own database.
The one subtle bit is turning "09:00 in New York" into an exact moment, because the offset changes with daylight saving. Intl.DateTimeFormat can do it without a library:
function zonedTime(y, m, d, hh, mm) {
const guess = Date.UTC(y, m - 1, d, hh, mm);
const p = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
.formatToParts(new Date(guess))
.map((x) => [x.type, x.value]),
);
const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
return new Date(guess - (shownAs - guess)); // shift by the zone's offset on that date
}3. The whole app
// buffer-clone.mjs: a Buffer-style queue scheduler built on Hapi.
// Run: HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node buffer-clone.mjs
import http from "node:http";
const API = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, APP_PASSWORD, PORT = 3000 } = process.env;
const TZ = process.env.QUEUE_TZ ?? "America/New_York";
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // 1 = Monday … 7 = Sunday
async function hapi(path, options = {}) {
const res = await fetch(API + path, {
...options,
headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json" },
});
if (res.status === 204) return null;
const body = await res.json();
if (!res.ok) throw new Error(body.error?.message ?? `Hapi returned ${res.status}`);
return body;
}
// "09:00 on 2026-11-20 in TZ" → the exact moment, daylight saving included.
function zonedTime(y, m, d, hh, mm) {
const guess = Date.UTC(y, m - 1, d, hh, mm);
const p = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
.formatToParts(new Date(guess))
.map((x) => [x.type, x.value]),
);
const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
return new Date(guess - (shownAs - guess));
}
function* upcomingSlots(after) {
const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(after).split("-").map(Number);
for (let i = 0; i < 60; i++) {
const day = new Date(Date.UTC(y, m - 1, d + i));
if (!SLOTS.days.includes(day.getUTCDay() || 7)) continue;
for (const time of SLOTS.times) {
const [hh, mm] = time.split(":").map(Number);
const at = zonedTime(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm);
if (at > after) yield at;
}
}
}
async function nextFreeSlot() {
const { data } = await hapi("/posts?status=scheduled&limit=100");
const taken = new Set(data.map((p) => new Date(p.scheduledAt).getTime()));
for (const at of upcomingSlots(new Date(Date.now() + 60_000))) {
if (!taken.has(at.getTime())) return at;
}
throw new Error("The queue is full for the next 60 days");
}
const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
const when = (iso) => new Date(iso).toLocaleString("en-US", { timeZone: TZ, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
async function page(message = "") {
const [{ data: accounts }, { data: queued }] = await Promise.all([hapi("/accounts"), hapi("/posts?status=scheduled&limit=100")]);
queued.sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt));
return `<!doctype html><meta charset="utf-8"><title>Queue</title>
<style>body{font:16px system-ui;max-width:640px;margin:40px auto;padding:0 16px}textarea{width:100%;height:110px}li{margin:10px 0}small{color:#666}</style>
<h1>Queue</h1>${message ? `<p><b>${esc(message)}</b></p>` : ""}
<form method="post" action="/post">
<textarea name="content" required placeholder="What do you want to share?"></textarea>
<p>${accounts.map((a) => `<label><input type="checkbox" name="accountId" value="${esc(a.id)}" checked> ${esc(a.platform)} · ${esc(a.username ?? a.displayName)}</label>`).join("<br>")}</p>
<button name="action" value="queue">Add to queue</button> <button name="action" value="now">Post now</button>
</form>
<h2>Up next <small>(${esc(TZ)})</small></h2>
<ul>${queued.map((p) => `<li><small>${esc(when(p.scheduledAt))}</small><br>${esc(p.content)}
<form method="post" action="/cancel/${esc(p.id)}" style="display:inline"><button>Remove</button></form></li>`).join("") || "<li>Your queue is empty.</li>"}</ul>`;
}
function readBody(req) {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => (data += chunk));
req.on("end", () => resolve(data));
});
}
http
.createServer(async (req, res) => {
// One shared password keeps the app, and your API key, private.
const encoded = (req.headers.authorization ?? "").split(" ")[1] ?? "";
const password = Buffer.from(encoded, "base64").toString().split(":").slice(1).join(":");
if (!APP_PASSWORD || password !== APP_PASSWORD) {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="queue"' }).end("Password required");
return;
}
let message = "";
try {
if (req.method === "POST" && req.url === "/post") {
const form = new URLSearchParams(await readBody(req));
const scheduledAt = form.get("action") === "now" ? null : (await nextFreeSlot()).toISOString();
await hapi("/posts", {
method: "POST",
body: JSON.stringify({ content: form.get("content"), accountIds: form.getAll("accountId"), scheduledAt }),
});
message = scheduledAt ? `Queued for ${when(scheduledAt)} ✓` : "Posted ✓";
} else if (req.method === "POST" && req.url.startsWith("/cancel/")) {
await hapi(`/posts/${encodeURIComponent(req.url.slice("/cancel/".length))}`, { method: "DELETE" });
message = "Removed from the queue.";
}
} catch (err) {
message = `Error: ${err.message}`;
}
try {
const html = await page(message);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html);
} catch (err) {
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(`Couldn't reach Hapi: ${err.message}`);
}
})
.listen(PORT, () => console.log(`Your Buffer clone is on http://localhost:${PORT}`));Run it:
HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one QUEUE_TZ=Europe/Berlin node buffer-clone.mjsWrite three posts, hit Add to queue three times, and they line up in the next three slots. Remove one, and the next post you add fills the gap.
What Hapi handled for you
Look at what's not in those 120 lines:
- OAuth flows and token refresh for X and LinkedIn, and Bluesky sessions
- Character limits, media rules and per-network validation (Hapi rejects a post that's too long before it's queued)
- A job runner that publishes at exactly the right minute
- Retries, and never double-posting when something retries (send an
Idempotency-Keyheader) - Encrypted credential storage
That's the infrastructure. It's also why most "build your own Buffer" weekends turn into months.
Make it a real product
- Per-network copy: pass
platformOptionsfor a longer LinkedIn version and a 300-character Bluesky one. - Images: upload with
POST /v1/media, then sendmediaIdswith the post. - Failure alerts: register a webhook for
post.failedand show a red badge in the queue. - Drafts and approvals: create with
"draft": true, approve withPATCH /v1/posts/{id}. - AI writing: let Claude draft posts through Hapi's MCP server (
https://hapi.tools/mcp), and drop the good ones in the queue. - Clients: on Hapi Pro, give each client their own profile and a key restricted to it, then run one queue per client.
Who can use it
As built here, the clone runs on your Hapi workspace: the accounts are the ones you connect in the Hapi dashboard. That's perfect for your own brand, your team, or an agency running clients' accounts. Turning it into a public SaaS where strangers sign up and connect their own accounts needs account connection through the API, which Hapi doesn't offer yet.
Build yours: 15 days for $5 → Five accounts and 300 posts: plenty to build the clone and run it for real.
Buffer is a trademark of its owner. This tutorial isn't affiliated with or endorsed by Buffer; "Buffer clone" describes the queue-scheduling workflow it recreates.