SPA vs Server-Rendered: Memilih Arsitektur Frontend
Setiap aplikasi web punya dua ujung: frontend (yang dilihat dan diinteraksikan pengguna) dan backend (yang memproses data dan logika bisnis). Sebagai backend engineer, Anda tidak selalu menulis HTML atau JavaScript, tapi Anda harus paham bagaimana ujung yang satu memakan output ujung yang lain. Artikel ini menjelaskan dua arsitektur frontend yang paling umum — server-rendered dan Single-Page Application (SPA) — dari sudut pandang backend: bagaimana keduanya berkomunikasi dengan server, apa yang mereka minta dari API, dan kapan sebaiknya Anda memilih yang mana.
Dua Ujung Aplikasi Web: Sudut Pandang Backend Engineer
Pertanyaan paling mendasar adalah: siapa yang bertugas merender halaman? Rendering di sini berarti mengubah data menjadi HTML yang bisa dilihat browser.
| Arsitektur | Siapa merender HTML? | Apa yang dikirim ke browser? | Bagaimana data didapat? |
|---|---|---|---|
| Server-Rendered | Server (menggunakan template) | HTML lengkap yang sudah jadi | Dicampur langsung ke HTML saat rendering |
| SPA | Browser (menggunakan JavaScript) | Satu HTML shell kosong + bundle JS | Request REST/JSON terpisah setelah halaman dimuat |
Kedua arsitektur ini valid dan sampai hari ini dipakai masif di produksi. Artikel di MDN tentang SPA mendefinisikan SPA sebagai aplikasi yang memuat satu halaman, lalu memperbarui bagian halaman (alih-alih memuat dokumen baru) saat pengguna berinteraksi. Kebalikannya dijelaskan di MDN tentang SSR: rendering HTML dilakukan di server, dan browser tinggal menampilkan dokumen yang sudah jadi.
Ingat: pilihan arsitektur frontend bukan hanya urusan tim frontend. Itu menentukan bentuk API Anda, strategi autentikasi, beban server, dan bahkan sulit-tidaknya halaman ditemukan oleh Google.
Server-Rendered (MPA): HTML Lengkap dari Server
Pendekatan ini juga dikenal sebagai MPA (Multi-Page Application) — setiap navigasi memuat dokumen HTML baru. Ini arsitektur "klasik" yang sudah ada sejak awal web.
Cara kerja
Alurnya sederhana dan lurus:
Browser Server Backend
| |
| 1. GET /users |
|-------------------------------------->|
| 2. Query database |
| 3. Render template |
| 4. 200 OK + HTML lengkap |
|<--------------------------------------|
| (Browser menampilkan dokumen jadi) |
| |
| 5. Klik link "Detail User" |
| 6. GET /users/42 (halaman baru) |
|-------------------------------------->|
Perhatikan: setiap kali pengguna mengklik link, browser mengirim request penuh dan menerima dokumen HTML baru. Tidak ada yang tersisa di memori browser — seluruh state (data form, posisi scroll, data di halaman) ikut hilang, kecuali yang dipertahankan server lewat session atau URL.
Interaktivitas memang ada, tapi biasanya terbatas: sedikit JavaScript ringan untuk validasi form, dropdown menu, atau menampilkan/sembunyikan elemen. Jangan heran jika server-rendered identik dengan "HTML dulu, JS seperlunya."
Thymeleaf: Template di Sisi Server
Di ekosistem Java/Spring, template engine yang paling populer adalah Thymeleaf. Thymeleaf menuliskan atribut th:* langsung di dalam tag HTML, sehingga file template tetap bisa dibuka sebagai HTML biasa oleh desainer.
| Atribut | Fungsi | Contoh |
|---|---|---|
th:text | Tulis teks dari data model (HTML-escaped otomatis) | <span th:text="${user.name}">Nama</span> |
th:each | Iterasi koleksi — setara for-each | <tr th:each="u : ${users}"> |
th:if / th:unless | Tampilkan elemen hanya jika kondisi terpenuhi | <p th:if="${user.admin}">Admin</p> |
th:href | Bangun link dinamis | <a th:href="@{/users/{id}(id=${u.id})}"> |
th:action | Tentukan tujuan submit form | <form th:action="@{/users}" th:method="post"> |
Contoh template users.html yang merender daftar user:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Daftar User</title>
</head>
<body>
<h1>Daftar User</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Nama</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr th:each="u : ${users}">
<td th:text="${u.id}">1</td>
<td th:text="${u.name}">Nama User</td>
<td th:text="${u.role}">USER</td>
</tr>
</tbody>
</table>
<p th:if="${#lists.isEmpty(users)}">Belum ada user terdaftar.</p>
<a th:href="@{/users/new}">Tambah User Baru</a>
</body>
</html>
Di sisi controller, Anda cukup mengembalikan nama view dan Spring menyerahkan model:
@Controller
public class UserController {
@GetMapping("/users")
public String listUsers(Model model) {
List<User> users = userService.findAll();
model.addAttribute("users", users);
return "users";
}
}
Kelebihan dan kekurangan
| Kelebihan | Kekurangan |
|---|---|
| SEO sangat baik — HTML langsung bisa dibaca crawler tanpa menunggu JavaScript | Setiap navigasi reload penuh — terasa "berkedip" dan lambat untuk aplikasi berat |
| Loading awal cepat — dokumen datang sudah jadi, tidak perlu menunggu bundle JS besar | Interaktivitas terbatas — UI kompleks jadi berantakan untuk ditulis dengan jQuery/aneka snippet |
| Sederhana — satu server, satu bahasa (Java), logika presentasi dekat dengan data | Beban server untuk render — setiap request memakan CPU untuk merender template |
| Mudah di-debug — render error langsung terlihat di halaman | State hilang antar halaman — perlu session/URL untuk membawa konteks |
Server-rendered adalah pilihan "aman" default untuk sebagian besar aplikasi web klasik: halaman informasi, blog, toko yang perlu ter-index Google, dan aplikasi internal yang tidak butuh banyak animasi.
SPA (Single-Page Application): Satu Shell, Banyak JavaScript
SPA membalik logika rendering. Server cukup mengirim satu HTML shell (biasanya berisi <div id="root"> kosong) plus bundle JavaScript. Setelah itu, JavaScript di browser yang menciptakan seluruh antarmuka.
Cara kerja
Browser Server Backend
| |
| 1. GET /app |
|-------------------------------------->|
| 2. 200 OK + HTML shell + bundle JS |
|<--------------------------------------|
| 3. JS mengeksekusi di browser |
| 4. fetch GET /api/users |
|-------------------------------------->|
| 5. 200 OK + JSON [...users...] |
|<--------------------------------------|
| 6. JS merender data ke UI |
| |
| 7. Klik "Detail" — TANPA reload |
| 8. fetch GET /api/users/42 |
|-------------------------------------->|
Perbedaan kuncinya: setelah shell dimuat, komunikasi selanjutnya adalah fetch ke endpoint API yang mengembalikan JSON, bukan request halaman. Navigasi antar "halaman" (misal dari daftar user ke detail user) ditangani client-side routing — URL berubah lewat history.pushState dan komponen ditukar, tanpa browser memuat dokumen baru.
Komunikasi REST/JSON dengan fetch
Berikut contoh memakai Fetch API, API bawaan browser untuk mengirim request:
async function loadUsers() {
const response = await fetch("/api/users", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
}
});
if (!response.ok) {
throw new Error("Gagal memuat data: " + response.status);
}
const users = await response.json();
renderUserTable(users); // fungsi di sisi frontend
}
// POST data baru
async function createUser(user) {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user)
});
if (!response.ok) {
const error = await response.json();
showValidationError(error); // tampilkan pesan ke pengguna
}
}
Konsekuensi penting bagi backend: di SPA, API REST bukan lagi "pelengkap" — ia adalah aplikasinya. Endpoint, kontrak data, dan status code harus konsisten karena frontend bergantung penuh padanya.
Contoh stack yang umum
Semua framework SPA utama bekerja dengan pola yang sama: komponen di browser, data dari REST API.
| Framework | Dipakai oleh | Sumber data |
|---|---|---|
| React | Facebook/Meta, banyak perusahaan | REST/GraphQL API via fetch atau Axios |
| Angular | Google, aplikasi enterprise | HttpClient (HTTP interceptor) |
| Vue | Laravel/Vite ecosystem | REST API via Axios |
Perhatikan satu hal: ketiganya tidak pernah merender HTML di server pada mode murni SPA. Server backend murni menyediakan JSON — inilah mengapa SPA sering dipasangkan dengan arsitektur frontend dan backend terpisah (dua repo, dua deploy, bahkan dua domain berbeda).
Kelebihan dan kekurangan
| Kelebihan | Kekurangan |
|---|---|
| UX mulus — navigasi tanpa reload, transisi halus, tanpa "kedipan" putih | SEO lebih sulit — crawler awalnya hanya melihat shell kosong; butuh SSR atau prerender |
| Interaktivitas kaya — drag-and-drop, dashboard real-time, update tanpa muat ulang | Bundle besar — ukuran JS memengaruhi waktu muat awal dan perangkat lemah |
| Backend murni API — API yang sama bisa dipakai aplikasi mobile, kiosk, integrasi pihak ketiga | Manajemen state & token — perlu pola untuk menyimpan data dan kredensial di browser |
| Beban render di browser — server fokus melayani API, CPU server tidak dipakai merender HTML | Kompleksitas naik — tooling, bundling, routing, error boundary, dsb. |
HTTP Interceptor: Token Bearer yang Otomatis
Salah satu masalah yang langsung dirasakan backend engineer ketika bekerja dengan SPA adalah autentikasi. Di SPA, setiap request ke API harus membawa bukti login, biasanya header Authorization: Bearer <token>.
Kenapa interceptor diperlukan
Bayangkan menulis 20 fetch di seluruh aplikasi. Jika setiap fetch harus menyalin Authorization: Bearer ... secara manual, itu:
- Rawan lupa — satu
fetchyang tidak membawa token membuat pengguna mendapat401membingungkan. - Rawan tidak konsisten — format header bisa beda-beda antar developer.
- Menyulitkan perbaikan terpusat — saat cara menambah token berubah, Anda harus menyentuh semua file.
Solusinya: interceptor — sebuah lapisan yang menyelip di antara kode aplikasi dan request keluar, dan otomatis menyisipkan header token ke setiap request.
Contoh Angular HttpInterceptor
Framework Angular punya mekanisme HttpInterceptor bawaan HttpClient:
import { Injectable } from "@angular/core";
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpErrorResponse
} from "@angular/common/http";
import { Observable, catchError, switchMap, throwError } from "rxjs";
import { AuthService } from "./auth.service";
@Injectable()
export class BearerTokenInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.auth.getAccessToken();
// 1. Sisipkan header Authorization ke salinan request
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
// 2. Kirim request, tangkap error 401
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401 && !req.url.includes("/auth/refresh")) {
// 3. Coba refresh token, lalu ulangi request yang gagal
return this.auth.refreshToken().pipe(
switchMap(() => next.handle(this.addToken(req))),
catchError((refreshError) => {
// 4. Refresh gagal → paksa login ulang
this.auth.redirectToLogin();
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
}
private addToken(req: HttpRequest<any>): HttpRequest<any> {
return req.clone({
setHeaders: { Authorization: `Bearer ${this.auth.getAccessToken()}` }
});
}
}
Alur yang dijelaskan oleh kode di atas:
- Sisipkan token — setiap request mendapat header
Authorization: Bearer <token>. - Tangani 401 — jika token kedaluwarsa (server menjawab
401), interceptor otomatis memanggil refresh token. - Ulangi request — setelah token baru didapat, request yang tadinya gagal dikirim ulang; pengguna tidak merasakan apa-apa.
- Redirect ke login — jika refresh pun gagal (sesi benar-benar berakhir), pengguna diarahkan ke halaman login.
Pola ini wajib dipahami backend engineer:
401bukan sekadar kode error. Di SPA,401adalah sinyal protokol yang memicu seluruh siklus refresh token. Jika backend mengembalikan401secara tidak konsisten (kadang403untuk token kedaluwarsa), rantai refresh token patah dan pengguna mendadak di-logout.
Bandingkan dengan cookie session di server-rendered
Di aplikasi server-rendered, masalah ini hampir tidak ada:
| Aspek | Server-Rendered (cookie session) | SPA (Bearer token) |
|---|---|---|
| Bukti login disimpan di | Cookie session, dikirim otomatis browser | localStorage/memori, diambil oleh JavaScript |
Header Authorization manual | Tidak perlu — browser mengirim cookie sendiri | Perlu — via interceptor |
| Token kedaluwarsa | Server cek session; session tahan lama | Perlu mekanisme refresh token |
| Risiko utama | CSRF (perlu CSRF token) | XSS (token bisa dicuri dari localStorage) |
Perbandingan Besar
Berikut tabel lengkap untuk membandingkan kedua arsitektur dari berbagai dimensi:
| Dimensi | Server-Rendered (MPA) | SPA |
|---|---|---|
| SEO | Bagus — HTML langsung dibaca crawler | Sulit — butuh SSR/prerender tambahan |
| Kecepatan awal | Cepat — dokumen datang lengkap | Lebih lambat — tunggu bundle JS dieksekusi |
| UX / navigasi | Reload penuh tiap pindah halaman | Mulus, tanpa reload, transisi halus |
| Interaktivitas | Terbatas, JS ringan | Kaya, hampir setara aplikasi desktop |
| Komunikasi dengan server | Request halaman HTML | fetch REST/JSON terpisah |
| Autentikasi | Cookie session (browser yang urus) | Bearer token via interceptor + refresh |
| Beban server | CPU untuk render template + query | Hanya serve JSON — lebih ringan per request |
| Skala / decoupling | Frontend melekat pada server | Frontend & backend bisa deploy terpisah |
| Kompleksitas proyek | Rendah, satu codebase | Tinggi — build tooling, state management, routing |
| Kasus pakai khas | Blog, toko, halaman informasi, admin sederhana | Dashboard real-time, app mobile-first, aplikasi interaktif |
Kapan Memilih Mana
Tidak ada jawaban "selalu SPA" atau "selalu server-rendered". Jawaban benar datang dari kebutuhan aplikasi Anda.
Aturan praktis
| Kondisi aplikasi | Pilihan yang tepat | Alasannya |
|---|---|---|
| Halaman harus ditemukan Google (SEO penting) | Server-rendered | HTML langsung dibaca crawler tanpa JS |
| Aplikasi internal / admin sederhana | Server-rendered | Kecepatan development, mudah dipelihara |
| Banyak CRUD sederhana, tim kecil | Server-rendered | Satu server, satu bahasa, satu deploy |
| Dashboard interaktif / real-time | SPA | Update data tanpa reload sangat natural |
| Perlu aplikasi mobile + web sekaligus | SPA | API yang sama bisa dipakai dua klien |
| UX mulus seperti aplikasi desktop | SPA | Navigasi tanpa reload, animasi kaya |
Aturan singkatnya: jika masalah utama aplikasi Anda adalah menampilkan informasi, pilih server-rendered. Jika masalah utamanya adalah interaksi berkelanjutan dengan data, pilih SPA. Dan untuk aplikasi admin, pertimbangkan dulu: apakah pengguna internal benar-benar butuh SPA?
Arsitektur hibrida
Jawaban yang sering luput dari diskusi adalah gabungan keduanya — framework SPA yang di-render di server, paling terkenal Next.js untuk React. Konsepnya:
- SSR (Server-Side Rendering) untuk halaman publik yang butuh SEO dan kecepatan awal.
- CSR (Client-Side Rendering) untuk bagian aplikasi yang sangat interaktif, seperti dashboard setelah login.
- ISR (Incremental Static Regeneration) untuk halaman yang jarang berubah — dirender statis sekali, di-update periodik.
Pola ini memberi UX SPA sekaligus SEO server-rendered — dengan biaya kompleksitas tambahan dan satu layer runtime lagi di server.
Peran Backend di Kedua Arsitektur
Inilah inti untuk backend engineer:
| Aspek | Server-Rendered | SPA |
|---|---|---|
| Backend memegang | Presentasi (template, HTML) + data | Hanya data (pure API) |
| Kontrak dengan frontend | Tidak resmi — template adalah kontraknya | Harus didokumentasi — frontend menulis kode mengikuti kontrak |
| Perubahan API | Aman — backend & template satu repo | Berbahaya — frontend yang sudah deploy bisa patah |
| Versioning | Tidak terlalu relevan | Perlu strategi versioning endpoint |
Di SPA, API adalah kontrak tim yang harus terdokumentasi dan stabil. Inilah kenapa praktik di artikel Swagger & OpenAPI jadi krusial: dokumentasi API yang di-generate otomatis menjadi satu-satunya jembatan antara frontend dan backend yang kini terpisah. Jika backend mengubah bentuk JSON tanpa memberi tahu frontend, aplikasi langsung rusak di produksi. Sebelum desain API dimulai, pastikan Anda juga membaca REST API Design agar endpoint dibangun dengan pola yang konsisten.
Jebakan yang Sering Terjadi
Tiga kesalahan paling umum yang ditemui di proyek nyata:
-
Memaksa SPA padahal tidak butuh interaktivitas. Halaman profil perusahaan atau formulir sederhana di-bangun dengan React lengkap dengan bundle 500KB. Hasilnya: SEO jelek, loading lambat, dan maintenance lebih sulit — padahal server-rendered menyelesaikannya dengan 30 baris Thymeleaf.
-
Memakai SSR untuk dashboard real-time. Dashboard monitoring yang menerima update data tiap detik dipaksa reload penuh tiap kali data berubah. Setiap reload memuat ulang seluruh halaman dan menyiksa server dengan render — padahal SPA dengan WebSocket atau
fetchberkala jauh lebih tepat. Baca WebSocket & Real-Time untuk pola yang benar. -
CORS & auth yang keliru di SPA. Karena frontend dan backend sering berbeda domain, request browser lintas-origin diblokir kalau server tidak mengonfigurasi CORS dengan benar. Dua kesalahan khas:
- Menyalakan
allowCredentials(true)bersama"*"origin — kombinasi ini ditolak browser. - Menyimpan token di
localStoragelalu lupa risiko XSS — token bisa dicuri lewat script yang disisipkan.
Detail konfigurasi dan jebakannya sudah dibahas di artikel CORS, Rate Limit & reCAPTCHA. Kombinasikan juga dengan pemahaman Session + CSRF vs JWT sebelum memutuskan strategi autentikasi.
- Menyalakan
Ringkasan
Server-rendered dan SPA adalah dua cara berbeda menjawab pertanyaan "siapa yang merender halaman?". Server-rendered membuat HTML di server lalu mengirim dokumen jadi — SEO bagus, loading awal cepat, dan sederhana, tapi interaktivitas terbatas dan setiap navigasi reload penuh. SPA mengirim satu shell kosong plus JavaScript, lalu berkomunikasi dengan backend lewat fetch REST/JSON — UX mulus dan interaktivitas kaya, tapi SEO lebih sulit dan kompleksitas lebih tinggi. Konsekuensinya untuk backend juga berbeda: di server-rendered backend memegang presentasi, sedangkan di SPA backend menjadi pure API yang wajib didokumentasikan (OpenAPI) dan stabil, lengkap dengan strategi token Bearer via HTTP interceptor. Pilih arsitektur berdasarkan kebutuhan, bukan tren: halaman informasi dan admin sederhana cukup server-rendered; aplikasi interaktif, real-time, dan mobile-first cocok dengan SPA; dan untuk kebutuhan keduanya, pertimbangkan hibrida seperti Next.js.
Lanjut membaca
- SPA - MDN Glossary — definisi dan karakteristik Single-Page Application
- SSR - MDN Glossary — definisi dan karakteristik Server-Side Rendering
- Fetch API - MDN — API browser untuk komunikasi HTTP dari JavaScript
- Thymeleaf Documentation — dokumentasi resmi template engine untuk Java/Spring
- Next.js Documentation — framework hibrida SSR/CSR/ISR untuk React
- REST API Design — landasan desain endpoint sebelum dikonsumsi frontend
- Swagger & OpenAPI — mendokumentasikan API agar frontend SPA bisa mengandalkannya