Skip to content

Repository files navigation

RapidMarket

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.


1. What it does (the loop)

brand kit ──▶ AI generation ──▶ draft queue ──▶ human approval ──▶ scheduled publish
   (once)        (daily cron)      (review)        (one tap)         (publish cron)
  1. Add a brand and fill in its brand kit + upload photos/graphics.
  2. 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).
  3. Posts land in the content queue as drafts. You approve (or reject) them, or let it run on autopilot.
  4. A publish cron (/api/cron/publish) publishes approved posts whose scheduled_for time has arrived, to Instagram (Graph API) and LinkedIn (Posts API).

2. Stack

  • Next.js 16 (App Router, Server Actions, proxy.ts middleware) + React 19
  • Tailwind v4 for styling, lucide-react for icons
  • Supabase: Postgres + Auth (magic link) + Storage (brand bucket), 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

3. How it was built, step by step

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.

Step 0: Scaffold

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.

Step 1: Data model & multi-tenant security (supabase/migrations/0001_init.sql)

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 | linkedin
  • asset_type: logo | photo | graphic
  • post_status: draftapprovedscheduledpublishingpublished, plus failed and rejected

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.ts to mirror the schema (with a Database shape for supabase-js). Regenerate with supabase gen types typescript once the CLI is linked.

Step 2: Supabase clients & auth (src/lib/supabase/, src/lib/auth.ts, src/proxy.ts)

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.

Step 3: Environment access (src/lib/env.ts)

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 dynamic process.env[name]. Next.js only inlines the static form into the client bundle; the dynamic form resolves to undefined in the browser. (This was a real fix, commit 172fabb.)

Step 4: AI content generation (src/lib/ai/)

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 direct ANTHROPIC_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() uses generateObject() 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() calls generateImage() (Gemini takes aspectRatio, OpenAI/gateway take size + a quality capped at medium for cost) and returns null on 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.

Step 5: Publishing adapters (src/lib/publish/)

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 with w_member_social and the author URN.

composeCaption() joins copy + hashtags consistently, and a typed PublishError carries the platform and underlying cause.

Step 6: Pipeline orchestration (src/lib/pipeline.ts)

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 the brand bucket (or fall back to a brand photo), insert a draft post scheduled for the next occurrence of the business's local post_time.
  • publishPost(supabase, postId): validates the post has an image and a connected account, flips status to publishing, calls the platform adapter, and records either published (+ external id, permalink) or failed (+ error message). Failures are persisted, never thrown away.

Step 7: Cron endpoints (src/app/api/cron/)

Two GET routes, both authorized by Authorization: Bearer $CRON_SECRET (which Vercel Cron sends automatically), both maxDuration = 300:

  • /api/cron/generate: for every is_active business, run runDailyGeneration. Uses the admin client to iterate all tenants.
  • /api/cron/publish: find approved posts whose scheduled_for ≤ now and 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.

Step 8: Server Actions (src/app/actions.ts)

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.

Step 9: The UI (src/app/, src/components/)

  • 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.tsx lists brands + an "add brand" form; dashboard/businesses/[id]/page.tsx is 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.


4. Project layout

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

5. Run it locally

  1. Create a Supabase project and run both migrations in supabase/migrations/ (SQL editor, or supabase db push).

  2. Copy .env.example to .env.local and fill in Supabase keys + a random CRON_SECRET. For local AI either set AI_GATEWAY_API_KEY (Vercel AI Gateway) or a direct provider key: ANTHROPIC_API_KEY for copy, and one of GOOGLE_GENERATIVE_AI_API_KEY / OPENAI_API_KEY for images (or set RAPIDMARKET_DISABLE_AI_IMAGES=true to use brand photos only).

  3. Install and run:

    npm install
    npm run dev
  4. 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.

Environment variables

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)

6. Connecting Instagram & LinkedIn

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:… or urn:li:organization:…).

Replacing this with proper OAuth onboarding flows (using the META_* / LINKEDIN_* env vars) is the natural next step.


7. Deploy

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.


8. Known gaps / next steps

  • OAuth onboarding for Meta & LinkedIn to replace pasted tokens.
  • Token encryption at rest: social_accounts.access_token is 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.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages