A practical guide to programmatic SEO with Next.js App Router, hybrid rendering, and Cloudflare Pages — based on real production experience and trade-offs.
When I set out to build a kaomoji (Japanese emoticon) website, I knew I'd need hundreds of individual pages — one for each emotion category. Manually creating 500+ pages wasn't an option. Instead, I built a system that programmatically generates SEO-optimized pages from structured data, deploys them on Edge Runtime, and handles everything from metadata to Schema.org markup automatically.
Here's exactly how I did it — including the trade-offs and gotchas I ran into along the way.
The Challenge
Japanese kaomoji like (╥﹏╥) and (づ。◕‿‿◕。)づ are searched thousands of times per month. Each emotion category (cry, cute, angry, etc.) needs its own page with:
- Unique title, description, and keywords
- Structured kaomoji data grouped by subcategories
- Schema.org markup (FAQ, ItemList, BreadcrumbList)
- Consistent layout with sidebar navigation
- Fast load times globally (especially Japan)
Doing this for 500+ categories by hand would be unmaintainable. Programmatic SEO was the answer.
Architecture Overview
data/kaomoji/
├── cry.ts # KaomojiItem[] data
├── cute.ts
├── smile.ts
└── ... (524 files)
public/data/
├── kaomoji/*.json # Runtime data (fetched, not bundled)
└── config/*.json # Page configs (title, description, FAQ, etc.)
app/[locale]/(default)/[slug]/page.tsx # Single dynamic route
The key insight: one dynamic route serves all 524 pages. The [slug] parameter (e.g., cute-kaomoji) determines which data and config to load.
Step 1: Define Your Data Schema
Every kaomoji item follows a strict TypeScript interface:
export interface KaomojiItem {
kaomoji: string; // "(╥﹏╥)"
category: string; // "cry"
subcategory: string; // "basic" | "sparkle" | "dramatic"
popularity: number; // 0-100
tags: string[]; // ["悲しい", "泣く", "涙"]
}
Each category file exports an array of these items. For example, data/kaomoji/cry.ts contains 50-100 kaomoji grouped by subcategory.
The data files are compiled to JSON at build time and served from /public/data/kaomoji/. This is critical — more on why below.
Step 2: Hybrid Rendering Strategy
Here's where it gets interesting. With 524 pages, pre-rendering everything at build time would be slow and wasteful. Instead, I use a hybrid approach:
// app/[locale]/(default)/[slug]/page.tsx
export const runtime = 'edge';
export const revalidate = 3600; // ISR: revalidate every hour
export const dynamicParams = true;
export async function generateStaticParams() {
const topPages = [
'cute', 'cry', 'smile', 'happy', 'sad', 'angry',
'surprised', 'love', 'shy', 'sleepy', 'please',
'thank', 'sorry', 'cat', 'dog', 'rabbit', 'bear',
'animal', 'heart', 'star', 'wink', 'hug', 'kiss',
'punch', 'tired', 'confused', 'mendokusai', 'nemui',
'gorogoro', 'uttori'
];
return topPages.map(slug => ({
slug: `${slug}-kaomoji`,
locale: 'ja'
}));
}
What this does:
- 30 top pages are pre-rendered at build time (instant load)
- 494 remaining pages are generated on-demand at first request
-
dynamicParams = trueallows paths not ingenerateStaticParamsto be handled at runtime
Important nuance: dynamicParams = true alone doesn't guarantee caching. For the on-demand pages to be cached and reused (ISR behavior), the route must be ISR-compatible — meaning you need export const revalidate set, and you must avoid Dynamic APIs like cookies() or headers() in the render path. Without this, each request triggers a fresh server render.
Step 3: Dynamic Data Loading (Not Static Imports)
This was the biggest performance win. Initially, I imported kaomoji data directly:
// BAD: This bundles ALL data into the page
import { cryKaomoji } from '@/data/kaomoji/cry';
With 524 categories, this approach bloated the JavaScript bundle to 25MB+. The solution: load data via fetch at runtime.
// GOOD: Fetch only what's needed
async function getKaomojiData(category: string) {
const response = await fetch(
`${baseUrl}/data/kaomoji/${category}.json`,
{ next: { revalidate: 3600 } }
);
return await response.json();
}
A note on what revalidate controls here: The { next: { revalidate } } option in fetch controls Next.js Data Cache — the framework's own server-side persistent cache. It does NOT automatically set CDN cache headers or Cloudflare cache policies. Those are separate concerns (more on this in the caching section).
Results:
- Bundle size dropped from 25MB to under 5MB
- Each page only loads its own category data
- Edge Runtime compatible (no Node.js
fsneeded) - Next.js Data Cache handles revalidation at the framework level
Step 4: Automated SEO Metadata
Each page needs unique metadata. I store page configs as JSON:
{
"metadata": {
"title": "泣く顔文字 500選【コピペ可】",
"description": "泣く・悲しい顔文字を500個以上収録...",
"keywords": ["泣く 顔文字", "悲しい 顔文字", "涙 顔文字"]
},
"hero": {
"title": "泣く顔文字",
"subtitle": "悲しい時に使える顔文字コレクション",
"description": "..."
},
"faqItems": [
{
"question": "泣く顔文字の使い方は?",
"answer": "..."
}
]
}
The generateMetadata function loads this config and returns proper Next.js metadata:
import type { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: { locale: string; slug: string } }
): Promise<Metadata> {
const category = params.slug.replace(/-kaomoji$/, "");
const config = await getPageConfig(category);
return {
title: "config.metadata.title,"
description: "config.metadata.description,"
keywords: config.metadata.keywords,
openGraph: {
title: "config.metadata.title,"
description: "config.metadata.description,"
type: "website",
locale: params.locale,
},
alternates: {
canonical: `https://www.kaomojiya.org/${params.slug}`,
},
};
}
Step 5: Schema.org Structured Data
Each page includes structured data merged into a single @graph. Choose your schema types based on what your page actually represents:
const schemas = mergeSchemas(
generateBreadcrumbSchema(category, title, locale),
generateFAQSchema(config.faqItems),
generateItemListSchema(kaomojiData, config)
);
This tells search engines what each page contains:
- BreadcrumbList — navigation hierarchy
- FAQPage — common questions (rich snippet eligible). Make sure the Q&A content is also visible on the page itself — Google requires this.
- ItemList — the kaomoji collection
Tip: Pick schema types that match your page's actual content. For a collection/reference page, CollectionPage + ItemList is often more appropriate than WebApplication. Using the wrong type won't necessarily hurt, but it won't help either.
Step 6: Caching — Two Separate Concerns
This is where things get nuanced. There are two distinct caching layers, and they're often conflated:
Layer 1: Next.js Data Cache (framework level)
// This controls Next.js server-side Data Cache
const response = await fetch(url, {
next: { revalidate: 3600 } // Revalidate after 1 hour
});
This tells Next.js to cache the fetch result server-side and serve stale data while revalidating in the background. It has nothing to do with CDN behavior.
Layer 2: CDN / Edge Cache (infrastructure level)
On Cloudflare, CDN caching is controlled by:
-
Cache-Controlresponse headers - Cloudflare Page Rules / Cache Rules
- Cloudflare Cache API (programmatic)
These are not automatically set by Next.js revalidate. You need to configure them separately.
My caching config
export const CACHE_CONFIG = {
pageConfig: 3600, // Next.js Data Cache: 1 hour
kaomojiData: 3600, // Next.js Data Cache: 1 hour
sitemap: 86400, // Next.js Data Cache: 24 hours
inMemory: 3600000, // App-level memory cache: 1 hour (ms)
};
A warning about in-memory cache on Edge: Edge/Workers memory is per-isolate and can be evicted at any time. It's not shared across edge locations. Treat it as a best-effort hot cache, not a reliable persistence layer — it's closer to a request-local optimization than Redis.
In development, all caches are disabled (isDev ? 0 : ...) for instant feedback.
Step 7: Deploy to Cloudflare Pages
My app runs on Cloudflare Pages with Edge Runtime using @cloudflare/next-on-pages:
# wrangler.toml
name = "kaomojiya"
compatibility_date = "2024-07-29"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[vars]
NEXT_PUBLIC_WEB_URL = "https://www.kaomojiya.org"
Build pipeline:
next build && npx @cloudflare/next-on-pages
wrangler pages deploy
Important note (2026): Cloudflare's official recommendation has shifted toward deploying Next.js apps to Cloudflare Workers using the OpenNext adapter. The @cloudflare/next-on-pages package was archived in late 2025 and is in maintenance-only mode. My setup still works on Pages, but if you're starting a new project, evaluate the Workers/OpenNext path — it has better long-term support and broader feature coverage.
I chose Pages at the time because it was the stable option, and migrating a working production app has costs. The architecture patterns in this article (hybrid rendering, runtime data fetching, structured metadata) apply regardless of which Cloudflare deployment target you choose.
Results
After deploying this system:
- 524 pages indexed by Google within weeks
- Bundle size: 25MB → under 5MB (80% reduction)
- Build time: ~2 minutes for 30 pre-rendered pages (vs. 20+ minutes for all 524)
- TTFB: under 200ms globally via Edge Runtime
- Zero manual page creation — add a data file, and the page exists
Key Takeaways
- Don't pre-render everything — Use hybrid rendering. Pre-render your top pages, let the rest generate on demand with ISR.
-
Fetch data at runtime — Static imports kill your bundle size at scale. Use JSON files +
fetchwith caching. - Separate your cache layers — Next.js Data Cache and CDN cache are different things. Configure both intentionally.
-
Be precise about ISR —
dynamicParams = trueallows on-demand rendering, but you needrevalidateand must avoid Dynamic APIs for pages to actually be cached. - Edge Runtime has trade-offs — Great for latency, but in-memory state is per-isolate and ephemeral. Design accordingly.
Further Reading
- Next.js App Router Documentation
- OpenNext Cloudflare Adapter
- Schema.org Structured Data Guide
- Next.js Dynamic Routes
- Kaomojiya
Built with Next.js 14, TypeScript, Tailwind CSS, and deployed on Cloudflare Pages. This article reflects my production experience and trade-offs as of early 2026.

Top comments (0)