Build an anime streaming app with React

You'll build a React app that searches anime, lists episodes and plays them, using the AnimeDoc API. Time: about 30 minutes.

What you need

  • Node.js 18 or newer.
  • An AnimeDoc API key. Get one free. Playback through the stream proxy needs a paid plan; the $1 3-Day Pass is enough to finish this tutorial.

1. Create the project

npm create vite@latest anime-app -- --template react
cd anime-app
npm install express
npm install -D concurrently

2. Add a small server for your API key

Never put your API key in React code: anyone can read it in the browser. A tiny Express server holds the key and forwards requests.

server.js
// server.js — keeps your API key off the client. Node 18+.
// npm install express
import express from "express";
import { Readable } from "node:stream";

const API = "https://api.animedoc.cloud/api/v2";
const KEY = process.env.ANIMEDOC_API_KEY;
const app = express();

async function animedoc(path, init = {}) {
  const res = await fetch(API + path, {
    ...init,
    headers: { "X-API-Key": KEY, "Content-Type": "application/json", ...init.headers },
  });
  return { status: res.status, body: await res.json() };
}

app.get("/api/search", async (req, res) => {
  const { status, body } = await animedoc("/anime/search/", {
    method: "POST",
    body: JSON.stringify({ keyword: req.query.q ?? "", page: 1, perPage: 24 }),
  });
  res.status(status).json(body);
});

app.get("/api/detail", async (req, res) => {
  const { status, body } = await animedoc("/anime/detail/?detailPath=" + encodeURIComponent(req.query.detailPath));
  res.status(status).json(body);
});

app.get("/api/sources", async (req, res) => {
  const qs = new URLSearchParams(req.query);
  const { status, body } = await animedoc("/anime/media-sources/?" + qs);
  res.status(status).json(body);
});

// Streams the video through AnimeDoc's stream proxy, forwarding Range so seeking works.
app.get("/api/stream", async (req, res) => {
  const upstream = await fetch(API + "/anime/stream-file/?video_url=" + encodeURIComponent(req.query.url), {
    headers: { "X-API-Key": KEY, ...(req.headers.range ? { Range: req.headers.range } : {}) },
  });
  res.status(upstream.status);
  for (const h of ["content-type", "content-length", "content-range", "accept-ranges"]) {
    const v = upstream.headers.get(h);
    if (v) res.setHeader(h, v);
  }
  if (!upstream.body) return res.end();
  Readable.fromWeb(upstream.body).pipe(res);
});

app.listen(3001, () => console.log("API proxy on http://localhost:3001"));

Tell Vite to forward /api to that server while developing:

vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3001" } },
});

Add "type": "module" to package.json if it isn't there, and a script that runs both:

package.json
"scripts": {
  "dev": "concurrently \"node server.js\" \"vite\""
}

3. Search screen

src/Search.jsx
import { useState } from "react";

export function Search({ onPick }) {
  const [q, setQ] = useState("");
  const [items, setItems] = useState([]);
  const [error, setError] = useState(null);

  async function run(e) {
    e.preventDefault();
    const res = await fetch("/api/search?q=" + encodeURIComponent(q));
    if (res.status === 429) return setError("Rate limit reached, try again in a minute.");
    const body = await res.json();
    setError(null);
    setItems(body.data?.items ?? []);
  }

  return (
    <div>
      <form onSubmit={run}>
        <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search anime" />
        <button>Search</button>
      </form>
      {error && <p>{error}</p>}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
        {items.map((item) => (
          <button key={item.subjectId} onClick={() => onPick(item)}>
            <img src={item.cover?.url} alt={item.title} loading="lazy" style={{ width: "100%" }} />
            {item.title}
          </button>
        ))}
      </div>
    </div>
  );
}

4. Episode picker and player

src/Player.jsx
import { useEffect, useState } from "react";

export function Player({ item }) {
  const [seasons, setSeasons] = useState([]);
  const [se, setSe] = useState(1);
  const [ep, setEp] = useState(1);
  const [src, setSrc] = useState(null);

  useEffect(() => {
    fetch("/api/detail?detailPath=" + encodeURIComponent(item.detailPath))
      .then((r) => r.json())
      .then((body) => {
        const list = body.data?.resource?.seasons ?? [];
        setSeasons(list);
        if (list[0]) { setSe(list[0].se); setEp(list[0].maxEp ? 1 : 0); }
      });
  }, [item.detailPath]);

  useEffect(() => {
    const qs = new URLSearchParams({ subjectId: item.subjectId, detailPath: item.detailPath, se, ep });
    fetch("/api/sources?" + qs)
      .then((r) => r.json())
      .then((body) => {
        const playable = (body.data?.downloads ?? [])
          .filter((d) => d.url && !d.vipLocked)
          .sort((a, b) => Number(b.resolution) - Number(a.resolution));
        setSrc(playable[0] ? "/api/stream?url=" + encodeURIComponent(playable[0].url) : null);
      });
  }, [item, se, ep]);

  const maxEp = seasons.find((s) => s.se === se)?.maxEp ?? 0;

  return (
    <div>
      <h2>{item.title}</h2>
      {maxEp > 0 && (
        <select value={ep} onChange={(e) => setEp(Number(e.target.value))}>
          {Array.from({ length: maxEp }, (_, i) => (
            <option key={i + 1} value={i + 1}>Episode {i + 1}</option>
          ))}
        </select>
      )}
      {src ? <video key={src} src={src} controls autoPlay style={{ width: "100%" }} /> : <p>No playable source.</p>}
    </div>
  );
}

5. Put it together

src/App.jsx
import { useState } from "react";
import { Search } from "./Search";
import { Player } from "./Player";

export default function App() {
  const [item, setItem] = useState(null);
  return item ? (
    <>
      <button onClick={() => setItem(null)}>← Back</button>
      <Player item={item} />
    </>
  ) : (
    <Search onPick={setItem} />
  );
}
ANIMEDOC_API_KEY=ad_live_... npm run dev

Open the URL Vite prints, search for a show and pick an episode.

Going to production

  • Deploy server.js anywhere Node runs and set ANIMEDOC_API_KEY there.
  • Cache search and detail responses for a few minutes to save requests; never cache source URLs (they expire).
  • Show a friendly message on 429 and read Retry-After. See rate limits.
  • Commercial apps need the Pro plan or higher.