# How to Add Social Media Post Scheduling to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Post scheduling needs five pieces: a composer with per-platform character counters, a Supabase scheduled_posts table with a status machine, encrypted OAuth token storage per connected account, a cron job (pg_cron or Vercel Cron) that publishes due posts every minute, and retry logic for failures. Ship a working scheduler in 1-2 days with Lovable or V0 for $0-25/month. OAuth token refresh race conditions and DST-aware timezone storage are the two patterns AI tools most often get wrong.

## What a Social Media Post Scheduling Feature Actually Requires

Post scheduling is deceptively complex because it spans five systems that must work in concert: a composer UI with platform-specific constraints, OAuth token management for each user's connected social accounts, a job queue that survives server restarts, a cron executor that fires at the right time in the user's timezone, and platform API calls that can fail and need retrying. AI tools generate the composer and the Supabase schema well. The OAuth token storage and refresh logic, timezone conversion, and retry-on-failure patterns are where every first build has production bugs. The key architectural decision: never store UTC offset — always store the timezone name ('America/New_York') so the scheduler survives Daylight Saving Time changes.

## Anatomy of the Feature

Six components. The composer and calendar UI generate well with AI tools. The scheduling cron job, OAuth token refresh, and timezone handling are where production builds consistently fail on first deploy.

- **Post composer** (ui): shadcn/ui Textarea with per-platform character counter. Twitter/X: 280-char hard limit (red counter under 20 chars remaining). LinkedIn: 3,000-char soft limit. Instagram: 2,200-char limit. Platform multi-select checkboxes with platform icons. Image upload to Supabase Storage with thumbnail preview. Zod validation prevents scheduling in the past and blocks Instagram posts without an image. Timezone-aware datetime picker using date-fns-tz.
- **Scheduling engine** (backend): Supabase Edge Function running on a pg_cron schedule (every minute: '* * * * *'). Queries scheduled_posts WHERE scheduled_at <= now() AND status = 'scheduled' LIMIT 10. For each due post, calls the platform API with the stored OAuth token, updates status to 'publishing' before the API call, then sets 'published' on success or 'failed' with error_message on failure. Increments retry_count on failure and reschedules 5 minutes later up to 3 retries.
- **Platform OAuth connection** (service): OAuth 2.0 authorization code flow per platform. Twitter/X uses PKCE (Proof Key for Code Exchange). LinkedIn, Facebook, and Instagram use standard authorization code. The Supabase Edge Function handles the OAuth callback, exchanges the code for tokens, and stores them in the user_oauth_tokens table with expires_at. Token refresh: check expires_at 5 minutes before use; if expired or expiring, call the platform's token refresh endpoint first.
- **Post queue** (data): Supabase scheduled_posts table: id, user_id, content, platforms (text array), media_urls (text array), scheduled_at (timestamptz, stored as UTC), timezone (text, e.g. 'America/New_York'), status enum (draft/scheduled/publishing/published/failed), error_message, retry_count, published_at, created_at. RLS ensures users can only access their own posts.
- **Calendar view** (ui): react-big-calendar or @fullcalendar/react renders scheduled posts on a calendar grid, color-coded by platform. Drag-to-reschedule updates the scheduled_at column via a Server Action and resets status to 'scheduled' if the post was in 'failed' state. Month/week/day views. Clicking a post opens an edit modal.
- **Webhook / callback handler** (backend): Supabase Edge Function endpoint at /functions/v1/social-callback receives platform webhooks (where available) that confirm a post was published or report a delayed failure. Updates the scheduled_posts row status to 'published' with published_at timestamp or 'failed' with the platform's error code as error_message. Triggers an in-app notification to the user.

## Data model

Two tables: scheduled_posts (the job queue) and user_oauth_tokens (encrypted platform credentials). Run this in the Supabase SQL Editor (Dashboard → SQL Editor):

```sql
create table public.scheduled_posts (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  content text not null,
  platforms text[] not null,
  media_urls text[] not null default '{}',
  scheduled_at timestamptz not null,
  timezone text not null,
  status text not null default 'draft'
    check (status in ('draft', 'scheduled', 'publishing', 'published', 'failed')),
  error_message text,
  retry_count integer not null default 0,
  published_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table public.user_oauth_tokens (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  platform text not null,
  access_token text not null,
  refresh_token text,
  expires_at timestamptz,
  scope text,
  platform_user_id text,
  platform_username text,
  connected_at timestamptz not null default now(),
  revoked_at timestamptz,
  constraint user_oauth_tokens_user_platform_unique unique (user_id, platform)
);

alter table public.scheduled_posts enable row level security;
alter table public.user_oauth_tokens enable row level security;

create policy "Users can manage their own scheduled posts"
  on public.scheduled_posts for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

create policy "Users can manage their own OAuth tokens"
  on public.user_oauth_tokens for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

create index scheduled_posts_user_id_idx
  on public.scheduled_posts (user_id, scheduled_at desc);

create index scheduled_posts_due_idx
  on public.scheduled_posts (scheduled_at)
  where status = 'scheduled';

create index user_oauth_tokens_user_platform_idx
  on public.user_oauth_tokens (user_id, platform)
  where revoked_at is null;
```

The scheduled_posts_due_idx partial index covers only rows with status = 'scheduled', making the cron query fast even with millions of historical posts in the table. The user_oauth_tokens table uses a unique constraint on (user_id, platform) so each user has one active token per platform — reconnecting the same platform updates the existing row rather than creating duplicates.

## Build paths

### Lovable — fit 3/10, 1-2 days + manual OAuth and cron verification

Lovable generates the composer UI and Supabase schema well. The pg_cron scheduling Edge Function is scaffoldable but rarely configured correctly on first pass — expect to manually verify the cron setup in Supabase. OAuth redirect URIs must be the published URL, not the preview.

1. Create a Lovable project with Lovable Cloud enabled; paste the prompt below and let Lovable generate the composer, post list, and Supabase schema
2. After generation, go to Lovable Cloud tab → Database → SQL Editor and verify the pg_cron extension is enabled: CREATE EXTENSION IF NOT EXISTS pg_cron; then create the cron job: SELECT cron.schedule('publish-posts', '* * * * *', 'SELECT net.http_post(url:=..., headers:=...) FROM pg_catalog.generate_series(1,1)')
3. For each social platform you want to support, register your published URL (not preview URL) as the OAuth redirect URI in the platform's developer console
4. Store platform API keys (Twitter/X client_id, client_secret, LinkedIn client_id, client_secret) in Lovable Cloud tab → Secrets
5. Publish and test: create a post scheduled 2 minutes in the future, wait, and verify status changes from 'scheduled' to 'published'

Starter prompt:

```
Build a social media post scheduling feature. Tables: scheduled_posts (id, user_id, content text, platforms text[], media_urls text[], scheduled_at timestamptz stored in UTC, timezone text as IANA name e.g. 'America/New_York', status text CHECK IN ('draft','scheduled','publishing','published','failed'), error_message text, retry_count int default 0, published_at timestamptz, created_at) and user_oauth_tokens (id, user_id, platform text, access_token text, refresh_token text, expires_at timestamptz, scope text, platform_user_id text, platform_username text, revoked_at timestamptz; unique constraint on user_id + platform). RLS: users manage only their own rows in both tables. Post composer page: Textarea with live per-platform character counter (Twitter 280 chars, LinkedIn 3000, Instagram 2200); platform checkboxes with icons (Twitter, LinkedIn, Instagram, Facebook); Instagram validation: require at least one image upload (block schedule if no image and Instagram selected); image upload to Supabase Storage 'post-media' bucket with thumbnail preview; date-time picker using date-fns-tz that shows the user's local time — convert to UTC using parseInTimeZone before saving to DB; store IANA timezone name in timezone column (detect from Intl.DateTimeFormat().resolvedOptions().timeZone); validate scheduled_at > now(). Post status display: badge showing 'Draft' / 'Scheduled' / 'Publishing' / 'Published' / 'Failed'; for failed posts show error_message in an expandable panel. Calendar view using react-big-calendar showing scheduled posts color-coded by platform; drag-to-reschedule calls a Supabase update on scheduled_at; week and month views. Scheduling Edge Function: runs every minute via Supabase pg_cron; queries scheduled_posts WHERE scheduled_at <= now() AND status = 'scheduled' LIMIT 10; for each post: set status = 'publishing', call platform API with OAuth token from user_oauth_tokens, on success set status = 'published' and published_at = now(), on failure set status = 'failed', error_message = API error, retry_count = retry_count + 1; if retry_count < 3, reset status = 'scheduled' with scheduled_at = now() + interval '5 minutes'. Token refresh: before each API call, check user_oauth_tokens.expires_at; if expires_at < now() + 5 minutes, call platform refresh endpoint and update the token row. Connected accounts panel: list user's connected platforms with username and avatar; 'Connect' button initiates OAuth flow redirecting to platform; 'Disconnect' sets revoked_at. Post list page: table of all posts with status badge, platform icons, scheduled time in user's timezone, and actions (edit if scheduled, view if published, retry if failed).
```

Limitations:

- OAuth redirect URIs must be the published URL — test OAuth only after publishing; preview URL breaks the callback flow
- Tokens stored in Lovable Cloud Supabase are not directly accessible from the Supabase Dashboard; use Lovable Cloud tab → Database to verify token rows
- pg_cron setup needs manual verification in Supabase SQL Editor — Lovable scaffolds the Edge Function but rarely configures the cron schedule correctly on first generation

### V0 — fit 4/10, 1-2 days

Next.js App Router with Vercel Cron Jobs is a natural fit for post scheduling. V0 generates clean composer UI and API routes for the OAuth callbacks. The calendar library Tailwind conflict is the most common post-deploy issue.

1. Prompt V0 with the spec below; it generates the PostComposer, ScheduleCalendar, and ConnectedAccounts components
2. Add a vercel.json in the project root with cron config: { "crons": [{ "path": "/api/publish-scheduled", "schedule": "* * * * *" }] }
3. Open the Vars panel and add: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY, and platform credentials (TWITTER_CLIENT_ID, TWITTER_CLIENT_SECRET, LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET)
4. Run the SQL from af_data_model in Supabase SQL Editor
5. After deploy, verify the calendar renders correctly — if react-big-calendar styles are broken, add its CSS import before Tailwind globals in the layout file

Starter prompt:

```
Build a social media post scheduling feature in Next.js. Component 1 (use client): PostComposer — Textarea with live character counter per selected platform (Twitter 280 hard limit shown in red when <= 20 remaining, LinkedIn 3000 soft, Instagram 2200 soft); platform multi-select using checkboxes with SVG icons; platform-specific validation: Instagram requires image upload; image/video upload to Supabase Storage; date-time picker using react-datepicker with timezone support via date-fns-tz; detect user timezone from Intl.DateTimeFormat().resolvedOptions().timeZone and store as IANA name; store scheduled_at as UTC using parseInTimeZone(localDate, timezone) conversion; zod validation: content required, platforms min 1, scheduled_at must be > now() + 1 minute. Server Action: insertScheduledPost — validates, converts datetime to UTC, inserts into scheduled_posts table with status 'scheduled'. Component 2 (use client): ScheduleCalendar — react-big-calendar with events from scheduled_posts; color by platform (Twitter blue, LinkedIn blue-darker, Instagram gradient, Facebook blue); drag-to-reschedule: updates scheduled_at via Server Action updatePostSchedule; click to open PostDetailModal (edit if scheduled, view if published). Component 3: ConnectedAccounts — lists user's user_oauth_tokens by platform with connected username; Connect button links to /api/auth/{platform}; Disconnect calls Server Action revokeOAuthToken (sets revoked_at, clears tokens). API route /api/auth/[platform]: initiates OAuth code flow with platform-specific parameters (Twitter uses PKCE: generate code_verifier, code_challenge); stores state param in a cookie; redirects to platform authorization URL. API route /api/auth/[platform]/callback: exchanges code for tokens; stores in user_oauth_tokens; redirects to /settings/connected-accounts. API route /api/publish-scheduled: protected by Vercel cron secret header (CRON_SECRET env var); queries due posts; for each: checks token expiry and refreshes if needed using platform refresh endpoint; calls platform publish API; updates post status; increments retry_count on failure and reschedules +5 minutes if retry_count < 3. Post list page: table with columns — platform icons, content preview (50 chars), scheduled time displayed in user's stored timezone using formatInTimeZone(scheduledAt, timezone, 'MMM d, h:mm a'), status badge, actions.
```

