Membangun Server-Side Rendering Express + Handlebars dari Nol

Membangun Server-Side Rendering Express + Handlebars dari Nol

Kamu bikin situs company profile pakai React. Sembilan halaman statis: home, about, layanan, blog, kontak. Bundle-nya 280 KB gzip. Lighthouse ngasih LCP 3,4 detik di Moto G4. Klien nanya kenapa artikel blog-nya nggak muncul di Google setelah tiga minggu, dan kamu baru sadar Googlebot me-render halaman itu di antrean kedua — kalau sempat.

Padahal HTML-nya nggak pernah berubah setelah load. Nggak ada state. Nggak ada routing di klien yang benar-benar perlu. Kamu mengirim runtime framework 280 KB untuk menampilkan teks yang server bisa kirim jadi dalam 40 milidetik.

Artikel ini membedah cara menyusun aplikasi Express + Handlebars yang rapi — wiring view engine, struktur router, passing data SEO, sampai jebakan produksi yang bikin kamu debugging jam dua pagi. Semua contoh diambil dari kode yang benar-benar jalan di situs ini.

Kenapa SSR Handlebars Masih Relevan untuk Situs Marketing 2026

Angka dulu. Ini perbandingan halaman "Tentang Kami" yang sama, dua implementasi:

MetrikSPA (React + Vite)Express + Handlebars
JS terkirim (gzip)~180 KB0 KB (HTML murni)
Time to First Byte90 ms40 ms
HTML di view-sourcekosong (<div id="root">)konten penuh
Dependency produksi400+ paket~20 paket
Waktu build8-14 detiktidak ada build

Poin ketiga yang paling menentukan untuk situs marketing. Crawler, preview link WhatsApp, scraper LinkedIn, bot Slack — semuanya baca HTML mentah. Mereka nggak jalanin JavaScript kamu. Kalau <meta property="og:description"> diisi setelah hydration, preview-nya kosong.

Kapan SSR Handlebars TIDAK cocok

Ini bagian yang biasanya dilewat penulis lain. Handlebars logic-less. Itu fitur, sekaligus batasnya.

Jangan pakai stack ini kalau:

  • Aplikasi kamu punya state klien yang kompleks. Dashboard dengan filter bersarang, drag-and-drop, atau optimistic update. Kamu bakal nulis 2.000 baris jQuery untuk hal yang React selesaikan dalam 200 baris.
  • Butuh update real-time. Trading, chat, kolaborasi live. Full page reload tiap perubahan itu mimpi buruk.
  • Tim kamu besar dan butuh komponen dengan tipe jelas. Partial Handlebars nggak punya kontrak props. Kirim {{price}} yang undefined, hasilnya string kosong — diam-diam, tanpa error.
  • Butuh ISR / edge rendering out of the box. Next.js kasih itu gratis. Di sini kamu bangun caching sendiri.

Aturan praktisnya: kalau rasio baca-tulis halaman di atas 90:10 dan kontennya berubah karena database, bukan karena interaksi user — SSR menang. Sisanya, pikir dua kali.

Konfigurasi hbs, layout.hbs, dan Auto-Register Partials di Express

Wiring intinya cuma empat baris. Dari app.js:

const express = require('express');
const path = require('path');
const hbs = require('hbs');

const app = express();

hbs.registerPartials(__dirname + '/views/partials');
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');

registerPartials memindai folder itu secara rekursif dan mendaftarkan tiap .hbs dengan nama filenya. File di views/partials/components/navbar.hbs jadi {{> navbar}} — perhatikan, nama subfolder tidak jadi bagian nama partial. Konsekuensinya: dua file bernama card.hbs di dua subfolder berbeda akan saling menimpa. Satu diam-diam kalah. Beri nama unik: card-pricing.hbs, card-blog.hbs.

views/layout.hbs otomatis membungkus setiap render. Isinya <head> lengkap plus satu placeholder:

<head>
  <title>{{title}}</title>
  <meta name="description" content='{{description}}'>
  <meta property='og:title' content='{{title}}' />
  <meta property="og:url" content="{{canonical}}" />
