Building a Static Encyclopedia with 1,000+ Entries, 8 Languages, and Zero Backend
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 { 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 ; } 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 ( {t('title')} {t('lede')} ); } 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) | ({ '@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 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)
Comments
No comments yet. Start the discussion.