simkl last watched.
Shows the last movie, show or anime I watched on Simkl. Backed by an Express service that pulls poster art from TMDB and caches the result for five minutes.
live component, try it
dependencies.
what to expect.
usage.
import LastWatched from "@/components/ui/last-watched";
export default function MyPage() {
return (
<div className="flex items-center justify-center p-8">
<LastWatched />
</div>
);
}the component.
"use client";
import useSWR from "swr";
import Image from "next/image";
import Simkl from "@/components/SVGs/platforms/Simkl";
import { feedback } from "@/lib/feedback";
import { LastWatchedSkeleton, LAST_WATCHED_RESERVE } from "@/components/ui/skeleton";
const SIMKL_API_URL = process.env.NEXT_PUBLIC_SIMKL_API_URL;
type LastWatchedResponse = {
ok: boolean;
data?: {
type: "movie" | "episode";
title?: string;
show_title?: string;
season?: number | null;
episode?: number | null;
year?: number;
poster_url?: string;
url?: string;
watched_at?: string;
};
};
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const LastWatched = () => {
const { data: response, isLoading } = useSWR<LastWatchedResponse>(
`${SIMKL_API_URL}/api/watch/last`,
fetcher,
{
// Five minutes, matching the service's in-memory cache.
refreshInterval: 300000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
refreshWhenHidden: false,
dedupingInterval: 60000,
}
);
const data = response?.ok ? response.data : null;
if (isLoading) {
return <LastWatchedSkeleton />;
}
if (!data) {
return (
<section className={`text-center ${LAST_WATCHED_RESERVE}`}>
<div className="widget-enter">
<div className="flex items-center justify-center gap-2 mb-3">
<Simkl className="text-lg" />
<h3 className="text-base sm:text-lg font-medium text-foreground/90">last watched.</h3>
</div>
<p className="text-sm text-muted-foreground">no recent activity.</p>
</div>
</section>
);
}
// Anime often uses absolute numbering with no season, so handle both shapes.
const episodeSuffix =
data.season != null && data.episode != null
? ` S${data.season} E${data.episode}`
: data.episode != null
? ` E${data.episode}`
: "";
const displayTitle = data.type === "episode"
? `${data.show_title ?? data.title}${episodeSuffix}`
: data.title;
return (
<section className={`text-center ${LAST_WATCHED_RESERVE}`}>
<div className="widget-enter flex items-center justify-center gap-2 mb-3">
<Simkl className="text-xl" />
<h2 className="text-base sm:text-lg font-semibold text-foreground/90">last watched.</h2>
</div>
<div className="flex flex-col items-center gap-3">
<div className="widget-enter [--enter-delay:70ms]">
{data.poster_url ? (
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
className="transition-transform hover:scale-105 active:scale-95 rounded-xl block ease-in-out duration-200"
aria-label={`View ${displayTitle} on Simkl`}
onClick={() => feedback("tick")}
style={{ color: 'inherit' }}
>
<Image
src={data.poster_url}
alt={displayTitle || "Poster"}
width={100}
height={150}
className="w-25 h-37.5 rounded-md shadow-md"
/>
</a>
) : (
<div className="w-25 h-37.5 bg-muted rounded-xl flex items-center justify-center">
<span className="text-xs text-muted-foreground">No image</span>
</div>
)}
</div>
<div className="widget-enter [--enter-delay:140ms]">
<p className="text-sm font-medium text-foreground">{displayTitle}</p>
{data.year && (
<p className="text-sm text-muted-foreground">{data.year}</p>
)}
</div>
</div>
</section>
);
};
export default LastWatched;how it works.
Simkl wants a client ID and an access token on every request, so this one also has to happen off the browser. A small Express service sits in front of it. It pulls a 45-day window from the shows, anime and movies buckets, sorts them locally, falls back to a full history pull when that window is empty, swaps in TMDB artwork where it can, and returns a single normalised object for the widget to fetch. Simkl tokens are far less fussy than Spotify's too: they last about five years, and there is no refresh cycle to automate.
what it talks to.
api.simkl.com/sync/all-items/{shows|anime|movies}/allWatch history per bucket. Wants a simkl-api-key header, a Bearer token, and client_id plus app-name and app-version in the query string. An empty bucket comes back as a blank body instead of JSON, so read the response as text before parsing.
api.themoviedb.org/3/{movie|tv}/{tmdb_id}Optional. Resolves a poster_path for better artwork than Simkl's own images. Skipped when no TMDB key is set.
your-service.example.com/api/watch/lastWhat the widget actually calls. Returns { ok, data } with type, title, year, poster_url and url, or a 503 carrying code: REAUTH_REQUIRED when the token has been revoked.
environment.
SIMKL_CLIENT_IDService-side. Sent as both a header and a query param.
SIMKL_ACCESS_TOKENService-side. From the one-time PIN flow below.
TMDB_API_KEYOptional. Without it, posters fall back to Simkl's own images.
FRONTEND_URLService-side. Origin allowed through CORS - your site, not the API's own domain.
NEXT_PUBLIC_SIMKL_API_URLOn the portfolio side. The public base URL of your service.
setup.
Register a Simkl app
At simkl.com/settings/developer/new, pick "Add a new app". The "Add a new website" option sitting next to it looks equivalent, but it grants limited permissions and cannot read watch history at all, and it fails later as an unexplained 403 rather than anything mentioning permissions. That one is an easy afternoon to lose. The redirect URI is unused by the PIN flow, so anything valid like urn:ietf:wg:oauth:2.0:oob will do. Copy the client ID once the app exists.
Get an access token via the PIN flow
Simkl uses device-style auth: request a code, enter it at simkl.com/pin, then poll until it is approved. The token you get back does not rotate, so this runs once and the result goes straight into your env file. Give the poll a deadline (the code expires after about 15 minutes) and bail out if Simkl hands back a fresh device_code, which is what happens when you keep polling past authorization.
// get-simkl-token.js, run once with: node get-simkl-token.js const CLIENT_ID = process.env.SIMKL_CLIENT_ID; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const pin = await fetch( `https://api.simkl.com/oauth/pin?client_id=${CLIENT_ID}` ).then((r) => r.json()); const verifyUrl = pin.verification_url || "https://simkl.com/pin"; const interval = (pin.interval || 5) * 1000; const deadline = Date.now() + (pin.expires_in || 900) * 1000; console.log(`Enter ${pin.user_code} at ${verifyUrl}`); // Poll with the user_code. A standard device flow would send the // device_code here, but Simkl wants the user_code. while (Date.now() < deadline) { await sleep(interval); const poll = await fetch( `https://api.simkl.com/oauth/pin/${pin.user_code}` + `?client_id=${CLIENT_ID}` ).then((r) => r.json()); if (poll.access_token) { console.log("SIMKL_ACCESS_TOKEN=" + poll.access_token); break; } // A device_code in the poll response means Simkl rotated the code // out from under us. Looping on it never succeeds. if (poll.device_code) { throw new Error("Simkl issued a new code. Re-run this script."); } }Fetch and sort the history
Simkl has no "sort by most recent" parameter, so pull a recent window from all three buckets and sort it yourself. Items with no last_watched_at are watchlist entries rather than history, so drop those. Movies arrive under `movie`, while shows and anime both come under `show`. Keep the bucket name anyway, since it is the only thing that tells anime apart later. If the window turns up nothing, repeat the pull without date_from so a quiet month doesn't blank the widget.
const SIMKL = "https://api.simkl.com"; const WINDOW_DAYS = 45; const headers = { "Content-Type": "application/json", "User-Agent": "WatchHistory/1.0 (+https://your-site.example.com)", "simkl-api-key": process.env.SIMKL_CLIENT_ID, Authorization: `Bearer ${process.env.SIMKL_ACCESS_TOKEN}`, }; async function fetchBucket(type, dateFrom) { const query = new URLSearchParams({ client_id: process.env.SIMKL_CLIENT_ID, "app-name": "simkl-api", "app-version": "1.0", ...(dateFrom ? { date_from: dateFrom } : {}), }); const res = await fetch( `${SIMKL}/sync/all-items/${type}/all?${query}`, { headers } ); if (res.status === 401 || res.status === 403) { throw new Error("Token revoked, re-run the PIN flow"); } // Empty buckets come back as an empty body, which JSON.parse would throw on. const text = await res.text(); return text.trim() ? JSON.parse(text) : {}; } async function fetchMostRecent(dateFrom) { const buckets = await Promise.all([ fetchBucket("shows", dateFrom), fetchBucket("anime", dateFrom), fetchBucket("movies", dateFrom), ]); const entries = buckets.flatMap((payload) => Object.entries(payload).flatMap(([bucket, items]) => (Array.isArray(items) ? items : []) .map((item) => ({ item, media: item.movie || item.show, bucket, isMovie: bucket === "movies" || Boolean(item.movie), })) .filter(({ item, media }) => media && item.last_watched_at) ) ); entries.sort( (a, b) => new Date(b.item.last_watched_at) - new Date(a.item.last_watched_at) ); return entries[0] ?? null; } export async function getLastWatched() { const since = new Date(Date.now() - WINDOW_DAYS * 86400000).toISOString(); // Nothing in the window? Pull everything rather than render an empty widget. return (await fetchMostRecent(since)) ?? (await fetchMostRecent(null)); }Read the episode marker
Episodes carry a last_watched marker in one of two shapes. Seasoned shows (and anime scrobbled by clients that map to TMDB or TVDB numbering) give you "S01E05". Anime tracked through Simkl itself uses absolute numbering with no season at all ("E366"), so a season-less result is normal rather than a parse failure. Return season as null there and let the UI decide how to render it.
function parseEpisodeMarker(marker) { if (!marker) return null; const seasoned = /S(\d+)E(\d+)/i.exec(marker); if (seasoned) { return { season: parseInt(seasoned[1], 10), episode: parseInt(seasoned[2], 10), }; } // Absolute numbering, e.g. "E366". No season exists to report. const absolute = /^E(\d+)$/i.exec(marker.trim()); if (absolute) { return { season: null, episode: parseInt(absolute[1], 10) }; } return null; } function formatEpisodeTitle(title, parsed) { if (!parsed) return title; return parsed.season != null ? `${title} S${parsed.season}E${parsed.episode}` : `${title} E${parsed.episode}`; }Build the link back to Simkl
Simkl routes on the numeric id, not the slug: /tv/my-show does not resolve, it needs /tv/1648284/my-show. The slug is cosmetic and the id alone works, so only append it when it exists. The path segment comes from the bucket (anime gets /anime/, shows get /tv/, movies get /movies/), which is the reason the bucket name was carried this far.
function buildSimklUrl(segment, media) { const simklId = media.ids?.simkl ?? media.ids?.simkl_id; if (!simklId) return null; const slug = media.ids?.slug; return slug ? `https://simkl.com/${segment}/${simklId}/${slug}` : `https://simkl.com/${segment}/${simklId}`; } // movies -> "movies", anime -> "anime", everything else -> "tv" const segment = isMovie ? "movies" : bucket === "anime" ? "anime" : "tv";Upgrade the poster art
Simkl returns its own poster path, but if the item carries a TMDB id you can fetch nicer artwork. Keep the Simkl image as the fallback, because plenty of anime entries have no TMDB match at all.
async function fetchPoster(type, media) { const tmdbId = media.ids?.tmdb; if (process.env.TMDB_API_KEY && tmdbId) { const res = await fetch( `https://api.themoviedb.org/3/${type}/${tmdbId}` + `?api_key=${process.env.TMDB_API_KEY}` ); if (res.ok) { const { poster_path } = await res.json(); if (poster_path) return `https://image.tmdb.org/t/p/w500${poster_path}`; } } // Simkl paths look like "24/24273cee77f9d9f"; _m is a 340px webp. return media.poster ? `https://simkl.in/posters/${media.poster}_m.webp` : null; }Expose the endpoints and cache them
Cache in memory for five minutes to match the widget's polling interval, which makes every extra visitor free. Lock CORS down to your own origin, and return 503 with its own code if the token is ever revoked, so that failure stands out from an ordinary 500. Add a root route as well: pasting the bare domain into a browser should say what the service is rather than return a 404 that reads like an outage. Worth being honest about the limits of the CORS check: requests with no Origin header (curl, server-side fetches) pass straight through, because CORS only restricts browsers. Treat the endpoint as public and put nothing private behind it.
import express from "express"; import cors from "cors"; const app = express(); const allowed = [process.env.FRONTEND_URL, "http://localhost:3000"].filter(Boolean); app.use(cors({ origin: (origin, callback) => { // No Origin header at all: curl, mobile apps, server-side fetches. if (!origin) return callback(null, true); callback(allowed.includes(origin) ? null : new Error("Not allowed by CORS"), true); }, credentials: true, })); app.get("/", (_req, res) => { res.json({ ok: true, service: "simkl-api", endpoints: ["/api/watch/last", "/health"], docs: "https://github.com/you/simkl-api", }); }); app.get("/health", (_req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); app.get("/api/watch/last", async (_req, res) => { try { const data = await getLastWatched(); // 5-minute in-memory cache inside res.json({ ok: true, data }); } catch (err) { if (err.code === "REAUTH_REQUIRED") { return res.status(503).json({ ok: false, code: "REAUTH_REQUIRED" }); } res.status(500).json({ ok: false, error: err.message }); } }); app.use((_req, res) => res.status(404).json({ ok: false, error: "Not found" })); app.listen(process.env.PORT || 3001);Point the widget at it
Set NEXT_PUBLIC_SIMKL_API_URL to your deployed base URL. Being public is fine here, since it is only a URL and the credentials never leave the service. If you host somewhere that sleeps when idle, point an external uptime monitor at /health every five minutes, or the first visitor after a quiet spell sits through a cold start. Monitor /health and not /: the root route is a static object that answers 200 even when the Simkl token is dead, which is precisely the failure you wanted the monitor to catch. A server that pings itself is not a substitute either: it is rude on a free tier, and it stops working the moment the thing you are monitoring goes down.
"use client"; import useSWR from "swr"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function LastWatched() { const { data } = useSWR( `${process.env.NEXT_PUBLIC_SIMKL_API_URL}/api/watch/last`, fetcher, { refreshInterval: 300_000, revalidateOnFocus: false } ); const item = data?.ok ? data.data : null; if (!item) return <p>no recent activity.</p>; return <p>{item.show_title ?? item.title}</p>; }