Making a Static Site Search-Friendly — SEO Strategy for CSR
Can a CSR-based static site do SEO properly — metadata architecture, sitemap automation, JSON-LD, OG images, and search console registration in practice
The Question — Why SEO Matters
SEO (Search Engine Optimization) is the set of technical practices that help search engines understand a website and surface it in relevant results.
Great content that doesn't appear in search results can't reach users. For a niche site like this one — covering space simulations, Canvas games, and astronomy tools — search is likely the primary traffic channel over social virality. Someone searching "gravity simulation web" or "starship simulation game" should be able to find this site.
The problem was that this project is a CSR-based static site.
SSR vs CSR — From a Search Engine's Perspective
Why SSR Has the SEO Advantage
With SSR (Server-Side Rendering), the server returns fully formed HTML. When a crawler requests a page, it immediately receives <title>, <meta>, and body text. There's nothing ambiguous to parse.
<!-- SSR response -->
<html>
<head>
<title>Gravity Simulation | js2devlog</title>
<meta name="description" content="An N-body gravity simulation..." />
<meta property="og:image" content="/og-image.png" />
</head>
<body>
<h1>Gravity Simulation</h1>
<p>Experience Newton's law of gravitation visually...</p>
</body>
</html>
The CSR Problem
In traditional CSR (React SPA), the server returns an empty HTML shell. Content only renders after JavaScript executes.
<!-- CSR response -->
<html>
<head>
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script src="/bundle.js"></script>
</body>
</html>
Googlebot can execute JavaScript, but with caveats.
First, crawling and rendering are separate. Googlebot parses HTML first (crawling), then queues JavaScript execution (rendering) for later. The gap can be seconds to days.
Second, not all crawlers execute JS. Googlebot and Bingbot both use Chromium, but Naver, Daum, and social media OG parsers (KakaoTalk, Slack, Twitter) have limited or no JS execution. They only read <meta> tags from raw HTML.
Third, crawl budget is finite. Search engines allocate limited resources per site. JS-dependent pages consume more of that budget.
Practical Comparison
| Factor | SSR | CSR (Pure SPA) | Static Export (This Project) |
|---|---|---|---|
| Metadata in initial HTML | ✅ | ❌ | ✅ |
| Body text in initial HTML | ✅ | ❌ | ⚠️ (build-time only) |
| Non-JS crawler support | ✅ | ❌ | ✅ |
| OG tags for social preview | ✅ | ❌ | ✅ |
| Server cost | High | Low | Very low |
| Dynamic content reflection | Instant | Instant | Requires rebuild |
The Solution — Next.js App Router Static Export
The answer was Next.js output: 'export'. It's a CSR app, but generates complete HTML for every page at build time.
The key is the generateMetadata function in App Router. It runs at build time, completing each page's <head> tags. No runtime server needed — yet the metadata is identical to what SSR would produce.
// app/[locale]/gravity/page.tsx
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'gravity' });
return {
title: t('meta.title'),
description: t('meta.description'),
openGraph: {
title: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/gravity`,
images: OG_IMAGES,
},
alternates: {
canonical: `${BASE_URL}/${locale}/gravity`,
languages: {
ko: `${BASE_URL}/ko/gravity`,
en: `${BASE_URL}/en/gravity`,
},
},
};
}
The generated static HTML already contains all metadata, so any crawler — JS-capable or not — can parse page information without executing JavaScript.
Metadata Architecture — Global and Per-Page Layers
Global Metadata
metadata.ts defines site-wide defaults.
// src/constants/metadata.ts
export const metadata: Metadata = {
metadataBase: new URL('https://js2devlog.com'),
title: {
default: 'js2devlog — Interactive Space Universe',
template: '%s | js2devlog',
},
description: 'An interactive web experience exploring the universe...',
keywords: [
'js2devlog', 'Silver.K', 'Canvas 2D', 'Web Audio API',
'NASA APOD', 'gravity simulation', 'starship', ...
],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
title.template: '%s | js2devlog' is key. When a child page sets title: 'Gravity Simulation', it automatically becomes Gravity Simulation | js2devlog. Consistent branding with unique per-page titles.
max-image-preview: 'large' lets Google show large image previews in results. For a visual-heavy site, this directly affects click-through rate (CTR).
Per-Page Metadata
Each page.tsx exports generateMetadata, branching title and description by locale.
// app/[locale]/starship/page.tsx
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'starship' });
const jsonLd = buildJsonLd({
type: 'VideoGame',
name: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/starship`,
locale,
applicationCategory: 'Game',
genre: 'Simulation',
});
return {
title: t('meta.title'),
description: t('meta.description'),
openGraph: {
title: t('meta.title'),
description: t('meta.description'),
url: `${BASE_URL}/${locale}/starship`,
images: OG_IMAGES,
},
alternates: {
canonical: `${BASE_URL}/${locale}/starship`,
languages: { ko: `${BASE_URL}/ko/starship`, en: `${BASE_URL}/en/starship` },
},
other: { 'script:ld+json': JSON.stringify(jsonLd) },
};
}
The OG Image Inheritance Trap
When a child page overrides openGraph in Next.js, the parent's openGraph.images is not automatically inherited. Missing this meant shared links on KakaoTalk and Slack showed no preview image.
// ❌ images missing — not inherited from parent
openGraph: { title, description, url }
// ✅ must be explicit
openGraph: { title, description, url, images: OG_IMAGES }
The fix was importing OG_IMAGES as a shared constant across all pages.
JSON-LD — Structured Data
For search engines to understand page "meaning," HTML tags alone aren't enough. JSON-LD (Linked Data) uses Schema.org vocabulary to describe page type, author, dates, and more in machine-readable format.
// src/utils/jsonLd.ts
export function buildJsonLd(opts: JsonLdOptions) {
const base = {
'@context': 'https://schema.org',
'@type': opts.type,
name: opts.name,
description: opts.description,
url: opts.url,
inLanguage: opts.locale === 'en' ? 'en' : 'ko',
author: { '@type': 'Person', name: 'Silver.K' },
};
if (opts.applicationCategory) base.applicationCategory = opts.applicationCategory;
if (opts.genre) base.genre = opts.genre;
if (opts.datePublished) base.datePublished = opts.datePublished;
return base;
}
Six types are used by purpose:
| JSON-LD Type | Pages | Effect |
|---|---|---|
VideoGame | Galaga, Asteroids, Starship | Google "game" rich results |
WebApplication | Gravity, Scale, Harmony | Web app rich results |
CollectionPage | Collections, category hubs | Content collection recognition |
Blog | Devlog list | Blog structure recognition |
BlogPosting | Individual devlog posts | Publish date, author display |
ProfilePage | About | Person info recognition |
For devlog posts, datePublished comes from the MDX frontmatter date field. Google displaying publish dates in results helps users judge content freshness.
OG Image — Automated Generation
The 1200×630px preview image shown when sharing links on social media is generated by a build script.
// scripts/generate-og.mjs
const W = 1200, H = 630;
const canvas = createCanvas(W, H);
const ctx = canvas.getContext('2d');
// 1. Space background (radial gradient)
const bgGrad = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, W*0.8);
bgGrad.addColorStop(0, '#0d0820');
bgGrad.addColorStop(1, '#020109');
// 2. Star field (220 stars, seeded pseudo-random)
for (let i = 0; i < 220; i++) { ... }
// 3. Logo + title + tech badges
// Badges: Canvas 2D, Web Audio, NASA API, TypeScript, Next.js
// 4. PNG output
fs.writeFileSync('public/og-image.png', canvas.toBuffer('image/png'));
Using Node.js canvas instead of external services (Vercel OG, Cloudinary) for two reasons. First, it runs once at build time — zero runtime cost. Second, it exactly matches the project's design system: purple gradients, space background, Orbitron font.
Sitemap and robots.txt — Crawler Guides
Why Sitemaps Matter
A sitemap tells search engines "what pages exist, how often they update, and where alternative language versions live" — all in one XML file.
Crawlers can discover pages by following links, but in SPA-style sites, internal links are often generated by JavaScript. Explicitly listing all pages in a sitemap is the safe approach.
The Static Export Problem
Next.js's sitemap.ts requires a server runtime. With output: 'export', a separate prebuild script generates the files.
// scripts/generate-seo.mjs
const LOCALES = ['ko', 'en'];
const ROUTES = [
{ path: '/', changefreq: 'weekly', priority: '1.0' },
{ path: '/apod', changefreq: 'daily', priority: '0.8' },
{ path: '/gravity', changefreq: 'monthly', priority: '0.8' },
{ path: '/galaga', changefreq: 'monthly', priority: '0.6' },
// ... 25 routes
];
This is wired to prebuild in package.json:
{
"prebuild": "node scripts/generate-seo.mjs",
"build": "next build"
}
Multilingual hreflang
The most careful part of sitemap generation was hreflang cross-referencing. Each URL entry includes alternate links for every language version:
<url>
<loc>https://js2devlog.com/ko/gravity</loc>
<lastmod>2025-02-05</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="ko"
href="https://js2devlog.com/ko/gravity" />
<xhtml:link rel="alternate" hreflang="en"
href="https://js2devlog.com/en/gravity" />
</url>
25 routes × 2 languages = 50 URL entries, each with 2 hreflang links. Search engines use this to show the correct language version based on user settings.
robots.txt
User-agent: *
Allow: /
Sitemap: https://js2devlog.com/sitemap.xml
Host: https://js2devlog.com
Allow all crawlers, specify the sitemap location. The Host directive is referenced by Naver's crawler (Yeti).
changefreq Strategy
Not all pages update at the same rate:
| Frequency | Pages | Reason |
|---|---|---|
daily | APOD, Daily | New content daily (NASA API) |
weekly | Main, Devlog list | Weekly updates |
monthly | Games, simulations | Only on code changes |
priority follows: Main (1.0) > API content (0.8) > Simulations (0.8) > Arcade (0.6). These values express relative importance within the same site.
Search Console Registration — Google and Naver
Google Search Console
-
Ownership verification: DNS TXT record method. Adding Google's TXT record to the domain's DNS confirms ownership. Covers all subdomains at once — more convenient than HTML file verification.
-
Sitemap submission: Submit the
sitemap.xmlURL. Crawling begins within days, with indexing status visible in the dashboard. -
Indexing requests: Rather than waiting for natural crawling, the URL Inspection tool requests individual page indexing. During initial registration, key pages were manually submitted to accelerate crawling.
-
Monitoring: The "Performance" tab shows impressions, clicks, and average position per query. This feedback loop helps refine metadata for better targeting.
Naver Search Advisor
Naver's search registration platform. Similar to Google, with key differences.
Naver's crawler (Yeti) doesn't support JavaScript rendering, so static HTML metadata is essential. This project's static export approach is actually advantageous for Naver SEO.
Naver supports sitemap submission and individual URL crawl requests via its webmaster tools. The Host directive in robots.txt is important for Yeti.
One caveat: Naver doesn't officially support hreflang in sitemaps. Instead, <link rel="alternate" hreflang="en"> tags directly in the page <head> are recommended. Next.js's alternates metadata setting auto-generates these tags, so no extra work was needed.
AWS CloudFront — SEO Augmentation for Static Sites
The Problem: S3 Alone Isn't Enough
Static files on S3 contain embedded metadata, so basic SEO works. But several additional configurations were needed.
URL normalization. If /ko/about and /ko/about/ are treated as different URLs, search engines may flag duplicate content. CloudFront Functions' URL rewrite enforces consistent trailingSlash rules.
HTTPS enforcement. Google has used HTTPS as a ranking signal since 2014. CloudFront's viewer protocol policy set to redirect-to-https ensures all HTTP requests redirect to HTTPS.
Response headers. X-Robots-Tag headers provide granular indexing control. JS/CSS files under _next/static/ don't need indexing, so they get noindex.
CloudFront Functions for Bot Support
Even with a static site, CloudFront Functions enable near-SSR bot handling.
The URL rewrite function maps crawler requests for /ko/about to S3's /ko/about/index.html. S3 doesn't auto-resolve directory indices, so without this function, crawlers get 404 responses — and repeated 404s cause search engines to remove pages from the index.
// CloudFront Function (viewer-request)
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
Cache Control and Crawling Efficiency
CloudFront cache settings affect crawling efficiency.
HTML no-cache ensures crawlers always get the latest metadata. If metadata changes after deployment but crawlers receive cached old HTML, search results show incorrect information.
Meanwhile, max-age=31536000, immutable on JS/CSS chunks means crawlers never re-request these files, saving crawl budget.
Build Pipeline and SEO Integration
SEO is fully integrated into the build pipeline:
pnpm build
│
├─ prebuild: generate-seo.mjs
│ ├─ public/sitemap.xml (50 URLs)
│ └─ public/robots.txt
│
├─ next build (output: 'export')
│ ├─ generateMetadata → complete <head> for each page
│ ├─ generateStaticParams → 86+ static HTML files
│ └─ JSON-LD inline injection
│
└─ out/ directory with complete static files
├─ ko/gravity/index.html (meta + JSON-LD included)
├─ en/gravity/index.html
├─ sitemap.xml
├─ robots.txt
└─ og-image.png
Checklist when adding a new page:
- Export
generateMetadatainpage.tsx - Add
meta.title,meta.descriptiontomessages/ko.jsonanden.json - Add route to
generate-seo.mjsROUTES array - Specify appropriate
buildJsonLdtype - Include
openGraph.images: OG_IMAGESexplicitly
Missing any of these five steps leaves that page's SEO incomplete. No build error occurs — it fails silently.
Looking Back
"Static sites are bad for SEO" is a misconception. The opposite is often true. HTML files with all metadata baked in at build time provide more predictable, stable SEO than SSR.
There are limits, of course. Dynamic content like NASA APOD that changes daily requires daily build-deploy cycles to keep search results current. Sites with user-generated content (comments, reviews) hit clear walls with static export.
But for a project like this — where content is embedded in code — static export + Next.js App Router is the most rational SEO strategy. Zero server cost, correctly indexed on both Google and Naver.
The most important lesson: SEO isn't "something to add later." It must be considered from the moment you choose your architecture. CSR vs SSR, static vs dynamic, server or serverless — these decisions already define the scope of what's possible for SEO.