Why hybrid?
City verticals show spiky intent. A few queries (“dieng tour 2D1N”, “sunrise sikunir”, “lodging near arjuna temple”) drive decisions; the long tail is unpredictable. A router + curated verticals boosts CTR for high-intent slices; a Google Programmable Search fallback preserves breadth without crawling the web.
Targets: P75 LCP < 2.5s, CLS < 0.1, and INP Good. Product metrics: vertical vs. PSE CTR and dwell time.
Live demo (client-only)
Try: paket wisata dieng or lodging dieng. The router decides the output.
// Router output will appear here…1) Intent Router (≈200 lines, zero deps)
Explicit, auditable rules. The router normalizes input, matches patterns, and emits either VERTICAL (curated slug) or PSE (fallback URL).
// intent-router.js export const INTENTS = [ { id: "tour_dieng", weight: 0.9, patterns: [ /paket\s+wisata\s+dieng/i, /tour\s+dieng/i, /\b2d1n\b|\b2h1m\b/i ], action: { type: "VERTICAL", slug: "/paket-wisata-dieng/" } }, { id: "lodging", weight: 0.6, patterns: [ /penginapan|homestay|hotel/i, /lodging|stay|guesthouse/i ], action: { type: "VERTICAL", slug: "/penginapan/" } }, ]; export const normalize = q => q.trim().replace(/\s+/g," ").toLowerCase(); export function route(q, opts={}) { const qq = normalize(q); for (const intent of INTENTS) { if (intent.patterns.some(rx => rx.test(qq))) { return { decision: "VERTICAL", intent: intent.id, target: intent.action.slug }; } } // default → Google Programmable Search (PSE) fallback const cx = (opts.cx||"YOUR_PSE_CX_ID"); const url = `https://cse.google.com/cse?cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(q)}`; return { decision: "PSE", intent: "generic", target: url }; }2) Curated Verticals as JSON (cacheable)
Curated content ships as static JSON chunks. No phone numbers; CTAs point to internal detail pages or provider websites.
{ "version": 4, "updated": "2025-08-09T07:00:00+07:00", "title": "Paket Wisata Dieng", "items": [ { "title": "Sunrise Sikunir + Arjuna + Sikidang (1D)", "slug": "/paket-wisata-dieng/one-day-sunrise", "highlights": ["Sikunir sunrise","Candi Arjuna","Kawah Sikidang"], "cta": { "type": "link", "href": "/paket-wisata-dieng/one-day-sunrise" } }, { "title": "2H1M Classic Dieng", "slug": "/paket-wisata-dieng/2h1m-classic", "highlights": ["Pintu Langit","Batu Ratapan Angin","Plateau Theater"], "cta": { "type": "link", "href": "/paket-wisata-dieng/2h1m-classic" } } ] }3) Google Programmable Search (fallback)
Lazy-load PSE and mount into a container; keep it off the critical path. Docs: overview.
<!-- HTML: placeholder container --> <div id="pse-container" class="card" hidden> <div class="gcse-searchresults-only"></div> </div> <script> const CX = "YOUR_PSE_CX_ID"; function ensurePSE(){ return new Promise((res,rej)=>{ if (window.__pseLoaded) return res(); const s = document.createElement('script'); s.src = `https://cse.google.com/cse.js?cx=${encodeURIComponent(CX)}`; s.async = true; s.onload = () => { window.__pseLoaded = true; res(); }; s.onerror = rej; document.head.appendChild(s); }); } function getResultsEl(){ try{ const api = google?.search?.cse?.element; if (!api) return null; const all = api.getAllResultsElements?.(); return (all && all[0]) || api.getElement('searchresults-only0') || api.getElement('searchresults-only'); }catch{ return null; } } async function showPSE(q){ await ensurePSE(); document.getElementById('pse-container').hidden = false; const el = getResultsEl(); if (el?.execute) el.execute(q); else location.href = `https://cse.google.com/cse?cx=${encodeURIComponent(CX)}&q=${encodeURIComponent(q)}`; } </script>4) Performance & Observability (field data)
Minimal RUM using PerformanceObserver; log anonymous aggregates only.
// perf.js function ob(name, cb){ try{ const po = new PerformanceObserver(l=>l.getEntries().forEach(cb)); po.observe({type:name,buffered:true}); }catch{} } ob('largest-contentful-paint', e => console.log('[LCP]', Math.round(e.startTime))); ob('layout-shift', e => { if (!e.hadRecentInput) console.log('[CLS]', e.value); }); ob('event', e => { if (e.name==='first-input') console.log('[INP]', Math.round(e.processingEnd - e.startTime)); });5) Security & Privacy
CSP (example): restrict scripts to self + cse.google.com; short log retention; no selling data. See /privacy. Note: This article uses small inline scripts for the demo; in production, prefer nonces or temporarily add 'unsafe-inline' while migrating.
Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self' https://cse.google.com; connect-src 'self'; frame-src https://cse.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com;6) Mini demo (single file)
Router + vertical render + PSE redirect (no phone numbers anywhere).
<!DOCTYPE html> <meta charset="utf-8"><title>Mini Hybrid Search</title> <input id="q" placeholder="Type a query…" style="width:100%;padding:.8rem;border-radius:10px;border:1px solid #1e293b;background:#0a0f1a;color:#e5e7eb"> <button onclick="go()" style="margin-top:.5rem">Search</button> <div id="out"></div> <script type="module"> const INTENTS=[{id:"tour_dieng",patterns:[/paket\\s+wisata\\s+dieng/i,/tour\\s+dieng/i,/\\b2d1n\\b|\\b2h1m\\b/i],slug:"/paket-wisata-dieng/"}]; const norm=q=>q.trim().replace(/\\s+/g," ").toLowerCase(); function route(q,cx){const qq=norm(q);for(const it of INTENTS){if(it.patterns.some(rx=>rx.test(qq)))return{d:"V",t:it.slug};} return{d:"P",u:`https://cse.google.com/cse?cx=${encodeURIComponent(cx)}&q=${encodeURIComponent(q)}`};} async function renderVertical(slug){out.innerHTML=`<h3>Paket Wisata Dieng</h3><a href="${slug}">Open details</a>`;} function go(){const q=document.getElementById('q').value, r=route(q,'YOUR_PSE_CX_ID'); if(r.d==='V') return renderVertical(r.t); location.href=r.u; } </script>7) Trade-offs
Pros: low-ops, fast to ship, keeps high-intent flows in our UI, preserves breadth.
Cons: platform dependency on PSE; limited control over fallback UX.
Mitigations: edge-cache curated JSON; explicit router rules; add a tiny first-party index for core entities if needed.
8) Open questions (for HN)
• Minimal viable first-party index for places/events?
• Collecting feedback (like/hide) without dark patterns?
• When to add a tiny backend instead of staying client-only?
References
Google Programmable Search – about / overview
Web Vitals (LCP/CLS/INP) – web.dev/vitals
.png)