Limitations:

- V0 does not scaffold the OAuth code flow for any specific platform automatically — Twitter PKCE and LinkedIn standard flow are separate implementations for each platform
- react-big-calendar and @fullcalendar/react CSS can conflict with Tailwind v4 reset styles after deploy — import calendar CSS before Tailwind in the root layout
- Vercel Cron Jobs on the free plan are limited to 1 cron per project; upgrade to Vercel Pro ($20/mo) for multiple cron jobs

### Flutterflow — fit 2/10, 2-4 days + custom Dart for OAuth

FlutterFlow can build the composer and post list UI; backend scheduling requires an external cron service. OAuth redirect handling on mobile requires custom Dart code and deep link configuration — significantly more complex than web.

1. Build the post composer page in FlutterFlow with text fields, platform checkboxes, and a date-time picker widget
2. Create a Custom Action for the character counter that counts text length and updates a page state variable for each platform
3. Use Supabase as the backend — insert post rows via FlutterFlow's built-in Supabase insert action; set scheduled_at from the date-time picker value converted to UTC using a custom Dart action
4. For the scheduling cron job, use a Supabase Edge Function with pg_cron or an external n8n workflow that queries due posts every minute — this cannot be built in FlutterFlow itself
5. For OAuth, implement a Custom Action using the flutter_web_auth_2 package to handle the OAuth redirect callback via deep link

Limitations:

- Deep-link OAuth callback handling in FlutterFlow requires custom Dart code and app URL scheme configuration — each social platform's OAuth flow is a separate custom action
- Twitter/X PKCE flow requires generating a code_verifier and code_challenge in Dart — no built-in FlutterFlow support
- The scheduling cron job cannot run inside FlutterFlow — an external service (Supabase Edge Function with pg_cron or n8n) is mandatory for actually publishing posts

### Custom — fit 5/10, 1-2 weeks

Full control over retry logic, token refresh with distributed locking, platform rate limit tracking, multi-step approval workflows, and analytics on post performance.

1. Implement advisory locking (pg_advisory_xact_lock) in the publish Edge Function to prevent concurrent token refresh race conditions when multiple posts for the same user fire simultaneously
2. Add a rate_limits table tracking API calls per platform per user with a retry_after timestamp; surface 'rate_limited' as a post status so users understand why their post is delayed
3. Build a team approval workflow: posts from team members require a manager review before scheduling; add an approver_id column and approved_at timestamp with email notification on pending approval
4. Integrate post performance metrics: for platforms that support analytics APIs (Twitter/X, LinkedIn, Instagram), fetch engagement metrics (likes, shares, impressions) 24 hours after publish and store in a post_metrics table linked to scheduled_posts

Limitations:

- Each additional social platform (Pinterest, TikTok, YouTube) requires platform-specific OAuth implementation, API client, and error handling — budget 2-3 days per new platform beyond the core four

## Gotchas

