D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

`opengraph-image` Membalas 500 di Next.js 16: `params` Sudah Promise, dan di Baliknya Satori Menolak Dua Node Teks

· · 9 menit baca
`opengraph-image` Membalas 500 di Next.js 16: `params` Sudah Promise, dan di Baliknya Satori Menolak Dua Node Teks

Bug ini ketemu sebagai catatan sampingan dari pekerjaan lain. Pada 28 September 2026 saya sedang meriset upgrade Next.js untuk proyek yang punya halaman bagi tiap koin. Upgrade itu didahulukan karena, menurut advisory keamanan untuk Next.js, versi terpasang 16.2.4 masuk rentang kerentanan kritis yang baru diperbaiki di 16.3.3. Riset read-only itu mencatat satu temuan yang bukan akibat upgrade: og:image halaman koin membalas 500 di produksi, dan bug itu sudah ada sebelumnya.

Pesan commit perbaikannya belakangan merangkum akibatnya: setiap alamat opengraph-image di bawah halaman koin membalas 500, jadi halaman koin yang dibagikan di media sosial tidak punya gambar. Kerusakan seperti ini gampang luput, karena opengraph-image adalah route terpisah yang diambil platform saat menyusun pratinjau tautan, bukan bagian dari halaman yang kita buka di browser.

Catatan riset itu langsung menunjuk penyebabnya: route opengraph-image.tsx milik halaman koin tidak meng-await params. Temuan itu dicatat terpisah dari pekerjaan upgrade, lalu saya putuskan perbaikannya ikut di paket upgrade yang sama. Catatan itu hanya menyebut params, padahal di balik crash tersebut ada galat kedua yang menunggu giliran.

params yang dibaca seperti objek biasa

Mulai Next 16, route gambar seperti opengraph-image menerima params sebagai Promise. Route kartu koin di repo ini ditulis seolah params objek biasa: commit pertama yang memuatnya, 21 April 2026, sudah mengetiknya sebagai { slug: string } tanpa Promise. Repo-nya lahir dari Create Next App sehari sebelumnya dengan next 16.2.4, dan di riwayat git baris versi itu baru berubah lagi saat upgrade ke 16.3.6 pada 28 September. Tepat sebelum commit perbaikan, package.json masih mematok 16.2.4. Jadi route ini ditulis dengan tipe objek biasa di atas Next 16 sejak hari pertama.

Sampai perbaikan, route itu membaca params.slug langsung tanpa await. Objek Promise tidak punya properti slug, jadi nilainya undefined, dan helper yang mengubah slug jadi nama tampilan melempar galat. Baris itu masuk lewat commit 31 Agustus 2026 yang membuang harga karangan. Sejak itu kartu bagikan sengaja tidak memuat harga, dan commit yang sama memasang nama dari slug serta satu baris alamat di kartu. Disederhanakan, dengan nama helper dan domain disamarkan, bentuknya begini:

// app/[slug]/opengraph-image.tsx, sebelum perbaikan
// disederhanakan; nama helper dan domain disamarkan
import { ImageResponse } from "next/og";
 
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default async function Image({ params }: { params: { slug: string } }) {
  // di Next 16 params adalah Promise: params.slug undefined, helper ini melempar galat
  const name = toDisplayName(params.slug);
 
  return new ImageResponse(
    (
      <div style={{ display: "flex", flexDirection: "column" }}>
        <div style={{ fontSize: 72 }}>{name}</div>
        <div style={{ fontSize: 22, color: "#6a6a80" }}>
          example.com/{params.slug}
        </div>
      </div>
    ),
    { ...size },
  );
}

Satu koreksi kecil layak dicatat. Draf komentar di kode dan pesan commit perbaikan sempat menyebut perubahan ini datang di Next 15, lalu review independen sebelum commit mengoreksi keduanya jadi Next 16. Bedanya penting kalau kamu sedang memeriksa proyek lain. Next 15 memang sudah menjadikan params di page, layout, dan generateMetadata sebagai Promise, tapi route gambar seperti opengraph-image baru menyusul di Next 16. Proyek yang sudah rapi untuk Next 15 masih bisa menyimpan route gambar yang membaca params secara langsung.

Galat kedua yang menunggu di belakang

