Skip to main content
RapidDev - Software Development Agency
Lovable PromptsMarketplaceIntermediate

Build a Niche Job Board in Lovable

A niche job board where employers post listings (free or paid via Stripe), seekers browse by location and salary, apply with a stored resume PDF, and receive email alerts for new matching jobs — built on Supabase with public-read listings and per-employer write access.

Time to MVP

1 day

Credits

~100-180 credits for full build

Difficulty

Intermediate

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Build mode and get a browsable job board where employers post listings (free or paid via Stripe) and seekers apply with a resume PDF. The critical trade-off: Lovable builds Vite SPAs with no server-side rendering, so Google won't index your listings without a prerender layer. Plan for this before launch. Full build runs ~100-180 credits in about 1 day.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores jobs, companies, applications, profiles, and job alerts. The pg_cron extension handles job expiry and alert email scheduling.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you'll see an empty Table Editor
  3. 3Leave it empty — the starter prompt creates the migration with all 5 tables plus pg_cron setup

Auth

Both employers and seekers need accounts with different roles. Role lives in JWT app_metadata.

  1. 1Cloud tab → Users & Auth
  2. 2Confirm Email sign-in is enabled (default)
  3. 3Set 'Allow new users to sign up' to ON — both seekers and employers self-register
  4. 4Set Site URL to your production domain before going live so email confirmation links point to the right URL

Storage

Holds seeker resume PDFs in private folders scoped per seeker. Employers never access other seekers' resumes directly.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'resumes', leave Public OFF (private bucket — seekers access their own via signed URL)
  3. 3Note the bucket name — the starter prompt's Storage RLS references it

Edge Functions

Handles paid post Stripe Checkout, the webhook (raw body), and the daily job-alert email batch.

  1. 1Cloud tab → Edge Functions — starter prompt creates all three function files
  2. 2After the starter prompt runs, deploy each function from the Edge Functions panel
  3. 3Register the stripe-webhook URL in Stripe Dashboard → Developers → Webhooks → Add endpoint listening for checkout.session.completed

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Used by create-paid-post-session Edge Function to create Checkout sessions for employer paid listings.

Where to get it: stripe.com → Dashboard → Developers → API keys → Secret key (sk_test_... for test mode).

STRIPE_WEBHOOK_SECRET

Purpose: Used by stripe-webhook Edge Function in constructEventAsync to verify Stripe event signatures.

Where to get it: stripe.com → Dashboard → Developers → Webhooks → your endpoint → Signing secret (whsec_...).

RESEND_API_KEY

Purpose: Used by send-job-alerts Edge Function to send daily matching job emails to subscribed seekers.

Where to get it: resend.com → API Keys → Create API Key. Free tier covers 3,000 emails/month.

Preflight checklist

  • You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
  • You're on Pro $25/mo — the full chain runs ~100-180 credits; Free plan's ~30/mo cap won't cover it
  • CRITICAL: Decide before prompting whether SEO-driven growth is your strategy. Lovable builds Vite SPAs — Googlebot sees an empty <div> with no job content. If organic search is your growth plan, budget time for the Cloudflare Workers prerender setup in follow-up #4 BEFORE announcing to the public.
  • Choose your paid posting price before prompting — the starter assumes $99 per listing per 30 days. Change this number in the create-paid-post-session Edge Function.

The starter prompt — paste this first

Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~50-70 credits
Build a niche job board. Stack assumptions: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.

## Database schema (create one migration)
Create five tables in the public schema:

1. `companies` — id (uuid pk default gen_random_uuid()), owner_id (uuid references auth.users(id) on delete set null), name (text not null), website (text), logo_path (text), description (text), created_at (timestamptz default now()). RLS: public SELECT; employer INSERT/UPDATE WHERE owner_id = auth.uid().

2. `jobs` — id (uuid pk default gen_random_uuid()), employer_id (uuid not null references auth.users(id)), company_id (uuid references companies(id)), title (text not null), description (text not null), location (text), remote_type (text check in ('onsite','hybrid','remote')), salary_min (int), salary_max (int), currency (text default 'USD'), employment_type (text check in ('full_time','part_time','contract','internship')), status (text default 'draft' check in ('draft','active','expired','removed')), is_paid_post (bool default false), posted_at (timestamptz), expires_at (timestamptz), created_at (timestamptz default now()). RLS: public SELECT TO anon, authenticated USING (status='active' AND expires_at > now()); employer INSERT/UPDATE/DELETE WHERE employer_id = auth.uid(); admin all via JWT app_metadata.role='admin'.

