Vibe code a Buffer clone

Vibe code a Buffer clone with a draft queue, one network, and a cron that publishes once. Follow the prompts, check each step, and skip the analytics suite.

  • guide
  • vibe-coding
  • buffer

You can vibe code a Buffer clone as a draft queue and one publisher. Buffer is that product. The catalog line is “A queue, a calendar, and your own terrible posts.”

You pay $18–$36 a month. That is $216–$432 a year. The verdict is “Worth a Saturday”. The first estimate is 8 hours: a post queue, one network, scheduled publish.

I already wrote the teardown, Vibe code your own Buffer. This guide is the walk. Prompts in order, then what to look at.

The audience is one creator publishing to two social accounts. If a team already lives in ten channels, approvals, and a shared analytics board, keep paying. The same v1 also replaces Typefully, Hypefury, and Later.

Postiz is the cross-post cousin at $29–$99 a month. Build that after one channel stays clean for a week. If the question is “did anyone see the launch tweet”, that is Plausible at $9–$19. I wrote about how Plausible works.

Vibe code a Buffer clone without every network

The Buffer page splits the work into three rows.

The first row is the one you want. A draft. A dueAt. A cron that sends. A log that says sent or failed. That is the 8-hour v1.

The second row is 3 days. Calendar drag-reschedule, previews, retries, a second network. Still your accounts.

The third row is a month and up. Every network API, approvals, analytics, AI writing. Skip it.

If you tell an agent to “clone Buffer”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 8 hours.

const channel = {
  id: 'ch-x-flavio',
  service: 'x',
  handle: '@flaviocopes',
  health: 'ok',
}

const post = {
  id: 'post-queue-27',
  channelId: 'ch-x-flavio',
  text: 'New post: vibe code a Buffer clone as a queue.',
  dueAt: '2026-10-27T16:00:00.000Z',
  status: 'queued',
  idempotencyKey: 'pub-post-queue-27',
}

const attempt = {
  postId: 'post-queue-27',
  startedAt: '2026-10-27T16:00:02.000Z',
  result: 'sent',
  remoteId: '1982400111222333444',
}

A channel, a post with five states (draft, queued, sending, published, failed), and one publish attempt. The extra sending state is the claim. Two overlapping crons cannot both send post-queue-27.

The card also lists MediaAsset. One optional image can sit in R2. Previews and character warnings wait until Tuesday.

channels.id is stable. posts.due_at is UTC. publish_attempts belong to one post. The idempotency key is born when the post enters the queue, not when the worker wakes up.

CHANNEL X, @flaviocopes Encrypted tokens, health ok QUEUED POST post-queue-27, due 16:00 UTC draft, queued, sending, published, or failed PUBLISH ATTEMPT Claim, then call the network sent, failed, or unknown Same key never sends twice
One channel. One due time. The attempt row is the receipt.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Buffer is Astro, Workers, and R2.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. Workers are there because a drafts page is not a scheduler. R2 holds the one image if the network wants a public URL. D1 holds the channel, the posts, and the attempts.

A TanStack Start Buffer still needs a scheduled job and private files. Do not invent a second product to host the queue.

Open Build your own Buffer. The guardrail is the useful sentence: encrypt provider tokens and make publishing jobs idempotent. The non-goal sits next to it. No AI writing, social listening, approval workflows, or every network in v1.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the calendar waits. Pick one stack and stay on it.

Prompt 1. Scaffold Queuebird and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 8-hour row only. Post queue,
one network, scheduled publish.

Project name: Queuebird.
Public URL is https://queue.flaviocopes.com.
Owner is Flavio. There are no team seats.

First network is X. Handle @flaviocopes.
Locally, DRY_RUN=1 prints the post to
the log and does not call the network.

Do not add a second network, a drag
calendar, previews, or retries yet.
Do not add AI writing, social listening,
approvals, or an analytics dashboard.

When the plan is done, stop and wait. Do
not write implementation code until I say
go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /channels for the one connection
  • /compose for the draft
  • /queue for upcoming posts
  • /log for attempts

You want a channels table and a posts table. publish_attempts can wait one prompt. post_variants should not appear. metrics should not appear.

If the plan includes Instagram carousels, an approval inbox, or a reactions chart, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see empty channel, compose, and queue screens.

