Down the rabbit hole — TanStack Start migration
The day started with me noticing an innocent-looking option in Lovable's project settings: Migrate to TanStack Start (Advanced). It turned out that, since May 13, 2026, newly created projects have used this stack, which supports server-side rendering (SSR), instead of the original Vite + React Router single-page application setup. Lovable has an information page explaining the differences.
Migration to TanStack Start
I first asked about the implications in Chat mode, and the migration looked like a good idea. It also brought several stack upgrades: React 18 → 19, Tailwind CSS 3 → 4, and the replacement of react-helmet-async with React 19's native document metadata support in SEO. The only backend function affected was admin-upload-image, which handles image uploads in the post editor and was converted into an in-app server function.
After the migration, I immediately noticed that post images flickered when I opened a post. The cause was that the post page still followed the original single-page application logic instead of using TanStack Start's native patterns. The solution was to have the route loader prefetch the post and its previous/next links, then pass the data to the page, which reads it using useSuspenseQuery. At the same time, loading and error states were moved to the route's pendingComponent and errorComponent, respectively, and the Markdown plugins and component maps were moved to module-level constants to prevent images from being remounted on every render. Boom, no more flickering on load.
Next, I set out to adopt native TanStack patterns across the site. The home, language, search, and admin pages now load their data in route loaders, with skeletons and error states handled by route-level pending and error components. Page, query, and admin-tab state is managed through typed URL search parameters. Hovering over a link preloads the destination page.
🚀 I have to say, the whole site became a rocket ship, with pages loading almost instantly.
After a full audit, I was able to remove the router compatibility shim that preserved the original paths, the legacy redirect components, the /404 route, the duplicate shadcn toast system (leaving sonner as the sole implementation), a dozen other unused files, and temporary files left over from the migration. Switching to useSuspenseQuery also made some hooks obsolete, so I deleted those, too. Finally, I removed the orphaned @radix-ui/react-toast and @radix-ui/react-toggle dependencies, which were no longer needed after their corresponding UI components had been removed.
I also discovered that a second, divergent Supabase browser client had been configured despite having no importers. Unlike the single SSR-safe @/lib/supabaseClient used by the application, this duplicate client had unguarded session persistence. I removed it along with its now-orphaned previewAuthStorage.ts helper, leaving the SSR-safe client as the only browser client in use.
However, Lovable's infrastructure regenerates src/integrations/supabase/client.ts whenever I ask it to make certain changes, even though the file is unused and comes with a comment telling me not to touch it. Since preventing its regeneration isn't an option, I added a lint guard using no-restricted-imports. If any code accidentally imports the generated client, the build now fails.
Social images
One major benefit I hadn't initially anticipated was that migrating to TanStack Start finally made per-post social share images possible. With the original Vite + React Router single-page setup, I couldn't provide a post-specific og:image in the initial HTML. Now, each post route can set its own Open Graph metadata through head(), allowing social platforms such as Bluesky, Facebook, and X to discover the post's first image when fetching the page and use it for the link preview.
The preview image is prepared at 1200 × 630 pixels, following common social-sharing guidelines. Posts without images fall back to the generic site banner.
To accommodate WhatsApp and other picky clients, I also added compressed social preview images. The preview endpoint serves a JPEG under 1 MB, using the following approach:
- JPEG source images already under 1 MB are returned unchanged.
- Other images are resized to the required dimensions and encoded as JPEG, with transparency flattened onto a white background for PNG sources.
- If the encoded image exceeds the byte limit, the encoder retries at progressively lower quality settings: 75, 60, 45, and 30.
- If the image still exceeds the limit after all four attempts, the endpoint rejects it rather than serving an oversized preview. When a post's image cannot be processed, the route falls back to the generic site banner.
The endpoint also enforces source-size and pixel limits, so it doesn't have to process the original full-resolution uploads. The result is a strict size limit for every preview image served, without sacrificing the original post photos.
With each route now setting its own metadata through head(), the SEO component was no longer needed and could be removed.
Shared Markdown renderer
I hadn't realised that the admin dashboard's preview and the live post display used separate component maps for Markdown rendering. The two implementations had already started to drift apart, meaning the preview no longer accurately reflected what the published post would look like.
To fix this, I moved the ReactMarkdown component maps and plugin arrays into a shared component that both the preview and the live post display now use. This also made the duplicated, roughly 140-line component map in the editor obsolete.
Prettier formatting baseline
While I was working on the migration, Miss Kiro flagged around 4,000 Prettier errors and asked whether I wanted to do anything about them. Prettier had been added to the project without a configuration file, so it was using its opinionated defaults.
Kiro helped create a .prettierrc.json that matched the existing code style as closely as possible. This eliminated most of the reported formatting issues and established double quotes, semicolons, trailing commas, and a 100-column line width as the project's formatting baseline.
I then formatted the existing, manually maintained source files (around 98 files). The changes were limited to whitespace, quotes, and line wrapping, with no behavioural changes. Generated files and archived scripts were excluded from both Prettier and ESLint where appropriate. Deno backend functions were also excluded because they run in a separate runtime, while archived plans and documentation were excluded from Prettier. This ensured that linting no longer failed on archived Edge Function code.
For local development, I added a pre-commit hook using Husky and lint-staged. Staged files are automatically formatted with Prettier and checked and fixed by ESLint before each commit. Lovable bypasses these hooks, so I also added a GitHub Actions CI workflow that runs type checking, tests, linting, and Prettier checks, followed by a build, on every push to main and every pull request.
🌀 By now, you can probably see how this spirals out of control: I keep finding new things to work on and fix at every turn…
Broken Skitch screenshots (2008–2011)
During manual testing, I stumbled upon a post with broken images. Investigating the issue revealed that the WordPress migration had stored Evernote's homepage HTML in place of 124 defunct img.skitch.com images across 91 posts. Another three posts still hot-linked to Skitch.
For those who don't remember it, Skitch was a popular macOS screenshot-editing and annotation utility originally created by Plasq, acquired by Evernote in August 2011, and eventually discontinued. I hadn't downloaded my image library back then, so those screenshots are now lost for good.
I removed the broken image blocks and their corresponding records from all 94 affected posts, deleted the placeholder files from storage, and added a new lost-images notice to the affected posts. The notice also supports localisation (l10n).
Unit tests
While I was working on the CI pipeline, Miss Kiro pointed out that tests had become more important now that they were part of the pipeline: a failing test would cause the build to fail. Automated testing had been configured, but was barely being used. Vitest 4, jsdom, and Testing Library were wired up, and — as mentioned above — CI ran bun run test on every push to main and every pull request. Yet only two test files existed.
I addressed this in three phases to bring test coverage to an acceptable level, ultimately expanding the suite to 130 tests across 18 files.
Phase 1 focused on pure-logic unit tests for the dependency-free helpers in src/lib/. Many regression cases came from CHANGELOG.md and the fixes documented there, including an HTML-entity SSR crash, slug transliteration, overly aggressive multilingual detection, and search snippet corruption.
Phase 2 covered hooks and validation. I added unit tests for additional helpers and the image-sort transformation, along with a reusable Supabase browser-client mock and a React Query render harness. These made it possible to test mutation and query hooks deterministically, without requiring a UI.
Finally, Phase 3 added the less critical route and component smoke tests. These included match-only route assertions for current and dated legacy routes, plus a guard ensuring that unknown multi-segment URLs fall back to the not-found page. I also added a post-page rendering test covering the null, one-link, and two-link cases, including HTML-entity-decoded titles, and a clipboard-copy test for the admin image embed-code button.
All in one day
And that's how I spent the day, with a couple of meal breaks, a grocery shopping trip, and an interview thrown in. 🤞🏻