DEV Community

dylan ma
dylan ma

Posted on

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:

  1. Hitting the API on every request would be slow and rate-limited

  2. A traditional backend felt like overkill for essentially static data

  3. 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   │
                                                 └──────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 assets

  • Cloudflare 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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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.`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 },
};
Enter fullscreen mode Exit fullscreen mode
// wrangler.jsonc
{
  "main": ".open-next/worker.js",
  "assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}
Enter fullscreen mode Exit fullscreen mode

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 },
      })),
    }),
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Try it →


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)