Autopilot social-media content for Instagram & LinkedIn. Give RapidMarket a business's brand kit (colors, logo, voice, photos, graphics) and it generates on-brand captions and visuals every day, then publishes them to the connected accounts on a schedule.
This README documents the project end to end: both how to run it and how it was built, layer by layer, with the decisions and trade-offs made along the way.
brand kit ──▶ AI generation ──▶ draft queue ──▶ human approval ──▶ scheduled publish
(once) (daily cron) (review) (one tap) (publish cron)
- Add a brand and fill in its brand kit + upload photos/graphics.
- A daily cron (
/api/cron/generate) generates one post per platform: AI writes the caption + hashtags + an image prompt, then an image model renders an on-brand visual (falling back to an uploaded brand photo if image generation is unavailable). - Posts land in the content queue as
drafts. You approve (or reject) them, or let it run on autopilot. - A publish cron (
/api/cron/publish) publishes approved posts whosescheduled_fortime has arrived, to Instagram (Graph API) and LinkedIn (Posts API).
- Next.js 16 (App Router, Server Actions,
proxy.tsmiddleware) + React 19 - Tailwind v4 for styling, lucide-react for icons
- Supabase: Postgres + Auth (magic link) + Storage (
brandbucket), all guarded by Row Level Security - AI SDK v6 with provider routing: Claude for copy, OpenAI/Gemini for images, routed through the Vercel AI Gateway by default
- Vercel Cron for daily generation + scheduled publishing
- Zod for structured-output schemas and validation
The project was built bottom-up: data model first, then the AI core, then the publishing adapters, then the cron orchestration, and finally the UI on top. The git history mirrors this order.
Started from Create Next App (Next 16, App Router, TypeScript, Tailwind v4). The
one project-wide rule lives in AGENTS.md: this Next.js has breaking changes
from older versions, so consult node_modules/next/dist/docs/ before writing
framework code rather than relying on memory. The most visible consequence is
that middleware is the new proxy.ts convention (src/proxy.ts), not
middleware.ts.
Everything hangs off five tables, each owned (directly or transitively) by an
auth.users row:
| Table | Purpose |
|---|---|
businesses |
One brand/tenant. Owns owner_id, timezone, daily post_time, is_active. |
brand_kits |
One row per business: colors, fonts, logo, tagline, voice, keywords, audience. |
brand_assets |
Uploaded photos / graphics / logos (used as image fallbacks). |
social_accounts |
Connected IG/LinkedIn destinations + tokens. Unique per (business, platform). |
posts |
Generated content units, with a status lifecycle (below). |
Three enums encode the domain:
platform:instagram | linkedinasset_type:logo | photo | graphicpost_status:draft→approved→scheduled→publishing→published, plusfailedandrejected
Security model. RLS is enabled on all five tables. A security definer
helper owns_business(uuid) checks owner_id = auth.uid(), and child tables
gate every operation on it. The result: a user's session can only ever read or
write its own rows, so most of the app can run on the user's session client
without a service role.
A second migration (0002_brand_storage_policies.sql) adds Storage policies for
the public brand bucket: anyone can read (so platforms can fetch image URLs),
authenticated users can write. This is what lets the interactive dashboard
upload assets and store generated images without the service role.
Types are hand-maintained in
src/lib/db/types.tsto mirror the schema (with aDatabaseshape for supabase-js). Regenerate withsupabase gen types typescriptonce the CLI is linked.
Three clients, by trust level:
server.ts: request-scoped, reads the user's cookies, enforces RLS. Used by Server Components and Server Actions.client.ts: browser client for client components (the login form), also RLS.admin.ts: service-role client that bypasses RLS. Used only in cron jobs and other trusted server contexts.
Auth is passwordless magic link. The login page calls signInWithOtp;
auth/callback/route.ts exchanges the code for a session; proxy.ts refreshes
the session on every request and redirects unauthenticated users away from
/dashboard. requireUser() / getUser() in auth.ts are the helpers the rest
of the app uses.
A single validated accessor for env vars. Two subtleties baked in from real bugs:
- Server secrets are read lazily (via getters) so importing the module in a client bundle doesn't throw; only touching a secret does.
NEXT_PUBLIC_*vars are read via static member access (process.env.NEXT_PUBLIC_SUPABASE_URL), never dynamicprocess.env[name]. Next.js only inlines the static form into the client bundle; the dynamic form resolves toundefinedin the browser. (This was a real fix, commit172fabb.)
Three files:
schema.ts: the Zod schema for one post's structured output:theme,caption,hashtags[],imagePrompt,altText.models.ts: provider resolution. Text: prefer a directANTHROPIC_API_KEY(Claude), else fall back to the AI Gateway. Image: preference order is direct Gemini, then AI Gateway, then direct OpenAI, with a "brand-photo mode" flag (RAPIDMARKET_DISABLE_AI_IMAGES) that skips image generation entirely and goes straight to an uploaded brand photo. Returns the model plus its provider family so the caller knows the right request shape.generate.ts: the actual generation.generatePostContent()usesgenerateObject()with the Zod schema and a brand-conditioned prompt (per-platform tone guidance, recent themes to avoid repetition, a strict "stay truthful, don't invent offers" system prompt), then normalizes hashtags.generatePostImage()callsgenerateImage()(Gemini takesaspectRatio, OpenAI/gateway takesize+ aqualitycapped atmediumfor cost) and returnsnullon any failure so the pipeline can fall back gracefully.
The provider flexibility here is the product of several iterations: switching
image models for cost (gpt-image-1-mini at medium quality), adding a gateway
toggle for when direct billing is capped, and adding brand-photo mode for when no
paid image provider is configured.
A small strategy pattern: a Publisher interface (publish(input) returns
{ externalId, permalink }) with one implementation per platform, selected by
getPublisher(platform).
instagram.ts: Meta Graph API Content Publishing flow: create a media container with the public image URL + caption, publish it, then best-effort fetch the permalink. Needs a long-lived Page/IG token and the IG Business account ID.linkedin.ts: LinkedIn REST Posts + Images APIs: initialize an image upload, PUT the bytes, then create a post referencing the image URN. Needs an OAuth token withw_member_socialand the author URN.
composeCaption() joins copy + hashtags consistently, and a typed PublishError
carries the platform and underlying cause.
The seam between AI, storage, and publishing, deliberately framework-agnostic (it takes a Supabase client as an argument) so it runs identically from a Server Action (user session) or a cron job (admin client).
runDailyGeneration(supabase, business): loads the brand kit + recent themes, picks platforms (connected accounts, or both by default so there's something to review), then per platform: generate copy, generate image, upload to thebrandbucket (or fall back to a brand photo), insert adraftpost scheduled for the next occurrence of the business's localpost_time.publishPost(supabase, postId): validates the post has an image and a connected account, flips status topublishing, calls the platform adapter, and records eitherpublished(+ external id, permalink) orfailed(+ error message). Failures are persisted, never thrown away.
Two GET routes, both authorized by Authorization: Bearer $CRON_SECRET (which
Vercel Cron sends automatically), both maxDuration = 300:
/api/cron/generate: for everyis_activebusiness, runrunDailyGeneration. Uses the admin client to iterate all tenants./api/cron/publish: findapprovedposts whosescheduled_for ≤ nowand publish each. Only human-approved posts are ever eligible.
Schedules live in vercel.json. Note: on the Vercel Hobby plan both crons run
daily (the publish cron can't run more frequently on Hobby, commit
88b8610); on a paid plan you'd run publish every few minutes.
All mutations are Server Actions, each guarded by assertOwner() /
requireUser() and running on the user's session client so RLS is the
backstop: createBusiness, saveBrandKit, uploadBrandAsset, generateNow
(manual trigger of the pipeline), setPostStatus (approve/reject), publishNow,
connectAccount, signOut. Each revalidatePaths the affected page.
- Landing page (
page.tsx): a full marketing site (hero, how-it-works, features, stats, FAQ, CTA) built entirely from divs/Tailwind, no images. - Dashboard:
dashboard/page.tsxlists brands + an "add brand" form;dashboard/businesses/[id]/page.tsxis the workhorse: brand-kit form, asset upload, connected-accounts panel, a "Generate today's content" button, and the content queue. post-card.tsx: renders a post with status-driven actions (approve, reject, publish now, view live).components/ui/: a small button + primitives library.
next.config.ts allowlists *.supabase.co / .in as remote image hosts so
next/image can render assets from the public bucket.
src/
app/
page.tsx Landing page
login/ Magic-link sign in
auth/callback/ Magic-link / OAuth code exchange
dashboard/ Authed app (brands, queue)
api/cron/{generate,publish}/ Cron endpoints (Bearer CRON_SECRET)
actions.ts Server actions (all mutations)
lib/
ai/{schema,models,generate}.ts Content + image generation
publish/{instagram,linkedin}.ts Platform adapters (+ types, registry)
supabase/{server,client,admin} Three clients by trust level
db/types.ts Hand-maintained DB types
env.ts Validated env access
storage.ts Brand-bucket upload helper
pipeline.ts generate -> store -> publish orchestration
proxy.ts Session refresh + dashboard guard (Next 16)
supabase/migrations/
0001_init.sql Schema, enums, RLS, storage bucket
0002_brand_storage_policies.sql Storage RLS policies
vercel.json Cron schedules
-
Create a Supabase project and run both migrations in
supabase/migrations/(SQL editor, orsupabase db push). -
Copy
.env.exampleto.env.localand fill in Supabase keys + a randomCRON_SECRET. For local AI either setAI_GATEWAY_API_KEY(Vercel AI Gateway) or a direct provider key:ANTHROPIC_API_KEYfor copy, and one ofGOOGLE_GENERATIVE_AI_API_KEY/OPENAI_API_KEYfor images (or setRAPIDMARKET_DISABLE_AI_IMAGES=trueto use brand photos only). -
Install and run:
npm install npm run dev
-
Sign in (magic link), create a brand, fill the brand kit, optionally upload a photo, and click Generate today's content to watch the full pipeline run.
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY |
yes | Public Supabase access (RLS-enforced) |
SUPABASE_SERVICE_ROLE_KEY |
yes | Cron/admin client (bypasses RLS) |
CRON_SECRET |
yes | Bearer token Vercel Cron sends to the cron routes |
AI_GATEWAY_API_KEY |
local only | Vercel AI Gateway (automatic via OIDC on Vercel) |
ANTHROPIC_API_KEY |
optional | Direct Claude key (bypasses the gateway for text) |
GOOGLE_GENERATIVE_AI_API_KEY / OPENAI_API_KEY |
optional | Direct image providers |
RAPIDMARKET_DISABLE_AI_IMAGES |
optional | true = skip AI images, use brand photos |
RAPIDMARKET_ENABLE_GATEWAY_IMAGES |
optional | Route images through the gateway |
META_* / LINKEDIN_* |
optional | App-level publishing credentials |
RAPIDMARKET_*_MODEL |
optional | Model overrides (defaults in env.ts / models.ts) |
Token onboarding is manual for now. Paste long-lived tokens in each brand's Connected accounts panel:
- Instagram: a long-lived Page/IG token + the IG Business account ID.
Requires a Meta app with
instagram_content_publish. - LinkedIn: an OAuth token with
w_member_social(or org equivalent) and the author URN (urn:li:person:…orurn:li:organization:…).
Replacing this with proper OAuth onboarding flows (using the META_* /
LINKEDIN_* env vars) is the natural next step.
Deploy to Vercel. The crons in vercel.json run automatically; set all env vars
(including CRON_SECRET) in project settings. On Vercel, AI Gateway auth is
automatic via OIDC, so no AI key is needed there. On the Hobby plan both crons
run daily; move publish to a sub-hourly schedule on a paid plan for true
scheduled posting.
- OAuth onboarding for Meta & LinkedIn to replace pasted tokens.
- Token encryption at rest:
social_accounts.access_tokenis plaintext today (noted as a TODO in the schema); move to a vault. - Sub-daily publish cron on a paid Vercel plan for real scheduled posting.
- Generated types from the Supabase CLI to replace the hand-maintained
db/types.ts.