DEV Community

Cover image for Facebook as a headless CMS: building a Sri Lankan F1 fan site on Next.js 16
Vihanga
Vihanga

Posted on

Facebook as a headless CMS: building a Sri Lankan F1 fan site on Next.js 16

F1 Paddock SL

F1 Paddock SL is a Formula 1 fan site I build for Sri Lankan supporters: race analysis, driver stories in Sinhala and English, live standings and telemetry, and paddock news pulled from our Facebook Page. It's live at f1paddocksl.com.

It's a fan project, not a startup. Nobody is paying for F1 data, and I don't have a newsroom. That constraint shaped every decision in the stack — and one of them turned out to be the most interesting thing in the codebase: Facebook is the CMS.

This is the six things that cost real time, and what I'd do differently.

The shape of it

app/            22 pages: calendar, drivers/[id], standings, dashboard,
                schedule, news, stories, teams, constructors, video, faq
app/api/        5 routes: openf1/[...path], fbimg, fb-chat, news, comments
components/     35 components (WebGL hero, live timing boards, dotted track maps)
lib/            f1.ts (data), facebook.ts (Graph API), blog-cache.ts, comments.ts
scripts/        refresh-fb-token.mjs
data/           fb-posts.json + one JSON file per cached post
Enter fullscreen mode Exit fullscreen mode

Next.js 16.3 (App Router), React 19.2, TypeScript, Tailwind v4, MUI 9, Supabase, three.js. 34 dependencies total.


1. Two free F1 APIs, and cache by how fast the data rots

There is no official F1 API you can afford. There are two good free ones, and they solve different problems:

  • Jolpica (the community-run Ergast successor) — standings, schedules, results, qualifying.
  • OpenF1 — live timing: car telemetry, positions, session metadata.

I used both, and the interesting part is that they get very different cache lifetimes. A constructor's point total changes once a week. A car's gear changes four times a second.

Data Source revalidate
Standings, schedule, results, qualifying Jolpica 3600s
Facebook post feed Graph API 600s
Facebook follower count Graph API 1800s
Facebook live chat Graph API no-store
Live positions, car telemetry OpenF1 15s

The whole TTL policy is one optional parameter:

async function fetchJson(url: string, revalidate = 3600) {
  const res = await fetch(url, {
    headers: { Accept: "application/json", ...(isServer ? openf1Headers() : {}) },
    ...(isServer ? { next: { revalidate } } : {}),
  });
  if (!res.ok) throw new Error(`API error ${res.status}: ${url}`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

You get this wrong in both directions. Set a 1-hour TTL on positions and your live timing board is a lie. Set no-store on standings and you've turned a static page into a request that hammers a free community API on every visit — which is how you get rate-limited and the whole site goes blank.

The same library, two transports

Server Components can't share a fetch cache with the browser, so the data layer picks a transport per environment:

function openf1(path: string): string {
  return isServer ? `${OPENF1_BASE}${path}` : `/api/openf1${path}`;
}
Enter fullscreen mode Exit fullscreen mode

Server components call OpenF1 directly and get Next's data cache for free. Client components hit our own /api/openf1/[...path] proxy, which re-applies the same revalidate and forwards the optional bearer token. One data layer, two callers, no duplicated logic.

The proxy is 20 lines and exists purely so a client component can read a JSON API without dragging CORS or a token into the browser.

Don't fan out without a cap

Generating season race reviews meant fetching results and qualifying for each of the last 10 rounds. Straight Promise.all means 20 simultaneous requests at a free API on the first cold cache hit.

export async function mapLimit<T, R>(
  items: T[], limit: number, fn: (item: T) => Promise<R>,
): Promise<R[]> {
  const out: R[] = new Array(items.length);
  let i = 0;
  async function worker() {
    while (i < items.length) {
      const idx = i++;
      try {
        out[idx] = await fn(items[idx]);
      } catch {
        out[idx] = undefined as unknown as R; // one failed round, not a failed page
      }
    }
  }
  await Promise.all(Array.from({ length: limit }, worker));
  return out;
}

const reviews = await mapLimit(finished, 3, async (race) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

Three workers, and a single failed round drops out of the list instead of taking the page with it.


2. The CMS was already on Facebook

Here's the actual problem. The people writing paddock news are not logging into an admin panel. They are posting on the Facebook Page, because that's where the audience already is.

So rather than build an editor, I read the Page through the Graph API and treat it as a content backend:

POST to Facebook Page  →  Graph API /{page-id}/posts  →  normalised FetchedPost  →  JSON on disk  →  blog
Enter fullscreen mode Exit fullscreen mode

The normalisation is the part that matters. Facebook's post shape is generous — message, story, description, attachments, child_attachments — and inconsistent. I flatten it into one type and make a decision about what a post's title is, because Facebook posts don't have titles:

const heading =
  message.split("\n")[0].trim() ||
  description.split("\n")[0].trim() ||
  attachment?.title ||
  story ||
  `New post from ${pageName}`;

const title = heading.slice(0, 110);
Enter fullscreen mode Exit fullscreen mode

First line of the message, truncated. That's it. It works because our posts are written with a deliberate first line, which is a content rule, not a technical one — worth being honest about that in a headless CMS.

The cache is the site

Once Facebook is your database, you have a hard dependency on someone else's uptime, rate limits, and token policy. So the cache isn't an optimisation. It's the site.

export async function getCachedBlogPosts(limit = 20): Promise<BlogPost[]> {
  const cached = readCache();
  try {
    const live = await getBlogPosts(30, "Facebook");
    if (live.length) {
      const map = new Map<string, BlogPost>();
      for (const p of [...live, ...cached]) {   // live wins, cache fills the gaps
        const key = dedupeKey(p);              // id → link → title
        if (key && !map.has(key)) map.set(key, p);
      }
      const merged = Array.from(map.values()).sort(
        (a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(),
      );
      writeCache(merged);
      merged.forEach(writePostFile);
      return merged.slice(0, limit);
    }
  } catch {
    // live fetch failed — fall back to cache
  }
  return cached.slice().sort(/* … */).slice(0, limit);
}
Enter fullscreen mode Exit fullscreen mode

Two files per refresh: a merged data/fb-posts.json for listing pages, and one permanent file per post in data/posts/. The per-post file is the important one. It's what makes a direct link to /blog/{id} keep working after a cache rebuild, a Facebook outage, or a deleted post.

There are 65 of them on disk right now. The blog is effectively a Git-less archive of our Page.

Every read path in this codebase does the same thing: try the network, and on any failure return the last known-good value. readCache swallows corrupt JSON, writeCache swallows read-only filesystems, getFacebookPosts returns [] instead of throwing. The F1 site's core promise is "never show the user an error page" — the blog is just where that promise is hardest to keep.


3. Sixty days is not a long time

Meta does not give you a permanent credential. A Page Access Token is good for about 60 days. This site needs to run for years.

The naive version is a human noticing the follower counter going to zero and copying a new token out of the Graph API Explorer into .env.local and the Vercel dashboard. That's a monthly chore with a failure mode nobody notices until it ships.

So the token became a scheduled job:

1. extend the stored long-lived user token   (oauth/access_token, fb_exchange_token)
2. swap it for a fresh page token            (/me/accounts, matched on FB_PAGE_ID)
3. verify it against the Graph API          (name + follower count)
4. write it back to .env.local              (masked output, never printed)
5. PATCH the value into Vercel project env  (production + preview + development)
Enter fullscreen mode Exit fullscreen mode

Step 5 is the one I care about. It hits api.vercel.com/v9/projects/{id}/env/{id} and patches the existing variable in place, rather than creating a duplicate, across all three targets. So production picks up a new token without a redeploy and without a dashboard session.

const res = await fetch(
  `https://api.vercel.com/v9/projects/${projectId}/env/${found.id}`,
  {
    method: "PATCH",
    headers,
    body: JSON.stringify({ value, target: ["production", "preview", "development"] }),
  },
);
Enter fullscreen mode Exit fullscreen mode

npm run fb:refresh, on a monthly cron. If the local write succeeds but the Vercel push fails, it logs a warning and exits cleanly — the local value is still correct, so re-running is safe.

The general lesson: anything with an expiry is infrastructure, not configuration. A credential in a .env file that a human is expected to renew is a scheduled job wearing a disguise. I wrote this script after the follower count silently hit zero twice.


4. Never render an image URL you don't control

Facebook post images broke the layout constantly, for two separate reasons:

  1. Hotlink protection. The browser can't load scontent.xx.fbcdn.net URLs directly — they need a referrer and a browser-shaped user agent.
  2. Signed expiry. The URL carries an oe= expiry token. Once it lapses, the CDN returns an error. A post from six months ago has a dead image URL, and that URL is baked into the JSON we cached.

You cannot fix this in the browser. The fix is a server-side proxy with an allowlist:

const u = req.nextUrl.searchParams.get("u");
if (!u || !/^https:\/\/(scontent\.|.*\.fbcdn\.net|www\.facebook\.com|m\.facebook\.com|platform-lookaside\.fbcdn\.net)/.test(u)) {
  return new Response("Invalid URL", { status: 400 });
}
Enter fullscreen mode Exit fullscreen mode

That regex is the whole security model of the route. Without it you've written an open proxy that will fetch any URL anyone sends it — including 169.254.169.254, which is the SSRF mistake that turns a fan site into a cloud-credentials leak. If you build one of these, the host allowlist is not optional, and it should be as narrow as you can make it.

And when the upstream fails anyway — expired signature, deleted post, CDN hiccup — the route returns a 200 with a generated SVG instead of a 404:

const svg = `<svg width="1200" height="675" viewBox="0 0 1200 675">
  <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">…</linearGradient></defs>
  <rect width="1200" height="675" fill="url(#bg)"/>
  <g fill="#ffffff" opacity="0.04">
    ${Array.from({ length: 10 }, (_, r) =>
      Array.from({ length: 16 }, (_, c) =>
        `<rect x="${c * 80 + (r % 2 ? 40 : 0)}" y="${r * 75}" width="40" height="40"/>`).join(""),
    ).join("")}
  </g>
  <rect width="10" height="675" fill="#e10600"/>
  <text x="600" y="330" text-anchor="middle" font-size="52" fill="#ffffff">F1 PADDOCK SL</text>
  <text x="600" y="386" text-anchor="middle" font-size="22" letter-spacing="6" fill="#e10600">FORMULA 1 · SRI LANKA</text>
</svg>`;
Enter fullscreen mode Exit fullscreen mode

The r % 2 offset generates a chequered flag. A dead image still reads as a flag, the layout never shifts, and a 40-year-old F1 fan site doesn't look broken.


5. Two design systems, one App Router

The UI is Tailwind v4 with shadcn components. A few data-heavy surfaces (the timing tables, the tabbed data views) are MUI. Both ship CSS-in-JS-or-utility opinions, and in the App Router that combination has a specific failure mode: Emotion injects styles at runtime, so MUI components FOUC or hydrate-mismatch if the cache isn't registered during SSR.

The fix is one component, and it has to wrap everything:

"use client";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v16-appRouter";

export default function MuiProvider({ children }: { children: React.ReactNode }) {
  const { resolved } = useThemeMode();
  return (
    <AppRouterCacheProvider options={{ key: "paddock" }}>
      <ThemeProvider theme={createAppTheme(resolved)}>{children}</ThemeProvider>
    </AppRouterCacheProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

AppRouterCacheProvider is MUI's own App Router integration — it handles Emotion's server style extraction and insertion. The subpath matters: /v16-appRouter for Next 16.

Then, so the two systems can't drift, the MUI theme is generated from the same light/dark tokens Tailwind reads:

export function createAppTheme(mode: "light" | "dark"): Theme {
  const C = mode === "light" ? LIGHT : DARK;
  return createTheme({
    palette: {
      mode,
      primary: { main: C.red, dark: C.redDark },
      background: { default: C.bg, paper: C.elevated },
      text: { primary: C.text, secondary: C.textDim },
      divider: C.border,
    },
    shape: { borderRadius: 4 },
  });
}
Enter fullscreen mode Exit fullscreen mode

One source of truth, two renderers. A MUI button and a shadcn button are the same red in dark mode because they read the same constant.


6. Bilingual by font fallback, not by translation layer

Driver stories ship in English and Sinhala on the same page. The naive approach is a language toggle and two strings per field. I just concatenated them and let the font stack do the routing:

const sinhala = Abhaya_Libre({ subsets: ["latin", "sinhala"], … });
const sinhalaHeading = Gemunu_Libre({ subsets: ["latin", "sinhala"], … });
const bigHeading = Bricolage_Grotesque({ subsets: ["latin"], axes: ["opsz"], … });
const f1Font = Russo_One({ weight: ["400"], subsets: ["latin"], … });
Enter fullscreen mode Exit fullscreen mode

Latin glyphs resolve against the Latin face at the front of the stack; Sinhala glyphs have no glyph in Geist, so the browser falls through to Abhaya or Gemunu. One journey field in English and one si.journey in Sinhala, both rendered, both indexed.

The F1-specific part: Russo_One is the closest freely-licensed stand-in for the official Formula1 wordmark, and it's applied only to the live race-countdown numerals, where wide condensed digits are the entire aesthetic. Bricolage_Grotesque carries the opsz axis so the browser picks a heavier display cut for large headlines.

While I was in there: no flash of the wrong theme. A beforeInteractive script reads localStorage and resolves system against prefers-color-scheme before first paint, so a dark-mode visitor never gets a white flash.

var stored = localStorage.getItem("paddock-theme");
var theme = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
var resolved = theme === "system"
  ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
  : theme;
document.documentElement.classList.add(resolved);
Enter fullscreen mode Exit fullscreen mode

It's 15 lines of blocking script and it's the difference between the site feeling native and feeling cheap.


Small things that mattered

SEO from one constant. SITE_NAME, SITE_URL, SITE_DESCRIPTION in lib/site.ts feed the root metadata, the WebSite JSON-LD, the manifest, robots.txt, and the sitemap. Renaming the site was a one-line diff that updated every surface — including the alternateName field that tells Google which name to index.

Live chat, carefully. A Messenger-style widget polls /api/fb-chat, which is force-dynamic + no-store and hits Graph API fresh each time. Nested comments come back in one request using field expansion rather than N+1:

comments.summary(true).limit(6){id,from{name,picture.width(64).height(64)},message,
  created_time,comments.summary(true).limit(3){…}}
Enter fullscreen mode Exit fullscreen mode

The constraint that shaped it: it must never be cached, because a stale comment count on a live race thread is worse than no widget at all.


What I'd do differently

  • Move the cache off the filesystem. data/*.json works great locally and on a long-lived Node host, but it's a poor fit for read-only serverless filesystems. Supabase is already connected for comments and authored posts; the blog cache should live there too.
  • Stop scraping titles from the first line. It works because we wrote the posts that way. A real editorial field would be one API permission away and would survive me changing how we write.
  • Add a token-expiry alarm. The cron keeps the token alive, but nothing tells me if the cron itself stopped. A 60-day silent failure is exactly the kind of bug that ships and nobody files a ticket about.
  • Delete the dual design system eventually. Tailwind and MUI in one App Router tree works, but every future contributor has to learn which one to reach for. I inherited that decision and haven't found a cheap way to undo it.

The through-line across all six: this project runs on other people's infrastructure — Meta's Graph API, Jolpica's mirrors, OpenF1's rate limits, Vercel's env store. The engineering wasn't really about Next.js or WebGL. It was about caching aggressively, failing soft, and turning every expiring credential into a scheduled job so the site keeps answering long after everyone's API keys expire.

If you're building something on free community APIs, budget for that. It's the actual project.

Top comments (0)