Membereskan params saja tidak akan cukup. Baris alamat di bagian bawah kartu memuat teks literal domain dan slug sebagai dua node teks, dan renderer OG menolak div seperti itu kecuali div-nya display: flex. Catatan proyek menyebut renderer itu dengan namanya, Satori, mesin di balik next/og. Div alamat tadi hanya punya fontSize dan color di style-nya, tanpa display sama sekali.

Yang membuatnya gampang terlewat adalah cara JSX bekerja. Teks literal yang diikuti ekspresi, seperti example.com/{slug}, menghasilkan dua anak, satu string dan satu nilai. Di halaman React biasa, browser menampilkan keduanya bersebelahan tanpa keluhan, jadi pola ini terlihat aman. Satori lebih ketat: div dengan lebih dari satu node anak wajib menyatakan display secara eksplisit.

Aturan itu bukan barang baru di berkas yang sama. Pada 11 Mei 2026, tim infrastruktur klien meneruskan log hosting yang memunculkan dua bug yang sudah ada sebelumnya, dan commit perbaikan hari itu mencatat teks galat renderer OG ini apa adanya:

Expected <div> to have explicit 'display: flex', 'display: contents', or 'display: none' if it has more than one child node

Commit Mei itu juga mencatat route kartu koin di antara route yang terdampak, waktu itu di div wordmark logo yang tidak punya display: flex. Pada 31 Agustus, hampir empat bulan sesudahnya, commit yang membuang harga karangan memasang pola yang sama di div lain, yaitu baris alamat, dan kali ini galatnya tertutup oleh crash params. Helper yang melempar galat berada di atas return, jadi pohon JSX tidak pernah sampai ke Satori. Karena baris helper dan baris alamat masuk lewat commit yang sama, baris alamat itu tidak pernah mendapat kesempatan dirender sampai perbaikan datang.

Satu template string, satu anak teks

Perbaikannya disiapkan sebagai satu commit fix(og) dengan dua perubahan: params di-await, dan baris alamat dijadikan satu anak teks. Di diff-nya, params diketik sebagai Promise dan di-await sebelum slug dibaca, sementara teks literal plus slug diganti dengan satu template string. Versi yang disederhanakan:

// sesudah perbaikan (disederhanakan)
// import dan export runtime, size, contentType sama seperti sebelumnya
export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const name = toDisplayName(slug);
 
  return new ImageResponse(
    (
      <div style={{ display: "flex", flexDirection: "column" }}>
        <div style={{ fontSize: 72 }}>{name}</div>
        {/* satu anak teks, jadi div ini tidak butuh display eksplisit */}
        <div style={{ fontSize: 22, color: "#6a6a80" }}>
          {`example.com/${slug}`}
        </div>
      </div>
    ),
    { ...size },
  );
}

Teks galat Satori sendiri menawarkan jalan lain, yaitu memberi div itu display: flex, contents, atau none. Perbaikan ini memilih menyatukan teksnya, sehingga div alamat cukup punya satu anak. Di next start lokal, og:image koin membalas 200 png dan kartu hasil render diperiksa secara visual sebelum di-commit. Perbaikan ini lalu masuk sebagai commit pertama dari tujuh di paket upgrade, satu commit sebelum next dinaikkan ke 16.3.6.

Route yang tadinya 500 jadi penanda rilis

Untuk memastikan staging benar-benar menjalankan build baru, rencana rilisnya memakai dua tanda sekaligus: penanda waktu build yang berubah, dan og:image satu halaman koin yang membalas 200 image/png. Logikanya sederhana. Build lama membalas 500 untuk setiap og:image koin, jadi 200 di route itu hanya bisa datang dari build baru.

Di staging, og:image beranda dan dua halaman koin membalas 200 png, dan kartunya diperiksa secara visual. Merge rilis di-push pukul 12:31 UTC, dan paling lambat pukul 12:37 UTC, enam menit kemudian, produksi sudah berganti build dengan og:image koin membalas 200 image/png. Pemeriksaan produksi setelah jeda tiga menit mencatat og:image beranda dan dua halaman koin membalas 200 png berukuran 1200x630, persis ukuran yang dideklarasikan route-nya.

Sesudah hidup lagi, kartunya mau menggambar apa saja