Open the migrations. posts.due_at is a UTC timestamp. posts.status is a string you will restrict in prompt 3. There is no impressions column.

If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say tokens stay encrypted on the server, and a publish job claims a row before it calls the network.

Prompt 2. Connect one channel and hide the tokens

The publisher is a guest on someone else’s platform. Tokens expire or get revoked, and a token that worked in March can be a 403 in April, which is why the card encrypts them on day one.

The later mini-prompt is “When the token dies, pause the queue, not the posts”. Steal the storage rule tonight. Refresh server-side. Never ship the secret to the browser.

Build the X channel for Queuebird on
https://queue.flaviocopes.com.

One channel row:
- id ch-x-flavio
- service x
- handle @flaviocopes
- health ok | paused
- tokenExpiresAt

Use the official OAuth flow for X.
Store access_token and refresh_token
encrypted at rest with
QUEUEBIRD_TOKEN_KEY. Decrypt only
inside the Worker.

Tokens never appear in HTML, logs,
error messages, or client JS.

A /channels page shows handle, health,
and last refresh time. Disconnect
clears the secrets. Reconnect starts
OAuth again.

Locally, a .env fixture can seed the
encrypted tokens so Flavio can queue
without clicking OAuth on localhost.

Do not add a second service.
Do not add team members who share
this connection.

Done when /channels shows @flaviocopes,
the raw token is not in the page
source, and a missing QUEUEBIRD_TOKEN_KEY
refuses to boot.
async function saveChannel(db, vault, input) {
  return db.insertChannel({
    id: 'ch-x-flavio',
    service: 'x',
    handle: '@flaviocopes',
    accessToken: await vault.encrypt(input.accessToken),
    refreshToken: await vault.encrypt(input.refreshToken),
    tokenExpiresAt: input.tokenExpiresAt,
    health: 'ok',
  })
}

The vault is a server-only helper. The page reads handle and health. It never reads the ciphertext.

Refresh before the POST, not after a 401. Buffer’s own access tokens die in an hour. Social networks expire tokens the same way. The teardown walks that OAuth guide. Your clone talks to X, not to Buffer, but the hour-long token is the same class of problem.

What to check after the channel lands

Open /channels. You see @flaviocopes and ok.

View source. Search for Bearer and AAAA. Nothing.

Open the channels row in D1. access_token is not the fixture string from .env. It is ciphertext.

Unset QUEUEBIRD_TOKEN_KEY and start the app. It should refuse. A Worker that boots without the key will store tokens in plaintext the first time you forget.

If the agent put the token in localStorage, delete that path. The capability line is a Worker.

Prompt 3. Write the draft and put it in the queue

Scheduling is “which row is due”. Sending is a later prompt. Keep them apart.

The owner writes text, picks a time, and the row becomes queued. addToQueue can fill the next hourly slot if you want. An exact dueAt is enough for Saturday.

Build compose and the queue for
Queuebird on https://queue.flaviocopes.com.

Compose fields: text, optional link,
optional one image, dueAt.

Text is required. Max 280 characters
for X tonight. A live counter can wait
for the 3-day row. Reject an empty
body on the server.

dueAt is stored as UTC. The form
shows Europe/Rome. A 17:00 local
slot becomes 16:00 UTC on 27 Oct
2026 (CET is UTC+1 after 25 Oct).

Status starts as draft. Queue sets
status to queued, writes dueAt, and
mints idempotencyKey
pub-{postId}. That key never changes.

Optional image goes to
media/{postId}/original.jpg in a
private R2 bucket. Do not enable a
public r2.dev hostname. The publish
job can mint a short-lived URL later
if the network needs one.

/queue lists upcoming posts by dueAt.
Edit text and time while status is
queued. Cancel returns the row to
draft and clears dueAt.

Seed post-queue-27:
text New post: vibe code a Buffer
clone as a queue.
dueAt 2026-10-27T16:00:00.000Z
channel ch-x-flavio

Do not add drag-to-reschedule.
Do not add per-network variants.

Done when I can save a draft, queue
it, reload, see 17:00 Europe/Rome,
and the D1 row still says 16:00 UTC.

