Wolf News
An Arabic-language news platform and editorial CMS, built under a hard cost ceiling — where "we can't afford that service" became the design constraint that shaped scheduling, video and caching.
- Role: Solo — frontend, backend, data model, editor, roles and permissions, SEO, deployment.
- Stack: Next.js 16, React 19, TypeScript, Supabase (Postgres, Auth, Storage), TipTap, TanStack Query, ffmpeg, sharp, Tailwind.
- Status: Live in production at wolfnews2026.com. Paid client work.
The problem
Wolf News is an Arabic-language publication. The build had to satisfy three things at once, and it's the combination that made it interesting rather than any one of them:
It had to be Arabic-first, not Arabic-translated. Right-to-left isn't a stylesheet toggle applied at the end — it's the document direction, and everything laid out against it has to be authored that way.
It had to be run by people who don't work in software. The editorial team publishes the articles. Every decision about the CMS had to answer to someone who would never read a tooltip that said "markdown supported."
It had to be cheap. Not "cost-conscious" in the abstract — cheap enough that paid infrastructure was largely off the table. That single constraint is the throughline of most of what follows.
What I built
A public Arabic news site, lang="ar" dir="rtl" from the document root, with articles, tags, search, related articles and full SEO surface area — sitemap, robots, RSS feed, JSON-LD structured data and a web manifest.
An editorial CMS with two roles. Admins manage articles, tags and creator accounts; creators write and manage their own. Each creator also gets a public link page of their selected articles.
A rich-text editor built on TipTap, extended with custom nodes for the things a news site actually needs that a starter kit doesn't ship.
Design decisions: what "it has to be cheap" actually changed
These aren't separate calls — they're one constraint showing up in four places, which is why they belong together rather than in a list of unrelated optimisations.
Scheduled publishing without a scheduler
Articles can be given a future publish date. The obvious implementation is a cron job that wakes up, finds articles whose time has come, and publishes them. Cron on the hosting tier this project could afford wasn't available.
So the sweep lives at /api/publish-scheduled, an authenticated endpoint that checks the caller is signed in and holds the admin role, then queries for articles with status = 'scheduled' and a publish_date at or before now, flips them to published, and revalidates the affected paths.
The honest description is that scheduling is settled when an admin next triggers the sweep, not at the instant the clock passes the timestamp. For a publication where someone from the team is in the admin panel regularly, that gap is acceptable — and it costs nothing. It is a real limitation, not a free win, and it's the first thing I'd replace given a budget for a scheduler.
Video that has to fit through a free storage tier
The editor supports video. Uploads go to /api/upload-video, which is admin-gated, capped at 20MB, and runs the file through ffmpeg server-side before anything is stored.
Transcoding rather than storing the original does two jobs at once, and both come back to the same constraint: it keeps files inside Supabase's storage limits, and it keeps them small enough to start quickly for readers on mobile connections. Storing what the editor happened to export — a phone video straight off a camera roll — would blow through the quota and the reader's patience in the same upload.
Caching to keep the database quiet
Article reads are memoised at request level with React's cache(), so a single page render that needs the article list in three places issues one query rather than three. On top of that, the homepage and the search and related-articles endpoints run ISR on a 300-second revalidate.
The query layer is instrumented — logQueryPerformance and logCachePerformance wrap the cached fetchers — because on a constrained tier the thing you need to know is which query is expensive, and guessing is how you end up optimising the wrong one.
Publishing, updating or deleting an article calls a central revalidateArticleCaches() that invalidates the homepage, the article route, and the search and related endpoints together. Keeping that in one function rather than scattering revalidatePath calls through the mutation handlers is what stops a five-minute staleness window from quietly becoming permanent staleness on one forgotten path.
Deep dive: an editor for people who would rather not use an editor
The editorial team aren't technical. I didn't wait for support calls to tell me that — it was obvious from the outset that the CMS would be the hardest part of the product for them, so the tooling went in up front.
Three separate guided tours, built on driver.js: one for the admin panel, one for the creator dashboard, and one specifically for the article editor. They're separate because the three surfaces have genuinely different jobs, and a single tour that walked someone through all of them would be a tour nobody finishes.
TipTap, extended where a news site needs it. The starter kit gives you formatting; it doesn't give you the two things this team actually asked the page to do. So there are custom extensions for button-links — a call-to-action rendered as a button inside article flow rather than a bare hyperlink — and for video, wired to the transcoding upload path above. Images and YouTube embeds come from official extensions, with sharp handling image processing on upload.
The editor's output is rendered on the server. There's a tiptap-server-viewer alongside the client viewer, so article content is HTML in the initial response rather than something a reader waits for JavaScript to assemble. For a news site whose traffic arrives from search and social, that's the difference between an article that's indexable and fast and one that's neither.
Deep dive: advertising that doesn't wreck the read
AdSense is the site's revenue model. That makes ad placement a design problem rather than an afterthought: the incentive is to cram inventory into every gap, and the cost of doing so is that people stop reading.
The placement system is built as three named components with distinct jobs rather than one generic ad slot dropped wherever there's room:
InArticleAd— fluid format with AdSense'sin-articlelayout, placed at intervals between content blocks, so an ad interrupts at a paragraph boundary rather than mid-thought.DisplayAd— a responsive block for the ends of content, where a reader has already finished the thing they came for.SidebarAd— vertical,sticky top-4, constrained to a sensible max height. It persists beside the article on desktop, which is the one placement that earns continuous visibility without ever pushing the text around.
The component also handles a problem that's specific to ads in a React app: it waits for the container to actually have width before initialising. AdSense measures its slot on init, and a slot measured at zero width during hydration renders nothing for the rest of the page's life. So the component mounts, checks getBoundingClientRect().width, and retries on a short interval until the layout has settled — and renders a fixed-height placeholder during SSR so the ad's arrival doesn't shift the text a reader is already looking at.
Current state, stated plainly: the AdSense publisher script is live in the root layout, but the individual ad units are commented out and return null, and the slot IDs in the article page are still placeholders. The placement system is built and positioned; the units are not yet serving.
Smaller decisions worth naming
Creator link pages. Every creator has a public page at /links/{short_id} listing articles they've selected — a link-in-bio destination they can put on social, generated by the platform rather than maintained on a third-party service.
Short IDs, not UUIDs, in URLs. Articles and profiles carry a short_id used for public routing, so a shared link is readable and compact rather than a 36-character identifier.
Referral tracking on article visits. The article route reads a ref search param and records the visit, which is how the client can tell whether a creator's link page is doing anything.
A separate mobile and desktop homepage. home-page-mobile.tsx and home-page-desktop.tsx are distinct components rather than one layout with breakpoints, because the two arrangements of a news homepage differ structurally — not just in column count.
Results
Live in production and running a real publication. Editors and creators write, schedule and publish through the CMS; readers get server-rendered Arabic articles with the full SEO surface — sitemap, RSS, structured data — and creators have platform-generated link pages to point an audience at.
The build came in under a hard cost ceiling, and the places where that shows are documented above rather than papered over: publishing is swept rather than scheduled, and video is transcoded down to fit a free tier.
Built and shipped solo.