All content was scraped from the live WordPress site (my own site, no copyright issues), transformed into structured JSON, and rendered as statically generated pages with no database or CMS dependency.
The project uses the browser-native View Transitions API via React's <ViewTransition> component — already bundled inside Next.js and enabled with a single flag in next.config.ts, no third-party library needed — in two distinct places: smooth directional slides and a shared title morph between pages, and per-item fade animations driven by the homepage search box.
- Tech Stack
- Project Structure
- Key Features & How They Were Built
- Tweaks & Bug Fixes
- Getting Started
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2.4 (App Router, Turbopack, Cache Components) |
| Language | TypeScript 5 (strict) |
| Styling | Tailwind CSS v4 + @tailwindcss/typography |
| Animations | React View Transitions (native browser API) |
| HTML parsing | node-html-parser |
| Package manager | pnpm |
| Hosting target | Vercel / any static host |
content/articles/ # 69 article JSON files (source of truth)
public/images/ # locally served hero images
public/uploads/ # locally served inline article images
public/equations/ # locally served QuickLaTeX equation PNGs
scripts/
scrape.mjs # scrapes WordPress and produces JSON
download-hero-images.mjs # downloads og:image for each article
download-inline-images.mjs # downloads wp-content/uploads inline images
download-equations.mjs # downloads QuickLaTeX equation PNGs
app/
page.tsx # homepage — article list with search
[slug]/page.tsx # dynamic article route
components/
ArticleList.tsx # client component — live title search with animated list
Header.tsx # shared nav with theme toggle
ThemeToggle.tsx # sun/moon button, persists to localStorage
ScrollToTop.tsx # fixed scroll-to-top button
DirectionalTransition.tsx # ViewTransition wrapper for page-level slide animations
GoBack.tsx # back-navigation link with nav-back transition type
lib/
articles.ts # data layer — loads JSON, processes HTML
scripts/scrape.mjs fetches the WordPress REST API (/wp-json/wp/v2/posts) to pull all 69 articles as structured JSON. Each post is saved as content/articles/{slug}.json with fields: slug, title, date, category, excerpt, and contentHtml (the raw WordPress HTML, which contains Gutenberg/Quill markup, inline styles, and <img> tags).
lib/articles.ts reads all JSON files at build time. The [slug]/page.tsx route uses generateStaticParams to emit one static HTML file per article — no runtime server needed.
Before the HTML is passed to the page, processArticleHtml() post-processes it with node-html-parser:
- Injects
idattributes on every heading for anchor links - Rewrites
hrefvalues on in-article TOC links to#heading-idfragments - Strips inline
styleattributes from equation container elements (WordPress injectedline-height: 69pxwhich created giant invisible whitespace that CSS!importantcan't override)
Two categories of images appear in articles, and they required completely different techniques.
Hero images (scripts/download-hero-images.mjs)
Hero images are referenced in the og:image meta tag on each article page. The og:image URL is intentionally public — it has to be, because social-media crawlers (Facebook, Slack, Twitter) fetch it with no Referer header at all when generating link previews. Blocking it would break every social share. So hotlink protection simply does not apply here.
The script exploits this: it fetches each article's HTML, extracts the og:image URL, and downloads the image with a plain fetch() — no header tricks needed. The image is saved to public/images/{slug}.ext and the heroImage field is written into the article JSON.
Inline content images (scripts/download-inline-images.mjs)
These are a different story. WordPress embeds <img src="https://techtrenduje.pl/wp-content/uploads/..."> tags directly inside the article HTML. These images are protected: when a browser loads an article it sends Referer: https://techtrenduje.pl/article-slug with each image request, and the server allows those. A request with a foreign or missing Referer gets a 403.
Hotlink protection is Referer-based and nothing more — the header is just a plain string, there is no cryptographic signature or session token involved. A Node.js script can send any Referer it likes:
fetch(imageUrl, {
headers: { Referer: "https://techtrenduje.pl/" },
});From the server's perspective this looks identical to a browser loading the image while reading the article. The script scans every JSON file for wp-content/uploads URLs with a regex, downloads each one to public/uploads/{filename} using the spoofed header, and rewrites the src attribute in-place — so no origin requests ever happen in the browser.
Some articles contain mathematical equations rendered by the QuickLaTeX WordPress plugin, which generates PNGs hosted on ql-cache.quicklatex.com. scripts/download-equations.mjs finds these URLs in contentHtml, downloads the PNGs to public/equations/, and rewrites the src values. In dark mode, a CSS rule inverts the white-background PNGs so they remain readable:
.dark img[src*="/equations/"] {
filter: invert(1);
}Dark mode is class-based (.dark on <html>) rather than media-query-based, so it can be toggled independently of the OS setting and persisted to localStorage. The challenge with class-based dark mode in Next.js is the flash of the wrong theme on first load (before React hydrates).
The fix is a tiny inline <script> injected directly into <head> in layout.tsx via dangerouslySetInnerHTML. It runs synchronously before the first paint, reads localStorage, and adds the .dark class immediately — so the browser never renders the wrong theme:
(function () {
try {
var t = localStorage.getItem("theme");
if (
t === "dark" ||
(t === null && window.matchMedia("(prefers-color-scheme:dark)").matches)
) {
document.documentElement.classList.add("dark");
}
} catch (e) {}
})();suppressHydrationWarning on <html> prevents React from complaining about the class mismatch between server-rendered HTML and the client-patched DOM.
The homepage is a React Server Component that fetches all articles at build time and passes them as a prop to <ArticleList> — a "use client" component. This keeps the data fetch on the server while enabling interactive filtering without any API route.
The search input filters the article list in real time by case-insensitive substring match against the title. The list numbering resets to reflect the filtered results, and a localised "no results" message appears when nothing matches.
A fixed <ScrollToTop> client component listens to the scroll event and appears after the user scrolls 300 px down. Clicking it calls window.scrollTo({ top: 0, behavior: 'smooth' }). Styled for both light and dark modes.
The component is rendered on both the home page and article pages. On the home page there is no table of contents, so the button always scrolls straight to the top of the page. On article pages a WordPress-generated TOC (TocToggle) may be present — previously the button tried to scroll to the TOC element first, but this was removed since the TOC is already visible near the top of the article and users expect the button to return them to the very top of the page in both contexts.
View transitions are one of the most visible features of the app. The browser-native View Transitions API is used in two completely separate contexts — page navigation and the homepage search box — and each required a different approach. Both are driven by React's <ViewTransition> component, available in Next.js App Router without any extra install (the framework ships React canary internally). It is enabled in next.config.ts with experimental: { viewTransition: true }.
All keyframes and timing variables (--duration-exit, --duration-enter, --duration-move) live in one block in app/globals.css. A @media (prefers-reduced-motion: reduce) rule zeros all durations for users who have opted out of motion.
Every page component is wrapped in <DirectionalTransition> (components/DirectionalTransition.tsx), a type-keyed <ViewTransition> that maps nav-forward and nav-back transition types to vertical slide animations. Navigation <Link> elements tag the intended direction via transitionTypes={[...]}, so navigating to an article slides the new page up while returning home slides it back down. The persistent <Header> is isolated with viewTransitionName: "site-header" so it stays visually frozen while page content animates beneath it.
On top of the slide, each article title in the list and the <h1> on the detail page share a named <ViewTransition name="article-title-{slug}" share="text-morph"> boundary. When you click a title it morphs in place from the small list link directly into the large page heading — no cross-fade, no jump. The text-morph CSS class hides the outgoing snapshot and renders the incoming text at full resolution, which avoids the blurry scaling artifact that standard image-pair morphing produces when text changes size dramatically (list item → text-4xl heading).
The homepage search box uses view transitions in a completely different way: not between pages, but to animate individual list items entering and leaving the DOM as you type.
Each <li> in ArticleList.tsx is wrapped in <ViewTransition key={slug} enter="fade-in" exit="fade-out">. Crucially, the search state update is wrapped in startTransition(() => setQuery(...)) — without that, React re-renders synchronously and the browser never gets the chance to snapshot the old state for the transition. With it, React defers the re-render just long enough for document.startViewTransition to capture before and after snapshots, so items that disappear fade out while new items fade in. The "no results" empty state uses the same mechanism with its own enter="fade-in" exit="fade-out" boundary.
next.config.ts has cacheComponents: true, which is the Next.js 16 way to enable Partial Prerendering (PPR). This replaces the old experimental.ppr flag.
With PPR enabled, a route can mix static shell (rendered at build time) and dynamic/cached subtrees (streamed in). The project currently serves all routes as fully static — every page is prerendered at build time from the JSON content files. The flag is in place so that 'use cache' directives can be added to individual async components or data functions in the future without any config change, enabling component-level caching with custom cacheLife profiles and cacheTag-based invalidation.
<ScrollToTop> is mounted on both the home page and article pages. An earlier version checked for the presence of .uagb-toc__wrap (the WordPress TOC element) and scrolled to it instead of the page top if found. This caused the button to silently do nothing on the home page when the TOC was absent but the DOM query still ran. The fix was to remove the detour entirely — the button now always calls window.scrollTo({ top: 0, behavior: 'smooth' }) on both pages. The TOC is already visible near the top of the article anyway, so there was no user benefit to that behaviour.
The persistent <Header> is isolated from page-slide animations with viewTransitionName: "site-header". Setting animation: none on ::view-transition-group(site-header) keeps the container from moving, but the browser was still running the default crossfade on the image pair — because the header content differs between pages (<h1> on the home page, <Link> on article pages). This caused a brief flash as the old snapshot faded out and the new one faded in.
Fix: add animation: none to both ::view-transition-old(site-header) and ::view-transition-new(site-header) in globals.css. This suppresses the crossfade entirely so the header stays visually frozen for the full duration of the page transition.
The shared title transition between the homepage list and the article <h1> was briefly flashing at the top of the article page before settling into place. The cause was that the article title was participating in two transitions at once: the named shared-element morph (article-title-{slug}) and the page-level directional slide wrapper.
Fix: move the detail-page title outside the <DirectionalTransition> boundary in [slug]/page.tsx, and keep the directional slide only around the metadata, hero image, and article body. That leaves the title owned by a single shared-element transition, which removes the split-second duplicate/flash during navigation.
When navigating back from an article to the homepage, the title could briefly flash as it landed back into the list. The cause was similar to the article-page flash: the incoming homepage was still wrapped in a page-level directional transition, so the returning title was being animated both as a shared element and as part of the home page itself.
Fix: remove the top-level <DirectionalTransition> from the home page in app/page.tsx. That leaves the shared title morph as the only owner of the title when returning to the list.
After the flash issues were fixed, the shared title morph could still look wrong at the moment it landed: instead of flashing, the text sometimes appeared doubled or tripled for a split second during arrival or return.
The fix was to stop morphing between different outer elements (<Link> on the list and <h1> on the article) and instead give the shared transition the same inner text shape on both sides. The named <ViewTransition> now wraps a matching inner <span> in both ArticleList.tsx and [slug]/page.tsx, and the title share uses its own slower title-share timing in globals.css.
That combination removed the duplicate text artifact while keeping the improved no-flash behavior from the earlier fixes.
# Install dependencies
pnpm install
# Development server
pnpm dev
# Production build
pnpm build
pnpm startTo re-scrape content or refresh images from the origin:
node scripts/scrape.mjs
node scripts/download-hero-images.mjs
node scripts/download-inline-images.mjs
node scripts/download-equations.mjs