- **OAuth redirect fails in Lovable preview — works only on published URL** — Social platforms (Twitter/X, LinkedIn, Facebook) register an exact redirect URI in their developer console. Lovable's preview URL (something-preview.lovable.app) differs from the published URL (something.lovable.app). When a user clicks 'Connect Twitter' in the preview, the OAuth callback fires to the preview URL, which is not registered, causing an immediate 'redirect_uri_mismatch' error from the platform. Fix: Register both the preview URL AND the published URL as allowed redirect URIs in each platform's developer console. Alternatively, test OAuth connections only on the published URL and add a note in the preview UI: 'Account connection available in the published app'. Document which URL is registered where to avoid confusion later.
- **Token refresh race condition corrupts OAuth tokens** — When two posts for the same user are scheduled at the same minute, the cron job picks up both simultaneously and both Edge Function invocations check token expiry at the same moment. Both see an expired token, both call the platform refresh endpoint, and the first successful refresh invalidates the token the second one received. The second post fails with 'invalid_token' and, critically, the good refreshed token may be overwritten by the stale one from the second invocation. Fix: Use a database advisory lock before token refresh: call SELECT pg_advisory_xact_lock(user_id_as_bigint) at the start of the publish transaction. Only one Edge Function invocation holds the lock at a time; the second waits, then reads the already-refreshed token. Alternatively, use an optimistic version counter on user_oauth_tokens: increment a version column on each refresh and use an UPDATE ... WHERE version = $known_version to detect concurrent updates.
- **Timezone offset bug fires posts at wrong time after DST change** — Storing a UTC offset integer like -300 (for UTC-5) instead of the timezone name 'America/New_York' produces correct results in winter but fires posts 1 hour late after Daylight Saving Time when the offset changes to -240. A user who schedules '9:00 AM every Monday' in November will get posts at 10:00 AM in March — without any error message. Fix: Always store the IANA timezone name (e.g., 'America/New_York') in a timezone column. When converting local time to UTC for scheduled_at, use date-fns-tz parseInTimeZone(localDateString, timezoneIANA). When displaying scheduled_at back to the user, use formatInTimeZone(utcDate, timezoneIANA, 'MMM d, h:mm a'). Detect the user's timezone from Intl.DateTimeFormat().resolvedOptions().timeZone on the client.
- **Platform API rate limits cause silent post failures** — Twitter/X Basic API allows 1,500 posts/15 minutes per app (not per user). At scale, if many users' posts are scheduled in the same 15-minute window, the cron job exhausts the rate limit budget. Later posts fail with a 429 response, their status is set to 'failed', and users see an unhelpful 'post failed' error with no explanation of when to retry. Fix: Add a rate_limits table: (platform text, window_start timestamptz, call_count int). Before each publish API call, check and increment the counter atomically. If count >= limit, set post status to 'rate_limited' with a retry_after timestamp. Show a user-facing message: 'Your Twitter post is queued — rate limit reached, will retry at 10:15 AM'. Stagger cron batch processing with a 100ms delay between API calls.
- **V0 calendar library Tailwind class conflicts break the calendar UI** — react-big-calendar and @fullcalendar/react ship their own CSS stylesheets that define grid layout, event colors, and cell heights. Tailwind v4 (V0's default since February 2026) applies a CSS reset that removes margins and sets box-sizing globally. When both are loaded, the calendar cells collapse to 0 height and events overlap in an unreadable stack. Fix: Import the calendar library's CSS file (import 'react-big-calendar/lib/css/react-big-calendar.css') in the root layout BEFORE any Tailwind imports. For Tailwind v4, wrap all calendar-specific overrides in @layer base { } with explicit !important on height and margin rules, or use the library's CSS custom properties API if available. Always test the calendar render after the first Vercel deploy.

## Best practices

- Always store scheduled_at in UTC and a separate timezone column with the IANA timezone name — never store a UTC offset integer, which breaks after every DST transition
- Set the publish Edge Function to update status to 'publishing' BEFORE the API call, not after — this acts as a distributed lock preventing duplicate publishes when the cron fires twice
- Store access_token and refresh_token as encrypted text in the Supabase user_oauth_tokens table using pg_crypto or Supabase Vault — never in localStorage or client-side state
- Implement retry logic with a maximum of 3 attempts and exponential backoff (5 min, 15 min, 45 min) — store retry_count and next_retry_at on the post row so the cron skips non-due retries
- Show the error_message to users when a post fails with an actionable label — not just 'Failed' but 'Failed: Twitter rate limit reached, retrying at 10:15 AM' or 'Failed: Account disconnected, please reconnect'
- Add a status = 'rate_limited' state with retry_after timestamp so users understand rate limit delays are not failures and will self-resolve
- Use the Instagram Graph API's content publishing flow (create container, then publish) — a two-step process that AI tools sometimes flatten into one call, causing 'container not ready' errors

## Frequently asked questions

### Which social media platforms can I connect to my app?

The four most commonly supported platforms are Twitter/X (OAuth 2.0 PKCE), LinkedIn (OAuth 2.0 authorization code with Marketing API approval), Facebook Pages (Facebook Graph API with business verification), and Instagram Business accounts (Instagram Graph API via Facebook). TikTok, Pinterest, and YouTube each have their own OAuth implementations and require separate API approvals — budget 2-3 days per additional platform.

### How does post scheduling handle different time zones?

Detect the user's timezone automatically from Intl.DateTimeFormat().resolvedOptions().timeZone (e.g., 'America/New_York') and store it in a timezone column alongside the post. Store scheduled_at in UTC using date-fns-tz parseInTimeZone. Display all scheduled times back to the user in their stored timezone using formatInTimeZone. Never store a UTC offset integer — it breaks after Daylight Saving Time transitions.

### What happens if a scheduled post fails to publish?

The cron publisher catches the API error, sets status to 'failed', stores the error_message from the platform response, increments retry_count, and reschedules the post 5 minutes later if retry_count < 3. After 3 failed attempts, status stays 'failed' and the user is notified in-app with the error reason. Common failure reasons are rate limits (show retry time), expired OAuth token (show 'reconnect account'), and platform API downtime (show 'platform unavailable, will retry').

### Can I schedule the same post to multiple platforms at once?

Yes — the platforms column is a text array (e.g., ['twitter', 'linkedin', 'instagram']). The cron publisher loops over the array and calls each platform's API in sequence within the same post processing job. Platform-specific validation (Instagram requires an image) runs at compose time, not at publish time, so invalid configurations are caught before the post is saved.

### How do I store social media OAuth tokens securely?

Store access_token and refresh_token in the user_oauth_tokens Supabase table as encrypted text — use Supabase Vault (SELECT vault.create_secret()) or pg_crypto (pgp_sym_encrypt) for column-level encryption. Set RLS so only the owning user can read their tokens. Never store tokens in localStorage, cookies, or application logs. The service role key used by the Edge Function to read and update tokens must never be exposed to the client.

### Do I need a business account to use the LinkedIn or Instagram API?

Yes for posting. LinkedIn requires Marketing Developer Platform access (apply at developer.linkedin.com) which typically takes 1-2 weeks to approve. Instagram only allows posting to Business or Creator accounts via the Instagram Graph API — personal accounts cannot be posted to programmatically. Facebook Page posting requires a verified business Meta app. Twitter/X posting requires a developer account at developer.twitter.com with an approved app.

### How do I build a calendar view of scheduled posts?

Use react-big-calendar or @fullcalendar/react. Fetch all scheduled posts for the visible date range from Supabase and pass them as events to the calendar. Color-code by platform. Handle drag-to-reschedule via the onEventDrop callback: update scheduled_at via a Server Action and optimistically update the local event state. Import the calendar library's CSS before Tailwind to prevent style conflicts that collapse event rows.

### Can users connect their own social accounts or only a single shared account?

The user_oauth_tokens table stores one token row per (user_id, platform) pair, so every user connects their own individual social accounts. Each user authenticates with their own Twitter/LinkedIn/Instagram credentials through the OAuth flow. If you want a single shared account (e.g., the company's Twitter handle), store the token against a service account user ID and grant other users access via Supabase RLS policies.

---

Source: https://www.rapidevelopers.com/app-features/social-media-post-scheduling
© RapidDev — https://www.rapidevelopers.com/app-features/social-media-post-scheduling