Review sebelum commit sebenarnya sudah memberi catatan kecil: route OG koin sekarang menggambar kartu bermerek untuk slug apa pun, dan pemeriksaan bentuk slug ditinggalkan sebagai catatan opsional. Pada 29 September catatan itu dilewati karena dinilai bernilai rendah. Pada 1 Oktober kartu generik untuk slug tak dikenal masuk butir keamanan tentang nilai yang datang dari URL, dan pada 5 Oktober saya putuskan kartu itu dikerjakan langsung.

Pesan commit 5 Oktober menjelaskan kenapa ini lebih dari soal kerapian. Alamat og:image halaman koin dengan teks apa pun di posisi slug menggambar teks itu 72 px di bawah logo situs, lengkap dengan alamat yang cocok: gambar bermerek siap pakai bagi siapa saja yang menginginkannya. Perbaikannya membuat kartu hanya menyebut koin yang memang dipegang cache. Modul kartu bertanya ke Redis satu kali, berupa EXISTS pada key snapshot dan key identitas koin, lewat REST biasa karena kartu berjalan di edge runtime. Slug yang bentuknya salah tidak pernah sampai ke Redis. Hasilnya empat cabang:

slug di luar alfabet id                 kartu polos, Redis tidak ditanya
slug dipegang cache                     kartu bernama seperti sebelumnya
slug berbentuk benar, tidak dipegang    kartu polos, tanpa teks dari URL
Redis tidak bisa menjawab               kartu polos, no-store, satu baris log

Kartu polos hanya berisi judul generik dan domain, dan route-nya punya tes yang memastikan slug yang tidak dipegang cache tidak menggambar apa pun dari URL. Kalau Redis tidak bisa menjawab, entah karena belum dikonfigurasi, panggilannya lambat atau gagal, atau jawabannya tidak dikenali, kartu polos itu dikirim dengan no-store supaya CDN tidak menyimpan tebakan selama setengah jam, dan log server mendapat satu baris. Jalur ini terlihat bekerja saat diuji dengan token Redis yang sengaja salah.

Aturan bentuk slug-nya adalah alfabet id, bukan pola kata, karena saat itu 18 dari 12.944 koin yang dipegang cache punya id dengan garis bawah atau diawali tanda hubung. Alfabet itu sengaja tanpa titik dua. Titik dua lazim menjadi pemisah segmen di nama key Redis, dan tanpa karakter itu key penanda 404 yang di-cache berada di luar jangkauan pencarian kartu. Panjangnya dibatasi 120 karakter, sementara id terpanjang yang dipegang cache 77 karakter. Sejak 6 Oktober kartu memakai satu aturan id bersama yang disimpan di satu berkas:

// aturan id bersama sejak 6 Oktober (nama konstanta disamarkan)
const ID_SHAPE = /^[a-z0-9_-]{1,120}$/;

Di build produksi lokal 5 Oktober, kartu koin yang sudah dikenal identik byte dengan produksi, id yang memuat garis bawah tetap mendapat namanya, sedangkan slug karangan dan slug koin berhuruf kapital mendapat kartu generik yang sama. Setelah dirilis sebagai commit terpisah, pemeriksaan produksi mencatat md5 kartu koin yang dikenal tidak berubah dan slug karangan mendapat kartu polos. Alamat og:image setiap halaman koin juga mendapat akhiran versi baru, karena Next menurunkannya dari berkas route.

Tiga pertanyaan untuk route gambar berikutnya

Kalau route opengraph-image di Next 16 membalas 500, pertanyaan pertama adalah apakah params-nya diketik sebagai Promise dan di-await. Di proyek ini tipe objek biasa itu sudah ada sejak commit pertama route-nya, dan 500-nya ditemukan oleh riset untuk pekerjaan lain.

Pertanyaan kedua datang setelah crash pertama hilang: adakah div yang memegang lebih dari satu node teks tanpa display eksplisit? Satori tidak mengikuti kelonggaran React di browser, dan galat semacam ini bisa bersembunyi di balik galat lain, persis seperti di sini.

Pertanyaan ketiga paling gampang dilupakan: begitu kartunya hidup lagi, apa yang dia gambar untuk slug yang tidak ada? Kartu yang menulis teks dari URL di bawah logo situs adalah gambar bermerek yang bisa dipesan siapa saja. Di sini jawabannya kartu polos untuk apa pun yang tidak dipegang cache, ditambah tes yang menjaga agar tidak ada teks dari URL yang ikut tergambar.