Sesam.hu — Engineering Manager & Trail Runner

Sesam.hu

Personal blog by Péter Szilágyi — Engineering Manager and trail runner based in Stockholm.
Writing on leadership, running, and life in the north.

Posts in English

Filtered by primary language.

English

After working with this journal, I decided to move every other published project of mine to the new stack. Now Blutag.app, the AI alt text generator site for Bluesky, is also on Tanstack, as well as my portfolio / CV one-pager peterszilagyi.dev.

During the migration I was usually facing very similar issues, so it was really helpful that I could prompt Lovable to look at the CHANGELOG.md and the Git history of an already migrated project and take those experiences into consideration so the same mistakes would not be repeated.

I also ended up updating the Workspace knowledge in Lovable. These are custom instructions that apply to any existing or future projects in my workspace. My set of general instructions look like this:

## Tooling & Runtime

- Package manager: Bun is REQUIRED.
- Always commit `bun.lock` with dependency changes.
- Run tests using `bun run test` (Vitest), never `bun test`.
- Scripts required in `package.json`:

  - `"typecheck": "tsc --noEmit"`
  - `"test": "vitest run"`
  - `"lint": "eslint ."`
  - `"format": "prettier --write ."`
  - `"format:check": "prettier --check ."`
  - `"build": "vite build"`

## Architecture & Framework (TanStack Start)

- Stack: TanStack Start v1 with Vite and file-based routing (`src/routes/`).
- Routing:

  - Use native `@tanstack/react-router` primitives (`Link`, `useNavigate`, `useRouter`, `createFileRoute`).
  - NEVER install or import `react-router-dom`, `BrowserRouter`, or compatibility shims.
  - Every parent and pathless layout route MUST render `<Outlet />`.
  - Root layout, providers, and `<head>` metadata belong in `src/routes/__root.tsx`.
  - Never edit generated `src/routeTree.gen.ts`.

- Server & Browser Boundaries (SSR Safety):

  - Route modules are evaluated on the server: NEVER access browser globals (`window`, `document`, `localStorage`, `IndexedDB`, canvas) at module top-level.
  - Mark browser-dependent routes with `ssr: false`, or isolate browser globals inside `useEffect` or dynamic `await import(...)` calls.

- Data Loading:

  - Default to route `loader` functions using TanStack Query: `context.queryClient.ensureQueryData(queryOptions)`.
  - Components consume loaded route data via `useSuspenseQuery(queryOptions)` rather than component-level `useEffect` fetches.

## Code Formatting (Prettier)

- Prettier is the single source of truth for code formatting across all files.
- Every project MUST have a root `.prettierrc.json` with this exact configuration:

```json
{
  "printWidth": 100,
  "tabWidth": 2,
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "arrowParens": "always"
}
```

- Maintain `.prettierignore` ignoring `node_modules`, `dist`, `.output`, `routeTree.gen.ts`, and lockfiles.
- All commits and CI pipelines must pass `bun run format:check` without diffs.
- ESLint must not define formatting rules that conflict with Prettier.

## TypeScript Configuration

- Strict mode is REQUIRED:

  - `strict: true`
  - `noUnusedLocals: true`
  - `noUnusedParameters: true`
  - `strictNullChecks: true`
  - `noImplicitAny: true`
  - `noFallthroughCasesInSwitch: true`

- Zero compiler errors or warnings.
- Never use `any`, `@ts-ignore`, or `@ts-nocheck`. Use `unknown` and narrow types properly.
- Avoid non-null assertions (`!`) across async boundaries; capture and validate beforehand.
- Prefer explicit return types for public APIs, custom hooks, and complex logic.

## Testing Strategy (Three-Phase Architecture)

All projects must establish automated test coverage from day one using a three-phase approach:

  1. Phase 1 — Pure Logic: Unit tests for pure functions, math, validators, parsers, and utilities (`src/lib/`). Zero React or DOM mocks.
  2. Phase 2 — State & Hooks: Custom hooks, contexts, and data access with stable mock harnesses (`src/test/mocks/`). Context mocks must return stable objects across renders to prevent infinite loop bugs.
  3. Phase 3 — Component & Route Smoke Tests: Key UI components and file routes. Test routes using `router.matchRoutes` rather than rendering full document trees to avoid stylesheet and network dependencies.

