2026-06-16 · 7 min
Astro Content Actions + Postgres untuk CMS Internal
Klien media digital Tangerang (~5 reporter, 200+ article/bulan, traffic ~80k unique visitor/bulan) protes ke saya bulan lalu: “Sanity bill naik jadi $89/mo, dan editor susah bantu reporter karena UI Studio confusing.” Saya bangun CMS custom pakai Astro + Postgres. Sekarang sebulan production. Hasilnya.
Setup
Stack:
- Astro 6 Content Actions untuk editor UI + publish flow
- Postgres 17 di Hetzner cx21 ($8/mo) untuk content + metadata
- Cloudflare R2 untuk image upload
- Cloudflare Pages untuk static output (public site)
- Astro SSR mode di subdomain
admin.media-klien.comuntuk editor
Stack baru cost total: ~Rp 200k/mo (mostly Hetzner).
Stack lama (Sanity): $89/mo Sanity + $20/mo Vercel = $109/mo (~Rp 1,75jt).
Saving: ~Rp 1,5jt/mo.
Architecture
[Reporter writes draft]
↓
[admin.media-klien.com] (Astro SSR)
↓ (Content Action: save)
[Postgres] articles table
↓ (Content Action: publish)
[trigger build via CF Pages deploy hook]
↓
[media-klien.com] static rebuild
↓
[Cloudflare CDN cache]
↓
[Public reader]
Editor publish article → trigger build → ~90 detik kemudian article live di public site.
Acceptable untuk media yang publish 6-10 article/hari. Bukan untuk real-time news (breaking news ticker etc).
Editor experience
Reporter butuh:
- Draft writing dengan markdown editor (saya pakai TipTap)
- Image upload dengan auto-resize
- Schedule publish
- Preview before publish
- Tag, category, author assignment
- SEO metadata (meta description, og:image)
Implementation:
---
// admin/articles/[id].astro
import { actions } from 'astro:actions';
const article = await db.articles.findById(Astro.params.id);
---
<form method="POST" action={actions.saveDraft}>
<input type="hidden" name="id" value={article.id} />
<input name="title" value={article.title} />
<Editor name="content" content={article.content} client:load />
<button name="action" value="save">Save Draft</button>
<button name="action" value="publish">Publish</button>
</form>
Pattern Content Actions familiar untuk reporter yang biasa pakai WordPress dulu. Training 2 minggu, semua 5 reporter onboarded.
Latency editor
Save draft (autosave every 10 detik):
- P50: 180ms
- P95: 380ms
Publish (with build trigger):
- Save to DB: P50 240ms
- Build trigger response: ~80ms
- Build completion: 88 detik average
- Live at CDN: ~90 detik total
Editor lihat indicator “Publishing…” dengan estimated 90 detik. Acceptable.
Latency public
Static page from Cloudflare CDN:
- P50: 32ms (Indonesia user, JKT POP)
- P95: 88ms
Untuk reader: instant feel. Lighthouse performance score 96+ di mobile.
Schema sederhana
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
excerpt TEXT,
status TEXT CHECK (status IN ('draft', 'scheduled', 'published')),
author_id UUID REFERENCES authors(id),
category_id UUID REFERENCES categories(id),
meta_description TEXT,
og_image_url TEXT,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON articles (status, published_at DESC);
CREATE INDEX ON articles USING gin(to_tsvector('indonesian', title || ' ' || content));
Full-text search Postgres dengan dictionary Indonesia. Reporter cari “OK Bos pemilu” return relevant article walaupun query informal.
Workflow publish
// src/actions/index.ts
export const server = {
publishArticle: defineAction({
accept: 'form',
input: z.object({
id: z.string().uuid(),
}),
handler: async ({ id }, context) => {
const user = context.locals.user;
if (!hasPermission(user, 'publish')) {
throw new ActionError({ code: 'FORBIDDEN' });
}
await db.articles.update(id, {
status: 'published',
published_at: new Date()
});
// Trigger CF Pages build
await fetch(env.CF_PAGES_BUILD_HOOK, { method: 'POST' });
return { success: true, build_eta_seconds: 90 };
},
}),
};
Sederhana. No vendor SDK.
Yang break
-
Build time naik linear: 50 article = 28 detik build. 500 article = 4 menit build. 2000 article (projected 1 tahun) = 16 menit. Saya optimize dengan incremental builds (Astro 6 feature beta): cuma rebuild page yang affected. Build time stabil di ~90 detik.
-
Image upload R2: pertama saya pakai client direct upload ke R2 dengan pre-signed URL. Worked, tapi kasus 4 reporter terjadi: koneksi putus di tengah upload, image partial. Switch ke server-side upload via Content Action (slower tapi reliable).
-
Concurrent edit: 2 reporter pernah edit article sama bersamaan. Last write win, kerja hilang. Fix: optimistic locking dengan
versioncolumn. Reporter dapat error “Article changed since you opened, reload?” kalau race. -
Build trigger spam: reporter publish 10 article dalam 5 menit. 10 build trigger. CF Pages queue, ngantri. Saya debounce: kalau build sudah jalan, defer next trigger sampai current selesai. Build cluster di sini akan combine multiple publish.
Cost detail (1 bulan)
- Hetzner cx21 (Postgres + Astro SSR): $8/mo
- Cloudflare R2 storage (8GB image): $0.12/mo
- Cloudflare R2 egress (40GB image traffic): $0 (free egress to CF network)
- Cloudflare Pages: $0
- Domain: $0 (klien existing)
- Total: ~$8.12/mo (~Rp 130k)
Sangat murah. Klien delighted.
Yang Sanity tetap menang
Untuk fairness:
- Editor UX: Sanity Studio polish. Custom CMS saya basic.
- Schema migration: Sanity GROQ query dan schema flexibility kuat untuk content yang sangat nested.
- Collaboration realtime: 2 reporter edit bersamaan dengan live presence. Saya skip ini.
- Asset library: Sanity built-in. Saya R2 minimal.
Untuk klien dengan budget besar / content kompleks: Sanity masih menang. Untuk SMB media Indonesia dengan flat content type (article + author + category): custom CMS clear menang di cost.
Verdict
Stack ini sekarang saya template untuk klien media kecil Indonesia. Setup baru ~3 hari per klien. Pattern reusable.
Bukan magic — saya nggak punya editor polish Sanity-grade. Tapi untuk reporter yang biasa pakai WordPress: lebih familiar dari Sanity Studio.
Pattern serupa dengan bangun MVP 3 hari saya: full-stack control, vendor lock-in nol, cost Indonesia-affordable. Buat klien media SMB Tangerang: stack ini menang.
Ditulis oleh Reza Pradipta