

1/1/1970
Bagaimana Saya Mengimplementasikan SSG dalam 2 Hari
Halo! Setahun yang lalu, saya pikir ini mustahil. Tapi saya baru saja selesai mengimplementasikan Static Site Generation (SSG) untuk Foony dalam 2 hari, dan saya cukup bersemangat dengan ini. Ini juga bukan kali pertama saya mencoba menyelesaikan SSG untuk Foony. Saya sudah pernah melihat NextJS, Vike, Astro, Gatsby, dan beberapa solusi lain di masa lalu. Saya bahkan sempat memulai dengan NextJS, tapi mengalami kesulitan karena kompleksitas SPA Foony dan ribuan file. Migrasinya akan jadi mimpi buruk dan butuh waktu berbulan-bulan. Itu juga akan menambah kerumitan bagi semua orang yang bekerja di situs ini karena mereka harus belajar NextJS dan keunikannya.
Saya ingin sesuatu yang ringan dan mudah diimplementasikan. Sesuatu yang memungkinkan kami terus menulis kode dengan cara yang sama seperti biasanya tanpa harus memikirkan SSG (kecuali useMediaQuery, tidak ada cara nyata untuk menghindari yang satu itu). Di bawah ini saya akan menjelaskan kenapa saya memilih solusi buatan sendiri, tantangan spesifik yang saya hadapi (terutama dengan Suspense boundaries milik React), dan bagaimana saya menyelesaikannya.
Kenapa Tidak Solusi Standar?
Ketika pertama kali saya mempertimbangkan menambahkan SSG ke Foony, saya secara alami mempertimbangkan NextJS (standar industri), Vike, dan Astro.
NextJS: Migrasi Terlalu Banyak
NextJS itu powerful, tapi akan membutuhkan migrasi besar-besaran dari React SPA Foony yang sudah ada. Kami punya ribuan file, logika routing yang kompleks, dan banyak infrastruktur kustom. Migrasi ke NextJS akan berarti:
- Menulis ulang seluruh sistem routing kami
- Merestrukturisasi cara kami memuat game dan komponen
- Berbulan-bulan kerja hanya untuk kembali ke fitur yang setara
- Potensi perubahan yang merusak bagi pengguna
- Mengubah cara kami menangani gambar
- Waktu build yang jauh lebih lambat (berpotensi 5-30 menit. Saya tidak punya angka konkret untuk mendukung ini selain diskusi berusia 5 tahun di GitHub)
- Seluruh tim harus mempelajari sesuatu yang baru (NextJS), dan kecepatan pengembang yang lebih lambat selamanya
- Memigrasikan kode setiap kali NextJS memutuskan untuk membuat perubahan yang merusak.
Saya bahkan sempat mencoba memulai dengan NextJS, tapi cepat menyadari biaya migrasinya terlalu tinggi. Kerumitannya tidak sepadan.
Vike: Kompleksitas Serupa
Vike (dulunya vite-plugin-ssr) memiliki masalah yang serupa. Meskipun lebih fleksibel daripada NextJS, masih akan membutuhkan restrukturisasi signifikan dari basis kode kami. Kurva belajar dan upaya migrasi tidak sebanding dengan manfaatnya.
Astro: Arsitektur yang Salah
Astro bagus untuk situs yang berfokus pada konten, tapi Foony adalah platform game multiplayer yang kompleks. Kami butuh pembaruan real-time, koneksi WebSocket, dan komponen React yang dinamis. Arsitektur Astro tidak cocok dengan apa yang sedang kami bangun.
Solusinya: SSG Buatan Sendiri
Diberi keberanian oleh pendekatan "fake SSG" yang saya implementasikan beberapa hari lalu setelah i18n, saya memutuskan untuk menggunakan solusi kecil, ringan, dan buatan sendiri untuk SSG Foony.
Pendekatan "fake SSG" saya melibatkan pengambilan konten blog post dari halaman dengan blog post (rute
/postsdan halaman game), dan memposisikannya tepat di mana klien akan me-render-nya, khusus untuk mesin pencari dan LLM agar membantu memahami Foony. Itu juga menerapkan skema ld+json dan beberapa hal SEO kecil.
Pendekatannya sederhana:
- Bangun di atas React SPA yang sudah ada: Tidak perlu migrasi, tinggal tambahkan generasi SSG saat build time.
- Gunakan
renderToReadableStream: API streaming SSR React 18 menangani Suspense secara native. - Buat file HTML statis: Pre-render rute saat build time dan sajikan sebagai file statis, menggunakan SitemapGenerator kami untuk mendapatkan daftar rute.
- Perubahan minimal pada basis kode yang ada: Sebagian besar komponen bekerja apa adanya.
Implementasi inti ada di client/src/generators/GenerateShellSsgFromSitemap.ts. File itu membaca sitemap, me-render setiap rute menggunakan renderToReadableStream milik React, dan menulis HTML ke file statis. Sederhana, persis seperti yang saya suka!
Ini juga ternyata cukup cepat. Sekitar 2.800 rute di-render dalam 10 detik. Mantap. Itu jauh lebih cepat daripada NextJS, Gatsby, dan Astro. <img alt="Log konsol SSG yang menunjukkan waktu yang dibutuhkan" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Saya bisa terus bicara tentang kesederhanaan. Meskipun mungkin tidak akan membuat Anda dipromosikan di perusahaan besar karena "kurang kompleks", kode yang sederhana itu indah, mudah dipelihara, dan secara umum jauh lebih baik untuk kecepatan pengembang. Ini sesuatu yang sangat saya kagumi dari prinsip Zen.
Masalah Suspense Boundary
Jadi sekarang saya punya SSG, dan kontennya muncul di HTML... tapi halaman saya kosong! Kok bisa?! <img alt="Halaman kosong SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Ternyata renderToReadableStream tetap memiliki Suspense boundaries, bahkan jika Anda await stream.allReady. Tebakan saya, ini karena ia adalah "stream", dan dirancang untuk dikirim ke klien saat byte diterima.
Apa yang React Hasilkan
Saat Anda menggunakan renderToReadableStream dengan Suspense, React menghasilkan HTML seperti ini:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Konten sebenarnya di sini -->
</div>
...
<script>/*Skrip yang menggantikan suspense boundaries*/</script>
<template id="B:0"> adalah placeholder di mana konten seharusnya berada. <div hidden id="S:0"> berisi konten yang sebenarnya sudah di-render. B:0 cocok dengan S:0 berdasarkan nomor (indeks berbasis 0).
Tanpa JavaScript, mesin pencari (lihat saja kamu, Bing) dan LLM akan melihat halaman yang hampir kosong dengan hanya placeholder template. Itu meniadakan seluruh tujuan SSG!
Saya tidak melihat cara yang bersih untuk menghapus Suspense boundaries ini, jadi solusi saya adalah menulis beberapa tes dan fungsi resolveSuspenseBoundaries untuk menukarnya. Ini lebih cepat daripada mem-parsing HTML dan mengeksekusi skrip dengan sesuatu seperti JSDOM. Dan, yang lebih penting, ini adalah persyaratan untuk apa yang sudah saya rencanakan: situs yang bagus dan mudah dibaca untuk mesin pencari/LLM tanpa JavaScript, tapi dengan dukungan Suspense boundaries dan hidrasi di klien.
Menguji Transformasi
Saya mulai dengan menulis tes untuk transformasi dengan mengambil beberapa contoh di DOM dari apa yang saya miliki (JavaScript dinonaktifkan), dan apa yang saya inginkan (JavaScript diaktifkan). Saya memberikannya ke LLM dan menyuruhnya menangani pembuatan tes, sesuatu yang cukup ahli ia lakukan.
Tes-tes ini ada di client/src/generators/ssr/renderRoute.test.ts dan memastikan transformasi bekerja dengan benar. Tes mencakup:
- Penggantian boundary sederhana (daftar blog)
- Boundaries kompleks dengan konten antara template dan komentar penutup
- Beberapa boundaries
- Boundaries tanpa marker komentar
- Kasus-kasus pinggir
Tipe "TDD" ini sebenarnya cukup berguna untuk kasus seperti ini di mana Anda memiliki input dan output yang diharapkan.
Ini tidak boleh dikacaukan dengan "TDD untuk segala hal karena Robert C. Martin bilang begitu" (yang akan memperlambat kecepatan pengembangan tim Anda). Anda TIDAK seharusnya menggunakan TDD untuk UI atau area kode Anda yang terus berubah!
Solusinya: resolveSuspenseBoundaries
Sekarang setelah tes-tesnya tersedia, saya menyuruh LLM menulis fungsi untuk resolveSuspenseBoundaries. Saya memilih cheerio untuk ini agar terhindar dari kerapuhan RegEx, meskipun menggunakan RegEx di sini akan memotong waktu SSG sekitar 40%.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Ini memastikan bahwa alih-alih melihat halaman yang hampir kosong, mesin pencari dan LLM melihat halaman yang sudah di-render sepenuhnya.
Sekarang SSG kita sudah bekerja dengan baik tanpa JavaScript!
<img alt="SSG tanpa JavaScript untuk blog Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
Untuk jangka panjang, mungkin saja React akan mengubah format Suspense mereka. Saya mungkin akan menghapus kode resolusi Suspense begitu saya punya solusi yang lebih baik untuk halaman-halaman yang di-lazy-load (dan karenanya membutuhkan Suspense boundaries).
Strategi Hidrasi (Update: Ini Butuh 3 Hari + 1 Hari Tambahan)
Hidrasi itu menantang. Saya tahu itu. Tapi, setelah sedikit bekerja, saya berhasil membuatnya bekerja!
Total waktu untuk hidrasi: 3 hari, ditambah 1 hari ekstra untuk mengganti pendekatan dehidrasi.
Bagian tersulit adalah mendapatkan hidrasi minimal pertama yang bekerja. Begitu saya berhasil me-render "Hello World" dengan navbar, saya percaya diri bahwa, ya, ini mungkin tidak akan butuh sebulan penuh!
<img alt="Hello World Foony berhasil dihidrasi dengan navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Untuk hidrasi minimal pertama yang bekerja itu, saya punya tantangan unik: Saya ingin hidrasi, tapi saya juga ingin SEO yang baik untuk mesin pencari dan LLM tanpa pengembang harus memikirkan Suspense boundaries.
Tantangannya
Hidrasi React sangat literal: jika DOM tidak terlihat seperti yang React harapkan untuk render pertama itu, Anda akan mendapat pesan error yang bagus tapi hampir tidak berguna ini di konsol Anda, dan React membuang semuanya dan me-render ulang dari awal. Bahkan tidak ada diff untuk memberitahu apa yang salah!
Dalam kasus kami, SSG memperburuk ini dalam beberapa cara:
- Kami melakukan post-processing pada HTML untuk menghapus/menyelesaikan artefak Suspense streaming React 18 (yang bagus untuk bot).
- Klien tidak selalu memiliki data persis sama yang tersedia pada waktu (t = 0) seperti yang dimiliki render server (data SSG, metadata blog, dll).
- i18n kami "lazy" secara default, yang berarti terjemahan bisa hilang untuk render pertama kecuali Anda mencatat terjemahan mana yang digunakan untuk SSG dan menyuntikkannya sebelum React me-render.
Apa yang Berhasil (Pendekatan Awal: Dehidrasi)
Awalnya, saya mencoba sesuatu yang pintar dan lucu: Saya menggunakan pola command untuk merekam command yang digunakan untuk menyelesaikan Suspense boundaries pada HTML, dan mengembalikan command transformasi terbalik sehingga saya bisa memulihkan HTML ke apa yang dibutuhkan React untuk hidrasi.
Harapan saya adalah saya bisa mengirim jauh lebih sedikit byte di index.html dengan metode command ini. Tapi, seperti kebanyakan solusi pintar, ini gagal karena browser memodifikasi HTML dengan cara yang halus, seperti menghapus atau menambah ; atau /, yang mengacaukan indeks pengganti.
Secara teknis Anda mungkin bisa memperhitungkan perubahan halus browser ini, tapi saya tidak akan mengirim sesuatu yang serapuh itu.
Alih-alih mencoba "membalikkan" transformasi Suspense-boundary kembali ke markup streaming React, saya melakukan sesuatu yang sangat sederhana:
Bundel HTML asli yang belum diselesaikan dalam <script type="text">.
Pendekatan "dehidrasi" ini bekerja, tapi saya menghabiskan satu hari ekstra untuk menggantinya dengan solusi yang lebih baik.
Pendekatan yang Lebih Baik: Penggantian Suspense Boundary Critical Path
Setelah implementasi awal, saya masih mengalami beberapa masalah dengan Suspense boundaries. Saat itulah saya menyadari ada solusi yang lebih bersih, lebih baik, dan lebih sederhana. Saya mengganti pendekatan dehidrasi dengan penggantian Suspense boundary critical path, yang:
- Memuat critical path sebelum hidrasi: Komponen yang di-preload selama SSR diidentifikasi dan di-preload di klien sebelum
hydrateRoot dipanggil
- Lebih mudah dipelihara: Tidak diperlukan internal React atau parsing AST (pendekatan dehidrasi perlu mem-parsing dan memulihkan HTML)
- Mengirim lebih sedikit byte: Kami tidak lagi membundel respons SSR asli dari React dalam tag script
- Mencegah potensi flash: Tidak perlu dehidrasi/rehidrasi HTML, menghilangkan potensi flash visual
Implementasinya melacak komponen lazy mana yang di-preload selama SSR (melalui SSRLazyComponentTracker), menyertakan path import-nya dalam data hidrasi, dan me-preload-nya secara sinkron sebelum hidrasi. Komponen critical path di-render langsung tanpa Suspense boundaries, sesuai persis dengan output SSR.
Untuk yang lainnya, kami membuat render klien pertama berperan sebagai SSR/SSG. Itu berarti menggunakan input yang sama, dan membuat input tersebut tersedia secara sinkron sebelum hydrateRoot. Ini dilakukan dengan membundel melalui "ssg-data" kami.
Secara konkret, penyesuaiannya adalah:
Bundel input SSR ke dalam satu skrip teks
- Selama SSG, kami menyuntikkan
<script type="text/foony-ssg" id="foony-ssg-data">...</script> tepat sebelum entrypoint modul Vite.
- Skrip itu berisi:
html: HTML yang sudah diselesaikan yang sebenarnya kami kirim dalam file statis
ssgData: SSGData ter-serialisasi yang digunakan oleh wrapper SSR. Saya berencana memperbarui ini menjadi Proxy atau sesuatu agar hanya data yang diakses yang disertakan.
translationData: blob key-value terjemahan yang kami sentuh selama SSR
Suntikkan input tersebut tepat sebelum hidrasi
- Di
main.tsx, secara sinkron kami:
- mengatur
#root.innerHTML menjadi HTML yang sudah diselesaikan dan ter-serialisasi (sehingga DOM persis seperti yang dilihat hidrasi)
- membungkus aplikasi dalam
SSGDataProvider agar komponen memiliki SSGData yang sama pada render pertama
Buat i18n instan dengan menyuntikkan nilai terjemahan
- Kami merekam objek terjemahan aktual yang diakses selama SSR dan mengirimkannya dalam skrip SSG.
- Di klien, kami menyuntikkannya langsung ke cache
LocaleQueryer melalui metode LocaleQueryer.inject() khusus, sehingga terjemahan tersedia segera.
Dan dengan itu, render pertama memiliki data yang sama dengan yang dimiliki SSR!
Hook useIsSSRMode() sudah diimplementasikan di client/src/generators/ssr/isSSRMode.ts:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Hook ini mengembalikan true selama SSR dan pada render klien pertama (hidrasi), kemudian beralih ke false setelah mount. Komponen seperti UserBanner, Navbar, dan Dialog sudah menggunakan ini untuk mencegah ketidakcocokan hidrasi.
- Patch React untuk diff yang lebih baik
Saya berharap saya bisa hanya menggunakan hydration-overlay. Tapi itu tidak dipelihara secara aktif, hanya didukung hingga React 18, dan belum siap untuk produksi. Jadi saya menyuruh LLM mengkloning repo untuk inspirasi, lalu LLM membuat overlay hidrasi minimal dalam beberapa menit. Saya tidak butuh sesuatu yang mewah, hanya sesuatu yang akan muncul selama pengembangan agar saya bisa mengetahui di mana ada yang salah.
Overlay baru ini sangat dasar, jadi diff-nya tidak terlalu sempurna. React menghapus komentar, menambahkan ; setelah atribut style, memodifikasi spasi, dan beberapa hal kecil lainnya yang belum diperhitungkan oleh overlay kami (untuk saat ini). Overlay kami juga menyertakan komentar HTML yang diabaikan React untuk hidrasinya.
<img alt="Overlay hidrasi baru kami" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Tapi cukup baik untuk mengetahui apa yang perlu diperbaiki.
<img alt="diff dari SSG kami vs render halaman pertama klien untuk hidrasi React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
Dalam Angka
Untuk memberi Anda gambaran tentang apa yang dilibatkan oleh implementasi ini:
- 2 hari kerja (dari awal hingga SSG bekerja). Ini hanya lebih dari 24 jam saat sedang liburan.
- 4 hari kerja untuk membuat hidrasi berperilaku baik tanpa race terjemahan async atau
useMediaQuery mengacaukan segalanya.
- 1 hari ekstra untuk mengganti pendekatan dehidrasi dengan penggantian Suspense boundary critical path (lebih sederhana, lebih sedikit byte, tidak ada potensi flash).
- ~200 baris kode generasi SSG inti (
GenerateShellSsgFromSitemap.ts)
- ~120 baris resolusi Suspense boundary (
resolveSuspenseBoundaries di renderRoute.tsx) - Catatan: Ini kemudian digantikan oleh pendekatan critical path
- ~50 baris utilitas SSR (
isSSRMode.ts)
- ~100 baris tes (
renderRoute.test.ts)
- ~150 baris polyfill untuk SSR (
setupSSREnvironment)
- Perubahan minimal pada komponen yang ada (sebagian besar menambahkan pemeriksaan
useIsSSRMode())
Solusinya ringan dan mudah dipelihara. Itu tidak memerlukan migrasi framework, dan bekerja dengan React SPA kami yang sudah ada.
Poin Penting
Kadang Solusi Buatan Sendiri Lebih Baik
Tidak setiap masalah membutuhkan framework. Untuk Foony, solusi SSG kecil buatan sendiri adalah pilihan yang tepat. Solusinya:
- Ringan: Tanpa dependensi berat atau overhead framework
- Mudah dipelihara: Kode sederhana yang kami pahami
- Fleksibel: Mudah dimodifikasi dan diperluas sesuai kebutuhan
- Kompatibel: Bekerja dengan React SPA kami yang sudah ada tanpa migrasi
SSR Streaming React Memiliki Keunikan
renderToReadableStream milik React bagus untuk menangani Suspense, tapi ia memiliki keunikan. Bahkan dengan await stream.allReady, Anda masih mendapatkan Suspense boundaries di output. Ini bukan bug, ini memang dirancang untuk streaming. Tapi untuk SSG, kami perlu HTML yang sepenuhnya diselesaikan. Rasanya seperti kegagalan tim React untuk tidak menangani skenario ini dengan cara yang bersih.
Solusi saya adalah melakukan post-processing pada HTML dan menyelesaikan boundaries. Tidak cantik, tapi cukup cepat dan fleksibel untuk kasus penggunaan saya.
TDD Bisa Berguna Untuk LLM
Transformasi HTML rentan kesalahan. Satu bug kecil dan Anda bisa merusak seluruh output SSG dan merusak pengalaman pengguna akhir. Saya menyuruh LLM menulis tes komprehensif (dengan masukan saya) untuk memastikan transformasi bekerja dengan benar.
Kesimpulan
SSG sekarang bekerja untuk Foony. Halaman-halaman di-render sepenuhnya untuk mesin pencari dan LLM, dan solusinya mudah dipelihara dan ringan. Hidrasi untuk rute SSG butuh lebih lama dari yang saya harapkan (3 hari), dan saya menghabiskan 1 hari ekstra untuk mengganti pendekatan dehidrasi awal dengan penggantian Suspense boundary critical path. Pendekatan baru ini lebih mudah dipelihara, mengirim lebih sedikit byte, dan mencegah potensi flash visual dari dehidrasi/rehidrasi HTML.
Saya masih kaget bahwa hanya butuh 2 hari untuk mengimplementasikan solusi buatan sendiri untuk SSG. Tapi terkadang solusi yang tepat adalah yang paling sederhana.
Pekerjaan ke depan termasuk menyelesaikan pencocokan hidrasi dan kemungkinan mem-patch React untuk debugging yang lebih baik. Tapi untuk sekarang, Foony memiliki SSG yang bekerja. Saya akan terus memantau Google Search Console dan Bing Webmaster Tools selama beberapa minggu ke depan untuk melihat efeknya pada SEO kami.