{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Bora Ocaker — Notes from the workbench",
  "home_page_url": "https://boraocaker.com/en/blog",
  "feed_url": "https://boraocaker.com/feed.json",
  "description": "Short technical notes about React, performance, GSAP and the messy reality of shipping web work.",
  "language": "en-US",
  "authors": [
    {
      "name": "Bora Ocaker",
      "url": "https://boraocaker.com"
    }
  ],
  "items": [
    {
      "id": "https://boraocaker.com/en/blog/og-images-with-satori",
      "url": "https://boraocaker.com/en/blog/og-images-with-satori",
      "title": "Generating OG images at build time with Satori",
      "summary": "Every post on this site gets its own 1200×630 social card, rendered from JSX to PNG at build time with Satori and resvg. No headless Chrome, no screenshot service, no runtime cost.",
      "content_text": "When you paste a link into LinkedIn or WhatsApp, the preview card is the first — often only — impression. A site that ships one static `og-image.jpg` for every page is leaving that impression on the table.\n\nI wanted a unique card per blog post, with the title, date, and reading time baked in. The options were: a screenshot service (slow, costs money, another dependency to babysit), headless Chrome in CI (heavy, flaky), or render the image myself. I picked the last one.\n\n## The pipeline\n\nThe whole thing is one Node script that runs before the build:\n\n```\nfrontmatter parse → satori(JSX-as-object) → SVG string → resvg → PNG → disk\n```\n\n[Satori](https://github.com/vercel/satori) takes a tree of elements with a flexbox subset of CSS and gives you back an SVG. [`@resvg/resvg-js`](https://github.com/yisibl/resvg-js) rasterizes that SVG to a PNG buffer. Neither needs a browser.\n\n## JSX without React\n\nSatori accepts JSX, but I didn't want to pull React into a build script. It turns out Satori only needs a plain object tree — `{ type, props: { children } }` — so a four-line helper is enough:\n\n```js\nfunction el(type, props, ...children) {\n  return { type, props: { ...props, children: children.length === 1 ? children[0] : children } };\n}\n```\n\nNow the card template is just nested `el(...)` calls. Zero framework dependencies — the script runs on a bare Node install.\n\n## The font gotcha\n\nSatori needs the font as raw bytes — an `ArrayBuffer`, not a `woff2` URL. The trick is hitting the Google Fonts CSS endpoint with a desktop User-Agent so it serves you a `.ttf`:\n\n```js\nconst css = await fetch(\n  `https://fonts.googleapis.com/css2?family=${family}:wght@${weight}&display=swap`,\n  { headers: { \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...\" } }\n).then((r) => r.text());\nconst ttfUrl = css.match(/url\\((https:\\/\\/[^)]+\\.ttf)\\)/)[1];\n```\n\nI cache the downloaded TTF to `scripts/.fonts/` so subsequent builds skip the network entirely. First build pays the round-trip once.\n\n> [!warn] Satori only supports a flexbox subset\n> No `grid`, no `position: absolute` in older versions, every element needs an explicit `display: flex`. If a card renders blank, it's almost always a missing `display`.\n\n## Making a feed read as a series\n\nThe detail I'm happiest with: each post gets a signature accent colour, assigned by **issue number** — oldest post is issue 01, and the number never changes. Six harmonised accents on the same dark ink base. A column of these cards on someone's LinkedIn feed reads as a series, not six random recolours.\n\nThe issue numbering has to be deterministic, so I sort by date ascending and index from there. The exact same palette lives in `src/utils/blog-palette.ts` and drives the on-site post list too — one source of truth for both.\n\n## What it costs\n\nNothing at runtime. The PNGs land in `public/og/blog/<lang>/<slug>.png`, Vite copies them into `dist/`, and they're served as static files. Adding a post regenerates one image in a couple hundred milliseconds. No service to pay for, nothing to break in production.\n\nIf you're on a framework with a built-in OG route (Next's `ImageResponse` is literally Satori under the hood), use that. If you're on a hand-rolled Vite SPA like I am, 120 lines of build script gets you the same result.\n",
      "date_published": "2026-06-01T00:00:00.000Z",
      "date_modified": "2026-06-01T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/og-images-with-satori.png",
      "tags": [
        "og",
        "satori",
        "build",
        "seo"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/prerendering-a-vite-spa",
      "url": "https://boraocaker.com/en/blog/prerendering-a-vite-spa",
      "title": "Prerendering a Vite SPA for SEO without going SSR",
      "summary": "My site is a client-rendered React SPA. To a crawler that's a near-empty body. Here's the 200-line post-build script that writes real HTML per route — without adopting Next or a server.",
      "content_text": "A Vite SPA ships one `index.html` and a JavaScript bundle. The router lives in the client. Open the page in a browser and React fills the `#root` div — but the *file on disk* has an empty body. Google can run JS now, but it's slower, racier, and you're betting your indexing on the crawler being patient.\n\nI didn't want to migrate the whole thing to Next just to get HTML in front of crawlers. So I wrote a post-build step that prerenders every route to a static file.\n\n## The shape of it\n\nAfter `vite build`, I have `dist/index.html` — the fully-built shell, with fonts, favicons, analytics, and the base meta tags already in place. I treat that as a **template** and, for every route the app knows about, write a real `dist/<path>/index.html` with route-specific content swapped in.\n\nCloudflare Pages serves `/<path>/index.html` when a request comes in for `/<path>`. So the prerendered file gets served first; the SPA catch-all redirect is only hit when no per-route file exists. Free per-route HTML, no server.\n\n## What gets swapped per route\n\nEach route overrides only what varies:\n\n- `<html lang>` — `en` or `tr`\n- `<title>` and `<meta name=\"description\">`\n- `<link rel=\"canonical\">` + hreflang alternates\n- Open Graph / Twitter title, description, and (for posts) image\n- Route-specific JSON-LD: `BlogPosting`, `Service`, `LocalBusiness`\n\nThe swaps are plain string `.replace()` against the shell. No DOM parser — regex on a known-shape template is faster and has zero dependencies:\n\n```js\nhtml = html.replace(/<title>[^<]*<\\/title>/, `<title>${escapeHtml(title)}</title>`);\n```\n\n## The part that actually matters for blog posts\n\nMeta tags help, but for an *article* the crawler wants the body. So for each post I render the markdown to HTML with [`marked`](https://marked.js.org/) and inject it straight into `#root`:\n\n```js\nhtml = html.replace('<div id=\"root\"></div>', `<div id=\"root\">${bodyContent}</div>`);\n```\n\nNow the file on disk contains the full article text. Here's the trick that makes it safe: **React wipes and re-renders on hydration.** The user mounts the SPA, React clears `#root`, and they get the exact same client app they always did. The prerendered HTML exists purely for the crawler that never runs the JS.\n\n> [!note] This is \"prerender\", not SSR\n> There's no server rendering React. I render markdown to HTML with a different library at build time, purely as crawler bait. The two never need to match byte-for-byte because the user never sees the prerendered version.\n\n## Routes, enumerated\n\nThe script loops over a known route list — home, about, projects, blog list, every blog slug × 2 languages, services, and the Turkish Antalya landing page. Because the routes are an enum, not a filesystem convention, I just iterate them explicitly. ~20 files written in well under a second.\n\n## What I'd watch out for\n\n- **Keep the regexes anchored to the shell's exact markup.** If you change the meta tag format in `index.html`, the replace silently no-ops. I'd add an assertion that each replace actually changed the string.\n- **The hydration wipe means a flash is possible** if your JS is slow — the crawler HTML shows, then React blanks it, then re-renders. On a fast bundle it's imperceptible; on a heavy one, prerender the *real* layout, not a simplified one.\n\nFor a portfolio with a dozen routes, this beats dragging in a meta-framework. For fifty routes that change hourly, you want real SSR or ISR. Match the tool to the blast radius.\n",
      "date_published": "2026-05-31T00:00:00.000Z",
      "date_modified": "2026-05-31T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/prerendering-a-vite-spa.png",
      "tags": [
        "seo",
        "vite",
        "prerender"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/markdown-blog-with-vite-glob",
      "url": "https://boraocaker.com/en/blog/markdown-blog-with-vite-glob",
      "title": "A markdown blog with no CMS — just import.meta.glob",
      "summary": "This blog has no database, no headless CMS, no content API. Posts are markdown files in a folder, pulled into the bundle at build time by one Vite feature. Here's the whole setup.",
      "content_text": "I write posts in my editor, commit them, and they're live. No CMS login, no content API to rate-limit me, nothing to migrate when the SaaS pivots. The posts are just markdown files:\n\n```\nsrc/content/blog/en/<slug>.md\nsrc/content/blog/tr/<slug>.md\n```\n\nThe entire loader is one Vite feature plus a tiny frontmatter parser.\n\n## import.meta.glob does the heavy lifting\n\nVite can eagerly import every file matching a glob, at build time, as a record of `path → contents`:\n\n```ts\nconst EN_FILES = import.meta.glob(\n  \"../content/blog/en/*.md\",\n  { eager: true, query: \"?raw\", import: \"default\" }\n) as Record<string, string>;\n```\n\n`eager: true` means it's inlined into the bundle — no runtime `fetch`, no waterfall. `query: \"?raw\"` gives me the raw string instead of a transformed module. The path key (`../content/blog/en/hello.md`) is where I derive the slug and language.\n\n## Frontmatter without a dependency\n\nI didn't want `gray-matter` in the client bundle for what is, realistically, four field types. So I parse a YAML subset by hand — string, ISO date, boolean, and inline arrays:\n\n```ts\nif (trimmed === \"true\" || trimmed === \"false\") fm[key] = trimmed === \"true\";\nelse if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\"))\n  fm[key] = trimmed.slice(1, -1).split(\",\").map((s) => s.trim()).filter(Boolean);\nelse fm[key] = stripQuotes(trimmed);\n```\n\nThat's the whole parser. It handles `tags: [react, vite]`, `draft: true`, and quoted strings. If I ever need nested YAML I'll regret this — but I won't, because frontmatter shouldn't be nested.\n\n## Things you get almost for free\n\nOnce every post is an object in memory, the \"features\" are just array methods:\n\n- **Reading time** — `Math.ceil(words / 200)`, floored at 1 minute.\n- **Sorting** — newest first, honouring an optional `updated` field over `date` so a meaningfully edited post bubbles back up.\n- **Related posts** — score by shared tags (×2 each) and shared series (×5), sort, slice. ~15 lines.\n- **Prev / next** — find the slug's index in the sorted list, return neighbours.\n\nNo queries, no joins. It's all `Array.prototype`.\n\n## The translation fallback\n\nThe one bit of real logic: a Turkish post that hasn't been written yet should fall back to English, so the TR blog index is never half-empty.\n\n```ts\nconst trBySlug = new Map(TR_POSTS.map((p) => [p.slug, p]));\nconst merged = EN_POSTS.map((p) => trBySlug.get(p.slug) ?? p);\n```\n\nWrite the TR file when you have time; until then the EN version shows with a small \"not translated yet\" note. Partial translation is a valid state.\n\n> [!tip] Drafts in dev, hidden in prod\n> A `draft: true` frontmatter flag, gated on `import.meta.env.PROD`, lets me iterate on unpublished posts locally while keeping them out of the live build and the feeds.\n\n## When this breaks down\n\nThis is great up to maybe a few hundred posts — past that, `eager: true` inlining everything into the bundle starts to hurt, and you'd want lazy globs or a real content layer. For a personal blog measured in dozens of posts, the simplest thing that could possibly work *is* the right thing. No CMS is a feature.\n",
      "date_published": "2026-05-30T00:00:00.000Z",
      "date_modified": "2026-05-30T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/markdown-blog-with-vite-glob.png",
      "tags": [
        "vite",
        "markdown",
        "react"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/trimming-syntax-highlighter",
      "url": "https://boraocaker.com/en/blog/trimming-syntax-highlighter",
      "title": "The 700 KB I never shipped — trimming a syntax highlighter",
      "summary": "react-syntax-highlighter will happily bundle every Prism grammar it ships with. That's hundreds of kilobytes of languages I'll never use. Here's how I registered only the handful this blog needs.",
      "content_text": "Code blocks need highlighting. The obvious move is `react-syntax-highlighter`, import the `Prism` component, done. Except the default `Prism` import drags in *every* language grammar Prism ships — Verilog, Prolog, COBOL, the lot. That's the better part of 700 KB of grammars for a blog that writes TypeScript, a little CSS, and the occasional shell command.\n\n## Use the Light build, register by hand\n\nThe library ships a `PrismLight` (and `Light`) build that registers **nothing** by default. You opt into exactly the languages you use:\n\n```ts\nimport { PrismLight as SyntaxHighlighter } from \"react-syntax-highlighter\";\nimport tsx from \"react-syntax-highlighter/dist/esm/languages/prism/tsx\";\nimport bash from \"react-syntax-highlighter/dist/esm/languages/prism/bash\";\nimport css from \"react-syntax-highlighter/dist/esm/languages/prism/css\";\n\nSyntaxHighlighter.registerLanguage(\"tsx\", tsx);\nSyntaxHighlighter.registerLanguage(\"bash\", bash);\nSyntaxHighlighter.registerLanguage(\"css\", css);\n```\n\nEach grammar is a ~3–5 KB module. I register about a dozen — including the aliases (`sh` and `shell` both point at `bash`, `ts` and `typescript` at the same grammar) so fenced blocks tagged either way Just Work. Total cost: a few tens of KB instead of a few hundred.\n\n## Why aliases matter\n\nMarkdown authors are inconsistent — sometimes I write ` ```ts `, sometimes ` ```typescript `. Rather than normalise in the parser, I register both names against the same imported grammar:\n\n```ts\nSyntaxHighlighter.registerLanguage(\"ts\", typescript);\nSyntaxHighlighter.registerLanguage(\"typescript\", typescript);\nSyntaxHighlighter.registerLanguage(\"js\", javascript);\nSyntaxHighlighter.registerLanguage(\"javascript\", javascript);\n```\n\nCosts nothing extra — it's the same module reference — and saves me from \"why isn't this block highlighted\" later.\n\n## The theme is the other half\n\nA theme (I use `one-dark`) is just an object. Importing it from the `esm` path keeps it tree-shakeable:\n\n```ts\nimport oneDark from \"react-syntax-highlighter/dist/esm/styles/prism/one-dark\";\n```\n\nEverything from `dist/esm/...` so the bundler can actually shake what I don't reference. The CJS paths defeat that.\n\n> [!note] Measure, don't guess\n> I only caught the bloat because the bundle analyzer showed a single chunk ballooning. The default `Prism` import gives no warning — it just quietly ships a dictionary's worth of languages. `PrismLight` makes the cost opt-in and visible.\n\n## The wider lesson\n\nThis is the same lesson as lazy-loading a carousel or deferring a contact-form script: **the cheapest kilobyte is the one you never send.** A convenience import that bundles \"everything just in case\" is a tax every visitor pays for a feature almost none of them use. Read what your dependencies pull in. The Light/selective-registration pattern shows up all over the ecosystem — `lodash-es`, icon libraries, date locales — and it's almost always worth the three extra lines.\n",
      "date_published": "2026-05-29T00:00:00.000Z",
      "date_modified": "2026-05-29T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/trimming-syntax-highlighter.png",
      "tags": [
        "performance",
        "bundle",
        "react"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/hand-rolling-rss-and-json-feeds",
      "url": "https://boraocaker.com/en/blog/hand-rolling-rss-and-json-feeds",
      "title": "Hand-rolling RSS and JSON feeds at build time",
      "summary": "RSS isn't dead, it's just quiet. Adding a feed to this blog took one build script, no library, and about an hour of reading two specs. Here's what I learned writing RSS 2.0 and JSON Feed by hand.",
      "content_text": "Every blog should have a feed. RSS readers still exist, people genuinely use them, and a feed is the polite way to let someone follow you without an algorithm in the middle. The cost is almost nothing: one script that reads my markdown posts and writes two files.\n\nI write both — `rss.xml` (RSS 2.0, what most readers expect) and `feed.json` ([JSON Feed 1.1](https://www.jsonfeed.org/), nicer to generate and parse). Per language: `dist/rss.xml`, `dist/feed.json`, `dist/tr/rss.xml`, `dist/tr/feed.json`.\n\n## RSS 2.0 is just XML you concatenate\n\nThere's no magic. An item is a string template:\n\n```js\nconst items = posts.map((p) => `    <item>\n  <title>${escapeXml(p.fm.title)}</title>\n  <link>${url}</link>\n  <guid isPermaLink=\"true\">${url}</guid>\n  <pubDate>${rfc822(p.fm.date)}</pubDate>\n  <description>${escapeXml(p.fm.description)}</description>\n  <enclosure url=\"${ogImage}\" type=\"image/png\" />\n  ${(p.fm.tags || []).map((t) => `<category>${escapeXml(t)}</category>`).join(\"\\n\")}\n</item>`).join(\"\\n\");\n```\n\nTwo things bite you if you skip the spec:\n\n1. **`pubDate` must be RFC 822**, not ISO 8601. Luckily JavaScript's `Date.prototype.toUTCString()` produces exactly the right format, so the helper is a one-liner.\n2. **Escape everything.** A stray `&` or `<` in a title makes the whole feed invalid XML — readers reject it wholesale, not gracefully. A five-replace `escapeXml` covers `& < > \" '`.\n\nI also attach the post's OG image as an `<enclosure>` so readers that show thumbnails get the same card the social previews use.\n\n## JSON Feed is the easy one\n\nIf you've ever built an API response, you already know JSON Feed. It's an object you `JSON.stringify`:\n\n```js\n{\n  version: \"https://jsonfeed.org/version/1.1\",\n  title, home_page_url, feed_url, description,\n  authors: [{ name, url }],\n  items: posts.map((p) => ({\n    id: url, url, title: p.fm.title,\n    summary: p.fm.description,\n    content_text: p.body,\n    date_published: new Date(p.fm.date).toISOString(),\n    tags: p.fm.tags || [],\n  })),\n}\n```\n\nNo escaping to think about, dates are plain ISO, and `content_text` lets me ship the full post body for readers that want it. If I were only doing one feed, it'd be this one — but RSS still has the wider reader support, so I do both.\n\n## Sorting and drafts\n\nSame rules as the on-site list, because it reads the same files: newest first, `updated` beats `date`, and `draft: true` posts are filtered out so an unfinished draft never leaks into someone's reader.\n\n```js\n.filter((p) => !p.fm.draft)\n.sort((a, b) => ((a.fm.updated || a.fm.date) < (b.fm.updated || b.fm.date) ? 1 : -1));\n```\n\n> [!tip] Point your `<head>` at the feed\n> A feed nobody can discover is useless. Add `<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/rss.xml\">` to your head so browser extensions and readers auto-detect it.\n\n## Why build-time, not a library\n\nI could have pulled in a feed-generator package. But the whole script is ~160 lines, has zero dependencies, and runs in the same post-build step as the prerender and OG generation. When the inputs are local markdown and the output is a static file, a library is just a layer of indirection between me and two specs I can read in an afternoon. Fewer dependencies, fewer surprises on the next `npm audit`.\n",
      "date_published": "2026-05-28T00:00:00.000Z",
      "date_modified": "2026-05-28T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/hand-rolling-rss-and-json-feeds.png",
      "tags": [
        "rss",
        "build",
        "seo"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/react-i18n-without-routing",
      "url": "https://boraocaker.com/en/blog/react-i18n-without-routing",
      "title": "Adding i18n to a React site without breaking the router",
      "summary": "How I added Turkish to my portfolio in an afternoon — react-i18next, a fixed top-left toggle, and zero new dependencies on the router.",
      "content_text": "A portfolio without language support is fine — until you start sending the link to a Turkish founder and realize they bounce off \"Marketing site.\" in 8 seconds.\n\nThis is the path I took to add Turkish, with the constraints I refused to break.\n\n## The constraints\n\n1. **No new router.** The site uses a hand-rolled enum-based router (`\"home\" | \"projects\" | \"about\"`). I wasn't going to drag in React Router just for `i18n`.\n2. **No SEO regression.** `<html lang>`, hreflang, and the rendered text all had to stay in sync.\n3. **No flash of English** when a Turkish visitor lands. Detection on first load, cached for the next visit.\n\n## The stack I picked\n\n- **`react-i18next`** — battle-tested, tiny runtime once you exclude the loader bundles.\n- **`i18next-browser-languagedetector`** — reads `localStorage` first, then `navigator.language`.\n- **Two JSON files**, one per language, organized by section (`hero`, `services`, `process`, …).\n\nThat's it. No CMS, no extraction script. The build pulls both JSON files into the bundle (~6 KB gzipped combined).\n\n## The toggle nobody can miss but everyone can ignore\n\nI started with a top-right toggle. Bad call: on the Projects page there was already a top-right meta badge (\"Infinity canvas · 34 projects · ∞\"), and on About there was \"About · Bora Ocaker.\" They collided on mobile.\n\nMoved it top-left, offset 72px down to clear the back-button row. Now it's a quiet `EN / TR` pill in monospace — the kind of UI element you only see once you're looking for it.\n\n## What I learned\n\n**`returnObjects: true` is the trick.** For arrays of strings (the FAQ list, the toolkit pills, the \"facts\" section), don't write one key per item. Write the array as JSON and pull it out as a typed list:\n\n```ts\nconst facts = t(\"about.panel4.facts\", { returnObjects: true }) as string[];\n```\n\n**Don't translate everything.** Reference site titles (\"Linear\", \"Vercel\", \"Stripe\") stay in English. Brand names stay in English. The hero's personal name stays in English. Translate the *prose*, leave the *proper nouns* alone.\n\n**Test the longest string.** \"Front-End Specialist\" is 21 chars. \"Front-End Uzmanı\" is 16 chars. Lucky. But \"Have an idea? Let's build it.\" becomes \"Bir fikrin mi var? Hadi inşa edelim.\" — 36 chars vs 28. If your hero uses `whiteSpace: nowrap`, this *will* break on a small phone. Audit every `nowrap` you have.\n\n## The part I'd do differently\n\nI shipped with **localStorage-only** language preference, not URL-based (`/tr/`, `/en/`). A shared link doesn't carry the language. For a portfolio it's fine. For a marketing site you actually want indexed in two languages, do URL routing from day one.\n\nIf you're starting fresh, this is the order I'd recommend:\n\n1. Set up `react-i18next` with two empty JSON files.\n2. Add the toggle and confirm it switches on a single test string.\n3. *Only then* start moving real strings into the JSONs, section by section.\n\nResist the urge to wrap every string at once. You will get tired, miss strings, and the diff will be unreviewable.\n",
      "date_published": "2026-05-26T00:00:00.000Z",
      "date_modified": "2026-05-26T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/react-i18n-without-routing.png",
      "tags": [
        "react",
        "i18n",
        "vite"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/hero-lcp-fetchpriority",
      "url": "https://boraocaker.com/en/blog/hero-lcp-fetchpriority",
      "title": "One attribute that cut my hero LCP in half",
      "summary": "A background-image div is invisible to the preload scanner. An <img> with fetchpriority=\"high\" isn't. Here's what changed when I made the swap.",
      "content_text": "My Cloudflare Analytics had been telling me a quiet story for weeks:\n\n- **LCP P75: 2,200 ms** — borderline \"Needs Improvement\"\n- **LCP P90: 4,604 ms** — somebody on a slow connection was waiting almost five seconds for the hero\n\nThe hero is one image. An 87 KB WebP. There is no reason it should take that long.\n\n## The mistake\n\nLike a lot of designers-turned-developers, I had been writing this for years:\n\n```tsx\n<div style={{ backgroundImage: `url(${heroBg})`, backgroundSize: \"cover\" }} />\n```\n\nLooks fine. Performs fine on my machine. Quietly miserable on a real phone.\n\n## Why a `<div>` background is slow\n\nThe browser has a **preload scanner** that runs before HTML parsing finishes. It walks the raw HTML looking for assets it can start fetching immediately — images, fonts, scripts.\n\nThe preload scanner can see:\n- `<img src=\"...\">`\n- `<link rel=\"preload\" as=\"image\">`\n- `<source srcset=\"...\">`\n\nThe preload scanner **cannot** see:\n- CSS `background-image: url(...)` — it has to parse CSS first\n- Inline-style `backgroundImage` — same problem, deferred\n\nSo my hero image waited for: HTML parse → React mount → component render → style application → *now* fetch the image. On a 3G connection that chain is brutal.\n\n## The fix\n\n```tsx\n<div style={{ position: \"absolute\", inset: 0 }}>\n  <img\n    src={heroBg}\n    alt=\"\"\n    aria-hidden=\"true\"\n    fetchpriority=\"high\"\n    decoding=\"async\"\n    style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n  />\n</div>\n```\n\nThree things happening:\n\n1. **`<img>` is visible to the preload scanner.** Fetch starts within the first 50ms of the response.\n2. **`fetchpriority=\"high\"`** tells the browser this is the LCP candidate — push it ahead of below-fold images and non-critical scripts.\n3. **`decoding=\"async\"`** keeps decode off the main thread once bytes arrive.\n\n`alt=\"\"` + `aria-hidden=\"true\"` keeps it semantically a background — screen readers ignore it, exactly as they would a CSS background.\n\n## What it cost\n\nZero. No library, no build step, no React refactor. One JSX swap.\n\n## What I'm watching for\n\nCloudflare Web Analytics aggregates over 24 hours, so I'll know the real-world delta tomorrow. My local Lighthouse went from LCP 1.4s → 0.9s. The interesting number is the **P90** — that's where the change matters most, because it tells you what your worst real users feel.\n\n## The broader lesson\n\nTreat **background-image as a design tool, not a delivery mechanism.** If a pixel is decorative and below the fold, fine — use CSS background. If a pixel is the first thing the user is meant to see, ship it as `<img>` and let the browser do its job.\n",
      "date_published": "2026-05-24T00:00:00.000Z",
      "date_modified": "2026-05-24T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/hero-lcp-fetchpriority.png",
      "tags": [
        "performance",
        "lighthouse",
        "react"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/lazy-third-party-scripts",
      "url": "https://boraocaker.com/en/blog/lazy-third-party-scripts",
      "title": "The 80 KB I removed from my critical path with one observer",
      "summary": "hCaptcha and Web3Forms only matter when someone actually submits the contact form. Here's how I delayed both until the form was on screen — and what that did to my Lighthouse score.",
      "content_text": "My contact form sits at the bottom of the page. Maybe 1 in 20 visitors scrolls to it. The other 19 never touch it.\n\nYet on every single page load, every visitor — including the ones who close the tab after 3 seconds — was downloading and parsing:\n\n- `js.hcaptcha.com/1/api.js` (~50 KB)\n- `web3forms.com/client/script.js` (~30 KB)\n\nThat's 80 KB of JavaScript blocking nothing in particular, but blocking the main thread, blocking the connection budget, and getting in the way of LCP.\n\n## The lazy approach\n\nTwo things needed to happen:\n\n1. **Remove the scripts from `index.html`.** They no longer load on first paint.\n2. **Load them only when the contact form is about to be visible.** `IntersectionObserver` is the cleanest primitive for this — it's free, native, and well-supported.\n\nThe trick is the second part. You can't just load on `useEffect`, because the form is at the bottom of the page; loading on mount defeats the whole point. You also can't load on focus, because hCaptcha needs to be parsed and initialized before the user can interact.\n\nThe sweet spot is loading when the form enters the viewport, with a generous `rootMargin` so the scripts arrive a half-screen before the form does.\n\n```ts\nuseEffect(() => {\n  const el = wrapperRef.current;\n  if (!el) return;\n  const io = new IntersectionObserver((entries) => {\n    if (entries.some((e) => e.isIntersecting)) {\n      loadScript(\"https://js.hcaptcha.com/1/api.js\");\n      loadScript(\"https://web3forms.com/client/script.js\");\n      io.disconnect();   // one-shot — once they're in, leave them in\n    }\n  }, { rootMargin: \"200px\" });\n  io.observe(el);\n  return () => io.disconnect();\n}, []);\n```\n\n`loadScript` is a four-line helper that appends a `<script async defer>` once per URL:\n\n```ts\nconst loaded = new Set<string>();\nfunction loadScript(src: string) {\n  if (loaded.has(src)) return;\n  loaded.add(src);\n  const s = document.createElement(\"script\");\n  s.src = src; s.async = true; s.defer = true;\n  document.head.appendChild(s);\n}\n```\n\n## One small extra\n\nI kept a `<link rel=\"preconnect\" href=\"https://js.hcaptcha.com\" crossorigin>` in the HTML head. That's not the script itself — it's a hint to the browser to do the TLS handshake early, so when the IntersectionObserver eventually fires, the connection is already warm. The cost of preconnect is ~1 KB of header chatter. The benefit is shaving 100–300 ms off the script-load chain.\n\n## What it cost me\n\nNothing functional. The form behaves identically. The only \"downside\" is that if a user scrolls extraordinarily fast and starts typing within ~100 ms of the form appearing, hCaptcha might still be loading. In practice this hasn't happened — and even if it did, the submit just queues until hCaptcha resolves.\n\n## What it bought me\n\n- **TBT dropped ~200 ms.** Lighthouse no longer flags `js.hcaptcha.com` as a main-thread offender.\n- **Initial JS payload shrank** by everything those two scripts were pulling in transitively.\n- **Cleaner waterfall.** The Network tab now shows a focused critical path: HTML → CSS → main JS → hero image. Third-party stuff arrives later, where it belongs.\n\n## The principle\n\nA page has a *budget* for the first second. Every byte you spend that the user doesn't see in that first second is theft. `IntersectionObserver` is the cheapest way to refund those bytes — no library, no build step, no risk.\n\nIf you're not lazy-loading at least your analytics, your captcha, and your embed scripts (Twitter, Spotify, YouTube), you're paying for things your user hasn't asked for yet.\n",
      "date_published": "2026-05-22T00:00:00.000Z",
      "date_modified": "2026-05-22T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/lazy-third-party-scripts.png",
      "tags": [
        "performance",
        "react",
        "lighthouse"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/lenis-gsap-scrolltrigger",
      "url": "https://boraocaker.com/en/blog/lenis-gsap-scrolltrigger",
      "title": "Syncing Lenis smooth scroll with GSAP ScrollTrigger",
      "summary": "Lenis hijacks the scroll position. ScrollTrigger reads the scroll position. If you don't tell them about each other, your scrubbed animations drift exactly enough to feel broken.",
      "content_text": "I added Lenis to a site for the smooth feel, and within ten minutes my GSAP ScrollTrigger scrub animations were lagging behind the actual scroll position. They'd catch up at the end, but the whole *point* of scrub is the synced feel. Lagged scrub is worse than no scrub.\n\nHere's why it happens and the four lines that fix it.\n\n## The mismatch\n\nBy default, ScrollTrigger reads the browser's native `window.pageYOffset` on every scroll event and on every `requestAnimationFrame`. That's fine when scroll is driven by the user's wheel or finger.\n\nLenis is different. Lenis intercepts the wheel/touch input, computes an *interpolated* scroll position (the actual smoothness), and writes that position to `window.scrollTo` itself, one frame at a time. The browser's native scroll position is now *behind* what Lenis is showing on screen — sometimes by 200 ms or more.\n\nScrollTrigger is reading the laggy native value. The animation lags.\n\n## The fix\n\nThree pieces, all standard:\n\n```ts\nuseEffect(() => {\n  const lenis = window.__lenis;     // however you stored your Lenis instance\n  if (!lenis) return;\n\n  // 1) Push every Lenis scroll event into ScrollTrigger.update.\n  const onScroll = () => ScrollTrigger.update();\n  lenis.on(\"scroll\", onScroll);\n\n  // 2) Let GSAP's ticker drive Lenis's RAF loop instead of Lenis's own RAF.\n  //    This way ScrollTrigger and Lenis tick on the same frame.\n  const ticker = (time: number) => lenis.raf(time * 1000);\n  gsap.ticker.add(ticker);\n  gsap.ticker.lagSmoothing(0); // disable GSAP's adaptive smoothing — we want exactness\n\n  return () => {\n    lenis.off(\"scroll\", onScroll);\n    gsap.ticker.remove(ticker);\n  };\n}, []);\n```\n\nThree things to notice:\n\n1. **`ScrollTrigger.update()` on every Lenis scroll event.** This makes ScrollTrigger re-read the (now Lenis-driven) scroll position immediately, instead of waiting for the next native scroll event (which may never come).\n2. **`gsap.ticker.add(time => lenis.raf(time * 1000))`.** GSAP's ticker now drives Lenis's animation loop. They share the same frame budget, the same timestamp, the same everything.\n3. **`gsap.ticker.lagSmoothing(0)`.** GSAP normally smooths over dropped frames by lying about delta time. For scrubbed animation that lie is the enemy. Turn it off.\n\nThat's the whole patch.\n\n## What changes\n\n- Scrubbed animations now feel **welded** to the scroll. Pull the scrollbar, the animation follows perfectly. Touchpad inertia, mouse wheel, mobile touch — all snappy.\n- Pinning behaves correctly: `pin: true` no longer \"jumps\" when the pin enters or exits.\n- `onUpdate` callbacks fire on the right frame.\n\n## Where I put it\n\nIn my React project this lives in a tiny hook (`useLenisGsapSync`) that's mounted on every page that uses both Lenis and ScrollTrigger. The hook is dumb on purpose — no dependencies, no options. If both libraries are present, they sync. If not, it bails.\n\n## The lesson\n\nWhen two libraries each think they own a global concept (scroll, time, focus, the cursor), the bug isn't in either one — it's at the seam. The fix is almost never to patch one of them; it's to *introduce them to each other*, formally, on every tick.\n\nSame trick applies to: Lenis + Lottie, Lenis + Three.js, GSAP + React-Spring. Find the shared concept, route both libraries through one ticker.\n",
      "date_published": "2026-05-18T00:00:00.000Z",
      "date_modified": "2026-05-18T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/lenis-gsap-scrolltrigger.png",
      "tags": [
        "gsap",
        "lenis",
        "scroll",
        "react"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/webgl-vs-css-mobile",
      "url": "https://boraocaker.com/en/blog/webgl-vs-css-mobile",
      "title": "When WebGL is wrong — and a CSS gradient is right",
      "summary": "My projects page used an animated WebGL ASCII grid as a background. It looked great. On a 3-year-old Android, it ate the battery and dropped frames. Here's what I replaced it with on mobile, and why I'm not feeling guilty about it.",
      "content_text": "The projects page on this site is an infinity canvas — drag-pannable, looping forever, 34 cards laid out in a 6×6 grid. Behind it I had an animated WebGL background: a slowly-shifting ASCII grid rendered via a fragment shader. Maybe 100 lines of GLSL.\n\nIt looked great on my MacBook. On a 2021 Android, it looked like the device was dying.\n\n## What the profiler said\n\nI tested on a mid-range Android (Snapdragon 695). The WebGL shader was running fine on its own — but combined with the pan-canvas, the touch-momentum RAF loop, and framer-motion's transform updates, the GPU was saturated. Frame times spiked to 80–120 ms during drag. The cards skipped frames. The whole page felt cheap.\n\nWorse: even when nothing was happening — just sitting on `/projects` looking at the canvas — the shader kept rendering. The battery icon turned red within about 8 minutes.\n\n## The non-fix: optimizing the shader\n\nI spent half an hour squeezing the shader. Smaller buffer, slower frame rate, simpler math. It got *less bad*. It didn't get good. The problem wasn't the shader — it was that I was running anything continuous on mobile.\n\n## The actual fix\n\nTwo divs. One on desktop, one on mobile. Tailwind responsive utility classes.\n\n```tsx\n<div className=\"hidden lg:block absolute inset-0\">\n  <AsciiGrid />\n</div>\n\n<div\n  aria-hidden=\"true\"\n  className=\"lg:hidden absolute inset-0 z-0\"\n  style={{\n    background: \"white\",\n    backgroundImage:\n      \"linear-gradient(to right, rgba(71,85,105,0.3) 1px, transparent 1px),\" +\n      \"linear-gradient(to bottom, rgba(71,85,105,0.3) 1px, transparent 1px),\" +\n      \"radial-gradient(circle at 50% 50%, rgba(139,92,246,0.25) 0%, rgba(139,92,246,0.1) 40%, transparent 80%)\",\n    backgroundSize: \"32px 32px, 32px 32px, 100% 100%\",\n  }}\n/>\n```\n\nThe mobile background is three stacked CSS backgrounds: a horizontal grid line, a vertical grid line, and a radial purple glow in the middle. Static. Zero JavaScript. Painted by the browser's CSS compositor — which on every device made in the last decade is *very* good at painting gradients.\n\n## What I gave up\n\nThe mobile version doesn't move. There's no shimmer, no breathing, no aliveness. A user comparing the two side by side would notice — they'd say the desktop one feels more premium.\n\nThat's fine. No one is comparing them side by side. Mobile users are mobile users. Their phone shouldn't get warm because I wanted my background to twinkle.\n\n## What I gained\n\n- **Frame times back to 16 ms.** The pan-canvas is smooth.\n- **Battery drain is a non-issue.** I left the page open for an hour during testing. Battery dropped 2%.\n- **The page loads ~80 ms faster** on mobile because the WebGL context never initializes there.\n- **Old phones are usable.** Two years from now this still works on whatever the visitor has.\n\n## The principle\n\nA pretty animation that drops frames is not pretty. A pretty animation that drains the battery is not premium. The user's *perception of quality* is built from a stack: does it load, does it scroll, does it respond, does it look good. The animations are at the top of that stack. You don't sacrifice anything underneath to feed the top.\n\nCSS is enough for almost every \"ambient\" effect you can think of. WebGL is for the moments where you *need* a shader — particle systems, custom blends, real generative art. For \"let's add a background that breathes,\" reach for `background-image` and `@keyframes` first. Reach for a shader only when the answer is genuinely no.\n\nThe desktop visitor still gets the shader. They have the budget for it. Everyone else gets the gradient. Everyone walks away with the same impression of the work.\n",
      "date_published": "2026-05-15T00:00:00.000Z",
      "date_modified": "2026-05-15T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/webgl-vs-css-mobile.png",
      "tags": [
        "performance",
        "webgl",
        "css",
        "mobile"
      ],
      "language": "en-US"
    },
    {
      "id": "https://boraocaker.com/en/blog/things-i-built-and-deleted",
      "url": "https://boraocaker.com/en/blog/things-i-built-and-deleted",
      "title": "Things I built and deleted in the same week",
      "summary": "A short list of features I shipped to a branch, lived with for a day or two, and then quietly removed. Each one taught me something about what this portfolio is actually for.",
      "content_text": "A portfolio is judged by what it includes. It's *built* by what you decide to leave out.\n\nHere's a list of features I genuinely wrote, committed, and then deleted before — or shortly after — they reached the live site. Each one was tempting. Each one had to go.\n\n## 1. The command palette (⌘K)\n\nI built it. It looked great. It had nav, language switching, copy email, GitHub link, the works. Inspired by Linear and Vercel.\n\nI deleted it the next day. Why: nobody asks \"where is X\" on a 5-page portfolio. ⌘K shines on apps with deep navigation — Slack, Notion, GitHub. On a portfolio it's a solution looking for a problem, and the keyboard-shortcut affordance only makes sense to developers, not to clients.\n\n**The lesson:** copy the gesture, not the artifact. Linear has ⌘K because Linear has 200 destinations. I have 4.\n\n## 2. A footer with a second language toggle\n\nI built a full-width footer with the email, copyright, and a chunky EN/TR pill toggle. The reasoning: \"what if the user scrolls to the bottom and forgets the toggle is in the top-left?\"\n\nI deleted it. Why: the top-left toggle is *always visible*. A second toggle in the footer is redundant — and worse, having two switches in the UI for the same state invites doubt (\"did I just toggle it twice?\"). One source of truth.\n\n**The lesson:** if you find yourself adding a \"what if they don't see the first one\" duplicate, the answer is to make the first one better, not to add a second.\n\n## 3. URL-based language routing\n\nI refactored the router to support `/en` and `/tr` prefixes. Built it cleanly. Added language-specific sitemap entries, hreflang tags, the works.\n\nI rolled it back the same day. Why: my router was a clean enum-based 90-line file. The /lang/page version was 140 lines, had two extra effects, and the only benefit was that someone could share `/tr/about` and the receiver would land in Turkish. For a portfolio with maybe 5 incoming shares per week, that's not a feature — it's a rewrite of working code for a marginal case.\n\n**The lesson:** routing is the kind of code you only refactor when there is a *concrete user complaint*. Until then, the lazy version is the right version.\n\n## 4. A multiplayer cursor\n\nI started spec'ing out Liveblocks integration so that multiple visitors on the page at the same time could see each other's cursors. The Lee Robinson / Bruno Simon move.\n\nI stopped after 20 minutes of reading the docs. Why: the median visit to my site is one person at a time. The multiplayer effect requires *concurrent visitors* to be visible. On a portfolio with 100 visits/day, that's maybe 2–3 minutes of overlap per day, in aggregate. Hundreds of lines of code for a feature 99% of visitors will never see.\n\n**The lesson:** \"would be cool\" is not a product decision. The question is \"what's the conversion rate impact\" or \"what's the joy-per-millisecond ratio.\" Multiplayer cursors are great on *toy sites where the toy is the point*. On a service site, the user is here to evaluate me, not to play.\n\n## 5. A Spotify \"Now playing\" widget\n\nImagine a small card in the footer: \"Right now I'm listening to: [song] — [artist].\" Live-updating from the Spotify API. Personality, warmth, the kind of detail Lee Robinson does perfectly.\n\nI built the OAuth flow. I built the widget. Then I looked at it and realized: I listen to music for 8 hours a day. The widget would broadcast that I'm a productive freelancer to anyone who looks. Cool. But it also broadcasts every embarrassing song I play at 2 AM. And it adds an extra API call to every page load.\n\nThe lift didn't justify the surveillance.\n\n**The lesson:** \"personality features\" are almost always good ideas in the abstract and weird in the implementation. The version of you that \"always has good taste in music\" is a lie. Don't ship the lie.\n\n---\n\nThe site is shorter now than the sum of what I've built for it. That's the point. Every deleted feature is a vote for the ones that survived.\n\nIf you're working on a portfolio: write the list of features you considered but didn't build. Put it in the codebase as a `DELETED.md`. Future-you will thank past-you for the restraint.\n",
      "date_published": "2026-05-12T00:00:00.000Z",
      "date_modified": "2026-05-12T00:00:00.000Z",
      "image": "https://boraocaker.com/og/blog/en/things-i-built-and-deleted.png",
      "tags": [
        "design",
        "restraint",
        "decisions"
      ],
      "language": "en-US"
    }
  ]
}