karawaci.kode

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.com untuk 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

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

  2. 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).

  3. Concurrent edit: 2 reporter pernah edit article sama bersamaan. Last write win, kerja hilang. Fix: optimistic locking dengan version column. Reporter dapat error “Article changed since you opened, reload?” kalau race.

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