A long URL in the tweet is a different job. Shorten it first with the link shortener guide if you care about the click count. Compress a heavy PNG with the TinyPNG guide before you upload.

function queuePost(post, dueAt) {
  if (post.text.trim() === '') return null
  return {
    ...post,
    dueAt,
    status: 'queued',
    idempotencyKey: `pub-${post.id}`,
  }
}

queuePost({
  id: 'post-queue-27',
  text: 'New post: vibe code a Buffer clone as a queue.',
}, '2026-10-27T16:00:00.000Z')

The key is minted here. A worker that invents a new key on every retry will post twice.

function labelDue(iso, timeZone) {
  const clock = new Intl.DateTimeFormat('en-GB', {
    timeZone,
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).format(new Date(iso))
  return `${clock} ${timeZone}`
}

labelDue('2026-10-27T16:00:00.000Z', 'Europe/Rome')
//'17:00 Europe/Rome'

Store UTC. Label the owner’s zone. A bare 17:00 hides which Tuesday you meant.

What to check after a row sits in the queue

Open /compose. Paste the seed sentence. Set 27 Oct 2026, 17:00, Europe/Rome. Queue it.

Reload /queue. The row is still there. The label says 17:00 Europe/Rome.

Open the D1 row. due_at is 2026-10-27T16:00:00.000Z. status is queued. idempotency_key is pub-post-queue-27.

Cancel. The row is a draft. due_at is null. Queue it again. The key is still pub-post-queue-27.

If the agent built a week calendar you can drag, stop. That is the 3-day row. The scope post already names it.

Prompt 4. Claim the row, then call the network

A minute cron finds due posts, claims one row, writes an attempt, then calls X.

The mini-prompt is “Never post twice, even when the API times out”. A timeout after the request left your Worker might mean the tweet is live. Do not blind-retry.

Build the publish job for Queuebird.

Cloudflare Cron Trigger: * * * * *
(every minute, UTC).

Claim with one SQL write:
update posts set status = 'sending'
where id = (select id from posts
where status = 'queued'
and due_at <= now
and channel health is ok
order by due_at limit 1)
returning *.

Two overlapping crons must not
claim the same id.

Write a publish_attempts row with
the existing idempotencyKey before
the network call.

DRY_RUN=1: do not call X. Store
result sent and remoteId
dry-{postId}. Print the text.

Live: refresh the token, then send
once. Success stores the remote id
and sets published. A definite
failure stores the reason and sets
failed. A timeout stores unknown
and leaves status sending.

The next run that sees unknown
asks the network whether
pub-{postId} already exists.
If yes, mark sent. If no, send
once.

/log shows post, time, result,
remote id, and a safe retry
button that only works on failed
rows. Retry does not mint a new
key.

A 401 pauses the channel, keeps
the posts, and banners
/channels. A 429 writes failed
plus retry-after. It does not
pause the channel.

Do not add a second adapter.
Do not add impression counts.

Done when I run the cron twice
on post-queue-27 and the log has
one sent row, DRY_RUN printed
the sentence once, and a forced
timeout still ends with one
dry id.
export default {
  async scheduled(controller, env) {
    const now = new Date(controller.scheduledTime).toISOString()
    const post = await env.db.prepare(`
      update posts
      set status = 'sending'
      where id = (
        select id from posts
        where status = 'queued' and due_at <= ?
        order by due_at
        limit 1
      )
      returning *
    `).bind(now).first()

    if (!post) return
    await publish(post, env)
  },
}

The update … returning is the claim. Buffer’s 2014 write-up used MongoDB findAndModify so two workers could not send the same tweet.

async function publish(post, env) {
  const prior = await env.db.getAttempt(post.id)
  if (prior?.result === 'sent') return prior

  if (prior?.result === 'unknown') {
    const found = await findRemote(post, env)
    if (found) {
      await env.db.finishAttempt(post.id, {
        result: 'sent',
        remoteId: found.id,
      })
      await env.db.setStatus(post.id, 'published')
      return found
    }
  }

  await env.db.startAttempt({
    postId: post.id,
    idempotencyKey: post.idempotencyKey,
    startedAt: new Date().toISOString(),
  })

  try {
    const remote = await sendOnce(post, env)
    await env.db.finishAttempt(post.id, {
      result: 'sent',
      remoteId: remote.id,
    })
    await env.db.setStatus(post.id, 'published')
  } catch (error) {
    if (error.name === 'TimeoutError') {
      await env.db.finishAttempt(post.id, { result: 'unknown' })
      return
    }
    await env.db.finishAttempt(post.id, {
      result: 'failed',
      reason: error.message,
    })
    await env.db.setStatus(post.id, 'failed')
  }
}

Write the attempt, then send, so a crash mid-call has a row the next cron can see. Flipping sending back to queued from the same job lets the next minute post again.

sendOnce is one adapter. Locally it prints. In production it refreshes the token and POSTs.

What to check after the cron fires

Set DRY_RUN=1. Queue post-queue-27 for a minute ago. Trigger the scheduled handler twice.

/queue no longer lists it. /log has one sent row. remoteId is dry-post-queue-27. The terminal printed the sentence once.

Force a timeout on the first send. /log says unknown. Status is still sending. Trigger again. One sent row. Still one dry id.

Revoke the fixture token and turn DRY_RUN off. The next due post fails as authorization. /channels says paused. The queued rows are still there. Nothing deleted.

If the agent retried a timeout by POSTing again, send prompt 4 back, because that path publishes the same sentence twice.

Failures the agent ships anyway

A drafts page with no cron

Saving text is a notes app. The capability line is Workers and a scheduled publish. If wrangler.jsonc has no trigger, you built half the product. The hosting post is the binding, then the cron, then the domain.

Blind retry after a timeout

The request left. The Worker died. The tweet may already be live. Mark unknown. Ask the network. Then decide.

A new idempotency key on every attempt

pub-post-queue-27-try-2 is how the same sentence lands twice. Mint the key when you queue. Keep it.

Tokens in the browser

OAuth “for simplicity” in a client component will leak the refresh token into the page and the analytics script. Encrypt on the Worker. Render the handle.

Two networks on Saturday

Instagram wants a public image URL, a container, a second publish call, and a rolling cap. The teardown already walked Meta’s publishing docs. That is the skip row. Postiz is the card if you want two networks on purpose.

A reactions chart

Once a post is sent, each network reports likes with different names. Buffer normalizes those onto Post.metrics. That is the other product. If the rent is “which tweet became a customer”, that is DataFast, $9–$49 a month, about 10 hours.

If the agent starts an approval inbox or an AI caption box, paste the non-goal and delete it.

Deploy the queue

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Queuebird wants Astro, Workers, and R2. Put it on a subdomain you already own, queue.flaviocopes.com. D1 holds the tables. The Cron Trigger is the publisher. R2 holds the optional image.

The hosting post is that path: the adapter, then the bindings, then the custom domain. Buffer is the example on that page that still has to publish later.

Attach the domain on the Worker. Add the cron in wrangler.jsonc. DRY_RUN stays off in production only after you have watched one real tweet land from a due row.

Ship with the seed channel. Queue a post for two minutes from now. Watch /log. Confirm the tweet exists on X. Confirm a second cron did not post it again.

Take a D1 backup before you delete the paid account. The refresh token is the key to an account people can see, so treat that backup like a password.

Use it as the real queue for a week, not as a demo. If you miss a drag calendar or a second network, those are the 3-day row and the other mini-prompts on the card. Add them after a week of clean logs.

Then cancel Buffer.

What to skip on purpose

Leave out every extra network API, approval workflows, analytics, and AI writing.

8-HOUR V1 Draft queue and due times One social network API Cron claim and a publish log SKIP THIS HALF Every network API Approvals and analytics AI writing and listening
Ship the queue and one publisher. Leave the network zoo on the skip row.

A second network comes after the first log stays clean for a week. A calendar can wait until you miss a drag. I would not build a first-comment composer or a reel uploader.

Hourly slots are enough. Recurring Mon/Wed/Fri times are the other mini-prompt on the card, “The queue that always knows its next open slot”. Add it when you are tired of typing dueAt.

A missed cron is a different job. That is Cronitor, $10–$49 a month, about 6 hours. Heartbeats, not a social queue.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Buffer. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: post queue, one network, scheduled publish. Eight hours.

Queue one real tweet tonight. Watch the log. If it sends once, you can cancel.

Related articles

Read all articles →