3. `profiles` — id (uuid pk references auth.users(id) on delete cascade), full_name (text), role (text default 'seeker' check in ('seeker','employer','admin')), email (text not null), headline (text), default_resume_path (text), created_at (timestamptz default now()). RLS: own read/write. Role here is a display mirror — auth role lives in JWT app_metadata.

4. `applications` — id (uuid pk default gen_random_uuid()), job_id (uuid references jobs(id)), seeker_id (uuid not null references auth.users(id)), resume_path (text not null), cover_letter (text), status (text default 'submitted' check in ('submitted','reviewed','shortlisted','rejected')), submitted_at (timestamptz default now()). RLS: seeker reads/inserts own WHERE seeker_id = auth.uid(); employer reads via SECURITY DEFINER function can_see_application(p_application_id uuid); NEVER public-read.

5. `job_alerts` — id (uuid pk default gen_random_uuid()), seeker_id (uuid references auth.users(id) on delete cascade), keyword (text), location (text), remote_type (text), frequency (text default 'daily' check in ('daily','weekly')), last_sent_at (timestamptz), created_at (timestamptz default now()). RLS: own read/write.

Create a SECURITY DEFINER plpgsql function `can_see_application(p_application_id uuid)` that runs as the function owner and returns true when auth.uid() = seeker_id OR auth.uid() = (SELECT employer_id FROM jobs WHERE id = (SELECT job_id FROM applications WHERE id = p_application_id)). Use LANGUAGE plpgsql only.

Enable RLS on all five tables.

Add a Storage policy for the resumes bucket (private, per-seeker folder scoping):
CREATE POLICY seekers_own_folder ON storage.objects
  FOR ALL TO authenticated
  USING (bucket_id = 'resumes' AND (storage.foldername(name))[1] = auth.uid()::text)
  WITH CHECK (bucket_id = 'resumes' AND (storage.foldername(name))[1] = auth.uid()::text);

Add pg_cron job expiry (run this at end of migration):
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('expire-jobs', '0 0 * * *', $$UPDATE jobs SET status='expired' WHERE expires_at < now() AND status='active';$$);

## Layouts
- `PublicLayout.tsx` — sticky header with logo, 'Post a Job' CTA button, Sign in / Sign up buttons, search bar. Footer with links.
- `EmployerLayout.tsx` — sidebar with: My Jobs, Applications, Company profile, Billing. AuthGuard reads JWT session.
- `SeekerLayout.tsx` — same header as PublicLayout + user dropdown with My Applications, Alerts, Account.
- `AdminLayout.tsx` — sidebar with Moderation. Checks JWT app_metadata.role='admin'.

## Pages
- / (Home) — hero with search input + 'Search jobs' button, 8 featured jobs (is_paid_post=true), recent 12 active jobs grid
- /jobs — jobs list with sidebar JobFilters: keyword input (debounced 300ms), location input, remote_type checkboxes (onsite/hybrid/remote), employment_type checkboxes, salary range slider. Results paginated 20/page, sorted by posted_at DESC with is_paid_post jobs pinned first.
- /jobs/:slug — job detail: title, company logo + name (link to company page), location + remote badge, salary range, employment_type badge, description (render as markdown), posted date, 'Apply Now' button (opens ApplicationDrawer for signed-in seekers, redirect to /signup?as=seeker for anon)
- /post — employer job posting wizard. Step 1: Company setup (name, website, logo upload, description). Step 2: Job details (title, description textarea, location, remote_type, salary range, employment_type). Step 3: Duration + payment — free posts last 14 days, paid ($99) last 30 days + featured. Free post: INSERT job with status='active', is_paid_post=false, expires_at=now()+14 days. Paid: call create-paid-post-session Edge Function.
- /employer/jobs — CRUD list of employer's own jobs. Each row: title, status badge (draft/active/expired), applications count, expires_at, Edit + View buttons. Edit opens a Sheet with the full job form.
- /employer/jobs/:id/applications — list of applications for that job. Each row: seeker name, submitted date, status dropdown (employer can update), resume download link (signed URL). Click row opens ApplicationDrawer with cover letter.
- /seeker/applications — seeker's own application history: job title, company, submitted date, status badge.
- /seeker/alerts — AlertsForm: keyword, location, remote_type preferences, frequency toggle (daily/weekly). Save button writes to job_alerts.
- /admin/moderation — active jobs list, flag count column, Remove button (sets status='removed')
- /login, /signup — standard auth pages. Signup has a ?as=employer or ?as=seeker query param that pre-selects the role and writes it to profiles.role + JWT app_metadata via a trigger.

