# How to Build Job board with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a niche job board with V0 using Next.js, Supabase, Stripe, and shadcn/ui. Employers post and pay for featured listings via Stripe Checkout, applicants search and apply with resume uploads, and webhook-verified payments automatically promote listings — all in about 1-2 hours.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works — connect via Vercel Marketplace)
- Your niche focus (e.g., remote jobs, AI roles, climate tech positions)

## Step-by-step guide

### 1. Set up the project with Supabase and Stripe

Create a new V0 project. Use the Connect panel to add Supabase for the database and auth. Then add Stripe via the Vercel Marketplace — this auto-provisions your test API keys into the Vars tab.

```
// Paste this prompt into V0's AI chat:
// Build a job board platform. Create a Supabase schema with these tables:
// 1. companies: id (uuid PK), name (text), logo_url (text), website (text), owner_id (uuid FK to auth.users)
// 2. jobs: id (uuid PK), company_id (uuid FK to companies), title (text), description (text), location (text), salary_min (int), salary_max (int), job_type (text), is_featured (boolean DEFAULT false), status (text DEFAULT 'draft'), published_at (timestamptz), expires_at (timestamptz), stripe_payment_id (text)
// 3. applications: id (uuid PK), job_id (uuid FK to jobs), applicant_name (text), email (text), resume_url (text), cover_letter (text), status (text DEFAULT 'new'), created_at (timestamptz)
// 4. saved_jobs: user_id (uuid FK to auth.users), job_id (uuid FK to jobs), PRIMARY KEY (user_id, job_id)
// Add RLS policies: anyone can read published jobs, only company owners can manage their jobs and view applications.
```

> Pro tip: Add Stripe via the Vercel Marketplace integration in V0 — it auto-provisions STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY into your Vars tab, saving manual configuration.

**Expected result:** Supabase is connected with all tables created, Stripe keys are in the Vars tab, and RLS policies are active.

### 2. Build the searchable job listing page

Create the public-facing job board page with search, filters, and featured job highlighting. Use Server Components for SEO and a client component for the interactive filter sidebar.

```
// Paste this prompt into V0's AI chat:
// Create a job listing page at app/jobs/page.tsx.
// Requirements:
// - Fetch published jobs from Supabase ordered by is_featured DESC, published_at DESC
// - Display as shadcn/ui Card grid: job title, company name + logo, location, salary range, job_type Badge
// - Featured jobs have a gold border and 'Featured' Badge
// - Add a Sheet sidebar for filters: job_type RadioGroup, salary range Slider, location Input
// - Add a search Input at the top that filters by title or company name
// - Each Card links to /jobs/[slug] for the full job detail
// - Show total results count and a 'Post a Job' Button linking to /employer/post
```

**Expected result:** The jobs page displays a searchable, filterable grid of job listings with featured jobs highlighted at the top.

### 3. Create the job detail page with apply functionality

Build the individual job page showing full details and a quick-apply form. The apply form uploads resumes to Supabase Storage and creates an application record.

```
// Paste this prompt into V0's AI chat:
// Create a job detail page at app/jobs/[slug]/page.tsx.
// Requirements:
// - Fetch the job by ID with company details from Supabase
// - Show job title, company logo + name + website link, location, salary range, job_type Badge, posted date
// - Show full job description in a formatted Card
// - Add an 'Apply Now' Button that opens a Dialog with:
//   - applicant_name Input, email Input, cover_letter Textarea
//   - Resume file upload (PDF only, max 5MB) to Supabase Storage
//   - Submit Button with loading state
// - Add a 'Save Job' Button (heart icon) that toggles saved_jobs
// - Show related jobs from the same company or category at the bottom
// - Server Action for the application submission that inserts into applications table
```

> Pro tip: Use Supabase Storage with a private bucket for resumes. Generate signed URLs server-side when employers view applications — this keeps resume files secure and inaccessible to unauthorized users.

**Expected result:** The job detail page shows full listing information with a working apply Dialog that uploads resumes and creates applications.

### 4. Build the employer dashboard and posting flow

Create the employer-facing dashboard for managing listings and viewing applicants. Include a job posting form with a Stripe Checkout redirect for featured listings.

```
// Paste this prompt into V0's AI chat:
// Create an employer dashboard at app/employer/dashboard/page.tsx.
// Requirements:
// - Fetch all jobs for the current user's company with application counts
// - Display in a shadcn/ui Table: title, status Badge (draft/active/expired), applications count, is_featured Badge, published_at
// - Each row has actions: Edit, View Applicants, Promote to Featured
// - 'View Applicants' opens a Sheet with application list: name, email, resume link, status Badge (new/reviewed/shortlisted/rejected), Select to change status
// - 'Promote to Featured' Button redirects to Stripe Checkout for $99 featured listing
// - Add a 'Post New Job' Button linking to /employer/post
// - Post page has a form: title, description (Textarea), location, salary_min, salary_max, job_type Select
// - Server Action for job creation that inserts with status 'draft'
```

**Expected result:** The employer dashboard shows all listings with applicant counts, status management, and a featured listing purchase flow via Stripe.

### 5. Set up Stripe webhook for featured listing activation

