TL;DR
I built a full-featured encyclopedia web app with 1,025 entries, 8 languages, and sub-second load times — all without a traditional backend. The trick? Mirror a public REST API as static JSON files at build time, deploy to Cloudflare Pages, and let the CDN do the heavy lifting.
Stack: Next.js 15 · TypeScript · Tailwind CSS · next-intl · Cloudflare Pages
The Problem
I wanted to build a reference site where users could browse, filter, and search a large dataset. The data was publicly available via a REST API, but:
Hitting the API on every request would be slow and rate-limited
A traditional backend felt like overkill for essentially static data
Multilingual support was a must — the audience is global
The dataset? A well-known creature encyclopedia with 1,025 entries, each with stats, types, abilities, evolution chains, and localized flavor text. (You probably know the one.)
The Architecture
┌──────────────┐ ┌───────────────────┐ ┌──────────────┐
│ Public API │─────▶│ Static JSON │─────▶│ Next.js │
│ (PokéAPI) │ │ /public/api/v2/ │ │ SSR / SSG │
└──────────────┘ └───────────────────┘ └──────┬───────┘
│
┌──────▼───────┐
│ Cloudflare │
│ Pages CDN │
└──────────────┘
Key insight: If the data rarely changes, fetch it once and serve it as static files. No runtime API calls, no database, no cold starts.
Step 1: The JSON Mirror
Instead of calling the public API at runtime, I pre-fetch all the data and store it as static JSON in /public/api/v2/.
// src/lib/api.ts
const API_BASE = '/api/v2';
export async function getPokemon(id: number) {
const key = String(id);
if (pokemonCache.has(key)) return pokemonCache.get(key);
const data = await fetchJson(`${API_BASE}/pokemon/${key}.json`);
pokemonCache.set(key, data);
return data;
}
What we store per entry:
Core data (stats, types, abilities, moves, dimensions)
Species data (flavor text, egg groups, habitat, generation)
Evolution chains (full trees with trigger conditions)
Type matchups (damage relations for all 18 types)
Why this works:
Next.js serves
/public/files as static assetsCloudflare CDN caches them globally — 300+ edge locations
No API keys, no rate limits, no external dependency at runtime
Data is version-controlled — you can see exactly what changed
Step 2: The Browsable Grid
The main page shows all 1,025 entries in a filterable grid. The filters:
Generation (9 options)
Type (18 options)
BST range (base stat total)
Legendary status toggle
Shiny mode toggle
Text search by name or number
The Challenge: 1,025 Entries Don't Load Instantly
Loading all entries at once would freeze the browser. Solution: batch loading with progressive rendering.
const PAGE_SIZE = 50;
useEffect(() => {
const loadEntries = async () => {
const pool = poolFor({ gen });
const loaded = [];
for (let i = 0; i < pool.length; i += PAGE_SIZE) {
const batch = pool.slice(i, i + PAGE_SIZE);
const results = await Promise.all(
batch.map(async (id) => {
try {
const data = await getPokemon(id);
return { id: data.id, name: data.name, types: data.types, stats: data.stats };
} catch { return null; }
})
);
loaded.push(...results.filter(Boolean));
setEntries([...loaded]); // Progressive update — user sees cards appearing
}
};
loadEntries();
}, [gen]);
Then I use Intersection Observer to render more cards as the user scrolls:
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setVisibleCount((c) => c + PAGE_SIZE);
},
{ rootMargin: '200px' }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [filteredEntries.length]);
Result: Users see the first 50 cards instantly, and more appear as they scroll — no janky layout shifts.
Step 3: The Detail Page
Each entry has its own detail page with:
Official artwork with a shiny toggle
Base stats with visual bars
Abilities (including hidden ones)
Full evolution chain
Type matchups (offensive and defensive)
Learnable moves table
Localized flavor text
Server-Side Fetching
The detail page uses SSR to fetch all data on the server:
export default async function DetailPage({ params }) {
const { locale, id } = await params;
const entryId = Number(id);
const [entry, species, chainData] = await Promise.all([
getEntry(entryId),
getSpecies(entryId),
getSpecies(entryId).then((d) => getEvolutionChain(d.evolution_chain.url)),
]);
const typeNames = entry.types.map((t) => t.type.name);
const relations = Object.fromEntries(
await Promise.all(typeNames.map(async (name) => [name, await getType(name)]))
);
return <DetailClient entry={entry} species={species} chain={chainData.chain} relations={relations} />;
}
Dynamic SEO Metadata
Each detail page generates unique, keyword-rich metadata:
export async function generateMetadata({ params }) {
const seo = await getSeo(entryId);
const types = seo.typeNames.join(' / ');
const number = String(entryId).padStart(4, '0');
return {
title: `${seo.name} (#${number}) — ${types} Base Stats, Abilities & Evolution`,
description: `${seo.name} is a ${types} entry with BST ${seo.bst}. View base stats, abilities, evolution chain, and type matchups.`,
};
}
Step 4: 8 Languages, One Build
The app supports: English, Japanese, Korean, German, Spanish, French, Portuguese, and Italian.
Translation Structure
{
"Pokedex": {
"title": "Pokédex",
"lede": "Browse and filter all 1,025 entries by generation, type, and stats",
"search": "Search by name or number...",
"noResults": "No results match your filters."
}
}
Using Translations
import { useTranslations } from 'next-intl';
export default function GridPage() {
const t = useTranslations('Pokedex');
return (
<section>
<h1>{t('title')}</h1>
<p>{t('lede')}</p>
</section>
);
}
Localized Content from the API
Species-specific content (like flavor text) is already localized in the source data:
const genus = species?.genera?.find((g) => g.language.name === locale)?.genus;
const flavor = species?.flavor_text_entries?.find(
(e) => e.language.name === locale
)?.flavor_text;
Step 5: Deploying to Cloudflare Pages
Why Cloudflare Pages?
Global CDN — 300+ edge locations
Free tier — generous for personal projects
OpenNext adapter — seamless Next.js deployment
Workers — SSR at the edge
The Cache Strategy
The key to fast load times is caching SSR responses:
// src/app/[locale]/layout.tsx
export const revalidate = 172800; // 48 hours
This tells Next.js: "Cache the rendered HTML for 48 hours. After that, the next request triggers a fresh SSR, and the result is cached again."
What this means in practice:
| Scenario | Load Time |
|---|---|
| First visit (cold SSR) | 4–8s |
| Subsequent visits (CDN hit) | <500ms |
| After 48h or redeploy | Cache refreshes |
Redeploying automatically clears the CDN cache — no manual purge needed.
Build Configuration
// next.config.js
const nextConfig = {
images: { unoptimized: true },
experimental: { optimizeCss: true },
};
// wrangler.jsonc
{
"main": ".open-next/worker.js",
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}
Step 6: SEO That Actually Works
JSON-LD Structured Data
Every page includes JSON-LD for rich search results:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
}),
}}
/>
Dynamic Sitemap
The sitemap covers all 1,025 detail pages across 8 languages:
export default async function sitemap() {
const urls = [];
for (const { path, priority } of PAGES) {
urls.push({ url: `${DOMAIN}${path}`, priority });
for (const locale of LOCALES) {
urls.push({ url: `${DOMAIN}/${locale}${path}`, priority });
}
}
for (let id = 1; id <= 1025; id++) {
urls.push({ url: `${DOMAIN}/pokedex/${id}`, priority: 0.7 });
for (const locale of LOCALES) {
urls.push({ url: `${DOMAIN}/${locale}/pokedex/${id}`, priority: 0.7 });
}
}
return urls;
}
That's 8,200+ URLs — all generated at build time.
Lessons Learned
1. Static JSON Mirrors > Live API Calls
By mirroring the API as static JSON, we eliminated rate limits, network latency, cold starts, and the need for API keys. The data is version-controlled and builds are instant.
2. Progressive Loading Is Non-Negotiable
Loading 1,025 entries at once freezes the UI. Batch loading with Intersection Observer gives users instant feedback while data streams in.
3. SSR Caching Changes Everything
revalidate = 172800 transforms the experience. First visit is slow (cold SSR), but every subsequent visit for 48 hours is instant.
4. i18n Multiplies Your SEO Surface
Content in 8 languages = 8x the search surface. hreflang tags tell search engines which language to serve to which users.
5. Keep Data Close to the Code
Static JSON in the repo means no external API dependency at runtime, version-controlled data, and instant builds.
Try It
The site is live and free to use. Browse all 1,025 entries, filter by generation and type, toggle shiny artwork, and click into any entry for detailed stats, evolution chains, and type matchups.
Built with Next.js 15, TypeScript, Tailwind CSS. Deployed on Cloudflare Pages.
Tags: nextjs typescript tailwindcss cloudflare webdev tutorial static-site i18n seo architecture
Top comments (0)