Schedule & publish to LinkedIn and Instagram for every client — from one calm dashboard.
A multi-client social media scheduler for agencies and solo social managers. One person runs the content for 15+ clients across LinkedIn and Instagram, all from a single workspace — engineered end-to-end to run on free tiers.
- Why Cue
- Features
- Tech stack
- Architecture
- Data model
- Getting started
- Environment variables
- Going live
- Deploy to Vercel + cron
- Project structure
- Scripts
- Design decisions
- Roadmap
Agencies juggling many clients usually pay per-seat for a tool like Buffer or Hootsuite, then pay again for every extra brand. Cue is a self-hostable alternative built around a single operator managing many clients:
- Multi-client by design — clients are first-class workspaces, each with its own connected accounts, brand color, and queue.
- Two platforms that matter for B2B + brand — LinkedIn and Instagram, done properly.
- Free to run — Supabase, Cloudflare R2, GitHub Actions and Vercel free tiers cover the whole stack. The only paid pieces are optional (X/Twitter API, AI assists).
- Privacy-minded retention — post text and media are purged after 7 days; a permanent, lightweight history row (client, platform, permalink, published date) survives for the record.
- App shell — Buffer-style sidebar + topbar, fully branded with the Cue logo and palette, dark/light aware.
- Dashboard — at-a-glance stats and upcoming posts across every client.
- Composer — pick a client and target accounts, write once, attach media, then Schedule, Post now, or Save draft, with a live per-platform preview.
- Calendar — month grid of scheduled posts, color-coded per client.
- Queue — filter by status, retry failed targets, delete, and jump to live permalinks.
- Clients — add clients, connect their LinkedIn / Instagram accounts via OAuth, and monitor connection health.
- Settings — account details and integration-readiness checks.
- Scheduling engine — atomically claims due targets, publishes per account, retries up to
3×, rolls each post up to an overall status, and writes a permanent
PostHistoryrecord. - Publish adapters — LinkedIn Posts API and Instagram Graph API (via Instagram Login — no Facebook Page required).
- Cron endpoints —
/api/cron/publish,/api/cron/keepalive(warms the DB + refreshes tokens), and/api/cron/cleanup(7-day purge + R2 object delete), all Bearer-secured. - GitHub Actions — publish (every 5 min), keepalive (every 5 days), cleanup (daily).
- Token encryption at rest — OAuth access/refresh tokens are encrypted with
TOKEN_ENC_KEY. - Zero-credential dev mode — runs locally without any secrets by auto-logging in as a seeded admin, so the whole UI is browsable before you wire a single integration.
| Layer | Choice | Notes |
|---|---|---|
| Framework | Next.js 16 (App Router) + React 19 | Server Components, server actions |
| Language | TypeScript 5 | strict |
| Database | Supabase (Postgres) | pooled URL for app, direct URL for migrations |
| ORM | Prisma 6 | classic url / directUrl (not v7 driver adapters) |
| Auth | Supabase Auth (@supabase/ssr) |
dev mode bypasses with a seeded admin |
| Media storage | Cloudflare R2 (S3 API) | public bucket, keys purged on cleanup |
| Scheduling | GitHub Actions cron → secured API routes | free, no always-on server |
| UI | shadcn/ui (on @base-ui/react) + Tailwind 4 |
render prop, not asChild |
| Motion | Framer Motion | |
| Validation | Zod 4 | |
| Hosting | Vercel (free tier) |
┌──────────────────────────────────────────────┐
│ Next.js app │
Browser ────► │ App Router · Server Components · Actions │
│ Composer · Calendar · Queue · Clients │
└───────┬───────────────────────┬──────────────┘
│ │
Prisma │ │ S3 API
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Supabase PG │ │ Cloudflare R2 │
│ posts/queue │ │ media │
└───────────────┘ └───────────────┘
▲
Bearer-secured │ /api/cron/*
┌─────────────────────┴───────────────────────┐
│ GitHub Actions cron │
│ publish (5m) · keepalive (5d) · cleanup (1d)│
└───────────────────────┬──────────────────────┘
│ publish adapters
▼
┌───────────────────────────┐
│ LinkedIn API · IG Graph │
└───────────────────────────┘
Publish flow: a post fans out into one PostTarget per connected account. The publish cron
claims due targets, hands each to its platform adapter, records externalPostId + permalink,
retries failures (up to 3×), and rolls the parent post up to PUBLISHED / PARTIAL / FAILED.
Keepalive cron pings the DB so Supabase's free tier never hits its 7-day inactivity pause, and refreshes OAuth tokens before they expire.
Cleanup cron purges post text + media older than 7 days (and deletes the R2 objects), while
copying the essentials into the permanent PostHistory table first.
Defined in prisma/schema.prisma:
- User — team members (
ADMIN/MANAGER). - Client — a managed brand/workspace (name, logo, brand color).
- SocialAccount — a connected LinkedIn/Instagram account under a client; stores encrypted access/refresh tokens and expiry.
- Post — the composed content for a client (
DRAFT → SCHEDULED → PUBLISHING → PUBLISHED / PARTIAL / FAILED). - PostTarget — one publish attempt per account, with its own status, external id, permalink, error, and attempt count.
- MediaAsset — image/video stored in R2 (public URL + storage key).
- Comment — internal collaboration notes on a post.
- PostHistory — permanent, lightweight record that survives the 7-day purge (no FK to Post), with optional metrics fields reserved for future analytics.
Requirements: Node 20+, npm.
git clone https://github.com/Karanjoshi128/Cue.git
cd Cue
npm install
npm run dev # http://localhost:3000Dev mode runs without any credentials — it auto-logs in as a seeded admin so you can browse the entire UI immediately. Wire the integrations below when you're ready to publish for real.
Copy the template and fill it in — every key is documented in .env.example:
cp .env.example .env| Group | Keys |
|---|---|
| Database | DATABASE_URL (pooled, 6543) · DIRECT_URL (direct, 5432) |
| Supabase Auth | NEXT_PUBLIC_SUPABASE_URL · NEXT_PUBLIC_SUPABASE_ANON_KEY · SUPABASE_SERVICE_ROLE_KEY |
| Cloudflare R2 | R2_ACCOUNT_ID · R2_ACCESS_KEY_ID · R2_SECRET_ACCESS_KEY · R2_BUCKET · R2_PUBLIC_URL |
| Security | TOKEN_ENC_KEY (32 bytes) · CRON_SECRET (shared with GitHub Actions) |
LINKEDIN_CLIENT_ID · LINKEDIN_CLIENT_SECRET |
|
| Meta / Instagram | META_APP_ID · META_APP_SECRET |
| App | APP_URL |
Generate the secrets:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # TOKEN_ENC_KEY
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" # CRON_SECRET- Supabase — create a project, then set the pooled
DATABASE_URL(port 6543), theDIRECT_URL(port 5432), and theNEXT_PUBLIC_SUPABASE_*+SUPABASE_SERVICE_ROLE_KEY. - Secrets — generate
TOKEN_ENC_KEYandCRON_SECRET(commands above). - Cloudflare R2 — create a bucket and an API token, enable public access, fill
R2_*. - LinkedIn & Meta apps — create dev apps, set the redirect URIs to
<APP_URL>/api/oauth/{linkedin,instagram}/callback, and fill the client id/secret pairs. - Push schema + seed:
npm run db:push # create tables in Supabase npm run db:seed # demo admin + sample clients (optional)
- Run:
npm run dev→ http://localhost:3000
- Deploy the repo to Vercel and add every env var in the project settings.
- Add these GitHub repo secrets so the scheduled workflows can reach your app:
PUBLISH_URL,KEEPALIVE_URL,CLEANUP_URL(your deployed/api/cron/*URLs) andCRON_SECRET(the same value as the app). - The workflows in
.github/workflows/then fire on schedule — publish every 5 minutes, keepalive every 5 days, cleanup daily.
src/
├─ app/
│ ├─ (app)/ # authenticated shell: dashboard, composer, calendar, queue, clients, settings
│ ├─ api/
│ │ ├─ cron/ # publish · keepalive · cleanup (Bearer-secured)
│ │ ├─ oauth/ # linkedin & instagram start + callback
│ │ └─ upload/ # media upload to R2
│ ├─ auth/ · login/ · logout/
│ └─ icon / opengraph image assets
├─ components/
│ ├─ ui/ # shadcn primitives (base-ui)
│ ├─ brand/ # logo
│ └─ composer, queue-list, clients-manager, sidebar, topbar, …
└─ lib/
├─ platforms/ # linkedin + instagram adapters (+ shared types)
├─ publish.ts # scheduling engine
├─ crypto.ts # token encryption
├─ r2.ts · prisma.ts · auth.ts · cron-auth.ts
└─ supabase/ # ssr client + server helpers
prisma/ schema.prisma · seed.ts
docs/ PLAN.md · future_Scope.md
.github/ workflows/ (publish, keepalive, cleanup)
| Command | What it does |
|---|---|
npm run dev |
Start the dev server |
npm run build |
Production build (runs prisma generate first) |
npm run start |
Start the production server |
npm run lint |
ESLint |
npm run db:push |
Push the Prisma schema to the database |
npm run db:migrate |
Create/apply a dev migration |
npm run db:seed |
Seed demo admin + sample clients |
npm run db:studio |
Open Prisma Studio |
- Prisma 6, not 7 — v7's config/driver-adapter rework added too much friction for the win;
Cue uses the classic
url/directUrlsetup. - shadcn on base-ui — components use the
renderprop, notasChild. - Brand icons are inline SVGs —
lucide-reactv1 dropped brand glyphs, so platform icons live insrc/components/platform-icons.tsx. - Instagram Login (not Facebook Login) — no Facebook Page needed; the account just has to be Professional/Business/Creator. Facebook Login is kept as a fallback path.
- No Sanity CMS — posts are relational/transactional, so Postgres + Prisma fit; media lives in R2.
- DB-touching pages use
export const dynamic = "force-dynamic".
Full planning context lives in
docs/PLAN.mdanddocs/future_Scope.md.
The MVP is LinkedIn + Instagram publishing and scheduling. Staged for later (see
docs/future_Scope.md): more platforms (X/Twitter, Facebook, Threads,
YouTube, TikTok), AI caption assists, analytics on the PostHistory metrics fields, approval
workflows, and team roles beyond admin/manager.
Explicitly out of scope by decision: browser extension, native mobile apps, and a public API.