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