SPA vs Server-Rendered: Memilih Arsitektur Frontend

14 min readIntermediate
SPAServer-RenderedSSRFrontend

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.

ArsitekturSiapa merender HTML?Apa yang dikirim ke browser?Bagaimana data didapat?
Server-RenderedServer (menggunakan template)HTML lengkap yang sudah jadiDicampur langsung ke HTML saat rendering
SPABrowser (menggunakan JavaScript)Satu HTML shell kosong + bundle JSRequest 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.

AtributFungsiContoh
th:textTulis teks dari data model (HTML-escaped otomatis)<span th:text="${user.name}">Nama</span>
th:eachIterasi koleksi — setara for-each<tr th:each="u : ${users}">
th:if / th:unlessTampilkan elemen hanya jika kondisi terpenuhi<p th:if="${user.admin}">Admin</p>
th:hrefBangun link dinamis<a th:href="@{/users/{id}(id=${u.id})}">
th:actionTentukan 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

KelebihanKekurangan
SEO sangat baik — HTML langsung bisa dibaca crawler tanpa menunggu JavaScriptSetiap navigasi reload penuh — terasa "berkedip" dan lambat untuk aplikasi berat
Loading awal cepat — dokumen datang sudah jadi, tidak perlu menunggu bundle JS besarInteraktivitas terbatas — UI kompleks jadi berantakan untuk ditulis dengan jQuery/aneka snippet
Sederhana — satu server, satu bahasa (Java), logika presentasi dekat dengan dataBeban server untuk render — setiap request memakan CPU untuk merender template
Mudah di-debug — render error langsung terlihat di halamanState 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.

FrameworkDipakai olehSumber data
ReactFacebook/Meta, banyak perusahaanREST/GraphQL API via fetch atau Axios
AngularGoogle, aplikasi enterpriseHttpClient (HTTP interceptor)
VueLaravel/Vite ecosystemREST 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

KelebihanKekurangan
UX mulus — navigasi tanpa reload, transisi halus, tanpa "kedipan" putihSEO lebih sulit — crawler awalnya hanya melihat shell kosong; butuh SSR atau prerender
Interaktivitas kaya — drag-and-drop, dashboard real-time, update tanpa muat ulangBundle besar — ukuran JS memengaruhi waktu muat awal dan perangkat lemah
Backend murni API — API yang sama bisa dipakai aplikasi mobile, kiosk, integrasi pihak ketigaManajemen state & token — perlu pola untuk menyimpan data dan kredensial di browser
Beban render di browser — server fokus melayani API, CPU server tidak dipakai merender HTMLKompleksitas 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:

  1. Rawan lupa — satu fetch yang tidak membawa token membuat pengguna mendapat 401 membingungkan.
  2. Rawan tidak konsisten — format header bisa beda-beda antar developer.
  3. 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:

  1. Sisipkan token — setiap request mendapat header Authorization: Bearer <token>.
  2. Tangani 401 — jika token kedaluwarsa (server menjawab 401), interceptor otomatis memanggil refresh token.
  3. Ulangi request — setelah token baru didapat, request yang tadinya gagal dikirim ulang; pengguna tidak merasakan apa-apa.
  4. Redirect ke login — jika refresh pun gagal (sesi benar-benar berakhir), pengguna diarahkan ke halaman login.

Pola ini wajib dipahami backend engineer: 401 bukan sekadar kode error. Di SPA, 401 adalah sinyal protokol yang memicu seluruh siklus refresh token. Jika backend mengembalikan 401 secara tidak konsisten (kadang 403 untuk 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:

AspekServer-Rendered (cookie session)SPA (Bearer token)
Bukti login disimpan diCookie session, dikirim otomatis browserlocalStorage/memori, diambil oleh JavaScript
Header Authorization manualTidak perlu — browser mengirim cookie sendiriPerlu — via interceptor
Token kedaluwarsaServer cek session; session tahan lamaPerlu mekanisme refresh token
Risiko utamaCSRF (perlu CSRF token)XSS (token bisa dicuri dari localStorage)

Perbandingan Besar

Berikut tabel lengkap untuk membandingkan kedua arsitektur dari berbagai dimensi:

DimensiServer-Rendered (MPA)SPA
SEOBagus — HTML langsung dibaca crawlerSulit — butuh SSR/prerender tambahan
Kecepatan awalCepat — dokumen datang lengkapLebih lambat — tunggu bundle JS dieksekusi
UX / navigasiReload penuh tiap pindah halamanMulus, tanpa reload, transisi halus
InteraktivitasTerbatas, JS ringanKaya, hampir setara aplikasi desktop
Komunikasi dengan serverRequest halaman HTMLfetch REST/JSON terpisah
AutentikasiCookie session (browser yang urus)Bearer token via interceptor + refresh
Beban serverCPU untuk render template + queryHanya serve JSON — lebih ringan per request
Skala / decouplingFrontend melekat pada serverFrontend & backend bisa deploy terpisah
Kompleksitas proyekRendah, satu codebaseTinggi — build tooling, state management, routing
Kasus pakai khasBlog, toko, halaman informasi, admin sederhanaDashboard 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 aplikasiPilihan yang tepatAlasannya
Halaman harus ditemukan Google (SEO penting)Server-renderedHTML langsung dibaca crawler tanpa JS
Aplikasi internal / admin sederhanaServer-renderedKecepatan development, mudah dipelihara
Banyak CRUD sederhana, tim kecilServer-renderedSatu server, satu bahasa, satu deploy
Dashboard interaktif / real-timeSPAUpdate data tanpa reload sangat natural
Perlu aplikasi mobile + web sekaligusSPAAPI yang sama bisa dipakai dua klien
UX mulus seperti aplikasi desktopSPANavigasi 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:

AspekServer-RenderedSPA
Backend memegangPresentasi (template, HTML) + dataHanya data (pure API)
Kontrak dengan frontendTidak resmi — template adalah kontraknyaHarus didokumentasi — frontend menulis kode mengikuti kontrak
Perubahan APIAman — backend & template satu repoBerbahaya — frontend yang sudah deploy bisa patah
VersioningTidak terlalu relevanPerlu 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:

  1. 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.

  2. 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 fetch berkala jauh lebih tepat. Baca WebSocket & Real-Time untuk pola yang benar.

  3. 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 localStorage lalu 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.

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

← Back to technical articles