SEO is the business model. JobPosting schema, paid postings, expiring listings, and the bootstrap tactic that breaks the chicken-and-egg problem.
Research-based methodology. This guide synthesizes Google’s JobPosting structured data documentation, public Indie Hackers and Pieter Levels writeups on niche job boards (Remote OK, Hacker News Who’s Hiring scrapes), Stripe Checkout docs, and our own Claude builds. Where we cite specific behavior we link the source. How we research.
LinkedIn and Indeed own the generic job market. They will continue to. The opportunity for a solo founder is the niche board: AI engineering jobs, remote design jobs, climate tech jobs, healthcare data jobs, RevOps jobs, security engineer jobs, dev rel jobs, freelance lawyer gigs, dental hygienist roles in three specific states. The candidates in these niches don’t want to wade through 200,000 LinkedIn results to find the 40 jobs that match. They want a curated list. The companies hiring them know the candidates won’t scroll past page 3 of LinkedIn search, so they’ll pay $200–$500 to be in front of the right 5,000 people.
Importantly: a niche job board is a content business dressed up as software. The product is a database of high-quality, well-categorized jobs that ranks in Google for “remote ML engineer jobs,” “climate startup jobs,” “part-time dev rel,” etc. The SaaS layer (employer dashboard, payment, expiry) is supporting infrastructure. If you build great software but no one finds you in search, you have nothing. We’re going to optimize for search from line 1.
The graveyard of niche job boards is enormous. They almost all die for the same reason: chicken-and-egg. No companies post because no candidates visit. No candidates visit because there are no jobs. Without breaking that cycle in the first 90 days, you ship a working product and watch it get zero traffic for 6 months until you give up.
The pattern that works, used by Remote OK, Hacker News Who’s Hiring scrapers, and most successful niche boards in our research: seed the board with manually-curated free postings for 60–90 days, drive traffic to those listings via the niche’s existing communities (subreddits, Discord servers, Twitter/X), then convert the highest-engagement employers into paid renewals. By the time you ask for $299/post, you’re already sending qualified applications. We’ll come back to this in the bootstrap section.
The schema is small. The hard part is making sure your fields map cleanly to Google’s JobPosting structured data spec, because that’s how you get into the “Jobs” rich result on the SERP — and the “Jobs” carousel is responsible for a huge share of organic traffic to job boards.
I'm building a niche job board for [AI engineering / remote design / climate tech] roles. The schema needs to power both the public listings pages (must be fast and SEO-friendly) and a Google JobPosting structured data feed (must include all required fields). Tables: - companies (id, name, slug, website, logo_url, description, verified) - job_posts (id, company_id, title, slug, description (HTML), location, remote_type enum [onsite, hybrid, remote, anywhere], employment_type enum [full_time, part_time, contract, intern], salary_min, salary_max, salary_currency, salary_period [year, month, hour], category_id, posted_at, expires_at, status enum [draft, active, expired, removed], featured boolean, tsvector_search_doc tsvector) - categories (id, name, slug, parent_id) -- for nesting like "Engineering > Machine Learning" - applications (id, job_post_id, candidate_email, resume_url, cover_letter, applied_at) -- optional, many boards just link out - payments (id, company_id, job_post_id, stripe_session_id, amount, status, created_at) Required: - A trigger that maintains tsvector_search_doc as to_tsvector(title || description || company.name) so search is fast - A GIN index on tsvector_search_doc - Indexes optimized for "active jobs in category X sorted by posted_at" (the homepage and category pages) - A partial index WHERE status='active' AND expires_at > now() so the hot path is small - A check constraint: salary_max >= salary_min when both set - ON DELETE behavior: deleting a company should NOT cascade to job_posts (we want history); deleting a job_post should cascade to applications Output as one Postgres SQL file with comments explaining each index choice. Then write Supabase RLS so: - Public anonymous can read job_posts WHERE status='active' - Companies can write only their own job_posts - Application inserts allowed for anonymous (rate-limited via RPC)
This is the single most important technical step in the entire build. Google’s JobPosting structured data is what gets your listings into the “Jobs on Google” rich result. Without it, you’re competing for normal blue links against LinkedIn and Indeed — you will lose. With it, you can outrank them on long-tail niche queries because their generic listings often miss niche-specific keywords your hand-curated ones nail.
Write a TypeScript function `jobPostingJsonLd(jobPost, company)` that returns a Google-compliant JobPosting structured data object per https://developers.google.com/search/docs/appearance/structured-data/job-posting. Requirements: - @context "https://schema.org/", @type "JobPosting" - title, description (must be the full HTML description, NOT a summary) - datePosted (ISO 8601), validThrough (= expires_at, ISO 8601) - employmentType mapped from our enum to Google's allowed values (FULL_TIME, PART_TIME, CONTRACTOR, INTERN, etc.) - hiringOrganization with @type "Organization", name, sameAs (website), logo - jobLocation: if remote_type='onsite' OR 'hybrid', emit a Place with PostalAddress (require addressLocality, addressRegion, addressCountry). If 'remote' or 'anywhere', emit applicantLocationRequirements with @type "Country" + name (or skip and use jobLocationType "TELECOMMUTE") - For remote_type='remote' set jobLocationType: "TELECOMMUTE" - baseSalary with currency, value, unitText (mapping our salary_period to YEAR/MONTH/HOUR). Use a QuantitativeValue with minValue/maxValue if a range, or a single value if min===max - directApply: true (we accept applications on-site) or false (we link out to the company's ATS) Validate using Google's Rich Results Test rules: - description must be at least 100 chars (warn if not) - baseSalary should be present (warn if not, since postings without salary rank lower in 2025+ per Google's announcements) - title should not contain location or salary (those have their own fields) Output the function plus a `validateJobPosting(obj)` helper that returns an array of warnings before publish.
Salary data is increasingly weighted by Google. As of 2025, postings without baseSalary visibly underperform in the Jobs results. Make salary either required or strongly encouraged in your post-creation flow.
For one-time job posting payments, Stripe Checkout in payment mode is the right choice. Lemon Squeezy is built for SaaS subscriptions and is awkward for “30-day post for $299.” If you also offer a subscription tier ($499/mo unlimited), use Stripe’s subscription mode for that and keep both flows. See our Lemon Squeezy vs Stripe comparison for the full tradeoff.
I'm using Stripe Checkout (no Connect — I'm the merchant of
record for posting fees).
Build a Next.js Route Handler /api/posts/[id]/checkout that:
1. Loads the draft job_post (status='draft') and verifies the user
owns the parent company
2. Creates a Stripe Checkout Session in mode='payment' with:
- line_items: one $299 line for "30-day job posting" (or $499 for
"Featured 30-day posting" if featured=true)
- metadata: { job_post_id, company_id }
- success_url: /dashboard/posts/{id}?payment=success
- cancel_url: /dashboard/posts/{id}?payment=cancelled
- customer_email: prefilled from the company.contact_email
3. Returns the checkout URL
Then a webhook handler /api/webhooks/stripe that on
checkout.session.completed:
- Verifies signature with STRIPE_WEBHOOK_SECRET
- Marks the job_post status='active', sets posted_at=now(),
expires_at=now() + interval '30 days'
- Inserts a payments row
- Triggers a re-index of the sitemap (call /api/revalidate-sitemap)
- Sends a Resend email to the company confirming the post is live
Also add a /api/posts/[id]/renew endpoint for re-payment when an
expired post wants to renew. Use the original price unless the post
was featured (in which case offer to upgrade for the difference).
Use the latest Stripe API version. Output the routes and the matching
Postgres updates.
Your URL structure is the second most important SEO decision after the JobPosting schema. The pattern that ranks: /jobs/[category]/[role]-at-[company]-[id], plus category landing pages at /jobs/[category] and a homepage that lists active jobs. Don’t use UUIDs in URLs — use a short hashid or the post’s autoincrement id. Don’t put location or salary in the URL — those are facets, and changing them shouldn’t change the URL.
Design a Next.js App Router route structure for a niche job board optimized for SEO. Routes I want: - / (homepage: list of all active jobs, sorted by posted_at desc, with category filter pills) - /jobs (paginated list of all jobs) - /jobs/[categorySlug] (e.g., /jobs/machine-learning — lists active jobs in that category, with subcategory filters) - /jobs/[categorySlug]/[subcategorySlug] (e.g., /jobs/engineering/machine-learning) - /companies/[companySlug] (a company page listing their active jobs) - /jobs/[categorySlug]/[postSlug]-[postId] (the actual job detail page, e.g. /jobs/machine-learning/senior-ml-engineer-at-acme-1234) Implementation requirements: - Each page is a Server Component with generateMetadata producing unique title + meta description (templated from the job/category) - Each detail page emits the JobPosting JSON-LD I designed in Prompt 2 - generateStaticParams for the category pages so they're pre-rendered - ISR with revalidate=600 on listings, 60 on details - A canonical URL on every page (handle the postSlug-postId form so if the slug changes the canonical still resolves) - A 301 redirect if someone hits /jobs/[wrongCategory]/[postSlug]-[id] — redirect to the correct category - A sitemap at /sitemap.xml that includes all active jobs and categories, regenerated when posts go active or expire Output the route folder layout, a sketch of each page.tsx, and the sitemap generator.
Posts that linger past their paid period are bad for SEO (Google penalizes “stale” jobs in the rich result and may delist your whole feed if too many are stale) and bad for user trust. Run a cron every hour, mark expired posts, regenerate the sitemap.
I'm on Vercel. Set up a cron-based expiry job using vercel.json
+ a Route Handler.
Requirements:
1. vercel.json schedule: every hour at :05
2. /api/cron/expire-posts handler must:
- Verify the request is from Vercel Cron (CRON_SECRET header)
- Find all job_posts where status='active' AND expires_at <= now()
- Update them to status='expired'
- Insert a notifications row per affected company
- Send a Resend email to each company saying "Your post expired,
renew here for $99 (50% off renewal)" with a deep link
- Trigger sitemap revalidation
- Return JSON { expired_count, notified_count }
3. A second job at /api/cron/expiring-soon-warnings (daily at 9am UTC)
that finds active posts expiring in the next 72 hours and emails
the company owner a "your post expires in N days" warning with a
one-click renewal link.
Both must:
- Use a Postgres advisory lock to prevent concurrent runs from
double-processing
- Log to a cron_runs table (started_at, finished_at, items_processed,
error)
- Be idempotent (running twice on the same minute does nothing the
second time)
Output: vercel.json, the two route handlers, and the cron_runs table
SQL.
Important: do NOT delete expired posts from the database. Keep them with status='expired' for archival, analytics, and the company’s renewal flow. Removing them from the sitemap and JobPosting feed is enough.
This is the playbook that breaks the chicken-and-egg problem. Skip this and your launch fails regardless of how good the code is.
Phase 1 (weeks 0–8): Seed. Manually scrape or curate 100–300 high-quality jobs in your niche from existing sources (LinkedIn, the company’s careers page, Hacker News Who’s Hiring, niche Slack and Discord communities, Twitter/X job threads). Post them as free listings, with clear attribution to the source company. Email each company a heads-up: “We listed your role on our new niche board for free, here’s the link, no action needed, but if you want analytics or to upgrade to a featured listing it’s $X.”
Phase 2 (weeks 8–16): Drive traffic. Distribute the listings to the niche’s existing communities. Subreddit posts (with mod permission), Discord channels (where allowed), Twitter/X threads, LinkedIn personal posts. Track click-throughs and applications per listing in your dashboard. The companies whose listings get the most engagement will email you asking how they can keep getting them.
Phase 3 (week 12+): Convert. Once you have engagement data on a company’s listing, reach out: “Your role got 240 clicks and 12 applications. Want to renew for 30 more days for $299?” This works because they have data, not a promise. Conversion rates we’ve seen in our research and own builds: 8–25% on companies with strong engagement. That’s your initial paying customer base.
Three viable pricing models, often layered:
The win is a niche where the candidate pool is sharp and the employer pool is willing to pay because LinkedIn delivers them noise. Some ideas where founders are still active and search demand is real:
More options in our micro SaaS ideas roundup and our AI SaaS ideas for 2026 list, both of which include several job-board adjacent ideas worth a look.
A niche job board is a content business with a Stripe checkout bolted on. Win the SEO with airtight JobPosting structured data, win the cold start with manually-seeded free listings, then convert engaged employers into paid renewals. The product is the database, not the dashboard.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.