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
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
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.
1Create a two-role job board with Supabase. Set up these tables:23- 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_at4- 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_at5- 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_at6- 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)89RLS:10- job_listings: anon/authenticated SELECT where status='published', employer SELECT/UPDATE/DELETE where company_id in their companies11- applications: seeker SELECT/INSERT/UPDATE(cover_letter only) where applicant_id=auth.uid(), employer SELECT/UPDATE(status, employer_notes) where listing belongs to their company12- saved_jobs: users manage their own rows13- user_profiles: users manage their own row14- companies: owner manages their company1516Create 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.
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.
1Build a job search page at src/pages/Jobs.tsx.23Layout:4- Search bar using shadcn/ui Command (not a plain Input) — searches job title and description via Supabase textSearch on search_vector5- Filter row below the search bar:6 - Job Type: multi-select Toggle group (All / Full-time / Part-time / Contract / Internship)7 - Remote only: Switch8 - Salary range: two Inputs (min/max) with a range Slider below them9 - Location: Input with debounce10- 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 time14 - 'Apply' Button and a bookmark icon Button (toggles saved_jobs)15- Show application_count as a small '12 applicants' note on each card16- Pagination: 'Load more' Button that appends to the current list1718Query logic:19- Start with: supabase.from('job_listings').select('*, companies(name, logo_url)').eq('status','published')20- Apply textSearch if keyword entered21- Apply .eq('job_type', type) if type selected22- Apply .eq('is_remote', true) if remote toggle on23- Apply .gte('salary_min', minSalary).lte('salary_max', maxSalary) if salary set24- Apply .ilike('location', '%' + location + '%') if location enteredPro 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.
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.
1Build an ApplyDialog component at src/components/ApplyDialog.tsx. Props: listing (JobListing), onClose.23Form 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.67Resume 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 column12- Optionally update user_profiles.resume_url with the same path for future reuse1314On 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 DialogExpected 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.
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.
1Build two employer pages:231. 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 Switch7 - Job Type Select, Salary Min/Max Inputs8 - Status Select (Save as Draft / Publish now)9 - Company auto-selected from the employer's company10112. src/pages/employer/Applications.tsx — review applications:12- A listing Select at the top filters to one job13- 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), Actions14- Status update: clicking the Status Badge shows a Select with all status options. On change, update applications.status15- Employer Notes: an expandable row showing employer_notes Textarea, auto-saved on blur16- Add a 'Send to Interview' Button shortcut that sets status to 'interview'1718Fetch 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.
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.
1// supabase/functions/resume-url/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1314 const authHeader = req.headers.get('Authorization')15 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })1617 const { applicationId } = await req.json()1819 const supabase = createClient(20 Deno.env.get('SUPABASE_URL') ?? '',21 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''22 )2324 // Verify the caller is an employer who owns the listing25 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 })3233 const { data: app } = await supabase34 .from('applications')35 .select('resume_url, job_listings(company_id, companies(owner_id))')36 .eq('id', applicationId)37 .single()3839 if (!app) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })4041 const ownerCheck = (app as any).job_listings?.companies?.owner_id42 if (ownerCheck !== user.id) return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })4344 const { data: signedData, error } = await supabase.storage45 .from('resumes')46 .createSignedUrl(app.resume_url, 60)4748 if (error || !signedData) return new Response(JSON.stringify({ error: 'Could not generate URL' }), { status: 500, headers: cors })4950 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1213 const authHeader = req.headers.get('Authorization')14 if (!authHeader) {15 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })16 }1718 const { applicationId } = await req.json()1920 const supabase = createClient(21 Deno.env.get('SUPABASE_URL') ?? '',22 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''23 )2425 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 }3435 const { data: app } = await supabase36 .from('applications')37 .select('resume_url, job_listings(company_id, companies(owner_id))')38 .eq('id', applicationId)39 .single()4041 if (!app) {42 return new Response(JSON.stringify({ error: 'Application not found' }), { status: 404, headers: cors })43 }4445 const ownerCheck = (app as any).job_listings?.companies?.owner_id46 if (ownerCheck !== user.id) {47 return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })48 }4950 const { data: signedData, error } = await supabase.storage51 .from('resumes')52 .createSignedUrl(app.resume_url, 60)5354 if (error || !signedData) {55 return new Response(JSON.stringify({ error: 'Could not generate signed URL' }), { status: 500, headers: cors })56 }5758 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation