Domain: belumkerja.com (literally: "not yet working")
Stack: 100% Cloudflare (Worker + D1 + R2 + KV + Vectorize + Workers AI + Queues + Cron + Durable Objects)
Code home: /home/ucok/web/belumkerja.com/worker-cf/
Sister project: beasiswa.net (siswa profile inheritance)
| belumkerja.com | Jobstreet | Glints | LinkedIn ID | |
|---|---|---|---|---|
| Audience | Fresh grads, students, career pivots | Mid-career Indonesians | Mid-career SEA | White-collar global |
| Profile cold-start | Auto-imported from beasiswa.net | Manual | Manual | Manual |
| Primary channel | WhatsApp + web | Email + web | Email + web | Web |
| Headhunter role | First-class | Hidden | Hidden | Implicit |
| Language | Indonesian-first | Bilingual | Bilingual | English-first |
| Free for job seekers | Yes | Yes | Yes | Yes |
| Job posting fee | TBD | Paid | Paid | Paid |
The wedge: the user just got a beasiswa.net scholarship → graduates → already has a verified profile → one tap to flip to "siap kerja" status. Zero re-onboarding. That's the entire moat.
┌────────────────────┐
│ USER (D1) │
│ user_type column │
└──────────┬─────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌───────────────┐ ┌─────────────────┐
│ pencari │ │ perekrut │ │ pemberi │
│ (siswa) │ │ (headhunter) │ │ (employer) │
└──────────────┘ └───────────────┘ └─────────────────┘
Job seeker Middleman/agent Company / individual
Inherits siswa Multi-company hiring direct
profile from pipeline mgmt
beasiswa.net Commission tracking
Single users table, user_type ENUM('pencari','perekrut','pemberi'), separate role-extension tables. One person can hold multiple roles (a senior dev moonlighting as a recruiter, etc.) — modelled as user_roles(user_id, role) join, not exclusive ENUM.
| Layer | CF Resource | Purpose |
|---|---|---|
| Edge HTML/SSR | Worker belumkerja-com |
All public pages, SSR'd HTML (no JS-filled divs — per CLAUDE.md) |
| Static assets | Workers Assets binding | Bundled CSS/JS/icons |
| Relational DB | D1 belumkerja_com_prod |
Users, profiles, jobs, applications, posts, articles |
| Object store | R2 belumkerja-com-assets |
CVs, profile pics, company logos, portfolio files |
| Hot cache | KV belumkerja-com-cache |
Autocomplete, hot job lists, search results |
| Config | KV belumkerja-com-config |
Feature flags, role taxonomy |
| Semantic search | Vectorize belumkerja-jobs, belumkerja-candidates |
AI matching (BGE embeddings) |
| AI | Workers AI | Llama 3.3 70B for articles/Q&A, BGE-base for embeddings |
| Async | Queue belumkerja-com-jobs (notifications), belumkerja-com-ingest (scrape/embeddings) |
|
| Coordination | Durable Objects belumkerja-com-coordination |
Live "X applied" counters, chat rooms, application locks |
| Scheduling | Cron Triggers | Daily article gen, weekly digest, autocomplete refresh, Vectorize sync |
| Auth | WhatsApp OTP via existing japri-wa-relay PM2 (only server dep), JWT in HttpOnly cookie |
Cross-site auth: beasiswa.net and belumkerja.com share the same phone-based OTP issuer and a small shared_users D1 (or KV mirror) keyed by phone hash. A siswa logging into belumkerja.com with their beasiswa phone gets their profile auto-imported on first login.
users — slug, phone, phone_hash, email, name, user_type, bio, is_verifiedstudent_profiles — education_level, school, university, faculty, major, GPA, graduation_year, province/city, achievements, skills, interestsportfolio_items — title, description, item_type, url-- Extended profile (LinkedIn-like)
job_seeker_profiles(user_id PK, headline, summary, current_status, expected_salary_min, expected_salary_max,
work_preferences JSON, available_from, cv_r2_key, video_intro_r2_key)
experiences(id, user_id, company_id, role_title, employment_type, start_date, end_date,
is_current, description, location, location_type)
skills(id, slug, name, category)
user_skills(user_id, skill_id, level, years, endorsement_count)
endorsements(id, from_user_id, to_user_id, skill_id, created_at)
certifications(id, user_id, name, issuer, issued_at, expires_at, credential_url, file_r2_key)
languages(id, user_id, language_code, proficiency)
-- Social / network
connections(follower_id, following_id, status, created_at) -- mutual when status='accepted'
posts(id, user_id, body, media_r2_keys JSON, post_type, created_at)
post_reactions(post_id, user_id, reaction)
comments(id, post_id, user_id, body, parent_comment_id)
recommendations(id, from_user_id, to_user_id, body, role_title, status)
-- Companies (employers)
companies(id, slug, name, industry_id, size, founded_year, hq_city, website, logo_r2_key,
verified, owner_user_id, claim_status)
company_followers(company_id, user_id)
company_reviews(id, company_id, reviewer_user_id, salary, rating, pros, cons, anonymous)
-- Jobs
job_categories(id, slug, name, parent_id)
industries(id, slug, name)
jobs(id, slug, company_id, posted_by_user_id, posted_by_role, -- 'pemberi' or 'perekrut'
title, category_id, employment_type, location_city_id, location_type, -- onsite/hybrid/remote
salary_min, salary_max, currency, experience_level, education_required,
description_md, requirements_md, benefits_md, application_url, application_email,
status, posted_at, expires_at, view_count, applicant_count,
embedding_id -- Vectorize key
)
job_skills(job_id, skill_id, required INTEGER)
applications(id, job_id, applicant_user_id, headhunter_user_id NULL, cv_r2_key, cover_letter,
status, applied_at, viewed_at, response_at)
saved_jobs(user_id, job_id, saved_at)
job_alerts(id, user_id, query JSON, frequency, last_sent_at)
-- Headhunter pipeline
talent_pools(id, headhunter_user_id, name, description)
talent_pool_members(pool_id, user_id, notes, status)
placements(id, headhunter_user_id, candidate_user_id, job_id, status,
commission_amount, commission_paid, placed_at)
-- Programmatic SEO
seo_pages(id, slug, page_type, params JSON, title, description, h1, body_md,
last_built_at, last_visited_at, view_count, status) -- pSEO output
seo_keywords(id, query, category, source, search_volume_estimate, harvested_at) -- from Google Autocomplete
seo_articles(id, slug, keyword_id, title, body_md, hero_r2_key, author_user_id,
published_at, view_count, ai_model, edited_by_human)
qna_questions(id, slug, question, category, source_keyword_id, view_count, answer_count)
qna_answers(id, question_id, body_md, author_user_id, ai_generated, upvotes, accepted)
-- Cities / geo (reuse beasiswa)
cities(id, slug, name, province, lat, lng, population)
-- Notifications & messaging
conversations(id, type, participants_hash)
messages(id, conversation_id, sender_user_id, body, media_r2_keys, read_at)
notifications(id, user_id, type, payload JSON, read_at)
URL: /u/{slug} (consistent with beasiswa.net /profil/{slug} — different path, same canonical user)
Sections in order:
summary)experiences)student_profiles + custom educations)skills, endorsements)portfolio_items)posts)All sections SSR'd into static HTML (CLAUDE.md rule: never empty divs filled by JS). Edit-in-place via inline forms with <dialog> modals + HTMX-style partial updates.
belumkerja-jobs × user's embedding)belumkerja-candidates)applications row with headhunter_user_id set)Target: ~500K indexable pages within 6 months. Each must be SSR'd, semantic, with FAQ schema and breadcrumbs.
| Pattern | Example | Volume estimate |
|---|---|---|
/lowongan/{kota} |
/lowongan/jakarta |
~514 |
/lowongan/{kota}/{kategori} |
/lowongan/bandung/programmer |
514 × 100 = 51,400 |
/lowongan/{kategori} |
/lowongan/marketing |
~100 |
/lowongan/{industri}/{kota} |
/lowongan/perbankan/surabaya |
50 × 514 = 25,700 |
/lowongan/fresh-graduate/{kota} |
/lowongan/fresh-graduate/yogya |
~514 |
/lowongan/tanpa-pengalaman/{kota} |
/lowongan/tanpa-pengalaman/medan |
~514 |
/lowongan/wfh/{kategori} |
/lowongan/wfh/desain-grafis |
~100 |
/lowongan/part-time/{kota} |
/lowongan/part-time/depok |
~514 |
/lowongan/lulusan/{jenjang}/{kota} |
/lowongan/lulusan/smk/bekasi |
6 × 514 = 3,084 |
/lowongan/lulusan/{jurusan} |
/lowongan/lulusan/teknik-informatika |
~200 |
/gaji/{posisi} |
/gaji/data-scientist |
~200 |
/gaji/{posisi}/{kota} |
/gaji/akuntan/jakarta |
200 × 514 = 102,800 |
/perusahaan/{slug} |
/perusahaan/gojek |
~10K (scraped) |
/perusahaan/{kota}/{industri} |
/perusahaan/jakarta/fintech |
514 × 50 = 25,700 |
/karir/{posisi} |
/karir/dokter-gigi |
~200 |
/karir/{posisi}/syarat |
/karir/pilot/syarat |
~200 |
/karir/{posisi}/jurusan |
/karir/pengacara/jurusan |
~200 |
/karir/{posisi}/skill |
/karir/data-analyst/skill |
~200 |
/lulusan/{universitas} |
/lulusan/itb |
~4,500 (PDDikti) |
/lulusan/{sekolah} |
/lulusan/sma-3-jakarta |
~30K (NPSN data) |
/q/{slug} |
/q/apakah-fresh-graduate-bisa-jadi-pns |
~50K (autocomplete) |
/artikel/{slug} |
/artikel/cara-menulis-cv-fresh-graduate |
~10K |
Total indexable: ~310K core pages + ~80K Q&A + ~10K articles = ~400K. Once content engine runs steady-state, it'll cross 500K within 6 months.
Empty /lowongan/{kota}/{kategori} pages with 0 jobs MUST NOT return 200 with thin content. Either:
Pattern lifted from /home/ucok/web/ourfuntech.com/src/cron.ts:
// Hourly cron — harvest autocomplete, dedup, queue article gen
const seeds = [
// city-specific
...cities.map(c => `lowongan kerja ${c.name}`),
...cities.map(c => `gaji di ${c.name}`),
// role-specific
...roles.map(r => `cara menjadi ${r.name}`),
...roles.map(r => `gaji ${r.name}`),
...roles.map(r => `syarat ${r.name}`),
// intent-specific
'lowongan kerja fresh graduate',
'lowongan kerja tanpa pengalaman',
'tips wawancara kerja',
'cara membuat cv',
'cara melamar kerja online',
// company-specific (top 200 companies)
...topCompanies.map(c => `cara melamar ${c.name}`),
];
for (const seed of seeds) {
const r = await fetch(`https://suggestqueries.google.com/complete/search?client=firefox&q=${encodeURIComponent(seed)}`);
const [, suggestions] = await r.json();
for (const s of suggestions) {
await db.prepare('INSERT OR IGNORE INTO seo_keywords (query, category, source) VALUES (?, ?, ?)')
.bind(s, classify(s), 'google_autocomplete').run();
}
await sleep(500); // rate-limit ourselves
}
Then a second cron picks N unprocessed keywords, classifies each as article|qna|salary_page|career_guide, and dispatches to Llama 3.3 70B with role-specific prompts. Output is human-edit-flag-able (seo_articles.edited_by_human); we batch-publish only after a daily quality check (length > 800 words, contains H2s, has internal links).
Long-tail queries that read as questions ("apakah", "bagaimana", "berapa", "kapan", "di mana") become qna_questions. Each Q&A page:
Article + FAQPage JSON-LD)category)This is identical to the existing pattern on anehtapinyata.com and alamgaib.com — just retargeted to job intent.
/lowongan and /beranda/lowongan (SpeechRecognition API)| Phase | Scope | Definition of done |
|---|---|---|
| 0 — Foundation | CF resources provisioned, schema migrated, beasiswa shared-auth wired, base layout SSR'd | Logged-in siswa from beasiswa.net lands on /profil/{slug} and sees their data |
| 1 — Profile + Jobs MVP | LinkedIn-style profile, employer can post a job, seeker can browse + apply, R2 CV storage | First real job posted by a real employer, first real application sent |
| 2 — Programmatic SEO core | /lowongan/{kota}, /lowongan/{kota}/{kategori}, /gaji/{posisi}/{kota}, sitemap, llms.txt, IndexNow |
First 50K pages indexed by Google |
| 3 — Article + Q&A engine | Autocomplete cron + Llama generator + Q&A threads + FAQ schema | 500 articles/Q&A pages live, editorial review queue |
| 4 — Social layer | Connections, posts, feed, recommendations, endorsements | DAU > 1K, average 3 connections/user |
| 5 — Headhunter dashboard | Talent search, pools, placements, commission ledger | First commission paid through platform |
| 6 — AI matching | Vectorize embeddings for jobs + candidates, "cocok untukmu", AI cover letter | 30%+ of applies use AI suggestion |
| 7 — Mobile polish | PWA install, push notifications, voice search, View Transitions | Lighthouse PWA + SEO + Performance all 90+ |
These three shape the product more than anything else:
When a phone hash logs in to belumkerja.com for the first time, the worker reads beasiswa-db (second D1 binding, read-only intent) by phone_hash. If student_profiles.graduation_year <= currentYear, we one-tap import (copy users + student_profiles + portfolio_items rows into belumkerja's D1). After import, profiles diverge — beasiswa edits don't auto-flow into belumkerja. User can re-trigger import manually if they want fresh data.
Why: simplest implementation (no Queue, no schema coupling), respects the "graduation = transition" semantic, and avoids polluting belumkerja with still-studying users who aren't ready to job-hunt.
Edge case: phone exists in beasiswa but graduation_year > currentYear → show "Kamu masih kuliah. Datang lagi setelah lulus 🎓" with a "Notify me when ready" button (writes to a pre_register KV that fires a cron-driven WhatsApp on grad date).
Pick one per row (or write your own variant) and I'll wire the schema + features around your choices in the next iteration.
This domain MUST operate within these constraints — no exceptions:
If the plan above describes any flow that violates these constraints, treat the plan as ASPIRATIONAL only and rework before building. The constraint trifecta wins.
Ask AI to research, improve, or generate content.
Try: "Research competitors for this niche"