Skip to main content
RapidDev - Software Development Agency

How to Build a Job Board with Lovable

Build a two-role job board in Lovable where employers post listings and seekers apply with resume uploads to Supabase Storage. Multi-dimension search covers title, location, type, and salary range. Employers manage applications in a DataTable. Seekers save jobs and track application status — all backed by Supabase RLS.

What you'll build

  • companies, job_listings, applications, and saved_jobs tables with role-based RLS
  • Employer dashboard for creating and managing listings with a DataTable of applications per job
  • Seeker-facing job search with multi-dimension filters (title, location, type, salary range, remote)
  • Resume upload to Supabase Storage with signed URLs for private access by employers
  • Application tracking page for seekers showing status (applied, reviewing, interview, rejected, offered)
  • Saved jobs functionality with a dedicated bookmarks page for seekers
  • tsvector full-text search on job title and description with a GIN index
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2.5–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a two-role job board in Lovable where employers post listings and seekers apply with resume uploads to Supabase Storage. Multi-dimension search covers title, location, type, and salary range. Employers manage applications in a DataTable. Seekers save jobs and track application status — all backed by Supabase RLS.

What you're building

A job board needs two completely separate experiences: employers managing their listings and reviewing applicants, and job seekers browsing and applying. The key challenge is making sure each role can only see and modify their own data.

Supabase RLS handles this elegantly. The job_listings table has policies that allow any authenticated user to read published listings, but only the listing owner (employer) can update or delete. Applications are even stricter: only the seeker who applied can see their own application, and only the employer who owns the listing can see applications for it.

Resumes are stored in a private Supabase Storage bucket. Seekers upload once and their resume_url is stored in their profile. When an employer needs to view a resume, your app generates a short-lived signed URL via an Edge Function — the actual file is never publicly accessible.

The search page uses a PostgreSQL tsvector full-text search on job title and description for keyword search, combined with standard filter columns (job_type, location, salary_min/max, is_remote) for faceted filtering.

Final result

A fully functional two-role job board with search, applications, resume uploads, and status tracking — all with proper data isolation.

Tech stack

LovableFrontend for both roles
SupabaseDatabase and Auth
Supabase StorageResume uploads (private bucket)
shadcn/uiDataTable, Command, Slider, Badge, Sheet components
TanStack Table v8Applications DataTable
Supabase Edge FunctionsSigned resume URL generation (Deno)

Prerequisites

  • Lovable Pro account (multi-page multi-role apps use more credits)
  • Supabase project with URL and anon key saved to Cloud tab → Secrets
  • A Supabase Storage bucket named 'resumes' set to private (not public)
  • Supabase Auth configured with email/password sign-up
  • Service role key saved to Cloud tab → Secrets as SUPABASE_SERVICE_ROLE_KEY

Build steps

1

Create the job board schema

Prompt Lovable to create all four tables with the correct RLS policies. The role differentiation (employer vs seeker) is stored in a user_profiles table linked to auth.users.

prompt.txt
1Create a two-role job board with Supabase. Set up these tables:
2
3- user_profiles: id (uuid pk references auth.users), role (text check in ('employer', 'seeker')), full_name (text), headline (text), resume_url (text), company_id (uuid), created_at
4- companies: id (uuid pk), owner_id (uuid references auth.users), name (text not null), logo_url (text), website (text), description (text), location (text), employee_count (text), created_at
5- job_listings: id (uuid pk), company_id (uuid references companies), title (text not null), description (text), location (text), is_remote (bool default false), job_type (text check in ('full-time','part-time','contract','internship')), salary_min (int), salary_max (int), status (text check in ('draft','published','closed'), default 'draft'), search_vector (tsvector), application_count (int default 0), published_at (timestamptz), created_at, updated_at
6- applications: id (uuid pk), listing_id (uuid references job_listings), applicant_id (uuid references auth.users), cover_letter (text), resume_url (text), status (text check in ('applied','reviewing','interview','rejected','offered'), default 'applied'), employer_notes (text), created_at, updated_at, UNIQUE(listing_id, applicant_id)
7- saved_jobs: applicant_id (uuid references auth.users), listing_id (uuid references job_listings), saved_at, PRIMARY KEY (applicant_id, listing_id)
8
9RLS:
10- job_listings: anon/authenticated SELECT where status='published', employer SELECT/UPDATE/DELETE where company_id in their companies
11- applications: seeker SELECT/INSERT/UPDATE(cover_letter only) where applicant_id=auth.uid(), employer SELECT/UPDATE(status, employer_notes) where listing belongs to their company
12- saved_jobs: users manage their own rows
13- user_profiles: users manage their own row
14- companies: owner manages their company
15
16Create a trigger to update job_listings.search_vector: to_tsvector('english', title || ' ' || coalesce(description,'') || ' ' || coalesce(location,'')). Create GIN index on search_vector.
17Create a trigger to increment/decrement job_listings.application_count on applications INSERT/DELETE.

Pro tip: Ask Lovable to add a function get_user_role() that returns the current user's role from user_profiles. Use this in your React context to switch UI between employer and seeker views without extra Supabase calls on every page.

Expected result: All five tables are created with RLS policies and triggers. TypeScript types are generated. The app shows a role selection on first login.

2

Build the job search page with multi-dimension filters

Create the seeker-facing job search page. The Command component handles keyword search, with filter controls for job type, salary range, location, and remote flag.

prompt.txt
1Build a job search page at src/pages/Jobs.tsx.
2
3Layout:
4- Search bar using shadcn/ui Command (not a plain Input) searches job title and description via Supabase textSearch on search_vector
5- Filter row below the search bar:
6 - Job Type: multi-select Toggle group (All / Full-time / Part-time / Contract / Internship)
7 - Remote only: Switch
8 - Salary range: two Inputs (min/max) with a range Slider below them
9 - Location: Input with debounce
10- Job listings as Cards in a grid (not a table):
11 - Company logo (Avatar), company name, job title (h3)
12 - Badges: job_type, is_remote ('Remote'), salary range (formatted)
13 - Location text, published_at relative time
14 - 'Apply' Button and a bookmark icon Button (toggles saved_jobs)
15- Show application_count as a small '12 applicants' note on each card
16- Pagination: 'Load more' Button that appends to the current list
17
18Query logic:
19- Start with: supabase.from('job_listings').select('*, companies(name, logo_url)').eq('status','published')
20- Apply textSearch if keyword entered
21- Apply .eq('job_type', type) if type selected
22- Apply .eq('is_remote', true) if remote toggle on
23- Apply .gte('salary_min', minSalary).lte('salary_max', maxSalary) if salary set
24- Apply .ilike('location', '%' + location + '%') if location entered

Pro tip: Debounce all filter inputs by 300ms before triggering a new Supabase query. Without debouncing, the salary range Slider fires a query on every pixel of movement, creating dozens of simultaneous requests.

Expected result: The jobs page renders published listings as Cards. Entering a keyword triggers a full-text search. Each filter combination updates the list in real time.

3

Build the application form with resume upload

Create the application Dialog that opens when a seeker clicks Apply. Resume upload goes to the private Supabase Storage bucket with the file path including the user's ID for RLS isolation.

prompt.txt
1Build an ApplyDialog component at src/components/ApplyDialog.tsx. Props: listing (JobListing), onClose.
2
3Form fields (react-hook-form + zod):
4- Cover Letter (Textarea, required, min 100 chars, show char count)
5- Resume: show the seeker's current resume from user_profiles.resume_url as a link if it exists, with a 'Use existing resume' Checkbox (default checked). If unchecked, show a file input for uploading a new resume.
6
7Resume upload logic:
8- Accept only PDF and DOCX (application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document)
9- Upload to 'resumes' storage bucket at path: {userId}/{listingId}/{filename}
10- After upload, do NOT use the public URL (bucket is private)
11- Store the storage path (not a URL) in the applications.resume_url column
12- Optionally update user_profiles.resume_url with the same path for future reuse
13
14On submit:
15- Insert into applications: { listing_id, applicant_id: currentUser.id, cover_letter, resume_url: storagePath, status: 'applied' }
16- Handle the UNIQUE constraint: if error code is '23505', show 'You have already applied to this job'
17- Show a success toast: 'Application submitted!'
18- Close the Dialog