Rules:

- High test coverage on business-critical paths (target ≥60% overall, ≥80% for Phase 1 pure logic).
- Bug regression rule: Every bug fix MUST include a regression test named after the user-visible symptom.

## Continuous Integration (CI)

- Every project must include a GitHub Actions workflow (`.github/workflows/ci.yml`) triggered on push to `main` and on pull requests.
- CI must use Bun (`oven-sh/setup-bun`) and run all verification gates with frozen lockfiles:

  1. Type check (`bun run typecheck`)
  2. Tests with coverage (`bun run test`)
  3. Linter (`bun run lint`)
  4. Format check (`bun run format:check`)
  5. Production build (`bun run build`)

## Code Organization & Structure

- File Structure (Guideline):

  - `components/` Reusable UI components
  - `components/ui/` Base UI primitives (shadcn/ui)
  - `features/` Feature-based modules
  - `hooks/` Shared custom hooks (queries and mutations)
  - `lib/` Pure utilities, clients, and business helpers
  - `config/` Global configuration and constants
  - `types/` Shared TypeScript types
  - `routes/` TanStack Start file routes
  - `test/` Shared test mocks, setup, and test providers

- Naming Conventions:

  - Components: PascalCase (e.g., `UserCard.tsx`)
  - Hooks: camelCase with `use` prefix (e.g., `useUserData.ts`)
  - Variables & functions: camelCase
  - Constants: UPPER_SNAKE_CASE
  - Types & interfaces: PascalCase

- Import Order:

  1. External libraries / framework modules
  2. UI primitives & design system components
  3. Custom hooks
  4. Services / integrations / API clients
  5. Utilities and helpers
  6. Types
  7. Relative imports

## Code Quality & Hygiene

- Constants (Avoid Magic Numbers):

  - Avoid hardcoded values, thresholds, and dimension literals.
  - Global constants live in `config/` or `lib/constants.ts`; feature constants are colocated with features.

- Data Fetching (React Query):

  - Do not fetch directly in UI components.
  - Extract queries into custom `useQuery` hooks and mutations into `useMutation` hooks.

- Logging:

  - No `console.log` in production code. Use a centralized environment-aware logger.

- Error Handling:

  - Separate technical error logs (for debugging) from user-facing error messages.
  - Provide actionable context when logging errors.

- Comments:

  - Explain "why", not "what".
  - Use JSDoc for public functions, custom hooks, and non-trivial algorithms.

- Styling:

  - Use Tailwind CSS with semantic design tokens and shadcn/ui.
  - Never hardcode color utilities or hex values (`bg-[#...]`) that bypass theming or break dark mode.

- Code Cleanliness:

  - Keep code clean of unused imports, dead code, and commented-out snippets.

## Changelog

- Maintain a simple dated `CHANGELOG.md` file.
- Headings use release dates (`## YYYY-MM-DD`), never semantic version numbers or `[Unreleased]`.
- Group entries under standard categories:

  - Added
  - Changed
  - Fixed
  - Security
English

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. 🤞🏻

English

One morning I sat down at the computer and — to my surprise — Docker stopped, Mail said it couldn't fetch, and a dozen other errors caused by not enough disk space. I was nonplussed since I knew for sure there had to be hundreds of Gigabytes available.

I set out to investigate and met with a strange anomaly. While df reported only about 2%, du said that about 140GB is used on the 500GB SSD. How was that possible?

There was no single large file that would stand out, so there was no easy solution.

My first idea was Docker, since I didn't limit the disk usage of the containers and readsb + ADSB feeders create copious amounts of logs. But still not hundreds of Gigabytes of them, and also the logging is limited in their configuration. Docker Desktop showed normal disk usage less than 2GB.