Create the webhook handler that listens for Stripe checkout.session.completed events and automatically marks the corresponding job as featured. This is the critical piece that connects payment to listing promotion.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json(
      { error: 'Webhook signature verification failed' },
      { status: 400 }
    )
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const jobId = session.metadata?.job_id

    if (jobId) {
      await supabase
        .from('jobs')
        .update({
          is_featured: true,
          stripe_payment_id: session.payment_intent as string,
          status: 'active',
        })
        .eq('id', jobId)
    }
  }

  return NextResponse.json({ received: true })
}
```

**Expected result:** After a successful Stripe Checkout payment, the webhook automatically marks the job as featured and active.

### 6. Deploy and register the Stripe webhook

Publish the job board to Vercel, then register the webhook URL in the Stripe Dashboard so payment events trigger the featured listing activation in production.

```
// Paste this prompt into V0's AI chat:
// Create a Stripe Checkout session creator as a Server Action in app/employer/actions.ts.
// Requirements:
// - Export async function createFeaturedCheckout(jobId: string) with 'use server'
// - Create a Stripe Checkout session with:
//   - mode: 'payment'
//   - line_items: one item for 'Featured Job Listing' at $99.00
//   - metadata: { job_id: jobId }
//   - success_url: '{origin}/employer/dashboard?featured=success'
//   - cancel_url: '{origin}/employer/dashboard?featured=cancelled'
// - Return the session URL for redirect
// - Use redirect() from next/navigation to send user to Stripe
```

> Pro tip: After publishing, register your webhook URL in Stripe Dashboard at Developers > Webhooks > Add endpoint. Use your Vercel URL: https://yourdomain.vercel.app/api/webhooks/stripe. Select the checkout.session.completed event.

**Expected result:** The job board is live on Vercel. Featured listing payments are processed through Stripe Checkout and automatically activate via the webhook.

## Complete code example

File: `app/api/webhooks/stripe/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    )
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const jobId = session.metadata?.job_id

    if (jobId) {
      const { error } = await supabase
        .from('jobs')
        .update({
          is_featured: true,
          stripe_payment_id: session.payment_intent as string,
          status: 'active',
          published_at: new Date().toISOString(),
          expires_at: new Date(
            Date.now() + 30 * 24 * 60 * 60 * 1000
          ).toISOString(),
        })
        .eq('id', jobId)

      if (error) console.error('Failed to update job:', error)
    }
  }

  return NextResponse.json({ received: true })
}
```

## Common mistakes

- **Using request.json() instead of request.text() in the Stripe webhook handler** — Stripe signature verification requires the raw body string. Calling request.json() parses the body, and re-stringifying it changes the format, causing signature verification to fail every time. Fix: Always use request.text() to get the raw body, then pass it directly to stripe.webhooks.constructEvent(rawBody, sig, webhookSecret).
- **Putting STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET with a NEXT_PUBLIC_ prefix** — The NEXT_PUBLIC_ prefix exposes the variable in the browser bundle. Your secret Stripe keys would be visible to anyone inspecting the page source. Fix: Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without any prefix. Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY should have the prefix.
- **Not validating the Stripe webhook signature before processing events** — Without signature verification, anyone can send fake webhook events to your endpoint and mark jobs as featured without paying. Fix: Always verify the webhook signature using stripe.webhooks.constructEvent() with the raw body and your STRIPE_WEBHOOK_SECRET before processing any event.
- **Calculating scores or filtering job results client-side** — Client-side filtering breaks when there are hundreds of jobs and exposes your full job database to the browser. Fix: Use Supabase server-side queries with .ilike() for search, .eq() for filters, and .range() for pagination. The Server Component fetches only the needed results.

## Best practices

- Use Server Components for job listing and detail pages to get SEO benefits — search engines see fully rendered HTML with job titles and descriptions
- Store all Stripe secret keys in V0's Vars tab without NEXT_PUBLIC_ prefix — only the publishable key needs the prefix
- Use Supabase Storage private buckets for resume uploads and generate signed URLs when employers view applications
- Add a unique constraint on job slugs for clean, SEO-friendly URLs like /jobs/senior-react-developer-acme
- Use V0's Design Mode (Option+D) to adjust Card layouts, Badge colors, and filter sidebar width without spending credits
- Set job expiration dates (expires_at) and filter them out in queries so stale listings do not appear to candidates
- Use Stripe metadata to pass the job_id through the Checkout session so the webhook knows which job to update
- Implement cursor-based pagination for the job listing page to handle thousands of listings efficiently

## Frequently asked questions

### How does the featured listing payment work?

When an employer clicks Promote to Featured, a Server Action creates a Stripe Checkout session with the job_id in metadata and redirects them to Stripe's hosted payment page. After payment, Stripe sends a webhook to your app, which verifies the signature and automatically updates the job to is_featured = true.

### Can I use the free V0 plan for this project?

You can start on the free tier, but Premium ($20/month) is recommended. The job board requires multiple pages (listings, detail, employer dashboard, webhook handler) and the free tier's limited credits may not cover the full build.

### How are resumes stored securely?

Resumes are uploaded to a Supabase Storage private bucket. They are not publicly accessible. When employers view applications, your server generates a signed URL that expires after one hour, ensuring only authorized users can download resume files.

### Can I add email notifications when someone applies?

Yes. Add a Resend API call in the application submission Server Action. After inserting the application record, send an email to the employer with the applicant's name and a link to their dashboard. Store RESEND_API_KEY in the Vars tab without a NEXT_PUBLIC_ prefix.

### How do I deploy the job board?

Click Share in V0, then Publish to Production. After deploying, register your Stripe webhook URL in the Stripe Dashboard at Developers > Webhooks. Use your production URL: https://yourdomain.vercel.app/api/webhooks/stripe and select checkout.session.completed.

### Can I make job listing pages SEO-friendly?

Yes. Job detail pages use Server Components by default, so search engines see fully rendered HTML. Add metadata with generateMetadata() for dynamic page titles and descriptions. Use ISR with revalidate for fast cached pages that update when jobs change.

### Can RapidDev help build a custom job board?

Yes. RapidDev has built over 600 apps including job boards and recruitment platforms with advanced features like applicant scoring, interview scheduling, and employer analytics. Book a free consultation to discuss your niche and get a production-ready platform.

---

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