Expected result: The Apply Dialog opens from a job Card. Uploading a resume stores it privately. Submitting creates the application row. Applying twice shows a clear error message.

4

Build the employer dashboard

Create the employer-facing pages for posting jobs and reviewing applications. Applications from seekers are shown in a DataTable with status management.

prompt.txt
1Build two employer pages:
2
31. src/pages/employer/Listings.tsx manage job listings:
4- DataTable with columns: Title, Status Badge (draft/published/closed), application_count, Published At, Actions (Edit, Close, Delete)
5- 'Post Job' Button opens a Sheet with a job creation form:
6 - Title, Description (Textarea, rich with 400px min height), Location, Remote Switch
7 - Job Type Select, Salary Min/Max Inputs
8 - Status Select (Save as Draft / Publish now)
9 - Company auto-selected from the employer's company
10
112. src/pages/employer/Applications.tsx review applications:
12- A listing Select at the top filters to one job
13- DataTable with columns: Applicant Name, Applied At, Status Badge (color per status), Cover Letter preview (truncated), Resume (Button to view calls Edge Function to get signed URL), Actions
14- Status update: clicking the Status Badge shows a Select with all status options. On change, update applications.status
15- Employer Notes: an expandable row showing employer_notes Textarea, auto-saved on blur
16- Add a 'Send to Interview' Button shortcut that sets status to 'interview'
17
18Fetch applications with applicant info: supabase.from('applications').select('*, user_profiles(full_name, headline)')

Expected result: The employer can post jobs and see a list of applications per listing. Changing an application status updates it in the database. The resume view button triggers the signed URL generation.

5

Create the signed resume URL Edge Function

Build the Edge Function that generates a 60-second signed URL for viewing a private resume. This keeps resume files private while giving authorized employers temporary access.

supabase/functions/resume-url/index.ts
1// supabase/functions/resume-url/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const cors = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
13
14 const authHeader = req.headers.get('Authorization')
15 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
16
17 const { applicationId } = await req.json()
18
19 const supabase = createClient(
20 Deno.env.get('SUPABASE_URL') ?? '',
21 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
22 )
23
24 // Verify the caller is an employer who owns the listing
25 const userSupabase = createClient(
26 Deno.env.get('SUPABASE_URL') ?? '',
27 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
28 { global: { headers: { Authorization: authHeader } } }
29 )
30 const { data: { user } } = await userSupabase.auth.getUser()
31 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
32
33 const { data: app } = await supabase
34 .from('applications')
35 .select('resume_url, job_listings(company_id, companies(owner_id))')
36 .eq('id', applicationId)
37 .single()
38
39 if (!app) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
40
41 const ownerCheck = (app as any).job_listings?.companies?.owner_id
42 if (ownerCheck !== user.id) return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })
43
44 const { data: signedData, error } = await supabase.storage
45 .from('resumes')
46 .createSignedUrl(app.resume_url, 60)
47
48 if (error || !signedData) return new Response(JSON.stringify({ error: 'Could not generate URL' }), { status: 500, headers: cors })
49
50 return new Response(JSON.stringify({ url: signedData.signedUrl }), { headers: cors })
51})

Expected result: The Edge Function generates a 60-second signed URL for a resume when called with a valid employer session and applicationId. Unauthorized callers receive 403.

Complete code

supabase/functions/resume-url/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const cors = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
12
13 const authHeader = req.headers.get('Authorization')
14 if (!authHeader) {
15 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
16 }
17
18 const { applicationId } = await req.json()
19
20 const supabase = createClient(
21 Deno.env.get('SUPABASE_URL') ?? '',
22 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
23 )
24
25 const userSupabase = createClient(
26 Deno.env.get('SUPABASE_URL') ?? '',
27 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
28 { global: { headers: { Authorization: authHeader } } }
29 )
30 const { data: { user } } = await userSupabase.auth.getUser()
31 if (!user) {
32 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
33 }
34
35 const { data: app } = await supabase
36 .from('applications')
37 .select('resume_url, job_listings(company_id, companies(owner_id))')
38 .eq('id', applicationId)
39 .single()
40
41 if (!app) {
42 return new Response(JSON.stringify({ error: 'Application not found' }), { status: 404, headers: cors })
43 }
44
45 const ownerCheck = (app as any).job_listings?.companies?.owner_id
46 if (ownerCheck !== user.id) {
47 return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })
48 }
49
50 const { data: signedData, error } = await supabase.storage
51 .from('resumes')
52 .createSignedUrl(app.resume_url, 60)
53
54 if (error || !signedData) {
55 return new Response(JSON.stringify({ error: 'Could not generate signed URL' }), { status: 500, headers: cors })
56 }
57
58 return new Response(JSON.stringify({ url: signedData.signedUrl }), { headers: cors })
59})

Customization ideas

Application status email notifications

Add a Supabase database webhook that fires when applications.status is updated. The webhook calls an Edge Function that sends a Resend email to the applicant: 'Your application at [Company] has moved to [Status].' Include a link back to their application tracker page.

Employer applicant scoring

Add a score int column (1-5) to applications. In the applications DataTable, add a star rating component per row using five clickable Star icons. Sort applicants by score descending to prioritize top candidates. Add a score filter: 'Show only 4+ star applicants'.

Job alert subscriptions

Add a job_alerts table where seekers save search criteria (keywords, job_type, salary_min, location). A daily scheduled Edge Function runs each alert's query and emails the seeker new matching listings published since their last notification.

Company profile pages

Add a public company profile page at /companies/{slug}. It shows the company description, logo, size, and all their published job listings. Link each job card's company name to this page. Companies can upload their logo to Supabase Storage from the employer settings page.

Applicant talent pool for employers

Allow employers to save interesting candidate profiles to a talent_pool table (employer_id, applicant_id, notes). The talent pool page shows saved candidate profiles with their headline and resume link. Employers can reach out directly via an in-app message.

Common pitfalls

Pitfall: Setting the resumes Storage bucket to public

How to avoid: Keep the resumes bucket private. Generate short-lived signed URLs via the Edge Function only for authorized employers. The signed URL expires in 60 seconds — enough time to download the file but not enough to share persistently.

Pitfall: Allowing seekers to update their application status

How to avoid: The RLS policy on applications for seekers should only allow UPDATE on the cover_letter column (so they can edit before it is reviewed). Status updates must be restricted to the employer RLS policy.

Pitfall: Not handling the UNIQUE constraint on (listing_id, applicant_id) in the frontend

How to avoid: Catch the error from the INSERT and check error.code === '23505'. Show a friendly message: 'You have already applied to this job.' Also disable the Apply button on job cards where the seeker already has an application.

Pitfall: Searching only on the title column instead of the full-text search vector

How to avoid: Use .textSearch('search_vector', keyword) instead of ilike. The search_vector combines title, description, and location so one search box covers all relevant fields.

Best practices

  • Enforce role selection at signup. When a user creates an account, immediately create their user_profiles row with a role. Use a Supabase Auth trigger (on auth.users INSERT) to auto-create the profile with role = null, then redirect to a role selection page.
  • Never expose the full resume file URL to the frontend. Always route through the signed URL Edge Function. This centralizes access control and makes it easy to add audit logging later.
  • Add a closed_at timestamp to job_listings set when status changes to 'closed'. Closed jobs remain visible to seekers as 'Position Filled' for a week, then are hidden. This reduces confusion for seekers who find the job via Google.
  • Rate-limit applications per seeker per day via a check in the INSERT trigger or Edge Function. Spam applications from a single account degrade employer experience and suggest automation.
  • Store the resume storage path (not the full URL) in applications.resume_url. Paths are stable even if your Supabase project URL changes. Full signed URLs expire and are not suitable for storage.
  • Index job_listings on (status, published_at) and (status, salary_min, salary_max) to keep filter queries fast as the listings table grows.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a job board with Supabase. The job_listings table has title, description, location, job_type, salary_min, salary_max, is_remote, and a search_vector tsvector column. I want to build a combined search that handles keyword search AND numeric range filters at the same time. Show me the TypeScript code using the Supabase JS client that combines textSearch on search_vector with .gte/.lte salary filters and .eq job_type filter in a single query chain.

Lovable Prompt

Add a seeker application tracker page at /my-applications. Fetch all applications for the current user joined with job_listings and companies. Render as a list of Cards grouped by status using shadcn/ui Tabs (All / Applied / Reviewing / Interview / Offered / Rejected). Each Card shows company name, job title, applied date, and current status Badge. Add a 'Withdraw' Button that deletes the application after an AlertDialog confirmation.

Build Prompt

In Supabase, create an RLS policy for the applications table that allows employers to SELECT applications where the application's listing belongs to a company owned by the current user. The policy needs to traverse two joins: applications.listing_id → job_listings.company_id → companies.owner_id = auth.uid(). Write the SQL CREATE POLICY statement for this using a subquery or EXISTS check.

Frequently asked questions

How do I prevent spam job postings from fake employers?

Add an email verification requirement before employers can post listings. Set the job_listings RLS INSERT policy to require auth.email_confirmed_at IS NOT NULL. In the employer dashboard, show a banner to unverified users explaining they must confirm their email before posting. Supabase Auth handles the confirmation email automatically.

Can employers see which seekers viewed their listing?

Not by default. Add a listing_views table with listing_id, viewer_id (nullable for anonymous), and viewed_at. Insert a row when a seeker opens a job Card's detail view. The employer dashboard can then show a view count. Anonymous views can be tracked via a cookie or session ID if needed.

What file formats should I accept for resumes?

Accept PDF and DOCX. PDF is universal and renders consistently everywhere. DOCX is common from job seekers using Microsoft Word. Add MIME type validation in the file input: accept='application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document'. Add a 5MB size limit to keep storage costs manageable.

How does the salary range search work when not all jobs have salary listed?

Only apply the salary filters when the user has entered values. If salary_min and salary_max inputs are empty, skip the .gte/.lte clauses entirely. Jobs with null salary columns should still appear in unfiltered results — they drop out only when a salary range filter is actively applied.

Can one employer manage multiple companies?

The schema supports it. The companies table has an owner_id but a user can own multiple companies. Add a company switcher in the employer dashboard header that sets the 'active company' in React state. All DataTable queries filter by the active company's ID rather than the user's ID directly.

How do I handle listing expiration?

Add an expires_at column to job_listings. Create a Supabase scheduled Edge Function (using pg_cron) that runs daily and sets status = 'closed' for listings where expires_at < now() and status = 'published'. The public search query already filters by status = 'published', so expired listings disappear automatically.

Is there a way for seekers to track their application statistics?

Add a statistics section to the seeker's profile page. Count applications by status: total, reviewing, interview, offered, rejected. Show a simple Recharts bar chart with application count per week over the last 3 months. All data comes from the seeker's own applications rows, so RLS already protects it.

Can RapidDev help me add an ATS pipeline view to this job board?

RapidDev builds full-featured hiring tools on Lovable. We can add Kanban pipeline views, interview scheduling with calendar integration, team collaboration on candidates, and offer letter generation. Reach out if you need to upgrade beyond the basic job board pattern.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.