# How to Build a Job Board with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create a two-role job board with Supabase. Set up these tables:

- 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
- 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
- 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
- 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)
- saved_jobs: applicant_id (uuid references auth.users), listing_id (uuid references job_listings), saved_at, PRIMARY KEY (applicant_id, listing_id)

RLS:
- job_listings: anon/authenticated SELECT where status='published', employer SELECT/UPDATE/DELETE where company_id in their companies
- 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
- saved_jobs: users manage their own rows
- user_profiles: users manage their own row
- companies: owner manages their company

Create a trigger to update job_listings.search_vector: to_tsvector('english', title || ' ' || coalesce(description,'') || ' ' || coalesce(location,'')). Create GIN index on search_vector.
Create 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.

```
Build a job search page at src/pages/Jobs.tsx.

Layout:
- Search bar using shadcn/ui Command (not a plain Input) — searches job title and description via Supabase textSearch on search_vector
- Filter row below the search bar:
  - Job Type: multi-select Toggle group (All / Full-time / Part-time / Contract / Internship)
  - Remote only: Switch
  - Salary range: two Inputs (min/max) with a range Slider below them
  - Location: Input with debounce
- Job listings as Cards in a grid (not a table):
  - Company logo (Avatar), company name, job title (h3)
  - Badges: job_type, is_remote ('Remote'), salary range (formatted)
  - Location text, published_at relative time
  - 'Apply' Button and a bookmark icon Button (toggles saved_jobs)
- Show application_count as a small '12 applicants' note on each card
- Pagination: 'Load more' Button that appends to the current list

Query logic:
- Start with: supabase.from('job_listings').select('*, companies(name, logo_url)').eq('status','published')
- Apply textSearch if keyword entered
- Apply .eq('job_type', type) if type selected
- Apply .eq('is_remote', true) if remote toggle on
- Apply .gte('salary_min', minSalary).lte('salary_max', maxSalary) if salary set
- 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.

```
Build an ApplyDialog component at src/components/ApplyDialog.tsx. Props: listing (JobListing), onClose.

Form fields (react-hook-form + zod):
- Cover Letter (Textarea, required, min 100 chars, show char count)
- 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.

Resume upload logic:
- Accept only PDF and DOCX (application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document)
- Upload to 'resumes' storage bucket at path: {userId}/{listingId}/{filename}
- After upload, do NOT use the public URL (bucket is private)
- Store the storage path (not a URL) in the applications.resume_url column
- Optionally update user_profiles.resume_url with the same path for future reuse

On submit:
- Insert into applications: { listing_id, applicant_id: currentUser.id, cover_letter, resume_url: storagePath, status: 'applied' }
- Handle the UNIQUE constraint: if error code is '23505', show 'You have already applied to this job'
- Show a success toast: 'Application submitted!'
- 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.

```
Build two employer pages:

1. src/pages/employer/Listings.tsx — manage job listings:
- DataTable with columns: Title, Status Badge (draft/published/closed), application_count, Published At, Actions (Edit, Close, Delete)
- 'Post Job' Button opens a Sheet with a job creation form:
  - Title, Description (Textarea, rich with 400px min height), Location, Remote Switch
  - Job Type Select, Salary Min/Max Inputs
  - Status Select (Save as Draft / Publish now)
  - Company auto-selected from the employer's company

2. src/pages/employer/Applications.tsx — review applications:
- A listing Select at the top filters to one job
- 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
- Status update: clicking the Status Badge shows a Select with all status options. On change, update applications.status
- Employer Notes: an expandable row showing employer_notes Textarea, auto-saved on blur
- Add a 'Send to Interview' Button shortcut that sets status to 'interview'

Fetch 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const { applicationId } = await req.json()

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  // Verify the caller is an employer who owns the listing
  const userSupabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userSupabase.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const { data: app } = await supabase
    .from('applications')
    .select('resume_url, job_listings(company_id, companies(owner_id))')
    .eq('id', applicationId)
    .single()

  if (!app) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })

  const ownerCheck = (app as any).job_listings?.companies?.owner_id
  if (ownerCheck !== user.id) return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })

  const { data: signedData, error } = await supabase.storage
    .from('resumes')
    .createSignedUrl(app.resume_url, 60)

  if (error || !signedData) return new Response(JSON.stringify({ error: 'Could not generate URL' }), { status: 500, headers: cors })

  return new Response(JSON.stringify({ url: signedData.signedUrl }), { headers: cors })
})
```

**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 example

File: `supabase/functions/resume-url/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
  }

  const { applicationId } = await req.json()

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const userSupabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userSupabase.auth.getUser()
  if (!user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
  }

  const { data: app } = await supabase
    .from('applications')
    .select('resume_url, job_listings(company_id, companies(owner_id))')
    .eq('id', applicationId)
    .single()

  if (!app) {
    return new Response(JSON.stringify({ error: 'Application not found' }), { status: 404, headers: cors })
  }

  const ownerCheck = (app as any).job_listings?.companies?.owner_id
  if (ownerCheck !== user.id) {
    return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: cors })
  }

  const { data: signedData, error } = await supabase.storage
    .from('resumes')
    .createSignedUrl(app.resume_url, 60)

  if (error || !signedData) {
    return new Response(JSON.stringify({ error: 'Could not generate signed URL' }), { status: 500, headers: cors })
  }

  return new Response(JSON.stringify({ url: signedData.signedUrl }), { headers: cors })
})
```

## Common mistakes

- **Setting the resumes Storage bucket to public** — Public buckets generate permanent, guessable URLs. Any employer (or anyone with the URL) could access any applicant's resume without authorization. Fix: 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.
- **Allowing seekers to update their application status** — Without strict RLS, a motivated seeker could change their own application status to 'offered', bypassing the employer's decision. Fix: 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.
- **Not handling the UNIQUE constraint on (listing_id, applicant_id) in the frontend** — Supabase throws a 23505 error code if a seeker tries to apply twice. Without handling this, the user sees a generic error or the form spinner loops. Fix: 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.
- **Searching only on the title column instead of the full-text search vector** — A plain .ilike('title', '%engineer%') query is slow on large tables and misses synonyms. The tsvector search is indexed and handles stemming ('engineering' matches 'engineer'). Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/job-board
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/job-board
