Engineering Notes

Hybrid Vertical Search for a City: Architecture, Code & Trade-offs

A low-ops, search-first portal for a single destination (Wonosobo/Dieng). High-intent queries route to curated vertical pages; everything else falls back to Google Programmable Search. Design choices, shipping code, perf & security notes.

ExploreWonosobo.com • Aug 2025 • No framework Edge-cache friendly HN-ready

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