Load CleanJobData job listings into Supabase for powerful search and real-time subscriptions.
Create a PostgreSQL table matching CleanJobData's schema.
CREATE TABLE jobs (
id BIGINT PRIMARY KEY, title TEXT NOT NULL,
company TEXT, location TEXT, salary_min NUMERIC,
description TEXT, posted_at TIMESTAMPTZ
);Create a function that fetches from CleanJobData and upserts into Supabase.
Set up PostgreSQL full-text search on your synced job data.
ALTER TABLE jobs ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || description)) STORED;
CREATE INDEX jobs_search_idx ON jobs USING GIN(search_vector);Real-world patterns beyond the basic setup.
A full, deployable Edge Function that fetches recently-ingested jobs and upserts them by id. Schedule it with pg_cron so it runs without an external worker.
import { createClient } from "jsr:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const apiKey = Deno.env.get("CLEANJOBDATA_API_KEY")!;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const res = await fetch(
"https://api.cleanjobdata.com/jobs?created_max_age=1h&limit=100",
{ headers: { "X-API-Key": apiKey } }
);
if (!res.ok) return new Response(`CleanJobData API ${res.status}`, { status: 502 });
const { data: jobs } = await res.json();
const rows = jobs.map((job: any) => ({
id: job.id,
title: job.title,
company: job.company?.name ?? null,
location: job.location,
salary_min: job.salary_min,
posted_at: job.published,
}));
const { error } = await supabase.from("jobs").upsert(rows, { onConflict: "id" });
if (error) return new Response(error.message, { status: 500 });
return new Response(JSON.stringify({ synced: rows.length }), {
headers: { "Content-Type": "application/json" },
});
});Run inside the Supabase SQL editor, once, to invoke the Edge Function every hour.
select cron.schedule(
'sync-cleanjobdata-jobs',
'0 * * * *', -- every hour
$$
select net.http_post(
url := 'https://<project-ref>.supabase.co/functions/v1/sync-jobs',
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'))
);
$$
);Yes — it's a natural place to run the sync: fetch from CleanJobData with `fetch()`, transform the response into your table's shape, and upsert into Postgres with the service role key. Trigger it on a `pg_cron` schedule (Supabase's built-in cron extension) so it runs without an external scheduler.
Run a scheduled job — an Edge Function on `pg_cron`, or any cron worker — that calls `/jobs` with `created_max_age` set to your run interval (`created_max_age=1h` for an hourly job) and upserts the results by `id`. `created_max_age` filters on when CleanJobData ingested the listing, not when the employer posted it, so it reliably catches jobs regardless of publish-date backfills. Don't reuse a pagination cursor across separate scheduled runs — a cursor only walks forward through one result set and has no meaning against a fresh query.
Keep it in the Edge Function's environment only (`supabase secrets set CLEANJOBDATA_API_KEY=...`), read via `Deno.env.get`. Never pass it to `supabase-js` calls made from a browser client — the sync should be the only thing that talks to CleanJobData; your frontend reads the already-synced `jobs` table instead, protected by your own Row Level Security policies.
Enable the Realtime replication toggle on the `jobs` table (Database → Replication in the dashboard, or `alter publication supabase_realtime add table jobs;`), then subscribe from the client with `supabase.channel('jobs-changes').on('postgres_changes', { event: '*', schema: 'public', table: 'jobs' }, callback).subscribe()`. New rows inserted by the sync Edge Function push to subscribed clients immediately, without polling.