Only when recursively trying to list the directories or files that are the culprit did I notice that a lot of directories are skipped. And then it dawned on me: I'm using iTerm2, and even with sudo, it does not have full disk access, as per MacOS policies. So one needs to explicitly go to MacOS settings and grant iTerm2 Full Disk Access in order to be able to scan everything.

After that it was easy to find that one of the directories in ~/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteData/Default was a whopping three-hundred-something GB. Nice for a website to use my SSD as a NAS, but how about no. The follow-up issue here is that for security reasons it isn't easy to tell which website it is, because the directory is just a hash, and the real URL is matched in Cache.db, an SQLite database. But I was curious.

You'll never guess which site it was: Microsoft Teams on the web. Apparently when you use Teams on the web, it basically downloads the whole software to cache, and then leaves it there (?). But it's never re-used, so if you join a different meeting, it does it again, and this goes on and on. I have been on a couple of interview calls lately, many in Teams, and apparently I inadvertently started to fill up my free space with copies of Teams…

After a quick purge, which can even be done in the GUI at Safari → Settings → Privacy → Manage Website Data (or you just delete the directory) I reclaimed my SSD again.

A little side-note as to why I don't use Teams Desktop. I used to, seemed like a more convenient choice, but one day I started playing World of Warcraft … only it had no sound. Whatever I did, I could't fix it, so I started investigating, and apparently the audio driver that Teams installs for whatever reason messes with WoW's sound output and without any workaround one of them had to go. (Take a wild guess.) This is how Teams Desktop and I parted ways.

Anyway, then I thought, if this happened once, it can happen again, I need to monitor this. So with heavy agentic help Miss Agent and I came up with the following bash script to be placed in ~/.zshrc which gives you a command, safari-data, that lists the top fifteen website data directories by size and matches them to their respective URL:

# --------------------------------------------------
# SAFARI DATA INSPECTOR
# --------------------------------------------------
safari-data() {
  local base_dir="$HOME/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteData/Default"
  local size path hash_name domain mb_size

  /usr/bin/du -sk "$base_dir"/* 2>/dev/null | /usr/bin/sort -rn | /usr/bin/head -n 15 | while read -r size path; do
    hash_name="${path##*/}"
    domain=""

    # Fast path: Search shallow SQLite databases and origin descriptors first
    domain=$(/usr/bin/find "$path" -maxdepth 3 -type f \( -name "*.sqlite" -o -name "*.db" -o -name "Origin*" \) -exec /usr/bin/strings {} + 2>/dev/null | /usr/bin/grep -E -o 'https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' | /usr/bin/sed -E 's#https?://##' | /usr/bin/head -n 1)

    # Fallback path: Full scan if shallow search misses
    if [[ -z "$domain" ]]; then
      domain=$(/usr/bin/find "$path" -type f -exec /usr/bin/strings {} + 2>/dev/null | /usr/bin/grep -E -o '([a-zA-Z0-9-]+\.)+(com|org|se|net|io|app|gov|edu|co|uk|de)' | /usr/bin/grep -v -E '(apple|webkit|sqlite|schema|system|localhost|bplist|SQLite)' | /usr/bin/head -n 1)
    fi

    mb_size=$((size / 1024))
    printf "%-8s %-35s %s\n" "${mb_size}MB" "${domain:-Unknown Domain}" "($hash_name)"
  done
}

This is the first line of an example output from today when I noticed that the Unifi Console decided it needed nearly 6GB in cache, because reasons:

❯ safari-data
5839MB   unifi.ui.com                        (zerE8ilEaEBfw61aLNeQ0pQtQn1C4PrfjyDriHGiLVc)

Did any of you run into something similar? Apparently Safari's caching has caused issues before.

English

I just wrapped up a passion project I built in a basically a single day: Blutag, an AI-powered alt text generator for Bluesky.

A screenshot of the Blutag website, showing the title

It's open source on Github.

Blutag focuses on accessibility while respecting privacy. Your images stay private: I don't track anything and the pictures are deleted after your browser session ends.