## Key components
- `JobCard.tsx` — title (large weight), company name + logo (small), location + remote badge inline, salary range if provided, employment_type badge, 'Featured' badge if is_paid_post, posted_at relative time
- `JobFilters.tsx` — sticky sidebar with controlled inputs, 'Clear all' button
- `JobPostingForm.tsx` — multi-step form with react-hook-form
- `ResumeUploader.tsx` — shadcn file input, validates PDF type + max 5MB before upload, uploads to Storage path: `${user.id}/${file.name}`
- `ApplicationDrawer.tsx` — right Sheet showing resume download + cover letter for employer view; application status dropdown
- `AlertsForm.tsx` — keyword + location + remote_type + frequency controlled form

## Styling
Light mode default. Primary color: indigo-600. JobCard clear visual hierarchy: title big, company name subdued, salary and remote badge as inline pills. 'Featured' badge in amber-500. Mobile-first layout.

## Edge Function stubs (create files — I will deploy after)
1. supabase/functions/create-paid-post-session/index.ts — creates Stripe Checkout session for $99 with metadata.job_id, expires_at=now()+30 days in metadata. Returns { url }.
2. supabase/functions/stripe-webhook/index.ts — STUB with TODO: use req.text() + constructEventAsync. Mark the job active on checkout.session.completed.
3. supabase/functions/send-job-alerts/index.ts — stub for daily alert emails.

Generate migration first, then layouts, then pages in order above.

What this prompt generates

  • Creates a SQL migration with 5 tables, public-read-active-only RLS on jobs, private resume Storage policy, can_see_application SECURITY DEFINER function, and pg_cron job expiry
  • Scaffolds four layouts (Public, Employer, Seeker, Admin) with appropriate AuthGuard patterns
  • Generates the full /jobs browsable list and /jobs/:slug detail page that works without authentication
  • Builds the multi-step employer job posting wizard and the seeker resume upload + application flow
  • Creates Edge Function stubs for paid posts, webhook, and job alerts

Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_jobboard_schema.sql

5 tables + public-read RLS on jobs + private resume Storage policy + can_see_application SECURITY DEFINER + pg_cron expiry

src/layouts/PublicLayout.tsx

Sticky header with search, Post a Job CTA, auth buttons

src/layouts/EmployerLayout.tsx