</head>
<body> {{> navbar}} {{{body}}} {{> footer}} </body>

Tiga kurung kurawal di {{{body}}} itu wajib. Dua kurawal meng-escape HTML — halaman kamu bakal tampil sebagai teks mentah dengan &lt;div&gt; di mana-mana.

Helper: tempat logika yang tidak muat di template

Handlebars sengaja nggak punya if x > y. Solusinya helper, semua terdaftar di satu file utils/hbsutils.js yang di-require sekali dari app.js:

const hbs = require('hbs');

hbs.registerHelper('eq', function (a, b) {
   return a === b;
});

hbs.registerHelper('formatDate', function (date) {
   if (!date) return '-';
   const d = new Date(date);
   return d.toLocaleDateString('en-US', {
      year: 'numeric', month: 'short', day: 'numeric'
   });
});

// Ubah newline jadi <br>, tapi escape dulu supaya aman dari XSS.
hbs.registerHelper('nl2br', function (text) {
   if (text == null) return '';
   const escaped = hbs.handlebars.Utils.escapeExpression(String(text));
   return new hbs.SafeString(escaped.replace(/\r\n|\r|\n/g, '<br>'));
});

Perhatikan nl2br. SafeString mematikan escaping otomatis — jadi kamu harus escape manual dulu. Helper yang mengembalikan SafeString dari input user tanpa escapeExpression adalah lubang XSS. Ini kesalahan paling sering di kode Handlebars.

Pakai di template:

{{#each blogs}} <article>
    <h3>{{this.title}}</h3>
    <time>{{formatDate this.created_at}}</time>
    <p>{{nl2br this.excerpt}}</p> {{#if (eq this.status "featured")}}<span class="badge">Pilihan</span>{{/if}} </article> {{/each}} 

Struktur Router Per Halaman vs Router Monolitik

Godaan awalnya menaruh semua di routes/index.js. Setelah 12 halaman, file itu 600 baris dan setiap perubahan copy SEO memicu konflik merge.

Pola yang dipakai di sini: satu file router per top-level path, di-mount eksplisit.

// app.js
const indexRouter = require('./routes/index');
const aboutRouter = require('./routes/about');
const blogRouter = require('./routes/blog');

app.use('/', indexRouter);
app.use('/about', aboutRouter);
app.use('/blog', blogRouter);
app.use('/layanan', serviceRouter);

Lalu tier kedua untuk endpoint JSON dan POST form, dipisah total dari yang me-render halaman:

app.use('/services/auth', authSers);
app.use('/services/contact', servSers);
app.use('/services/blog', blogSers);

Pemisahan ini bukan kosmetik. Router halaman mengembalikan HTML dan redirect. Router service mengembalikan JSON dan status code. Middleware yang dibutuhkan berbeda — requireAuth dan rate limiting cuma relevan di tier kedua. Kalau tercampur, kamu akan kirim halaman error HTML ke pemanggil fetch().

Gotcha: urutan middleware itu signifikan

Express mengeksekusi middleware sesuai urutan pendaftaran. Dua bug nyata muncul dari sini.

Pertama, static sebelum router. Kalau express.static didaftarkan sebelum router, request /blog yang kebetulan cocok dengan file public/blog akan dilayani sebagai file — router-mu nggak pernah jalan. Sebaliknya, kalau static didaftarkan setelah router, tiap request aset melewati seluruh rantai router dulu. Untuk situs kecil ini bukan masalah, tapi sadari biayanya.

Kedua — dan ini ada di repo ini — compression() didaftarkan setelah express.static():

app.use(express.static(path.join(__dirname, 'public'), { maxAge: cacheTime }));
app.use(compression());   // terlambat: file statis sudah terkirim

compression bekerja dengan membungkus res.write. Kalau express.static sudah mengirim respons di middleware sebelumnya, pembungkusan itu nggak pernah kena. Artinya CSS dan JS bundle kamu dikirim tanpa gzip. Perbaikannya satu baris — naikkan compression() ke atas:

app.use(compression());
app.use(express.static(path.join(__dirname, 'public'), { maxAge: cacheTime }));

Cek dengan:

curl -sI -H "Accept-Encoding: gzip" http://localhost:3000/stylesheets/index.min.css | grep -i encoding
# harusnya: content-encoding: gzip

Untuk index.min.css berukuran 120 KB, ini selisih sekitar 95 KB per pengunjung baru.

Ketiga, route mati. Kalau kamu daftarkan app.use('/', indexRouter) di atas, lalu di bawahnya ada app.get('/', ...) — handler kedua nggak akan pernah tereksekusi. Router pertama sudah merespons. Kode seperti itu gampang menumpuk dan bikin bingung orang berikutnya.

Passing Data SEO (title, canonical, description) ke Setiap Render

Layout membaca {{title}}, {{description}}, {{canonical}}. Berarti setiap res.render wajib mengisinya. Lupa satu, dan kamu punya halaman dengan <title></title> kosong di produksi.

Bentuk lengkapnya, dari routes/about.js:

const express = require('express');
const router = express.Router();

router.get('/', function (req, res, next) {
  res.render('pages/about', {
    title: 'Tentang Zalvice - End-to-End System Development Company',
    type: 'website',
    author: 'M. Ridwan Zalbina',
    description: 'Zalvice adalah perusahaan pengembangan sistem end-to-end sejak 2020. 100+ proyek terdelivery.',
    canonical: 'https://zalvice.com/about',
    keywords: 'tentang Zalvice, system development company, software house Indonesia',
    breadcrumbs: [
      { name: 'Home', link: '/' },
      { name: 'Tentang Kami' }
    ]
  });
});

module.exports = router;

Untuk halaman dinamis, data SEO datang dari database. Query pakai mysql2 dengan placeholder — selalu parameterized, jangan pernah gabung string:

const connection = require('../config/db');

router.get('/:slug', function (req, res, next) {
  const sql = 'SELECT title, excerpt, content, created_at FROM blogs WHERE slug = ? LIMIT 1';

  connection.query(sql, [req.params.slug], (err, rows) => {
    if (err) return next(err);
    if (!rows.length) return next();   // jatuh ke handler 404

    const post = rows[0];
    res.render('pages/blog-detail', {
      title: `${post.title} - Blog Zalvice`,
      type: 'article',
      description: post.excerpt,
      canonical: `https://zalvice.com/blog/${req.params.slug}`,
      post
    });
  });
});

return next() tanpa argumen itu penting. Ini melewatkan request ke middleware berikutnya, yang akhirnya kena catch-all 404 — bukan next(err) yang memicu halaman error 500.

Kurangi duplikasi dengan res.locals

Menyalin author dan type ke sebelas router itu boros. Pasang default sekali sebelum router:

app.use((req, res, next) => {
  res.locals.author = 'M. Ridwan Zalbina';
  res.locals.type = 'website';
  res.locals.canonical = 'https://zalvice.com' + req.originalUrl.split('?')[0];
  next();
});

Objek yang kamu kirim ke res.render menimpa res.locals per-key. Jadi router tinggal isi yang spesifik.

Gotcha: single connection mysql2 yang mati saat idle

config/db.js pakai createConnection, bukan createPool:

module.exports = mysql.createConnection({ host: ..., user: ... });

Satu koneksi TCP untuk seluruh aplikasi. MySQL menutup koneksi idle setelah wait_timeout — default 28.800 detik (8 jam). Situs dengan trafik rendah di malam hari akan kena PROTOCOL_CONNECTION_LOST pada request pertama pagi hari. Semua query gagal sampai proses di-restart.

Perbaikannya ganti ke pool, yang otomatis membuat koneksi baru saat yang lama mati:

const mysql = require('mysql2');

module.exports = mysql.createPool({
   host: process.env.DB_HOST,
   user: process.env.DB_USER,
   password: process.env.DB_PASS,
   database: process.env.DB_NAME,
   port: process.env.DB_PORT || 3306,
   waitForConnections: true,
   connectionLimit: 10,
   queueLimit: 0
});

API .query(sql, params, cb) identik, jadi ini drop-in replacement. Nggak ada kode pemanggil yang perlu berubah.

Menghindari Jebakan Minifikasi HTML pada Ekspresi {{...}}

Ini gotcha yang paling membingungkan karena baru muncul di produksi.

express-minify-html memproses respons HTML. Masalahnya, minifier menganggap {{ sebagai teks biasa. Saat collapseWhitespace aktif, ekspresi seperti:

<meta name="description" content='{{ description }}'>

bisa kehilangan spasi atau — lebih parah — minifyJS akan mencoba mem-parse blok <script> yang berisi {{}} sebagai JavaScript, gagal, lalu membuang seluruh isinya. Kamu dapat halaman kosong tanpa satu pun pesan error.

Solusinya ignoreCustomFragments. Regex ini menandai setiap {{...}} sebagai wilayah terlarang:

const minifyHTML = require('express-minify-html');

app.use(minifyHTML({
  override: true,
  htmlMinifier: {
    removeComments: true,
    collapseWhitespace: true,
    minifyJS: true,
    minifyCSS: true,
    ignoreCustomFragments: [/\{\{[\s\S]*?\}\}/]   // lindungi Handlebars
  }
}));

[\s\S]*? cocok lintas baris dan bersifat non-greedy — penting supaya dua ekspresi berdekatan nggak tergabung jadi satu blok raksasa.

Satu catatan lagi: removeComments: true menghapus komentar HTML. Kalau kamu pakai komentar kondisional atau penanda <!-- build:css -->, semuanya lenyap. Verifikasi dengan menjalankan mode produksi lalu bandingkan output:

NODE_ENV=production node ./bin/www &
curl -s http://localhost:3000/about | grep -c "og:description"
# harus 1, bukan 0

Jadikan ini smoke test. Satu perintah curl menangkap kelas bug yang bisa menghabiskan setengah hari.

Checklist Sebelum Deploy

Jalankan berurutan. Tiap item bisa diverifikasi dalam hitungan detik.

  1. Cek gzip aktif. curl -sI -H "Accept-Encoding: gzip" localhost:3000/stylesheets/index.min.css | grep -i encoding. Kalau kosong, naikkan compression() ke atas express.static.
  2. Ganti createConnection jadi createPool di config/db.js. Lima menit sekarang, hemat satu insiden nanti.
  3. Audit SafeString. grep -rn "SafeString" utils/hbsutils.js — pastikan setiap helper yang menerima input user memanggil escapeExpression dulu.
  4. Cari partial bernama sama. find views/partials -name "*.hbs" -printf "%f\n" | sort | uniq -d. Output apa pun berarti ada partial yang saling menimpa.
  5. Verifikasi meta tag lolos minifier. curl -s localhost:3000/about | grep "og:description" dengan NODE_ENV=production.
  6. Cek {{{body}}} pakai tiga kurawal di views/layout.hbs.
  7. Pastikan tiap res.render mengirim title, description, canonical. Kalau capek mengulang, pasang middleware res.locals sebelum blok router.
  8. Hapus route mati. Handler yang terdaftar setelah app.use('/', router) untuk path yang sama tidak akan pernah jalan.
  9. Konfirmasi query pakai ?. grep -rn "connection.query" routes/ | grep '+' — hasil apa pun perlu ditinjau sebagai potensi SQL injection.
  10. Bandingkan view-source: dengan tab Elements. Kalau isinya sama, SSR kamu benar-benar bekerja.

Tumpukan ini nggak akan bikin kamu terkesan di konferensi. Tapi untuk situs yang tugasnya menyajikan konten dari database ke pembaca dan crawler, dia mengirim HTML lebih cepat, dengan dependency lebih sedikit, dan lebih sedikit hal yang bisa rusak jam dua pagi.