Some features I’m particularly proud of:

  • Drag-and-drop reorder of pictures
  • Full ATProto feature consistency, including facets (rich text annotations with byte offsets)
  • AI-powered alt text suggestions to make Bluesky posts more inclusive

It’s amazing what a day of tinkering can produce! If you care about accessibility, privacy, or just love playing with AI + social tech, give it a spin and let me know what you think.

English

For a while, one of my World of Warcraft characters kept showing a pop-up saying, “You have a new Herbalism specialization to unlock” every time I logged in. The thing was, I hadn’t actually unlocked anything.

After a bit of research, I discovered there was no built-in way to clear it. I had already tried every file, macro, and reset trick that works for active professions, but the message persisted. The reason? It was coming from an unlearned profession:

  • The client internally keeps a “new specialization tab” flag for each skill line ID in memory, even if the profession is no longer learned.
  • The saved variables file Blizzard_Professions.lua only handles active professions, so flags for old or unlearned professions cannot be cleared via macros or file deletion.
  • The surprising part is that the message defaults to Herbalism because it’s the first profession loaded, even if the real culprit is another long-gone profession.

I figured there wasn’t much I could do, since this issue has apparently been around since Dragonflight (and I doubt it’s high on the dev team’s bug-fix list).

Since I had Midnight and TWW Herbalism sorted, it had to be Dragonflight. I ported back to Valdrakken, and lo and behold, I had unlearned Herbalism at some point during that expansion. After re-learning it, I was finally presented with the option to unlock Bountiful Harvests! I probably just neglected this when unlearning the profession (which is completely understandable).

A screenshot from the video game World of Warcraft, showing the
“You have a new Herbalism specialization to unlock”

Lessons learned:

  1. WoW still tracks these unlocks for unlearned professions and doesn’t provide a way to clear the flags.
  2. It’s best practice to unlock profession tabs as they become available.
  3. Even though it’s purely cosmetic, the notification can still drive a person (me) nuts.
English

I was to write about the books I read in 2025, but then I wanted to know when I last posted about my year in books, and once again I ran into the issue that I miss a search option which also includes post content. Google is an option since the blog's been indexed, but I also had some rollover credits in Lovable I'd lose, so things naturally escalated.

The functionality itself took three prompts, the initial explanation of the requirements and two follow-ups where I asked to fix some design issues, correct requirements that were not matched and added the extra requirements to 1) render the result page snippets as Markdown instead of raw; 2) remove HTML and orphaned Markdown code from the snippets.

The original prompt

I plan to add a search option to the site. 



From a design perspective I imagined a circular button to the left of the theme switcher in a similar style with a loupe icon. Upon clicking on it, it opens with an animation to the left revealing an input field, which is selected, so the user can start typing at once. 

The search targets post titles, post content and tags. On typing, it offers the top 5 results in a dropdown dynamically.

If more than 5 results are present the last item in the dropdown is a ”View more” link. This link or where the user presses enter takes the user to a search results page which lists all the hits with where the word was found. As an example, look at the google search results page for the site when I search for ”marathon”.

Clicking out of the search field or dropdown collapses it back to the circular loupe icon.

Fine tuning

  • The Liquid Glass-like effect of the dropdown was originally added by the agent during one of the iterations but I liked it a lot so it remained.
  • A couple of prompts were needed to get the HTML/Markdown stripping right so that orphaned tags like <ifra were also included.
  • For a better experience Markdown links — instead of being fully removed — retained the title text as plain from the [title](url) format, even if orphaned.
  • I had the context snippet expand to word boundaries not to cut words in half.
  • The bug was fixed that separate hits were counted as duplicate results (and caused a pagination error) if a post both matched on title and content, for example.
  • Added keyboard navigation for the dropdown with Enter, Esc, ArrowUp and ArrowDown keys.

Now this was all nice but then I asked Kiro to look at the implementation from a code quality perspective and based on the steering documents she identified some bad habits that Lovable reverted back to, which needed to be corrected. Examples include:

✅ Constants Centralization: all configuration values had to be centralized in src/config/constants.ts
✅ Custom Hooks Pattern: all data fetching was extracted into custom hooks with proper JSDoc
✅ Error Handling to use the centralized error handling system
✅ Loading States: dedicated skeleton components following the loading states standard were missing (for the results page)
✅ TypeScript Strict Mode and Import Order were also checked

It's quite fun having agents race each other. 😅

Analytics

After I tested searching it occurred to me that it would be splendid to know what people actually search for.

Instead of aggregation I opted for granular logging of raw data since I don't expect huge traffic. Events could come from two sources: the dropdown and the search page. Once a QA, always a QA, I found a race condition in the first implementation with my very first manual test: typing a keyword and pressing enter before the dropdown loaded results ended up being logged twice, first with zero results then once with the correct number when the results page loaded due to the enter key navigation. Eventually, Miss Agent and I agreed that the best would be to only log dropdown clicks to actual hits and the view all navigation would be caught by the results page. (Her original suggestion was to drop the dropdown logging entirely, losing valuable data from search words which yielded results people found immediately useful.)

The rest of the time I spent on prettifying the admin dashboard showing the data and implementing date range filtering with today, 7 days, 30 days, 90 days, and all time options. Error handling, constant usage, loading states & skeletons, Zod validation, and the options pattern for mutations were handled by Kiro as previously.

English

This year too we visited the annual light art festival in Stockholm inspired by Nobel Prize awarded discoveries and achievements. During the darkest time of the year it's a real treat to walk among large scale light art installations all over the inner city. It's hard to pick favorites, but here are some examples.

One Way or Another

Honoring Ralph Bunche, an American political scientist and diplomat, winner of the Nobel Peace Prize in 1950, these signposts reflect his lecture, in which he said that “The road to peace can never be traversed by threatening to fight at every bend.”

Nobel Week Lights 2025 artwork
One Way or Another

Pro Pace

As far as my experience goes back, the City Hall (Stadshuset) is always a part of Nobel Week Lights. Artwork is projected on the whole building accompanied by music. This year the installation celebrates some selected Peace Prize laureates, plus the two cities involved, Oslo and Stockholm.

I also learned that the title references the inscription on the back of the prize medal “Pro pace et fraternitate gentium”, meaning “For peace and brotherhood among men”.

Nobel Week Lights 2025 artwork
Pro Pace

Moments of Peace