Sidebar for /employer/* with AuthGuard

src/layouts/SeekerLayout.tsx

Header + user dropdown for /seeker/* routes

src/components/JobCard.tsx

Title, company, salary, remote badge, featured badge

src/components/JobFilters.tsx

Sticky sidebar with keyword, location, remote type, salary filters

src/components/JobPostingForm.tsx

Multi-step employer form with react-hook-form

src/components/ResumeUploader.tsx

PDF-only file input with 5MB cap and Storage upload

src/components/ApplicationDrawer.tsx

Right Sheet with resume download link and cover letter for employer view

src/pages/Home.tsx

Hero search + featured jobs + recent jobs grid

src/pages/JobsList.tsx

Filtered jobs list with sidebar and pagination

src/pages/JobDetail.tsx

Full job detail with Apply button

src/pages/PostJob.tsx

3-step job posting wizard

src/pages/EmployerJobs.tsx

Employer CRUD list with application counts

src/pages/EmployerApplications.tsx

Per-job applicant list with status management

src/pages/SeekerApplications.tsx

Seeker's own application history

src/pages/SeekerAlerts.tsx

Job alert preference form

supabase/functions/create-paid-post-session/index.ts

Stripe Checkout session for $99 paid job postings

supabase/functions/stripe-webhook/index.ts

Stub — filled in by follow-up #2

supabase/functions/send-job-alerts/index.ts

Stub — filled in by follow-up #3

Routes
/

Home with hero search, featured jobs, and recent jobs grid

/jobs

Filtered jobs list with sidebar and pagination

/jobs/:slug

Job detail with Apply button

/post

Multi-step employer job posting wizard

/employer/jobs

Employer CRUD list with application counts

/employer/jobs/:id/applications

Per-job applicant list with status management

/seeker/applications

Seeker's own application history

/seeker/alerts

Job alert preferences

/admin/moderation

Active jobs with flag count and remove action

/login

Email+password sign-in

/signup

Signup with ?as=employer or ?as=seeker role selection

DB Tables
jobs
ColumnType
iduuid primary key default gen_random_uuid()
employer_iduuid not null references auth.users(id)
company_iduuid references companies(id)
titletext not null
descriptiontext not null
locationtext
remote_typetext check in ('onsite','hybrid','remote')
salary_minint
salary_maxint
employment_typetext check in ('full_time','part_time','contract','internship')
statustext default 'draft'
is_paid_postbool default false
expires_attimestamptz
created_attimestamptz default now()

RLS: Public SELECT TO anon, authenticated USING (status='active' AND expires_at > now()); employer INSERT/UPDATE/DELETE WHERE employer_id=auth.uid(); admin all via JWT app_metadata

companies
ColumnType
iduuid primary key default gen_random_uuid()
owner_iduuid references auth.users(id)
nametext not null
websitetext
logo_pathtext
descriptiontext
created_attimestamptz default now()

RLS: Public SELECT; employer INSERT/UPDATE WHERE owner_id = auth.uid()

applications
ColumnType
iduuid primary key default gen_random_uuid()
job_iduuid references jobs(id)
seeker_iduuid not null references auth.users(id)
resume_pathtext not null
cover_lettertext
statustext default 'submitted'
submitted_attimestamptz default now()

RLS: Seeker reads/inserts own; employer reads via can_see_application() SECURITY DEFINER function; NEVER public-read

profiles
ColumnType
iduuid primary key references auth.users(id)
full_nametext
roletext default 'seeker'
emailtext not null
headlinetext
default_resume_pathtext
created_attimestamptz default now()

RLS: Own read/write only

job_alerts
ColumnType
iduuid primary key default gen_random_uuid()
seeker_iduuid references auth.users(id) on delete cascade
keywordtext
locationtext
remote_typetext
frequencytext default 'daily'
last_sent_attimestamptz
created_attimestamptz default now()

RLS: Own read/write only

Components
JobCard

Title, company, salary, remote badge, featured badge for grid and list views

JobFilters

Sticky sidebar filter panel with keyword, location, remote type, employment type, salary range

JobPostingForm

Multi-step react-hook-form wizard for employer job creation

ResumeUploader

PDF-only file input with 5MB cap and Storage upload to seeker folder

ApplicationDrawer

Employer-facing Sheet showing resume download and cover letter, with status dropdown

AlertsForm

Seeker job alert preference form

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Tighten RLS for application privacy

Verified application privacy between seekers and scoped employer access

~30 credits
prompt
Audit that seekers can never see each other's applications and employers only see applications for their own jobs.

1. Open an incognito window. Sign in as Seeker A and apply for a job. Note the application ID. Sign out. Sign in as Seeker B. In the browser console, try: await supabase.from('applications').select('*') — you should get 0 rows, not Seeker A's application.

2. Paste in Build mode: 'List every RLS policy on the applications table. Confirm: (a) there is NO public SELECT policy and NO anon SELECT policy, (b) the authenticated SELECT policy either uses seeker_id = auth.uid() OR can_see_application(applications.id), (c) the INSERT policy requires seeker_id = auth.uid() WITH CHECK.'

3. Confirm the can_see_application SECURITY DEFINER function exists: SELECT proname, prosecdef FROM pg_proc WHERE proname='can_see_application'; The prosecdef column should be TRUE.

4. If any gaps are found, fix them: the golden rule is applications must only be readable by (a) the seeker who submitted them or (b) the employer who owns the job they applied for. No other read access.

When to use: IMMEDIATELY after the starter prompt, before any real applications are submitted

2

Add paid post tier with Stripe

Working paid job post Stripe flow with raw-body webhook and job activation on payment

~80 credits
prompt
Replace the stripe-webhook stub with a working implementation and wire the paid post flow end to end.

1. Update supabase/functions/create-paid-post-session/index.ts:
   - Accept POST with { job_id }
   - Verify caller is authenticated and is the employer who owns the job
   - Create Stripe Checkout session: mode='payment', amount=9900 (cents), currency='usd', metadata={ job_id }, success_url='/employer/jobs?paid=true', cancel_url='/post'
   - Return { url }

2. Replace stripe-webhook/index.ts stub:
   - const body = await req.text();
   - const event = await stripe.webhooks.constructEventAsync(body, req.headers.get('Stripe-Signature') ?? '', Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '');
   - On checkout.session.completed: read event.data.object.metadata.job_id
   - Run: UPDATE jobs SET is_paid_post=true, status='active', posted_at=now(), expires_at=now()+interval '30 days' WHERE id=job_id;
   - Return 200

3. In the /post wizard Step 3, add a 'Boost for $99' button that calls create-paid-post-session for the draft job. Free posts: UPDATE job SET status='active', expires_at=now()+14 days immediately. Paid: redirect to Stripe, webhook activates.

4. On /employer/jobs, show a 'Boost' button on active free posts that calls the same create-paid-post-session flow.

When to use: When free posts are working and you want to monetize the board

3

Add daily job-alert emails via Resend

Daily Resend job-alert emails matching seeker keyword, location, and remote-type preferences

~70 credits
prompt
Build the daily job-alert email system that notifies seekers of new matching listings.

1. Update supabase/functions/send-job-alerts/index.ts:
   - This function runs without a user context (called by pg_cron) — use the Supabase service role client
   - For each job_alert where frequency='daily' (or frequency='weekly' AND last_sent_at < now()-7 days):
     - Query jobs WHERE status='active' AND created_at > job_alert.last_sent_at
       AND (keyword IS NULL OR title ILIKE '%' || keyword || '%' OR description ILIKE '%' || keyword || '%')
       AND (location IS NULL OR location ILIKE '%' || job_alert.location || '%')
       AND (remote_type IS NULL OR remote_type = job_alert.remote_type)
     - If matches > 0:
       - Send Resend email to seeker with job list (title, company, location, link)
       - UPDATE job_alerts SET last_sent_at=now() WHERE id=alert.id

2. Schedule via pg_cron:
   SELECT cron.schedule('daily-job-alerts', '0 14 * * *', $$ SELECT net.http_post(url := '[your-edge-function-url]/send-job-alerts', headers := '{}'::jsonb, body := '{}'::jsonb); $$);
   (14:00 UTC = ~8-10am US mornings)

3. On /seeker/alerts, show 'Last email sent: [relative time]' next to each alert using last_sent_at. Add an 'Unsubscribe' button that deletes the alert row.

When to use: When you have 50+ seekers signed up and want recurring engagement

4

Add SEO meta tags and document the prerender plan

Per-job meta tags for social sharing plus dynamic sitemap — prerequisites for any prerender or SSR layer

~40 credits
prompt
Add per-job meta tags as the first step toward search engine indexability, and document the Cloudflare Workers prerender setup for getting job listings indexed by Google.

1. Add react-helmet-async to the project. On /jobs/:slug, use HelmetProvider + Helmet to set:
   - <title>{job.title} at {company.name} — {location} | [Your Board Name]</title>
   - <meta name='description' content={`${employment_type} ${remote_type} role. ${salary_range}. Apply now on [Board Name].`}/>
   - <meta property='og:title' content={same as title}/>
   - <meta property='og:description' content={same as description}/>

2. On /jobs (list page), set generic meta: <title>Jobs — [keyword filter if active] | [Board Name]</title>

3. Add a visible page comment in JobDetail.tsx: '// NOTE: Lovable builds a Vite SPA. This meta tag helps social sharing but does NOT get indexed by Googlebot until a prerender layer is added. See follow-up for Cloudflare Workers prerender setup.'

4. Paste in Build mode: 'Add a /sitemap.xml route that returns a dynamic XML sitemap with all active job URLs. Format: <?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{active jobs}</urlset>. Use a Supabase RPC to fetch active job IDs and generate the URL list server-side from the Edge Function.'

When to use: BEFORE any public launch if SEO is your growth strategy

5

Add featured and sticky paid placement

Featured pinned placement for paid posts with 7-day featured window and visual badges

~30 credits
prompt
Add a premium 'Featured' placement tier where paid posts appear pinned at the top of the /jobs list for 7 days.

1. Add featured_until (timestamptz) column to jobs: ALTER TABLE jobs ADD COLUMN featured_until timestamptz;

2. Update the /jobs query sort order: ORDER BY (featured_until > now()) DESC, is_paid_post DESC, posted_at DESC. This pins jobs with active featured_until first, then all paid posts, then free posts by recency.

3. In stripe-webhook handler on checkout.session.completed, when activating a paid post, also set: featured_until = now() + interval '7 days'.

4. On JobCard, show the 'Featured' badge (amber-500) when job.featured_until > now(). Show a subtle 'Sponsored' text in gray-400 when is_paid_post=true but featured_until has expired.

5. In /post wizard Step 3, add a table comparing Free (14 days, not featured) vs Paid $99 (30 days, featured for 7 days, pinned at top). Make the visual distinction clear.

When to use: When you have 100+ listings and want to offer employers visible premium placement

6

Add company profile pages

Public company profile pages with open positions list and admin verification badge

~50 credits
prompt
Add public company profile pages so employers can build a branded presence on the job board.

1. Create /companies/:slug route. The slug is generated from the company name (lowercased, spaces replaced with hyphens). Add a slug column to companies: ALTER TABLE companies ADD COLUMN slug text UNIQUE; generate it on INSERT via a trigger.

2. /companies/:slug page shows:
   - Company logo (from Storage, or initials avatar fallback)
   - Company name, website link, description
   - 'Open positions' count badge
   - Grid of active jobs from this company (same JobCard component)
   - 'Follow company' button (stores to a company_follows table if you want, or skip for v1)

3. On JobCard and /jobs/:slug, make the company name a link to /companies/:slug.

4. Add an admin-verified badge: add a is_verified bool column to companies. Admin can set is_verified=true from /admin/moderation. Verified companies get a blue checkmark on their company page and on every JobCard that shows their listing.

When to use: When employers start asking for branded landing pages for recruiting

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

permission denied for table jobs on /jobs (anonymous visitor)

Lovable's default RLS scaffold added auth.uid() IS NOT NULL to the SELECT policy on jobs — but anonymous visitors must be able to browse job listings without signing in.

Fix — paste into Lovable Agent Mode
Drop the existing SELECT policy on jobs. Create a new one that explicitly allows the anon role: CREATE POLICY public_read_active ON jobs FOR SELECT TO anon, authenticated USING (status='active' AND expires_at > now()); — the TO anon, authenticated clause is the key part. Test by opening an incognito window and loading /jobs without signing in.
row violates row-level security policy when seeker uploads resume

The ResumeUploader is writing to the Storage path 'resumes/filename.pdf' (no folder prefix) instead of 'resumes/{user_id}/filename.pdf'. The Storage policy scopes access by folder name matching auth.uid(), so a flat path fails.

Fix — paste into Lovable Agent Mode
In ResumeUploader, change the Storage upload path from file.name to `${user.id}/${file.name}`. The full path passed to supabase.storage.from('resumes').upload() should be `${user.id}/${file.name}` so the folder matches the RLS policy's (storage.foldername(name))[1] = auth.uid()::text check.
Google doesn't index any job listings even after a week

Lovable apps are Vite SPAs — Googlebot fetches the page and gets a near-empty <div id='root'></div> with no job content. The JavaScript rendering happens client-side which Googlebot does not fully execute for indexing purposes.

Fix — paste into Lovable Agent Mode
Add react-helmet-async to set <title> and <meta description> per job on /jobs/:slug. This won't fix indexing alone but is the prerequisite for any prerender layer to surface useful metadata to Googlebot.

Manual fix

Option A (cheap, recommended): Set up Cloudflare Workers prerender. Create a Worker that intercepts requests from known bot user-agents (Googlebot, Bingbot), fetches a prerendered HTML snapshot from a service like prerender.io or your own Puppeteer Lambda, and returns it. Cloudflare Workers free tier handles 100K requests/day. Option B (proper, longer): Export to GitHub, port to Next.js with generateStaticParams over active jobs table, redeploy on Vercel. This gives you full SSG + ISR for job listings.

Paid post webhook fires but job.status stays 'draft'

Same pattern as other Stripe webhook issues — either the STRIPE_WEBHOOK_SECRET doesn't match the endpoint signing secret, or metadata.job_id was not passed when creating the Checkout session.

Fix — paste into Lovable Agent Mode
Check Stripe Dashboard → Developers → Webhooks → your endpoint → Recent deliveries → look at the response. If you see a 400 with signature failure, update STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets. If response is 200 but job didn't update, add console.log(event.data.object.metadata) to the webhook and redeploy — confirm job_id is present. In create-paid-post-session, pass: { metadata: { job_id: jobId } } when creating the Checkout session.
pg_cron cron.schedule returns permission denied or function does not exist

pg_cron requires the extension to be enabled AND scheduling requires postgres-role ownership. Regular authenticated/anon users cannot call cron.schedule directly.

Fix — paste into Lovable Agent Mode
In Cloud tab → Database → SQL Editor, run as the postgres role: CREATE EXTENSION IF NOT EXISTS pg_cron; Then: SELECT cron.schedule('expire-jobs', '0 0 * * *', $$UPDATE jobs SET status='expired' WHERE expires_at < now() AND status='active';$$); If still failing, wrap the schedule in a SECURITY DEFINER function: CREATE OR REPLACE FUNCTION setup_cron() RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN PERFORM cron.schedule('expire-jobs', '0 0 * * *', 'UPDATE jobs SET status=''expired'' WHERE expires_at < now() AND status=''active'';'); END; $$; SELECT setup_cron();
Employer can see applications from other employers' jobs

Naive RLS WHERE job_id IN (SELECT id FROM jobs WHERE employer_id = auth.uid()) works but can leak when the public-read policy on jobs allows any authenticated user to see the jobs table — the subquery returns more job IDs than intended in some query planner edge cases.

Fix — paste into Lovable Agent Mode
Create a SECURITY DEFINER plpgsql function can_see_application(p_application_id uuid) that runs as function owner and returns true only when auth.uid() = seeker_id OR auth.uid() = the employer_id of the job linked to this application. Replace any direct subquery in the applications SELECT policy with a call to this function. Test: sign in as Employer B, try to SELECT applications WHERE job_id IN (SELECT id FROM jobs WHERE employer_id = [Employer A's UUID]) — should return 0 rows.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo recommended. Full chain runs ~100-180 credits — within Pro's monthly allowance if you don't loop on RLS or prerender. One top-up ($15 for 50 credits) if you do.

Monthly run cost breakdown

~100-180 credits (starter ~50-70, six follow-ups ~300 if all done; most readers ship after 3 follow-ups for ~130 total) total
ItemCost
Lovable Pro

Drop after build — app runs on Lovable Cloud

$25/mo
Lovable Cloud (Supabase)

500MB DB + 1GB Storage handles ~10K jobs + ~2K resume PDFs at 500KB each

$0/mo at MVP
Supabase Pro (when needed)

At ~2K resume PDFs or 500K+ job records

$25/mo
Stripe

At $99/post and 30 posts/mo, you net ~$2,841 minus ~$90 Stripe fees

2.9% + 30¢ per paid post
Resend

Covers ~100 daily-alert subscribers. $20/mo for 50K — needed once you grow

$0/mo up to 3K emails
Cloudflare Workers (prerender)

100K requests/day free — more than enough for a niche board's bot traffic

$0/mo free tier
Custom domain

Required for professional presence

~$10-15/yr

Scaling notes: Storage 1GB free hits at ~2K resume PDFs at 500KB each. DB 500MB allows ~500K job records. Resend 3K/mo covers ~100 daily-alert subscribers. The real scaling ceiling is Stripe fees on paid posts — at $99/post × 100 posts/mo you're generating ~$9,603 net revenue after Stripe fees, well past the point where the Lovable build pays for itself.

Production checklist

Steps to take before you share the URL with real users.

SEO (critical for job boards)

  • Set up Cloudflare Workers prerender OR accept direct-traffic-only growth before launching publicly

    Either: (A) Create a Cloudflare Worker that serves prerendered HTML to bots. Route it: in Cloudflare Dashboard → Workers → create worker → use prerender.io or a self-hosted Puppeteer function to snapshot each /jobs/:slug URL and cache it. Point your domain's DNS to Cloudflare. (B) Export to GitHub, migrate to Next.js on Vercel with generateStaticParams. (C) Accept no SEO for now and grow via email list + social — valid for a community-first niche board.

  • Submit a dynamic sitemap to Google Search Console

    After implementing the /sitemap.xml route from follow-up #4, go to search.google.com/search-console → select your property → Sitemaps → enter the sitemap URL → Submit. Monitor indexing coverage weekly for the first month.

Resume storage safety

  • Enforce PDF-only and 5MB cap in ResumeUploader before any seekers upload

    Confirm the ResumeUploader component validates: file.type === 'application/pdf' and file.size <= 5 * 1024 * 1024 before calling supabase.storage.from('resumes').upload(). Add a visible error message 'Please upload a PDF under 5MB' if validation fails. Without this, employers will receive DOCX files that blow your Storage cap and are harder to display.

Job expiry monitoring

  • Verify pg_cron expiry is running after first week

    Cloud tab → Database → SQL Editor: SELECT * FROM cron.job_run_details WHERE jobname='expire-jobs' ORDER BY start_time DESC LIMIT 5; You should see recent runs with status='succeeded'. If empty or all failed, the schedule isn't running — rerun the cron.schedule() call and check for permission errors.

Anti-spam

  • Add rate limiting on job posting before making the board public

    Limit each employer to 5 free posts per 30 days. Add a check in the /post wizard: SELECT count(*) FROM jobs WHERE employer_id=auth.uid() AND is_paid_post=false AND created_at > now()-interval '30 days'. If >= 5, show 'Free post limit reached — boost a listing for $99 to continue' and disable the free post button.

Frequently asked questions

Will Google actually index my job listings on a Lovable-built board?

Not by default. Lovable builds Vite SPAs — when Googlebot crawls /jobs/senior-engineer, it gets back a near-empty HTML page with a <div id='root'></div> placeholder. The actual job content is rendered by JavaScript after page load, which Googlebot does not fully execute for indexing. To get indexed, you need either: (A) Cloudflare Workers prerender — intercept bot requests and serve a cached HTML snapshot, (B) migrate to Next.js with generateStaticParams for static site generation, or (C) accept that you're a direct-traffic-only board until you solve this. Most niche job boards in the 0-100 listing range grow via email and communities first, which buys time to solve SSR properly.

How much does it cost to run a job board for the first year?

If you stay on the free tiers: Lovable Pro $25/mo (only while building, ~2-3 months), Lovable Cloud $0/mo, Resend $0/mo up to 3K emails, Cloudflare Workers free tier, custom domain ~$12/yr. First-year cost: ~$75-90 total (3 months Pro) + domain. At 30 paid posts/mo at $99 each, you're generating ~$2,850 net after Stripe fees — the board pays for itself on month 2. The Supabase Pro upgrade ($25/mo) only kicks in if you exceed 2K resume PDFs in Storage.

Should I charge employers to post from day one?

No — start free to build listing supply. A job board with 0 paid listings is worth $0 to seekers. The typical pattern is: free posting for the first 3 months to reach 50+ active jobs, then introduce paid posting for new listings while grandfathering existing employers. This gives you enough seeker traffic to show employers the ROI of a paid post before you ask them to spend $99.

How do I prevent spam or fake job postings?

Three layers: first, require email verification on signup (Supabase email confirmation is on by default — don't turn it off). Second, add a rate limit of 5 free posts per employer per 30 days (follow-up for /post wizard — see production checklist). Third, add the admin moderation queue from the starter prompt so you can review and remove bad listings. For high-volume spam, add Cloudflare Turnstile (free) on the signup and post-job forms — the Turnstile integration is a 2-hour follow-up.

Can seekers apply without creating an account?

Not with the starter prompt as written — applications are tied to seeker_id which requires auth.uid(). The simplest workaround is to let seekers start the application by entering their email, send them a magic-link OTP to verify, and only collect auth.uid() after verification. This is a 30-40 credit follow-up prompt. Alternatively, add a 'Quick apply' form that emails the resume directly to the employer without creating a database row — simpler but you lose the centralized application tracking.

What's the best way to migrate to Next.js once I outgrow the SPA?

Click the GitHub icon in Lovable's top-right toolbar, connect your GitHub account, and export the project to a new repository. Then clone the repo locally, open in Cursor or VS Code, and run: npx create-next-app@latest --empty to scaffold a Next.js app. Copy your Supabase client setup and DB schema. Port each page component to a Next.js page or Server Component, replacing React Router links with next/link. Use generateStaticParams on /jobs/[slug] to pre-generate all active job pages at build time. Redeploy on Vercel. Expect 2-3 days of migration work for a 5-10 page job board.

When should I just pay Niceboard $179/mo instead of building?

Pay Niceboard if: you need SEO-indexed listings live within a week (Niceboard is server-rendered, you get indexing day 1), you're not technical enough to set up Cloudflare Workers prerender, or you need Niceboard's built-in employer billing portal + ATS features. Build in Lovable when: you want zero per-month platform fee after the build (vs $179/mo forever), you need a data model Niceboard can't support (e.g., equity-based job postings with vesting schedules), or you're embedding the job board inside a larger Lovable app (community + courses + jobs in one domain). If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.