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 concurrently2. 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 — 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:
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:
"scripts": {
"dev": "concurrently \"node server.js\" \"vite\""
}3. Search screen
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
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
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 devOpen the URL Vite prints, search for a show and pick an episode.
Going to production
- Deploy
server.jsanywhere Node runs and setANIMEDOC_API_KEYthere. - 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.