I believe another recurring location for lights is the courtyard of the Nobel Prize Museum (on the other side there's the magnificent Stockholm Cathedral). This year illuminated benches were placed to sit on and listen to music and speeches by laureates.

Nobel Week Lights 2025 artwork
Moments of Peace

21.09

September 21 is the International Day of Peace. This visual and sonic fresco-like video is projected on the ceiling within the Royal Palace. I think I liked this one best, it had everything: stunning imagery, multi language narration, breathtaking placement. Pictures don't do it justice, it had to be experienced in motion, preferably lying on the floor of the palace.

Nobel Week Lights 2025 artwork
21.09

The week of the Nobel Prize ceremonies is an especially good one to be in Stockholm.

English

The title is slightly misleading, because this post is going to be about me learning more TypeScript and React. I think.

It all started with AWS organizing Dev Days in Stockholm, and since I attended the Nordic Tech Week earlier, I signed up, even though I wasn't sure it was for me… Impostor syndrome aside, of course it proved to be awesome, and I got to see two demo sessions where the presenters used Kiro, AWS's new IDE, henceforth be known as 👻, to build apps from scratch showcasing (among other things) spec-driven development and the new Kiro CLI. Kiro actually went GA only two days earlier.

I really didn't use any of the shiny new features other than the gifted 500 credits, the automatic selection of language model, and the Autopilot mode, where one mostly lets the agent run free and enjoy the results. (Writing this, I just realized that Steering would actually have been extremely useful. Added to todo list.)

Anyway, I went ahead and asked Kiro to:

"Examine this codebase written by a junior developer for software development best practices, clean code, security, possible improvements."

She started screaming that a high priority issue was the .env file being on GitHub, which is actually needed for the Lovable sync, so I eventually had to admit that there was no junior developer, after all.

👻 went:

"Ah, that changes everything! Let me reframe this for a Lovable-generated codebase where you're doing hybrid development (Lovable web editor + local IDE)."

And so a plan was formed from which I learned more than I expected. Kiro (Claude, probably, agent was set to Auto,) proposed a sequential plan to follow from quick wins to improving code quality. Here's how it went.

Caveats:

  • Code by Kiro, problems/lessons by me.
  • This is very particular for this blog and my learning path, but might be interesting to look at regardless.

Day 1: The Quick Wins (90 Minutes)

The Toast That Wouldn't Leave

The Problem: Lovable, for some unfathomable reason, decided that toasts on the admin dashboard should persist eternally (?).

// Before: 1000000ms = 1000 seconds = 16+ minutes
const TOAST_REMOVE_DELAY = 1000000;

// After: 5000ms = 5 seconds
const TOAST_REMOVE_DELAY = 5000;

Lesson: Always validate timeout values, because agents can choose weird numbers.

The N+1 Query Trap

The Problem: At some point, because "it's always been so", I modified the index page to load the latest ten blog posts. This apparently triggered 11 database queries: one for posts, then one for each post's images.

The Solution: Kiro mentioned that modern database tools support joins and recommended using them.

// Before: 11 queries
const posts = await db.posts.findMany();
const postsWithImages = await Promise.all(
  posts.map(post => db.images.findMany({ where: { postId: post.id }}))
);

// After: 1 query with join
const posts = await db.posts.findMany({
  include: { images: true }
});

Impact: Kiro was claiming significantly faster page loads, although it was quick enough already, but I suppose this might be different for larger projects with more complex queries.

Lesson: Look for N+1 patterns when fetching related data.

Error Boundaries: The Safety Net

The Problem: Any component error crashed the entire app, leaving users with a blank white screen. This wasn't really an issue, because I'd have caught it early in the Lovable preview, but again, outside of this bubble things are different.

The Solution: React Error Boundaries provide graceful degradation.

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  render() {
    if (this.state.hasError) {
      return <FriendlyErrorMessage />;
    }
    return this.props.children;
  }
}

Lesson: Error boundaries are essential for production apps: users should never see a blank screen.

Professional Logging

The Problem: 30+ console.log() statements cluttering the production console, thanks to Lovable littering the code with them when asked to investigate why something broke.

The Solution: Environment-aware logging utility. Thanks, 👻.

export const logger = {
  log: (...args) => {
    if (process.env.NODE_ENV === 'development') {
      console.log(...args);
    }
  },
  error: console.error,  // Always log errors
  warn: console.warn,
  debug: (context, ...args) => {
    if (process.env.NODE_ENV === 'development') {
      console.log(`[${context}]`, ...args);
    }
  }
};

Lesson: Debug logs are valuable during development but unprofessional in production, so they should be gated appropriately.

Day 2: Performance & UX (2 Hours)

Code Splitting with Lazy Loading

The Problem: Admin dashboard code loaded for all users, even though I'm the only person accessing it.

The Solution: React's lazy loading with Suspense.

// Before: Always loaded
import AdminDashboard from './pages/AdminDashboard';

// After: Loaded on demand
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));

<Suspense fallback={<Loader />}>
  <AdminDashboard />
</Suspense>

Impact: Smaller initial bundle, faster first page load, even though it was quick enough already.

Lesson: Lazy load features that most users don't need, helps with the bundle size.

React Query: Caching Done Right

The Problem: Lovable opted for a manual state management with useState and useEffect, no caching, and repeated fetches.

The Solution: Using React Query to handle caching, background refetching, and loading states automatically.

// Before: ~100 lines of manual state management
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetchPosts().then(data => {
    setPosts(data);
    setLoading(false);
  });
}, []);

// After: ~20 lines with automatic caching
const { data: posts, isLoading } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 5 * 60 * 1000,  // 5 minutes
});

Impact:

  • 80% less code
  • Automatic caching
  • Instant navigation between cached pages
  • Background refetching keeps data fresh

Lesson: Reinventing the wheel is probably pointless.

Image Optimization

The Problem: Loading full-resolution 2.5MB images when most likely the one shown was closer to a 200KB version.

The Solution: Image transformation at the CDN level.

// Add width and quality parameters
const optimizedUrl = `${imageUrl}?width=1200&quality=85`;

Impact: 92% (according to 👻) bandwidth savings, this time with faster page loads that are humanly discernible.

Lesson: Modern CDNs make optimizing images trivial with URL parameters.

Skeleton Loading States

The Problem: Lovable just added generic "Loading..." spinners.

The Solution: Content-matching skeleton screens that look way more professional, by the way.

// Instead of: <Spinner />
// Show structure that matches actual content:
<div className="space-y-4">
  <div className="h-8 bg-gray-200 animate-pulse rounded w-3/4" />
  <div className="h-4 bg-gray-200 animate-pulse rounded w-1/2" />
  <div className="h-64 bg-gray-200 animate-pulse rounded" />
</div>

Impact: Better perceived performance and avoiding layout shifts.

Lesson: Skeleton screens make loading feel faster and show users what's coming.

Centralized Constants

The Problem: Magic numbers were scattered throughout the codebase (10, 5 * 1024 * 1024, 200). Lovable tended to do this as she was focusing on the problem at hand and the individual files that were affected, not the entirety of the codebase.

The Solution: Single source of truth for configuration.

// config/constants.ts
export const POSTS_PER_PAGE = 10;
export const MAX_FILE_SIZE = 5 * 1024 * 1024;  // 5MB
export const EXCERPT_LENGTH = 200;
export const CACHE_STALE_TIME = 5 * 60 * 1000;  // 5 minutes

Lesson: Magic numbers are maintenance nightmares, so centralizing and documenting them are the way.

Day 3: Code Quality (4 Hours)

Standardized Error Handling

The Problem: Similarly to magic numbers, error handling was inconsistent: some places logged and continued, others threw, and some technical error messages leaked to visitors.

The Solution: Centralized error handling with user-friendly messages.

// Error handler with types
export class AppError extends Error {
  constructor(
    message: string,
    public type: ErrorType,
    public originalError?: unknown
  ) {
    super(message);
  }
}

export const handleError = (error: unknown, context: string): AppError => {
  logger.error(`[${context}]`, error);
  
  if (error instanceof AppError) return error;
  if (isNetworkError(error)) return new AppError('Network error', 'NETWORK');
  if (isAuthError(error)) return new AppError('Authentication failed', 'AUTH');
  
  return new AppError('Something went wrong', 'UNKNOWN');
};

export const getUserFriendlyMessage = (error: AppError): string => {
  const messages = {
    NETWORK: 'Connection problem. Please check your internet.',
    AUTH: 'Please sign in again.',
    NOT_FOUND: 'Content not found.',
    // ...
  };
  return messages[error.type] || 'Something went wrong. Please try again.';
};

Impact: Consistent error handling, better debugging, user-friendly messages.

Lesson: Standardize error handling early (and make your agent follow this pattern).

Custom Hooks for Reusability

The Problem: Data fetching logic duplicated across multiple components is a classic Lovable thing, according to yours truly. Once again, the fact that she focuses on particular problems can result in duplicated efforts.

The Solution: Extract into reusable custom hooks.

// Before: Inline in every component
const [posts, setPosts] = useState([]);
useEffect(() => {
  fetchPosts().then(setPosts);
}, []);

// After: Reusable hook
export const usePosts = (page: number) => {
  return useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    staleTime: CACHE_STALE_TIME,
  });
};

// Usage in component
const { data: posts, isLoading } = usePosts(currentPage);

Impact:

  • Removed ~150 lines of duplicate code
  • Single source of truth for data fetching
  • Easier testing
  • Consistent patterns

