AdFlow is a TikTok-style ad-discovery and ad-generation platform. It scrapes trending short-form video content (TikTok, YouTube Shorts), lets brands remix those trends into AI-generated ads (copy + image), and publishes finished ads back into a swipeable public feed.
This document describes the end-to-end system architecture so you can work against the backend from a local CLI or any other client.
┌────────────────────┐ ┌──────────────────────────────────────────┐
│ React SPA (Vite) │ │ Supabase project │
│ — /feed (public) │ ─────▶ │ Postgres + RLS │
│ — /trends │ │ Auth (email + Google) │
│ — /ads (gen) │ │ Storage buckets: avatars, ad-images │
│ — /my-ads │ │ Edge Functions (Deno): │
└─────────┬──────────┘ │ • ad-chat (streaming SSE) │
│ │ • ad-generate (brief + image) │
│ HTTPS + JWT │ • scrape-trends (Apify ingestion) │
▼ │ • industry-news (NewsAPI / mock) │
supabase-js client └─────────────┬────────────────────────────┘
│ │
▼ ▼
PostHog ┌──────────────┼──────────────┬─────────────┐
(product analytics) ▼ ▼ ▼ ▼
Google Gemini API Apify Actors NewsAPI.org Firecrawl
(generativelanguage (TikTok, YouTube, (industry (homepage
.googleapis.com) Instagram) headlines) scraping)
gemini-2.5-flash (text + tool calls)
gemini-2.5-flash-image (ad visuals)
Key idea: the frontend never talks directly to third parties. All AI / scraping / news calls go through Supabase Edge Functions, which hold the secrets. PostHog is the one exception — the browser sends events directly using the publishable VITE_POSTHOG_KEY.
| Item | Value |
|---|---|
| Project ref | from VITE_SUPABASE_PROJECT_ID |
| API base URL | from VITE_SUPABASE_URL (e.g. https://<ref>.supabase.co) |
| Edge Functions base | ${VITE_SUPABASE_URL}/functions/v1 |
| Publishable (anon) key | from VITE_SUPABASE_PUBLISHABLE_KEY (safe in code) |
For a CLI you only need the API base URL + anon key. For privileged operations (e.g. seeding data) you'll need a service role key — fetch it from the Supabase Dashboard → Project Settings → API.
All tables live in the public schema. Row-Level Security is enabled on every table.
One row per auth user. Created automatically by the handle_new_user() trigger.
id uuid(=auth.users.id)company_name,company_email,industry(enum),avatar_urlwebsite_url text— derived from the company email domain during onboardingcompany_context jsonb— auto-enriched brand data (see §6 Onboarding enrichment)onboarding_complete bool- RLS: user can
SELECT/INSERT/UPDATEonly their own row.
Roles stored separately to prevent privilege-escalation bugs.
(user_id, role)whereroleis enumapp_role='admin' | 'user'- Read via
has_role(_user_id, _role)security-definer SQL function. - RLS: users can read their own roles; only the trigger inserts.
Scraped short-form videos from TikTok / YouTube Shorts.
external_id text,platform text('TikTok'or'YouTube Shorts')industry industry_typeenum (technology,consumer_products,gas_energy,b2b_saas,healthcare,finance,retail,media,education,other)- Metrics:
views, likes, comments, shares(bigint),engagement_rate numeric - Media:
title, description, thumbnail_url, video_url, creator, hashtags text[] raw_payload jsonb— full upstream object (used to build deep-link fallback URLs)- RLS: anyone (incl. anon) can
SELECT; onlyadminrole can mutate.
AI-generated ads owned by users. Some are flipped public so they appear on the landing feed.
user_id uuid,headline, body, cta, image_prompt textimage_url text(CDN URL from the AI image gateway)brief jsonb(full structured output ofad-generate)is_public bool(default false),like_count int- RLS: owner can CRUD; anyone can
SELECTrows whereis_public = true.
Per-session chat history for the ad-briefing assistant.
(session_id, user_id, role, content)- RLS: owner-only.
avatars(public) — profile pictures.ad-images(public) — reserved for storing AI images permanently. Todaygenerated_ads.image_urlpoints at the gateway CDN.
All functions live under supabase/functions/<name>/index.ts and are auto-deployed. They share a CORS preamble and read secrets from Deno.env.
- POST
/functions/v1/ad-chat - Body:
{ messages: ChatMsg[], context: { company, industry, trend? } } - Calls the Google Gemini REST API (
gemini-2.5-flash) through_shared/gemini.ts, which streams:streamGenerateContent?alt=sseand re-emits it as OpenAI-compatible SSE chunks (data: {choices: [{delta: {content: "..."}}]}). - Returns
text/event-stream. Parsedata: {...}lines and pullchoices[0].delta.content. - Error codes:
429rate-limit,402quota exhausted.
- POST
/functions/v1/ad-generate - Body: same shape as
ad-chat. - Two-step pipeline:
- Tool-call against
gemini-2.5-flash(via the shared adapter) to extract a strict JSON brief:{ headline, body, cta, image_prompt, style }. - Image generation via
gemini-2.5-flash-image-previewusingimage_prompt. Returns a base64data:URL.
- Tool-call against
- Returns
{ brief, image_url }.image_urlmay benullif image gen fails (brief still returned).
- POST
/functions/v1/scrape-trends - Body (all optional):
{ industries?: string[], platforms?: ("tiktok"|"youtube")[] } - For each industry, uses curated hashtag/keyword seeds (see
SEEDSmap in the function) to query:clockworks/tiktok-scraper→ up to ~10 TikTok videos / industrystreamers/youtube-scraper→ up to ~12 YouTube Shorts / industry
- Normalizes both shapes into the
trendsrow format, computesengagement_rate = (likes+comments+shares)/views * 100, dedupes in-batch by(platform, external_id), then upserts intotrends. - Idempotent — safe to run on a cron.
- POST
/functions/v1/industry-newswith{ industry: string }. - If
NEWS_API_KEYis set → live results from NewsAPI.org filtered by industry query. - Otherwise falls back to a curated mock list per industry. Used by the dashboard for "what's happening in your space."
- POST
/functions/v1/enrich-company - Body:
{ company_name, company_email, industry } - Pipeline (all best-effort, never blocks signup):
- Domain derived from email (skips free providers like gmail/yahoo).
- Logo auto-fetched from Clearbit Logo API (
https://logo.clearbit.com/<domain>) — no key needed. - Website scrape via Firecrawl (if
FIRECRAWL_API_KEYis set) — markdown of the homepage. - Brand inference via Google Gemini (
gemini-2.5-flashthrough_shared/gemini.ts) using a strict tool-call schema → returns tagline, description, brand tone, product categories, target audience (persona / age / interests), 3–5 competitors, 5–10 hashtags. - Company news via NewsAPI scoped to the company name (5 most recent articles).
- Persists everything to
profiles.company_context jsonband setsprofiles.website_url. - Fires
scrape-trendsfor the user's industry in the background to pre-warm the feed.
Stored as Supabase project secrets (supabase secrets set KEY=value), injected into edge functions as env vars. Never shipped to the browser.
| Secret | Used by | Purpose |
|---|---|---|
GEMINI_API_KEY |
ad-chat, ad-generate, enrich-company, competitor-analysis |
Google AI Studio key for Gemini REST API |
APIFY_TOKEN |
scrape-trends, social-trends |
Run Apify actors |
NEWS_API_KEY |
industry-news, enrich-company, competitor-analysis |
Live headlines from NewsAPI.org |
FIRECRAWL_API_KEY |
enrich-company, competitor-analysis (optional) |
Scrape company / competitor homepages |
SUPABASE_* |
All functions | Auto-injected by the platform |
- Provider: email/password + Google OAuth (native Supabase Auth).
- On signup, the
handle_new_user()trigger inserts aprofilesrow and auser_rolesrow with role'user'. - Frontend uses
supabase.authfor session; the JWT is automatically attached to PostgREST + Edge Function calls. - Public landing feed (
/feed) reads anonymously thanks to:trends:Anyone can view trendspolicygenerated_ads:Anyone can view public adspolicy (is_public = true)
| UI | Backend call |
|---|---|
/feed |
from('generated_ads').select(...).eq('is_public',true) + from('trends').select(...) |
/trends |
from('trends').select(...) filtered by user industry |
/ads chat panel |
fetch(/functions/v1/ad-chat) — SSE stream |
/ads Generate button |
supabase.functions.invoke('ad-generate', { body }) |
/ads Save / Publish |
from('generated_ads').insert({ ..., is_public }) |
/my-ads |
from('generated_ads').select(...).eq('user_id', uid) |
| Onboarding | from('profiles').upsert(...) |
| Admin / cron | supabase.functions.invoke('scrape-trends') |
Minimal Node example using @supabase/supabase-js:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.VITE_SUPABASE_URL!,
process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,
);
// Sign in (or use service role key for admin scripts)
await supabase.auth.signInWithPassword({ email, password });
// Read public feed
const { data: ads } = await supabase
.from("generated_ads")
.select("id, headline, body, cta, image_url, like_count")
.eq("is_public", true)
.order("created_at", { ascending: false });
// Trigger a trend scrape
const { data, error } = await supabase.functions.invoke("scrape-trends", {
body: { industries: ["technology"], platforms: ["tiktok", "youtube"] },
});
// Generate an ad
const gen = await supabase.functions.invoke("ad-generate", {
body: {
messages: [{ role: "user", content: "Promote our new air fryer to Gen Z" }],
context: { company: "CrispCo", industry: "consumer_products" },
},
});
console.log(gen.data); // { brief: {...}, image_url: "https://..." }For raw HTTP, every edge function expects:
POST ${VITE_SUPABASE_URL}/functions/v1/<name>
Authorization: Bearer <anon-or-user-jwt>
Content-Type: application/json
The streaming ad-chat endpoint returns text/event-stream; consume with fetch().body.getReader(), split on \n, then JSON-parse each data: ... line.
npm install
cp .env.example .env # fill in your own keys
npm run dev # Vite at http://localhost:8080
npm test # VitestEdge functions read secrets from supabase/functions/.env when served locally:
# one-off: wire up secrets for local serve
cat > supabase/functions/.env <<EOF
GEMINI_API_KEY=...
APIFY_TOKEN=...
NEWS_API_KEY=...
FIRECRAWL_API_KEY=...
SUPABASE_URL=${VITE_SUPABASE_URL}
SUPABASE_SERVICE_ROLE_KEY=...
EOF
supabase functions serveFor production, set the same keys as project secrets:
supabase secrets set GEMINI_API_KEY=... APIFY_TOKEN=... NEWS_API_KEY=... FIRECRAWL_API_KEY=...
supabase functions deploy ad-chat ad-generate enrich-company competitor-analysis scrape-trends social-trends industry-newsThe Supabase client (src/integrations/supabase/client.ts) is thin — it reads VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY from import.meta.env. Types (src/integrations/supabase/types.ts) are regenerated from the database schema with supabase gen types typescript.
The browser initializes PostHog in src/lib/posthog.ts using VITE_POSTHOG_KEY / VITE_POSTHOG_HOST. AuthContext automatically calls posthog.identify(userId, { email }) on sign-in and posthog.reset() on sign-out. Turn it off locally by leaving VITE_POSTHOG_KEY blank.
- Trend freshness:
scrape-trendsis on-demand today. Schedule it via a cron (Supabase scheduled function or external scheduler) to keep the feed lively. - AI cost control: Both
ad-chatandad-generatepropagate429(rate limit) and402(credits exhausted) so the UI can show actionable toasts. - Image storage: Generated images are currently referenced by their gateway CDN URL. If you need permanent storage, download the image in
ad-generateand upload to thead-imagesbucket before returningimage_url. - RLS first: any new table must enable RLS and define explicit policies — otherwise nothing will be readable through the anon key.