DEV Community

AstroworldMC
AstroworldMC

Posted on • Originally published at api.astroworldmc.com

Building a Free Public Minecraft Data API

Why I built it

For one of our Minecraft community projects I needed structured data: HP values, drop tables, spawn biomes, biome temperatures, enchantment max levels, structure rarities. The data exists, but if you scrape it from the wiki you spend a week cleaning it, and Mojang's own APIs only cover player profiles and skin endpoints.

The plan was simple. Build a small JSON API that exposes everything (mobs, biomes, items, enchantments, structures, commands, versions, achievements, trades), keep it free, no signup, CORS open, and host it on a single VPS.

That was a weekend project. It now serves around 1,500 records under api.astroworldmc.com, open source on github.com/astroworld-mc.

This post is the actual decisions I made, with the code that's running in production right now, and the things I'd do differently if I started again.

Stack

  • Next.js 15 App Router
  • TypeScript
  • No database. Data lives in flat .ts files
  • One small in-memory rate limiter
  • Nginx in front for TLS + a clean /v1/... rewrite
  • Runs on a single next start process on port 3040

That's it. No Redis, no Postgres, no Kubernetes, no edge functions. The whole thing fits in about 250 MB resident memory and the cold-start build takes 14 seconds.

Decision 1: TypeScript arrays, not a database

I knew this was going to be controversial so I want to address it first.

Every record is a TypeScript const, like this:

// data/mobs.ts (excerpt)
export const MOBS: Mob[] = [
  {
    name: "Creeper",
    id: "creeper",
    type: "hostile",
    hp: 20,
    damage: { easy: 0, normal: 24, hard: 49 },
    spawnBiomes: ["Plains", "Forest", "..."],
    drops: [
      { item: "Gunpowder", count: { min: 0, max: 2 }, chance: 100 },
    ],
    behavior: "Silently approaches players and detonates after a 1.5s fuse.",
    // ...
  },
  // ~184 more
];
Enter fullscreen mode Exit fullscreen mode

Three reasons I went with this over a database:

1. The data does not change at request time. A Creeper has 20 HP. It had 20 HP last year. It will have 20 HP next year. The only writes happen when Mojang patches the game, which is roughly four times a year. So writing performance is irrelevant and durability is git.

2. Git diffs are readable. When Mojang nerfs a mob in 1.21.4 I can see the change as a normal pull request. I can revert with git revert. I can blame a line. I cannot do any of that with a mobs table that someone updated through pgAdmin.

3. TypeScript checks the shape. Every record is type-checked at build time. If I forget a field I get a red squiggle in VS Code, not a 500 at runtime. With a database I would have to write a Zod schema and validate on the way in.

The cost: the whole dataset is loaded into memory at start. For 1,500 records of mostly short strings this is around 4 MB, so it does not matter. If the dataset hits 100,000 records I will re-evaluate.

Decision 2: One route handler per resource

Every endpoint looks like this:

// app/api/v1/mobs/route.ts
import { NextRequest, NextResponse } from "next/server";
import { MOBS } from "@/data/mobs";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const search = searchParams.get("search");
  const type = searchParams.get("type");

  let results = [...MOBS];
  if (search) {
    results = results.filter(m =>
      m.name.toLowerCase().includes(search.toLowerCase())
    );
  }
  if (type) {
    results = results.filter(m => m.type === type.toLowerCase());
  }

  const pretty = searchParams.get("pretty") === "true";
  const body = { success: true, count: results.length, data: results };

  return new NextResponse(JSON.stringify(body, null, pretty ? 2 : 0), {
    headers: { "Content-Type": "application/json", ...corsHeaders },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, { status: 204, headers: corsHeaders });
}
Enter fullscreen mode Exit fullscreen mode

I considered a generic /api/v1/[resource]/route.ts with a switch on the slug. I'm glad I did not. The current setup means every endpoint can have its own filter set, its own type signature, and its own validation. The duplication is around 6 lines per file and that's a worthwhile trade for the readability.

?pretty=true is a small thing I always add. Nine times out of ten when someone is poking at the API for the first time they paste the URL into a browser, and pretty-printed JSON is friendly. The server cost is negligible because JSON.stringify is the same work either way.

Decision 3: CORS open by default

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};
Enter fullscreen mode Exit fullscreen mode

* is fine here. The data is public, there are no cookies, no auth, no PII. Someone building a Discord bot or a static site needs to call it from the browser without a CORS proxy. If you set this to a specific origin you cripple the API's main use case.

The trade-off you accept with *: you cannot ever attach auth without changing the policy. That's a problem for future-me. Future-me can deal with it.

Decision 4: In-memory rate limiting

Public, free API with no signup means someone will hammer it eventually. The middleware is short:

// middleware.ts
const rateMap = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 100;

export function middleware(req: NextRequest) {
  if (!req.nextUrl.pathname.startsWith("/api/")) {
    return NextResponse.next();
  }

  const ip = getClientIp(req);
  const now = Date.now();
  const entry = rateMap.get(ip);

  if (!entry || now > entry.resetAt) {
    rateMap.set(ip, { count: 1, resetAt: now + WINDOW_MS });
  } else {
    entry.count++;
  }

  const current = rateMap.get(ip)!;
  const remaining = Math.max(0, MAX_REQUESTS - current.count);

  if (current.count > MAX_REQUESTS) {
    return new NextResponse(
      JSON.stringify({
        success: false,
        error: "Rate limit exceeded. Max 100 requests per minute.",
      }),
      { status: 429, headers: { /* ratelimit headers + cors */ } }
    );
  }

  const response = NextResponse.next();
  response.headers.set("X-RateLimit-Limit", String(MAX_REQUESTS));
  response.headers.set("X-RateLimit-Remaining", String(remaining));
  return response;
}
Enter fullscreen mode Exit fullscreen mode

Two things to flag.

The Map never gets cleaned up. Each unique IP gets an entry. After a year of traffic that Map could grow to millions of keys. In practice the process gets restarted every few weeks for deployments and the Map resets, but if you copy this verbatim for a long-running process you want a periodic sweep that drops expired entries. Add this:

setInterval(() => {
  const now = Date.now();
  for (const [ip, entry] of rateMap) {
    if (now > entry.resetAt) rateMap.delete(ip);
  }
}, 5 * 60_000);
Enter fullscreen mode Exit fullscreen mode

It does not work across multiple instances. If you scale to two processes behind a load balancer, each has its own Map, and a client can do 100 requests per minute against each. For a single-VPS hobby API this is fine. For anything bigger you move to Redis or upstash.

The header names match the IETF draft (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset). Clients that already speak that convention work without changes.

Decision 5: Nginx for TLS, not Next.js

next start listens on 0.0.0.0:3040. Nginx terminates TLS and reverse-proxies to it:

location /v1/ {
  rewrite ^/v1/(.*) /api/v1/$1 break;
  proxy_pass http://127.0.0.1:3040;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Enter fullscreen mode Exit fullscreen mode

Two notes.

The rewrite lets users hit https://api.astroworldmc.com/v1/mobs (clean) and have Nginx forward to /api/v1/mobs (which is where Next puts route handlers). I didn't want users to type /api/v1/ because nothing about the implementation should leak through the URL.

X-Real-IP is what the rate limiter reads. Without it, the rate limiter sees Nginx's loopback address and rate-limits the whole world together. Easy mistake.

What I would do differently

Add HEAD support. Some clients probe with HEAD before GET. Next.js route handlers don't auto-derive HEAD from GET, so right now those probes get 405. Not a real bug, but it's noisy in the logs.

Stream JSON for large responses. Returning all 184 mobs in one response is fine, but /v1/structures with all generation rules is around 200 KB. For dashboard consumers that's noticeable. Streaming would help.

Document the schema. Right now there's a README and the TypeScript types. There's no OpenAPI spec. I keep meaning to generate one from the types. If you have a good TS-to-OpenAPI generator, drop a comment.

Add an etag. The data only changes a few times a year. An ETag based on the build hash would let clients cache aggressively with If-None-Match.

Use it, fork it

The whole repo is at github.com/astroworld-mc, MIT licensed. The API itself is live at api.astroworldmc.com, try /v1/mobs?search=creeper or /v1/biomes?type=overworld.

If you build something on top of it (a Discord bot, a Twitch overlay, a wiki generator) I would love to see it. The Astroworld project lives at astroworldmc.com and started as a small Minecraft server. The API is the first thing we open-sourced from it.


Built by Astroworld. The API and 7 supporting plugins are on GitHub. Server hosting that runs this kind of project well: Astroworld Hosting.

Top comments (0)