Lesson: Data fetching logic is best extracted into a custom hook, TIL.

TypeScript Strict Mode

The Problem: By default Lovable sets up a loose TypeScript configuration, which won't bother 99.9% of users, but this post is about code quality and best practices.

The Solution: Enable strict mode gradually. (Or just effin🐦 it and enable everything.)

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "strictNullChecks": true
  }
}

Result: Zero errors! The previous improvements had already made the codebase type-safe.

Lesson: Strict mode catches bugs before they reach production (is the promise I wait to see.)

So how about that? I enjoyed myself immensely while doing these, and asking the agent to stop before committing or pushing anything was very useful, because one could look at the changes and learn from them. Also, agents are very good at summarizing why they do something, which also proves to be helpful in an educational setting.

🎁 Get Kiro and play with the 500 initial gift credits!

English

These past days I’ve been working on mostly under-the-hood improvements to the site in small increments.

Post URLs had a different format under WP and Jekyll following a /<year>/<month>/<day>/<slug>|.html pattern. I implemented redirects so these legacy links are forwarded to their current /post/<slug> address. While doing so Lovable recommended to further improve the function by using a calculation of Levenshtein distance between strings so that slugs would be properly matched even though the current implementation sanitizes special characters. (For example fästing becomes fsting.)

Additionally, the same URL forwarding needed to be done for pagination. /page/<number> URLs now point to /?page=<number>.

With the redirection completed I also wanted to improve SEO by providing a sitemap for search engines. It proved to be a little more complex than I expected. Lovable’s original solution was to have an edge function serve a sitemap that is later served through React Router. Other than the fact that the sitemap was being served as text/html (a React app), not application/xml or text/xml that search engines expect, the routing proved to also be problematic. Meaning that in case of a curl https://sesam.hu/sitemap.xml Google Search Console would look for /sitemap.xml as a static file which doesn't exist (404), and the React app/SitemapProxy component is never reached. So I’ve had to have the sitemap generation create an actual static public/sitemap.xml file that gets deployed with the app. Also, I added a pre-build step in package.json so that every deployment automatically generates a fresh sitemap.

To make the writing of posts easier I added a modal to the post editor backend that shows a searchable grid of uploaded images and inserts the relevant embed code in posts for me.

Finally, for sharing purposes I tried to create better OpenGraph social images for the site and posts. Lovable’s default is a screenshot of the page, which unfortunately very often captured the loading wheel only. I had to give up my original idea that if a page has images, have the first one as an OpenGraph social image, because the way the app is deployed through Lovable’s infra, which kept interfering with my SEO component. For an easy enough solution I opted for simply having a static image for every page:

sesam.hu OpenGraph image showing the site logo with pine woods as a background
English

Between 6—10 October, 2025, the Banff Mountain Film Festival visited Stockholm during its annual fall tour. It's the world’s largest film festival showcasing films in the categories of mountain sports and experiences. I went to see the Red Day of the program held at the gorgeous Bio Skandia in central Stockholm.

Fortunately, bar one, I found all of the shorts available publicly and free to rewatch on demand. You can check out the Red and Blue programs for yourself.

All of the eight short films shown were quite excellent but I wanted to share the very subjective top three list of mine.

A Team Sport

Courtney Dauwalter is definitely the GOAT when it comes to ultrarunning. This documentary follows her iconic 2023 achievement of finishing three of the most grueling 100+ mile ultra races within a year. Two of which she won. With a course record. 🤯

Of A Lifetime

I didn't know about the De Le Rue family, but looking at this documentary they are deserving renown in the skiing discipline for sure. This short is a chronicle of the journey of Xavier, Victor, and 18-year-old Mila to Antarctica where they sail through breathtaking scenery and take on equally breathtaking downhill lines. 🇦🇶

Jirishanca

I don't handle heights super well and when I tried climbing, I didn't enjoy it very much. The two protagionists of this film attempt a free ascent of the Jirishanca, an epic 6000-metre peak in the Peruvian Andes. Is it dangerous? Well, it's not tennis. 🎾