Cara Pakai Sanity CMS: Kuasai Skema, GROQ, dan Pengambilan Next/Astro lewat Kode
Sanity CMS dari nol ke produksi: skema, Studio, query GROQ, gambar, dan pengambilan data Next.js/Astro plus preview, dengan kode siap pakai.
Waktu pertama kali menyentuh Sanity, saya mengetik sekitar 10 artikel di layar admin, lalu pulang dengan puas. Keesokan harinya saya coba ambil datanya dari frontend, dan langsung kaku. Halaman daftar memuat seluruh isi body sekaligus, build jadi anehnya lambat. Lebih parah lagi, artikel yang saya kira masih draft malah nongol dengan percaya diri di halaman produksi.
Saya sempat panik. Kesalahan saya adalah menganggap CMS sebagai “kotak tempat menaruh artikel”. Sanity itu bukan kotak, melainkan alat untuk menulis cetak biru konten itu sendiri. Bagaimana kamu memotong tipe (skema), apa yang kamu ambil (GROQ), bagaimana kamu menyingkirkan draft. Kalau bagian ini salah di awal, semuanya harus diulang dari nol nanti.
Di artikel ini saya akan menelusuri “hal-hal yang harus diputuskan paling awal” itu sambil mengetik langsung, dengan urutan: skema, Studio, GROQ, gambar, pengambilan data dari Next.js/Astro, lalu preview. Kodenya saya batasi pada bentuk yang benar-benar saya jalankan.
Poin penting
- Sanity adalah headless CMS yang memisahkan layar admin dari layar tampilan. Kamu mendefinisikan “tipe” konten lewat kode, lalu mengambil hanya yang dibutuhkan dengan GROQ.
- Puncak tersulit pertama adalah desain skema. Jangan jejalkan segalanya ke body; pisahkan item SEO dan CTA ke field tersendiri supaya tidak menangis di kemudian hari.
- Pisahkan GROQ untuk daftar dan untuk detail. Mengembalikan seluruh body di halaman daftar adalah biang kerok build yang lambat.
- Pengecualian draft tidak perlu manual. Cara sekarang: kunci
apiVersionke2025-02-19atau lebih baru, lalu serahkan keperspective: "published". - Next.js maupun Astro bisa mengambil data yang sama lewat
@sanity/client. Untuk gambar, optimalkan ukurannya dengan@sanity/image-url.
Apa yang membuat Sanity CMS berbeda
Headless CMS adalah mekanisme yang memisahkan tempat menyunting (layar admin) dari tempat menampilkan (situs atau aplikasi). Tidak seperti WordPress yang “admin dan tema-nya satu paket”. Karena itu artikel yang sama bisa dikirim dari satu data ke situs web, newsletter, aplikasi ponsel, hingga alat internal.
Yang membedakan Sanity dari yang lain adalah kamu menulis tipe konten dengan kode yang mirip TypeScript. Aturan seperti “artikel wajib punya judul, deskripsi maksimal 120 karakter, dan alt gambar” dideklarasikan lewat kode, bukan lewat klik-klik di layar pengaturan. Ini bisa masuk Git, bisa direview, dan mudah diminta untuk ditulis oleh Claude Code.
Wujud datanya disimpan di cloud bernama “Content Lake”, lalu dari sana kamu menarik hanya field yang dibutuhkan dengan bahasa query bernama GROQ (Graph-Relational Object Queries). Kalau kamu sudah tahu SQL, kira-kira terasa seperti “versi SELECT yang lebih bertenaga”.
Pintu masuk resminya di sini. Untuk tipe ada Schema Types, untuk query ada GROQ syntax. Bookmark dulu sebelum mengetik supaya tidak tersesat.
Kalau kamu masih di tahap ragu antara membangun sendiri atau memakai headless, saya sudah memisahkan kriteria penilaiannya ke artikel lain. Baca CMS blog: bikin sendiri atau headless lebih dulu supaya terlihat apakah kamu memang perlu memasang Sanity. Artikel ini ditujukan untuk orang yang sudah memutuskan “jalan dengan Sanity”.
Tulis dulu skema minimal yang langsung jalan
Sebelum penjelasan, saya taruh dulu skema post yang langsung jalan. Bentuknya bisa langsung dipakai sebagai schemaTypes/post.ts di Sanity v3. Kuncinya: deskripsi SEO, alt gambar, dan CTA dipotong ke field independen terpisah dari body (body). Pelit di sini akan membuat kamu terjebak neraka migrasi nanti.
// schemaTypes/post.ts
import {defineField, defineType} from 'sanity'
export const post = defineType({
name: 'post',
title: 'Artikel',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Judul',
type: 'string',
validation: (rule) => rule.required().max(90),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
// Dibuat otomatis dari judul. Keunikan URL dijamin di sini
options: {source: 'title', maxLength: 96},
validation: (rule) => rule.required(),
}),
defineField({
name: 'locale',
title: 'Bahasa',
type: 'string',
options: {
list: [
{title: 'Bahasa Indonesia', value: 'id'},
{title: 'English', value: 'en'},
{title: '日本語', value: 'ja'},
],
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'description',
title: 'Deskripsi SEO',
type: 'text',
rows: 3,
// Lebih dari 120 karakter akan terpotong di hasil pencarian, jadi batasnya dipaksakan
validation: (rule) => rule.required().max(120),
}),
defineField({
name: 'heroImage',
title: 'Gambar utama',
type: 'image',
options: {hotspot: true}, // Editor bisa menentukan titik fokus pemotongan
fields: [
defineField({
name: 'alt',
title: 'Teks alternatif',
type: 'string',
validation: (rule) => rule.required(), // Menolak gambar tanpa alt
}),
],
validation: (rule) => rule.required(),
}),
defineField({
name: 'body',
title: 'Isi',
type: 'array',
// Portable Text. Body terstruktur yang bisa mencampur teks dan gambar
of: [{type: 'block'}, {type: 'image'}],
validation: (rule) => rule.required(),
}),
defineField({
name: 'cta',
title: 'Tombol ajakan (CTA)',
type: 'object',
fields: [
defineField({name: 'label', title: 'Teks tombol', type: 'string'}),
defineField({name: 'href', title: 'URL tujuan', type: 'url'}),
defineField({name: 'intent', title: 'Maksud', type: 'string'}),
],
}),
defineField({
name: 'publishedAt',
title: 'Waktu publikasi',
type: 'datetime',
validation: (rule) => rule.required(),
}),
],
preview: {
// Apa yang ditampilkan di daftar Studio
select: {title: 'title', subtitle: 'locale', media: 'heroImage'},
},
})
Skema yang sudah kamu tulis harus selalu didaftarkan ke indeks. Kalau lupa baris ini di schemaTypes/index.ts, tipenya tidak akan muncul di Studio dan kamu akan bingung “lho?”. Pertama kali saya menguapkan 30 menit gara-gara ini.
// schemaTypes/index.ts
import {post} from './post'
export const schemaTypes = [post]
title maksimal 90 karakter, description maksimal 120, alt wajib. Triknya adalah menanam penjaga gerbang yang menolak input di dalam skema sejak saat pengisian. Daripada setiap kali review pra-publikasi menegur “deskripsinya kepanjangan”, jauh lebih awet kalau sejak awal memang tidak boleh diisi melebihi batas.
Jalankan Studio dan masukkan artikel
Setelah skema jadi, jalankan layar penyuntingan (Sanity Studio). Studio adalah layar admin yang berjalan lokal, sudah termasuk dalam paket sanity.
# Buat proyek Studio (pilih project dan dataset lewat dialog interaktif)
npm create sanity@latest -- --template clean --typescript
cd <folder-yang-dibuat>
npm run dev # Studio terbuka di http://localhost:3333
Begitu terbuka, “Artikel” akan berbaris di menu kiri. Field yang tadi saya beri required() tidak akan bisa dipublikasikan kalau dibiarkan kosong. Lupa mengisi alt, langsung dimarahi dengan warna merah. Ini diam-diam sangat efektif, karena UI input menghentikan kesalahan bahkan sebelum review manusia.
Yang ingin saya putuskan di sini adalah cara membagi dataset (wadah data). Kalau dipisah seperti production dan development, kamu bisa bereksperimen mengubah tipe atau query tanpa merusak data produksi. Mulai dengan satu saja tidak masalah, tetapi sekadar tahu bahwa “ini bisa dipisah” sudah membuat hati lebih tenang.
Ambil hanya yang dibutuhkan dengan GROQ
Inilah bagian yang paling penting. GROQ untuk daftar dan untuk detail wajib dipisah. Kalau di halaman daftar kamu ikut menarik body (body), volume transfer dan waktu build membengkak, dan penanganan cache pun memburuk. Inilah yang saya kacaukan pertama kali.
Di daftar, cukup title, slug, description, gambar, dan cta yang dibutuhkan tampilan kartu. Di detail, baru ambil body juga. Query di bawah ini bisa langsung dipakai.
// src/lib/sanity/queries.ts
// Untuk daftar: tidak menarik body. Hanya field yang dibutuhkan kartu
export const postsByLocaleQuery = `
*[
_type == "post" &&
locale == $locale &&
defined(slug.current) &&
defined(publishedAt)
] | order(publishedAt desc) [0...$limit] {
_id,
title,
description,
"slug": slug.current,
publishedAt,
"heroImageUrl": heroImage.asset->url,
"heroImageAlt": heroImage.alt,
cta
}
`
// Untuk detail: tarik sampai body dan CTA
export const postBySlugQuery = `
*[
_type == "post" &&
locale == $locale &&
slug.current == $slug
][0] {
_id,
title,
description,
"slug": slug.current,
publishedAt,
body,
cta,
"heroImageUrl": heroImage.asset->url,
"heroImageAlt": heroImage.alt
}
`
Cara membaca GROQ baris demi baris. * adalah seluruh dokumen, isi di dalam [...] adalah syarat penyaringan. -> adalah tanda untuk menelusuri referensi; heroImage.asset->url berarti “telusuri aset gambar lalu ambil URL-nya”. Bagian {...} di akhir adalah “penentuan field yang dikembalikan”, dan hanya yang ditulis di sini yang akan kembali. Karena itu, cukup dengan mengeluarkan body dari daftar, payload langsung jadi jauh lebih ringan.
| Kegunaan | Field yang dikembalikan | Body | Pengurutan | Batas jumlah |
|---|---|---|---|---|
| Daftar | title, slug, description, gambar, cta | Tidak diambil | publishedAt menurun | [0...$limit] |
| Detail | di atas + body | Diambil | Tidak perlu (1 item) | [0] |
| Sitemap | slug, publishedAt | Tidak diambil | Tidak perlu | Semua |
Ambil dari klien dan singkirkan draft
Setelah query jadi, panggil lewat @sanity/client. Di sinilah ada perubahan penting per 2026. Kalau kamu mengunci apiVersion ke tanggal 2025-02-19 atau setelahnya, cara pandang bawaan klien (perspective) menjadi published, dan draft serta versi yang belum dipublikasikan otomatis tersingkir. Kalau tanggalnya lebih lama, bawaannya raw dan draft ikut tercampur.
Artinya, “insiden draft muncul di produksi” yang saya kacaukan di awal tadi bisa dicegah dengan mengunci apiVersion dan memakai perspective: "published". Saya jadi tidak perlu lagi bergantung pada penjaga manual seperti defined(publishedAt).
// src/lib/sanity/client.ts
import {createClient} from '@sanity/client'
import {postBySlugQuery, postsByLocaleQuery} from './queries'
export const sanityClient = createClient({
projectId: process.env.SANITY_PROJECT_ID || '',
dataset: process.env.SANITY_DATASET || 'production',
// Sejak tanggal ini, bawaan perspective menjadi 'published' (draft tersingkir)
apiVersion: '2025-02-19',
perspective: 'published', // Ditulis eksplisit supaya niatnya jelas
useCdn: true, // Halaman publik aman lewat CDN yang cepat
})
// Ambil daftar
export async function getPosts(locale: string, limit = 12) {
return sanityClient.fetch(postsByLocaleQuery, {locale, limit})
}
// Ambil satu artikel
export async function getPostBySlug(locale: string, slug: string) {
return sanityClient.fetch(postBySlugQuery, {locale, slug})
}
Dependency dan variabel lingkungan cukup sebanyak ini. Di sisi Studio pakai sanity, di sisi frontend pakai @sanity/client, dan untuk optimasi gambar pasang @sanity/image-url.
npm install sanity @sanity/client @sanity/image-url
# Masukkan ke .env (kalau di Next.js dipaparkan ke sisi publik, tambahkan NEXT_PUBLIC_)
SANITY_PROJECT_ID=project-id-kamu
SANITY_DATASET=production
useCdn: true adalah pengaturan untuk halaman publik. Pada preview (saat kamu ingin melihat draft) yang akan dibahas nanti, di sini kita set false dan memakai klien terpisah dengan perspective yang berbeda.
Tampilkan dari Next.js dan Astro
Begitu fungsi pengambilan data jadi, sisi frontend hampir sama cara memanggilnya walaupun framework-nya berbeda. Pertama, halaman artikel Next.js (App Router). Karena ini server component, bisa langsung diambil dengan await.
// app/[locale]/blog/[slug]/page.tsx
import {getPostBySlug} from '@/lib/sanity/client'
import imageUrlBuilder from '@sanity/image-url'
import {sanityClient} from '@/lib/sanity/client'
import {notFound} from 'next/navigation'
const builder = imageUrlBuilder(sanityClient)
export default async function PostPage(
{params}: {params: {locale: string; slug: string}}
) {
const post = await getPostBySlug(params.locale, params.slug)
if (!post) notFound() // Tidak ditemukan diarahkan ke 404
// Ambil gambar hanya pada ukuran yang dibutuhkan untuk menekan volume transfer
const hero = builder.image(post.heroImageUrl).width(1200).height(630).url()
return (
<article>
<h1>{post.title}</h1>
<img src={hero} alt={post.heroImageAlt} width={1200} height={630} />
{/* Body (Portable Text) dirender dengan @portabletext/react */}
</article>
)
}
Di Astro, kamu mengambil data di frontmatter (di dalam ---) lalu mengalirkannya langsung ke HTML. Halaman daftar Astro jadi seperti ini.
---
// src/pages/blog/index.astro
import {getPosts} from '../../lib/sanity/client'
const posts = await getPosts('id', 12)
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>
<img src={post.heroImageUrl} alt={post.heroImageAlt} width="400" height="210" />
<h2>{post.title}</h2>
<p>{post.description}</p>
</a>
</li>
))}
</ul>
Kalau kamu ingin mendalami desain di sisi Astro (cara memilih Islands dan mode output SSG/SSR, serta bedanya dengan Content Collections), saya sudah memisahkannya ke Pengantar Astro: pakai Islands untuk hydrate bagian yang perlu saja. Sanity menyatu dengan rapi sebagai “sumber data eksternal” bagi Astro.
Dengan memakai @sanity/image-url untuk gambar, kamu bisa membuat URL berukuran spesifik lewat width() atau height(), sehingga mencegah insiden menyajikan gambar asli raksasa apa adanya. Karena alt sudah diwajibkan di skema artikel, di sini kamu pasti bisa meneruskannya.
Preview: tunjukkan draft ke editor
Halaman publik menyembunyikan draft dengan perspective: "published", tetapi editor ingin memeriksa tampilannya sebelum publikasi. Karena itu kita siapkan klien khusus preview secara terpisah. Bedanya hanya dua: buat perspective mencakup draft, dan set useCdn ke false (menghindari cache CDN agar mengambil yang terbaru).
// src/lib/sanity/preview-client.ts
import {createClient} from '@sanity/client'
// Khusus preview: ambil yang terbaru termasuk draft
export const previewClient = createClient({
projectId: process.env.SANITY_PROJECT_ID || '',
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2025-02-19',
perspective: 'drafts', // Sertakan draft
useCdn: false, // Hindari cache agar isi yang sedang disunting terpantul
token: process.env.SANITY_VIEWER_TOKEN, // Membaca draft butuh token baca
})
Untuk melihat draft dibutuhkan token dengan izin baca. Pakai ini hanya di sisi server, dan jangan teruskan ke browser. Kalau Next.js, pola yang lazim adalah menggabungkannya dengan Draft Mode, lalu beralih ke previewClient hanya saat preview.
Sebagai catatan, jangan salah pakai klien preview di halaman produksi. Kalau klien yang membawa token kamu paparkan ke sisi publik, draft jadi terlihat. Saya memisahkan “untuk publik” dan “untuk preview” per file, supaya tidak tertukar di sumber import.
Tiga kegagalan yang saya alami
Saya tulis jujur. Operasi Sanity pertama saya penuh lubang.
Pertama, membuat skema hanya dengan “item yang dibutuhkan artikel saat ini”. Memang cepat berdiri dan terasa enak, tetapi setiap kali kemudian menambah locale multibahasa, pemilahan tampilan CTA, dan tanggal review, migrasi data lama jadi berjalan. Item yang bisa dipisahkan dari body seharusnya sejak awal dibuat sebagai field tersendiri.
Kedua, ikut menarik body di query daftar. Tampilan halaman depan jadi berat, dan saya mencari penyebabnya setengah hari. Cukup dengan mengeluarkan body dari field yang dikembalikan GROQ, build maupun tampilan jadi ringan. Pisahkan query untuk daftar dan untuk detail; hanya dengan ini masalahnya selesai.
Ketiga, kebocoran draft seperti di awal. apiVersion saya biarkan bertanggal lama, sehingga bawaannya raw. Begitu saya naikkan ke 2025-02-19 atau setelahnya dan set perspective: "published", draft hilang tanpa perlu mengutak-atik syarat pengecualian buatan tangan. Mengunci versi memang sepele, tetapi efektif.
Pertanyaan umum
T. GROQ dan GraphQL, mana yang sebaiknya dipakai?
J. Yang paling alami di Sanity adalah GROQ. Karena field yang dikembalikan bisa dipangkas detail lewat {...}, ia cocok untuk meringankan daftar. GraphQL juga bisa, tetapi saya menyarankan menyusun dengan GROQ lebih dulu.
T. Bagaimana cara agar draft tidak keluar di produksi?
J. Kunci apiVersion ke 2025-02-19 atau setelahnya, dan set klien publik ke perspective: "published". Dengan ini draft dan versi yang belum dipublikasikan otomatis tersingkir. Untuk preview pakai klien terpisah dengan perspective: 'drafts'.
T. useCdn sebaiknya true atau false?
J. Halaman publik true (cepat dan murah). Preview, atau saat ingin segera memantulkan isi yang baru ditulis, pakai false. Jangan rangkap keduanya dalam satu klien; lebih aman memisahkan per kegunaan.
T. Bagaimana mengoptimalkan gambar?
J. Buat URL dengan width() dan height() lewat @sanity/image-url. Jangan sajikan gambar asli apa adanya. Kalau alt diwajibkan di skema, kamu tidak akan lupa meneruskannya.
T. Antara Next.js dan Astro, mana yang cocok dengan Sanity?
J. Keduanya bisa memakai fungsi pengambilan data yang sama lewat @sanity/client. Untuk situs statis yang nyaris tidak mengirim JS, Astro pas; kalau ingin memakai preview dinamis atau ISR secara intens, Next.js lebih menyatu. Untuk cara memilih, lihat Pengantar Astro. Kalau kamu butuh sisi dinamis penuh, pengembangan full-stack Next.js bisa jadi rujukan.
Hasil setelah saya mencoba langsung
Sejak insiden “draft muncul di produksi” di awal, hal pertama yang saya lakukan di Sanity selalu saya tetapkan jadi tiga. Pisahkan body dari item SEO dan CTA di skema, bagi GROQ untuk daftar dan untuk detail, lalu naikkan apiVersion ke 2025-02-19 atau setelahnya dan set klien publik ke published. Hanya dengan ini, build yang lambat maupun kebocoran draft sama-sama berhenti.
Daripada menyusun workflow rumit sejak awal, memperkuat tiga poin ini lebih dulu membuat operasinya lebih tahan lama. Setelah memahami dengan badan sendiri bahwa CMS bukan kotak tempat menaruh artikel, melainkan desain tipe dan pengambilan data, barulah akhirnya terasa lega.
Kalau kamu ingin sekaligus memantapkan desain skema, cara menyusun GROQ, dan operasi konten termasuk jalur monetisasi, minta review yang berbasis repositori dan daftar artikelmu yang sudah ada lewat konsultasi pelatihan & penerapan. Template yang langsung bisa dipakai juga sudah saya rangkum di daftar materi.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Checklist Permission Sebelum Claude Code Mengedit Situs Klien
Panduan agensi untuk membatasi area read-only, editable, dan forbidden saat memakai AI.
Ubah Bug Report Support SaaS Jadi Langkah Reproduksi dengan Claude Code
Workflow support untuk mengubah tiket kabur menjadi repro step, bukti, dan memo developer.
Rutinitas 10 Menit: Ubah Catatan Obsidian Lama Jadi Brief Kerja Claude Code
Catatan Obsidian jadi sampah saat ditempel ke AI? Pilah jadi fakta, keputusan, dan hal belum pasti agar Claude Code langsung bekerja.