{"version":"https://jsonfeed.org/version/1.1","title":"Marcus Jenshaug","home_page_url":"https://marcusjenshaug.no/en","feed_url":"https://marcusjenshaug.no/feed.json?lang=en","description":"Fullstack developer at Redi AS. I build web applications with Next.js, TypeScript and Supabase, and share what I learn along the way.","language":"en-US","authors":[{"name":"Marcus Jenshaug","url":"https://marcusjenshaug.no"}],"items":[{"id":"https://marcusjenshaug.no/en/blog/from-vercel-to-hetzner-and-coolify-why-i-moved-home","url":"https://marcusjenshaug.no/en/blog/from-vercel-to-hetzner-and-coolify-why-i-moved-home","title":"From Vercel to Hetzner and Coolify: why I moved back home","summary":"Vercel is lovely to get started with, but at some point I found myself paying for magic I didn't need, and for data I wanted to keep in the EU. Here's the story of how I moved Søknadsbasen to a single Hetzner server running Coolify, what was actually hard, and what I'm left with.","content_text":"## Why move at all?\r\n\r\nI have nothing bad to say about Vercel. It's probably the best\r\ndeveloper experience out there, and for a side project the free tier\r\nis more than enough. But Søknadsbasen grew from \"side project\" into \"something people\r\nactually pay for\", and that's when three things started to grate:\r\n\r\n1. **Cost and predictability.** Serverless is cheap right up until it\r\n   isn't. PDF generation with headless Chrome eats memory, and every\r\n   function with a high memory ceiling costs.\r\n2. **Data storage in the EU.** I want to be able to say honestly on the privacy page that\r\n   data lives in Europe. With my own server in Germany I know exactly\r\n   where things are.\r\n3. **Things serverless doesn't like.** I have a WebSocket service for\r\n   real-time collaborative CV editing. Long-lived connections and serverless\r\n   aren't best friends.\r\n\r\nThe solution became a single Hetzner server in Falkenstein, with **Coolify**\r\nas \"my own little Vercel\" on top.\r\n\r\n## What is Coolify?\r\n\r\nCoolify is an open-source PaaS you run yourself. You point it at a\r\nGit repo, it builds with Nixpacks or a Dockerfile, and it handles\r\ndomains, Let's Encrypt certificates, environment variables and deploys. You get\r\na lot of the Vercel feeling, but on hardware you own, at a fixed\r\nmonthly price.\r\n\r\nIn my case: one CPX41 at Hetzner runs Coolify, the Next.js app itself,\r\nand the collab server, all on the same box.\r\n\r\n## The easy part\r\n\r\nThe server itself takes ten minutes. Order it, SSH in, and run:\r\n\r\n```bash\r\ncurl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash\r\n```\r\n\r\nThe Coolify dashboard comes up, you connect GitHub, pick a repo, and you're\r\nalmost there. Certificates and domains are a couple of clicks.\r\n\r\n## The part that actually took some thought\r\n\r\nHere are the things I wish someone had told me up front.\r\n\r\n### 1. Next.js has to build \"standalone\"\r\n\r\nFor a slim Docker image I set:\r\n\r\n```ts\r\n// next.config.ts\r\nconst nextConfig = {\r\n  output: \"standalone\",\r\n  // ...\r\n};\r\n```\r\n\r\nNext then puts everything the server needs in `.next/standalone`, and the image\r\nstays small.\r\n\r\n### 2. NEXT_PUBLIC variables are baked in at BUILD time\r\n\r\nThis is the classic trap. Anything starting with `NEXT_PUBLIC_`\r\ngets inlined into the client bundle when you build, not at startup. On Vercel\r\nthat happens automatically. In a Dockerfile you have to pass them in as\r\nbuild args:\r\n\r\n```dockerfile\r\nARG NEXT_PUBLIC_SUPABASE_URL\r\nENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL\r\nRUN npx next build\r\n```\r\n\r\nForget this and the app builds fine, but the browser gets empty values\r\nand everything breaks in production.\r\n\r\n### 3. Headless Chrome in a container\r\n\r\nMy PDFs are generated with Puppeteer. On Vercel I used a\r\nserverless-specific Chromium package. In a regular container you're better off\r\ninstalling system Chromium and pointing Puppeteer at it:\r\n\r\n```dockerfile\r\nRUN apt-get update && apt-get install -y --no-install-recommends \\\r\n      chromium fonts-liberation libnss3 libgbm1 libasound2 # ...\r\nENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium\r\n```\r\n\r\nAnd the code picks the right browser based on the environment, so local development and\r\nproduction behave the same.\r\n\r\n### 4. Migrations at startup, not at build\r\n\r\nThe build step doesn't have access to the database, and it shouldn't\r\neither. So I run the migrations in an entrypoint when the container\r\nstarts:\r\n\r\n```sh\r\n#!/bin/sh\r\nset -e\r\nprisma migrate deploy\r\nexec node server.js\r\n```\r\n\r\n### 5. Cron isn't free anymore\r\n\r\nVercel Cron was just a few lines in `vercel.json`. With Coolify it became\r\n\"Scheduled Tasks\" that call my own endpoints with a secret\r\nkey:\r\n\r\n```bash\r\ncurl -fsS -H \"Authorization: Bearer $CRON_SECRET\" \\\r\n  https://soknadsbasen.no/api/cron/jobs-sync\r\n```\r\n\r\nWhile I was at it, I made the cron routes \"fail-closed\": without the right\r\nsecret they respond 401. Better that a forgotten variable stops the job than\r\nthat anyone at all can trigger it.\r\n\r\n### 6. The long-lived WebSocket service\r\n\r\nThe real-time collaboration (Yjs over Hocuspocus) is a separate process that just\r\nneeds to sit there and run. This is exactly the kind of thing serverless makes\r\nhard, and an always-on container makes trivial. On Coolify it\r\nwas just an extra \"resource\" with its own subdomain and its own Dockerfile.\r\n\r\n## Don't forget the moving boxes\r\n\r\nThe app itself is half the job. The rest is everything around it:\r\n\r\n- Pull the environment variables out of Vercel: `vercel env pull`.\r\n- Point the Stripe and email webhooks at the new domain, and update\r\n  the signing secrets.\r\n- Add the new domain to Supabase's auth configuration.\r\n- Lower the DNS TTL a day before you move, so the cutover goes fast.\r\n\r\nAnd most important of all: **keep Vercel running until everything is verified.**\r\nI tested login, AI, PDF and a real test payment on the new\r\nsetup before touching DNS. Then rollback is just pointing the domain back.\r\n\r\n## What am I left with?\r\n\r\nI own the operations now. That's both the point and the price. I have to\r\nthink about updates, backups and monitoring myself, things Vercel hid from\r\nme. In return I get predictable cost, data in the EU, full control\r\nover long-lived processes, and a setup I understand all the way down.\r\n\r\nFor a small, paying product it was worth it. For a weekend prototype\r\nI would have stayed on Vercel.\r\n\r\n## Recommendation\r\n\r\nIf you're considering the same: start by making the app Docker-ready while you're\r\nstill on Vercel. Get `docker build` working locally first. Once\r\nthe image builds and runs on your machine, the rest is just a server, a\r\ndomain and a bit of patience.","date_published":"2026-06-10T20:47:00+00:00","date_modified":"2026-09-19T17:06:38.369+00:00","tags":["vercel","coolify","hetzner","selvhosting","docker","nextjs","devops","eu","gdpr"],"language":"en-US"},{"id":"https://marcusjenshaug.no/en/blog/coolify-does-not-build-the-packages","url":"https://marcusjenshaug.no/en/blog/coolify-does-not-build-the-packages","title":"When Coolify doesn't build the packages CI builds","summary":"Coolify builds a pnpm monorepo with pnpm filter, not with Turborepo. When internal packages export from dist/, the prod build falls over while CI stays green","content_text":"Last week I pushed a feature with a Vipps integration to stage. CI went green, all tests passed. The deploy to Coolify failed with an error message I didn't recognize: webpack complained that it couldn't find `@flowment/payments`. The package exists. It exports what I'm importing. It builds in CI without a peep. And yet, in the Docker build in Coolify, it wasn't there.\r\n\r\nIt turned out to be a difference I hadn't considered between how Turborepo builds locally and how Coolify builds in prod. That difference isn't obvious, and it fooled me for two hours before I saw what was going on.\r\n\r\n## Two different builds\r\n\r\nLocally and in GitHub Actions I run `pnpm turbo run build`. Turbo reads `turbo.json`, sees that `@flowment/web` has `\"dependsOn\": [\"^build\"]`, and builds all upstream packages first. `@flowment/payments`, `@flowment/domain` and `@flowment/db` each get their own `build` script run, which tsc-compiles TypeScript down to `dist/`. When Next.js 15 then starts its build, it finds `@flowment/payments/dist/index.js` right where it expects it.\r\n\r\nCoolify's default setup for a pnpm monorepo takes a different approach. The generated Dockerfile ends with:\r\n\r\n```dockerfile\r\nRUN pnpm install --frozen-lockfile\r\nRUN pnpm --filter @flowment/web build\r\n```\r\n\r\n`pnpm --filter` only runs the build script for `@flowment/web`. It doesn't build the packages web depends on. pnpm has no notion that `@flowment/payments` needs to be built first; that's a convention Turborepo owns. The result is that Next.js starts its build, and every `dist` folder in the workspace packages is empty.\r\n\r\nThe error from webpack ended up being:\r\n\r\n```\r\nModule not found: Can't resolve '@flowment/payments'\r\n\r\nImport trace for requested module:\r\n./app/checkout/actions.ts\r\n```\r\n\r\nThe package is installed. The symlink exists in `node_modules`. But `package.json` points at something that doesn't exist:\r\n\r\n```json\r\n{\r\n  \"name\": \"@flowment/payments\",\r\n  \"exports\": {\r\n    \".\": {\r\n      \"types\": \"./dist/index.d.ts\",\r\n      \"import\": \"./dist/index.js\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n## The obvious choice I didn't make\r\n\r\nThe immediate fix is to build the packages explicitly in the Dockerfile before Next.js:\r\n\r\n```dockerfile\r\nRUN pnpm -r --filter \"./packages/*\" build\r\nRUN pnpm --filter @flowment/web build\r\n```\r\n\r\nThat works. It's also the wrong place to solve the problem. Every time Coolify regenerates the Dockerfile after a Nixpacks update, or if I move the project to another platform, that line disappears. The build becomes dependent on me remembering a specific infrastructure detail.\r\n\r\nWhat I ended up with is removing the tsc build step for internal packages entirely. Next.js 15 transpiles workspace packages if you tell it to:\r\n\r\n```ts\r\nconst nextConfig: NextConfig = {\r\n  transpilePackages: [\r\n    \"@flowment/payments\",\r\n    \"@flowment/domain\",\r\n    \"@flowment/db\",\r\n  ],\r\n};\r\n```\r\n\r\nAnd the packages' `package.json` points straight at the TypeScript source:\r\n\r\n```json\r\n{\r\n  \"name\": \"@flowment/payments\",\r\n  \"exports\": {\r\n    \".\": {\r\n      \"types\": \"./src/index.ts\",\r\n      \"import\": \"./src/index.ts\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nNo `dist/`, no tsc step, no dependency on build order. Next.js reads the TypeScript straight from the package's source folder and runs it through its own SWC pipeline. For packages consumed by the Trigger.dev runner in the same monorepo I do the same thing, and let the bundler there handle transpilation.\r\n\r\n## What still grates\r\n\r\nEditor support is still a bit odd. TypeScript project references work, but the typegen package for Supabase expects to find compiled `.d.ts` files. That had to be solved package by package by keeping a minimal tsc emit for the `types` field, while the `import` condition points at the source:\r\n\r\n```json\r\n{\r\n  \"exports\": {\r\n    \".\": {\r\n      \"types\": \"./dist/index.d.ts\",\r\n      \"import\": \"./src/index.ts\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nThat grates against the principle that files on disk should be real files, not optional artifacts. If I were doing it over, I'd consider dropping the `exports` field entirely and pointing `main` at `src/index.ts`. Or just accepting a build step in prod and running `tsup --watch` in dev. Both are trade-offs; neither is obviously right.\r\n\r\n## The real problem\r\n\r\nCI building via Turborepo and prod building via `pnpm --filter` are not the same build. If they diverge, it's CI that's lying. I've added a separate GitHub Actions job that builds with `pnpm --filter @flowment/web build` without the Turbo cache, so the gap between the setups gets caught before it hits Coolify. That one job is a copy of Coolify's Dockerfile step, nothing more.\r\n\r\nIt should have been the first one I set up. Every time something lands on my \"only deploy to prod\" list, it has turned out that CI builds with an assumption that doesn't hold in prod. That isn't a Coolify problem, it's a general monorepo problem. The next deploy pattern I move to (Railway, Fly, Render) will have a different variant of the same thing.","date_published":"2026-05-13T20:53:00+00:00","date_modified":"2026-05-13T11:10:49.501+00:00","tags":["nextjs","pnpm","turborepo","monorepo","coolify"],"image":"https://dzfrsfecffomzwlhemzt.supabase.co/storage/v1/object/public/media/blog/15501058-4569-4622-8446-6a386718b199.png","language":"en-US"},{"id":"https://marcusjenshaug.no/en/blog/soknadsbasen-the-tool-i-couldnt-find-so-built-myself","url":"https://marcusjenshaug.no/en/blog/soknadsbasen-the-tool-i-couldnt-find-so-built-myself","title":"Søknadsbasen: the tool I couldn't find, so I built it myself","summary":"A CV tool and job application tracker that started as frustration with existing solutions, and turned into a small product I actually use myself.","content_text":"Søknadsbasen started with irritation.\r\n\r\nI've been job hunting a couple of times, and every time I've ended up with the same mess: a folder of half-finished CV files, a spreadsheet of application statuses that's out of date after three days, and a list of contacts I can't keep track of. There are plenty of tools for this, but most of them are either too simple to be useful, or so packed with features that they require an onboarding process just to send a single application.\r\n\r\nI wanted something in between. Something I would actually open.\r\n\r\nSo I built it myself.\r\n\r\n## The problem with existing tools\r\n\r\nMost job search tools think of job hunting as a list. You add a position, set a status, maybe jot down a note. That's fine for the first few weeks. After a month of active searching you have fifty rows in that list, and you've lost the context on most of them.\r\n\r\nWhat I was missing was a way to think about job hunting as something more structured, an ongoing process with phases, not just a register of what you've submitted.\r\n\r\nWhat I ended up calling \"job search sessions\" was really this: that a job search period is its own object in the system. It has a start, it has a goal, it has associated applications, and it has an outcome. That lets you look at a single application in the context of the whole period it belonged to, not detached from everything else.\r\n\r\nIt might sound complicated, but it's intuitive once you use it. It's simply the right level of abstraction for the problem.\r\n\r\n## Building for yourself is a different exercise\r\n\r\nI'm used to building for clients, or for users I never meet. That's a kind of discipline: you have to make things explicit, you have to write onboarding, you have to help someone who doesn't know the code understand what the button does.\r\n\r\nBuilding for myself opened up shortcuts that were tempting and dangerous at the same time.\r\n\r\nThe positive version: I didn't need to build everything. I didn't need an admin panel to edit data, I could just go into the database. I didn't need a fancy onboarding flow, I already knew how things worked. That saved me a lot of time on things that didn't matter.\r\n\r\nThe negative version: I put too much implicit knowledge into the product. Functionality that makes sense to me, but that wouldn't make sense to anyone else. That's not a problem right now, but it's a limitation if I ever want to let others use it.\r\n\r\nThe lesson: build for yourself with a clear awareness of what breadth you're sacrificing. It's a deliberate choice, not a bug.\r\n\r\n## The CV builder is the hardest part\r\n\r\nA job application tracker is really just a database with an interface. It's not trivial, but it's logical.\r\n\r\nA CV builder is something else entirely.\r\n\r\nCVs have opinions about everything. Font choices send signals about industry and seniority. Layout hierarchy affects what the recruiter sees in the first three seconds. What you include and what you leave out are editorial choices, not just filling in fields.\r\n\r\nI spent far more time on the CV part than I expected. Not on the technical implementation, but on thinking through what a CV tool should actually help you with. Is it making the CV look pretty? Is it helping you choose what to include? Is it generating text?\r\n\r\nI landed on control and predictability being the most important things. The exported PDF should look exactly like the preview. Changes should be reflected immediately. You should never wonder whether what you see is what the recruiter sees.\r\n\r\nThat's a lower ambition than \"AI-generated CV tailored to the position\", but it's something I actually trust.\r\n\r\n## Technical choices I'm happy with\r\n\r\nNext.js and Supabase are my defaults now, and they worked fine here too. It's not exciting to write about, but that's the point: default choices that work let you spend your energy on the product, not on infrastructure.\r\n\r\nTwo choices I'm especially happy with:\r\n\r\nModeling job search sessions as a first-class model in the schema, not just as a tag or a status on the application. That gave me query capabilities I hadn't thought about when I designed it, and it made it natural to show aggregated statistics per session.\r\n\r\nLetting PDF generation happen server-side. I considered client-side PDF generation for a long time because it's easier to set up. But server-side gives consistent rendering regardless of browser, and that's worth the complexity for something that's the core of the product.\r\n\r\n## What I underestimated\r\n\r\nFile handling is always more complicated than it looks. I wanted to let users upload a profile picture for their CV, and I estimated half an hour for the setup. It took a day and three rewrites, and I'm still not a hundred percent happy with the flow.\r\n\r\nThat's nothing unusual in itself. What surprised me was that I underestimated it even after having done it before. Something about file handling can't be fully taught, it's something you have to meet anew in the context of this exact project.\r\n\r\nI also underestimated how much time I would spend designing the CV template itself. Technically it isn't hard. Aesthetically it's infinitely tweakable. I set myself a hard stop rule after the third iteration: this is good enough, not good enough to be distracting.\r\n\r\n## What I would have done differently\r\n\r\nBuilt more of the admin panel earlier. I edited directly in Supabase at the start, exactly as I learned from the Klink project, and forgot the lesson anyway.\r\n\r\nDecided on a PDF library on day one. I switched between three alternatives and lost time on every transition. Next time I'll sit down and evaluate properly once, not iterate through them in production.\r\n\r\nBeen clearer about what the product is not. Without that boundary, scope is open at both ends, and that's expensive.\r\n\r\n## Why it was worth it\r\n\r\nSøknadsbasen is not a big product. It's a small tool that helps one person, me, keep track of something that otherwise creates friction.\r\n\r\nBut it was the first time I built something I actually used actively while building it. That gives a different kind of feedback than looking at analytics or reading user reports. You feel it in your body when something doesn't work well enough, because you're frustrated by it yourself.\r\n\r\nIt's a luxury most product developers don't have, being your own most demanding user. I try to carry that lesson into other projects too: understand the user's frustration so well that you feel it yourself.\r\n\r\nSøknadsbasen lives at søknadsbasen.no. It's not ready for the general public yet, but that's where it's headed.","date_published":"2026-04-24T09:39:52.799+00:00","date_modified":"2026-04-24T09:39:52.799+00:00","tags":["solodev","nextjs","supabase","produktutvikling","jobbsøking"],"image":"https://dzfrsfecffomzwlhemzt.supabase.co/storage/v1/object/public/media/blog/262de503-66ca-49a7-ae0a-7e8f21cd5b96.png","language":"en-US"},{"id":"https://marcusjenshaug.no/en/blog/two-and-a-half-years-as-sole-developer","url":"https://marcusjenshaug.no/en/blog/two-and-a-half-years-as-sole-developer","title":"Two and a Half Years as the Only Developer","summary":"A retrospective after building Eiendomsavtaler.no from the first line of code. From contracted consultant to founding developer, through three technical generations.","content_text":"I joined Eiendomsavtaler.no in the autumn of 2023 — first as a contracted consultant through my own agency, [Spiderweb AS](/en/projects/spiderweb). There was no technical platform yet. The job was to build it. In January 2025 I became a full-time employee, and that same year I wound down Spiderweb to focus fully on Eiendomsavtaler. I was the only developer. Everything from architecture to production, from security to monitoring, sat with me.\r\n\r\nIn May 2026 I'm handing it over. The platform is in stable operation, has been for a long time, and there's no dramatic reason for me leaving — I'm moving on to Redi AS to build other things. But the transition is a good occasion to write down what I've actually learned. Not in LinkedIn format with \"drove growth and optimized performance\", but what stays in your bones after two and a half years.\r\n\r\n## Three generations in two and a half years\r\n\r\nThe first thing you need to know is that I built the platform through three technical generations.\r\n\r\n**Generation 1** was a WordPress installation. Advanced Custom Fields for structured forms, WP User Frontend for uploads, WP Statistics for tracking — the plugin stack you end up with when you need to get going fast and validate that the product has a place in the market. I chose it deliberately. An MVP is meant to ship, not to be architecturally impressive.\r\n\r\n**Generation 2** was my first Next.js app — an investor platform, a side project within the same organization. I wrote it while I was still running the WordPress site. In practice it was my training ground: the first time I worked with the App Router, server components, server actions. It's still alive, but it never became the main product channel.\r\n\r\n**Generation 5** (yes, we skipped 3 and 4 — more on that below) is the one running today. A full rewrite to TypeScript, Next.js, PostgreSQL. No WordPress left. This is what I'm handing over.\r\n\r\n## Why the version numbering jumps\r\n\r\nVersions 3 and 4 aren't rewrites — they're architecture drafts that never got deployed. I first tried a heavier approach with a separate backend API and Next.js as a pure frontend. It was oversized for a team of one. Then I tried an architecture with more specialized modules. Also oversized.\r\n\r\nWhat I landed on in Version 5 was boring: one Next.js app with server actions, PostgreSQL as the primary database, image processing via sharp, type validation with zod. No microservices, no separate APIs, no event bus I'd spend three months setting up properly.\r\n\r\nThe most important lesson from the discarded versions was that **complexity punishes you exponentially when you're alone**. Every abstraction you add is something only you know, only you can debug, only you can change. And two years from now, not even you are sure you remember why.\r\n\r\n## Why I took it\r\n\r\nSole-developer jobs are polarizing in the industry. Half the people warn you: no pair programming, no code review, no one to spar with, full responsibility when things break at three in the morning. The other half talk about ownership, speed and the absence of committees.\r\n\r\nBoth sides are right. Neither side prepares you for what it's actually like to do it.\r\n\r\nThe reason I said yes wasn't the romance of \"building everything myself\". It was that I wanted to know whether I *could*. I had worked in teams for several years, and I'd always had a feeling that when something went well, it was because the team was good. I didn't know what was me and what was them. Being the only developer is the only way to find out.\r\n\r\n## What I underestimated\r\n\r\nThe first months went to technical decisions. Framework, hosting, database, auth, monitoring. I had an architecture I was happy with on paper, and I got it running.\r\n\r\nWhat I hadn't planned for was *everything else*. A sole developer isn't just the developer. You're also:\r\n\r\n- The security officer who has to keep up with CVEs\r\n- The DevOps engineer who owns the deploy pipeline\r\n- The database administrator who has to answer \"why is it slow now?\"\r\n- Support when something doesn't work for a customer at 4:30 on a Friday afternoon\r\n- The tech lead who has to say no to features that are cool but unnecessary\r\n- The product person who translates \"couldn't we have a button that...\" into something actually buildable\r\n\r\nThe last one is what I was least prepared for. Code is a surprisingly small part of the job when you're alone. Far more of the day goes to communication, prioritization and explaining why things take the time they take.\r\n\r\n## WordPress had some of the answers\r\n\r\nOne of the most surprising things along the way was how much WordPress actually got right. I had developed in WordPress before, and I knew the stack has a bad reputation among more \"modern\" developers. But it solves real problems very effectively:\r\n\r\n- **Structured forms without writing them** (ACF)\r\n- **User-generated content with moderation** (WP User Frontend)\r\n- **Analytics without third-party trackers** (WP Statistics)\r\n\r\nWhen I built v5, I had to re-implement each of these. And that's *a lot* of code for something that used to be filled in in fifteen minutes in an admin panel. What I gained was type safety, better performance, and control over the entire stack. What I lost was development speed on things WordPress had simply solved.\r\n\r\nMy conclusion after the migration isn't \"WordPress is bad\" or \"self-built is better\". It's that **choosing a framework is a calibration exercise, not a principle**. WordPress was the right choice for the phase the platform was in. It isn't right for this phase. The next phase will show whether v5 is still the right choice.\r\n\r\n## Being your own code reviewer\r\n\r\nThe single heaviest exercise in being alone is assessing your own work soberly. It's well known, a lot has been written about it, and still it was harder than I thought.\r\n\r\nWhen you're on a team, you get pushback. Someone asks \"why did you do it that way?\", and you have to defend the choice — and sometimes you realize mid-defense that you don't have a good answer. Without that counterpart, the defense disappears too. You write code, you merge it, you forget why.\r\n\r\nWhat I landed on was two simple things:\r\n\r\n**Writing small commits with clear messages.** Not because anyone would read them later — no one would — but because I, the one writing them, forced myself to articulate *what* I had done and *why*. At least I got halfway to a justification.\r\n\r\n**Letting it sit overnight before merging when it was big.** The evening glow after something finally works is the worst reviewer. Morning-me is a stricter colleague than evening-me.\r\n\r\nNeither of these replaces a real pair. But the two together meant I took fewer dumb shortcuts than I otherwise would have.\r\n\r\n## On building for one client you never meet\r\n\r\nEiendomsavtaler.no has real users. Brokerage professionals, real estate players, people who use the platform to close actual deals. I have never met any of them face to face. What I knew about them, I knew through the business side — people who translated needs to me and took my technical answers back to them.\r\n\r\nThat shaped how I worked. I had to get better at asking questions that didn't assume I'd seen the problem myself. \"What is the user trying to do when this fails?\" instead of \"what's the error?\". The first gives you context. The second gives you a stack trace.\r\n\r\nWhen you work alone without direct user contact, the business side is your only bridge to reality. I learned to respect that role a lot more than I did before.\r\n\r\n## The performance work I learned the most from\r\n\r\nOne of the most instructive exercises in v5 was the performance work. I kept a simple audit file in the repo where I noted which pages dragged, what caused it, and what I did to fix it. Nothing advanced — just a markdown file that grew over time.\r\n\r\nWhat I learned from that exercise was that most performance problems are boring. The solution is rarely \"switch to a faster runtime\" or \"implement a caching strategy\". More often it's that an image isn't lazy-loaded, a database query fetches more than necessary, or a component renders client-side because the developer (that is, me) forgot to check whether it could run server-side.\r\n\r\nBoring fix, big win. That kind of work isn't rewarded by the tech community — nobody gives talks on \"I added the `sizes` attribute to `<Image>`\" — but it's what actually makes the difference in Core Web Vitals.\r\n\r\n## Why simple solutions won\r\n\r\nI started the project with an architectural ambition that was a bit too big for the problem. Microservices here, event-driven there, a queue for the heavy jobs, Kubernetes at some point in the future. None of it was technically wrong — it was just the wrong *size*.\r\n\r\nWhat I ended up with was much closer to boring. A monolith with clear boundaries between modules. A primary database with a replica. A cache layer in the obvious places. CI/CD that's straightforward enough that I can make changes to it without being afraid of breaking it. Monitoring that gives me two or three metrics I actually keep an eye on, not twenty I ignore.\r\n\r\nThe reason this worked isn't that I was right in my choices. It's that I was the only developer, and complexity punishes you exponentially when you're alone.\r\n\r\nIf I were building the same platform today, I'd start even simpler.\r\n\r\n## What was worth it\r\n\r\nWhat I take with me isn't that I managed it — that was never the question. What I take with me is that I now have a calibration for what actually costs time when you own the entire stack. I know what \"we just need to get it out\" means for a real-world deploy, not just for a sprint. I know what gets choked when an architecture is too big for the team that has to run it. I know what you gain by moving away from a ready-made framework, and what you lose.\r\n\r\nThat calibration is hard to get any other way. It costs you two and a half years, a fair number of weekends and some evenings you wish you could have back. But it sticks once you have it.\r\n\r\n## What happens now\r\n\r\nThe platform lives on without me — the handover has been orderly, the documentation is there, and the new team has access to everything they need. I'm moving on to [Redi AS](https://redi.as) and fullstack development on a multi-tenant product, where I'm one developer among several. It will be a different job in ways I probably still underestimate.\r\n\r\nBut I think it's easier to be part of a team when you know what you actually contribute — and what you need from the others.\r\n\r\nYou'll find the platform at [eiendomsavtaler.no](https://eiendomsavtaler.no).","date_published":"2026-04-20T16:50:00+00:00","date_modified":"2026-04-22T14:51:18.964+00:00","tags":["arkitektur","solo-utvikling","retrospektiv","plattform","wordpress","nextjs"],"image":"https://dzfrsfecffomzwlhemzt.supabase.co/storage/v1/object/public/media/blog/047683c6-4f47-43d0-bc4b-46989a64751c.png","language":"en-US"},{"id":"https://marcusjenshaug.no/en/blog/what-i-learned-building-klink-alone","url":"https://marcusjenshaug.no/en/blog/what-i-learned-building-klink-alone","title":"What I learned from building Klink alone","summary":"A drinking-game web app that was supposed to be a weekend project, and turned into a lesson in scope, architecture and actually shipping something finished.","content_text":"Klink was supposed to be a weekend project. One evening on the couch with an idea for a Norwegian drinking game in the browser, a domain that was available (klinkn.no — klink.no was taken), and the notion that I could ship something \"quickly\". Six months later the app is in production, has PWA support, a Hot Seat timer, custom game packs and an easter egg triggered by tapping the logo ten times.\r\n\r\nHere is what I learned along the way. None of it is particularly original — but these are things I now actually know, rather than just nod along to when someone else writes them.\r\n\r\n## Scope is the most important decision\r\n\r\nThe first mistake I made was thinking: \"it's just a drinking game, this is simple\". It isn't. Even a \"simple\" product has hundreds of micro-decisions — how player names get interpolated into cards, how you handle a player who drops out halfway through, how you share a game via QR code without creating user accounts.\r\n\r\nWhat I did right was committing early to a few deliberate constraints:\r\n\r\n- **No user accounts.** Player state lives in sessionStorage. You open the app, add players, play, close the tab. No DB writes per session, no auth, no GDPR overhead.\r\n- **No multiplayer.** One device, passed around between the players — exactly like a physical deck of cards.\r\n- **One language.** Norwegian. Klink is a Norwegian concept for a Norwegian context. i18n is a chore I don't need right now.\r\n\r\nEach of these decisions eliminated weeks of work. The most important skill on a solo project isn't building fast — it's saying no to things that don't belong yet.\r\n\r\n## Server-first is mostly right, but not always\r\n\r\nI work server-first by default: Next.js App Router, server components, data on the server, client components only when needed. It's almost always the right call.\r\n\r\nKlink is one of the rare cases where it isn't right for everything. The card logic (shuffle, next card, interpolating names) runs entirely in the client with React Context, because:\r\n\r\n1. The game has to feel instant. No latency between \"tap next\" and \"new card appears\".\r\n2. It has to work offline. PWA support means the app must be able to run without a network.\r\n3. It isn't sensitive. Who gets which card can leak to the client — it is the client.\r\n\r\nWhat I use the server for is fetching game packs and cards from Supabase, and that's ISR-cached with a long revalidation time. I like this split: knowledge on the server, behaviour on the client.\r\n\r\n## Fisher-Yates, and why Math.random is good enough\r\n\r\nA drinking game needs the cards to be shuffled. The first version used `array.sort(() => Math.random() - 0.5)`, which is a classic antipattern — it isn't uniform, and some cards statistically end up early or late in the deck more often than they should.\r\n\r\nI switched to [Fisher-Yates](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle):\r\n\r\n```typescript\r\nfunction shuffle(array) {\r\n  const result = [...array];\r\n  for (let i = result.length - 1; i > 0; i--) {\r\n    const j = Math.floor(Math.random() * (i + 1));\r\n    [result[i], result[j]] = [result[j], result[i]];\r\n  }\r\n  return result;\r\n}\r\n```\r\n\r\nI briefly considered using `crypto.getRandomValues()` to get cryptographic randomness. Then I reminded myself that this is a drinking game. `Math.random()` gives you biased bits in the fourth decimal place. Nobody is going to notice.\r\n\r\nA principle I try to remember: the solution should be as robust as the problem requires, not as robust as the problem could conceivably require.\r\n\r\n## The design is the difference\r\n\r\nKlink isn't the only Norwegian drinking game online. There's one called Børst that is well built and has been around for a long time. I spent time choosing a visual identity that couldn't be confused with it:\r\n\r\n- Lime and dark green as the dominant colours (Børst is blue/orange)\r\n- A sans-serif with character, not something generic\r\n- Glassmorphism with white container cards on coloured backgrounds\r\n- An easter egg — \"Athina mode\" — triggered by tapping the logo 10 times, which switches the whole app to leopard print + pink. The more little touches like this, the more personality.\r\n\r\nThere's a paradox here. The technical side of Klink is solid craftsmanship but not impressive — Next.js and Supabase do 90% of the work. The design decisions are what people remember. That taught me to care more about details I would previously have delegated.\r\n\r\n## The deploy flow that actually holds up\r\n\r\nOn Eiendomsavtaler.no I built a CI/CD pipeline with stages, tests, staging, approvals, the works. That was the right call there.\r\n\r\nOn Klink I have: `git push origin master`. Vercel merges to production if the build passes. That's it. No staging. No manual approval. No \"post-deploy smoke test\".\r\n\r\nThis isn't laziness — it's the right size for the problem. Klink has one developer, no SLA, no customers bleeding money if the site is down for ten minutes. The complexity of a CI pipeline has to be proportional to the cost of a failure.\r\n\r\n## What I would have done differently\r\n\r\n1. **Written the database schema on paper first.** I changed the game pack and card tables four times because I hadn't thought through the relations. Supabase migrations are easy enough to roll out, but it eats time.\r\n2. **Built an admin panel earlier.** I edited cards directly in the Supabase dashboard for months. A simple form would have saved me hours.\r\n3. **Not left the PWA setup until the end.** next-pwa is fairly straightforward, but service workers have their own caching rules that clash with Next.js ISR if you don't think it through. Easier to set it up from day one.\r\n\r\n## Why it was worth it\r\n\r\nKlink doesn't have thousands of users. It isn't a business. But it was the first app I shipped from idea to production completely on my own — no team, no customers, nobody to blame if something wasn't good enough.\r\n\r\nIt taught me that I can deliver an entire product by myself. That's a different kind of confidence than delivering well within a team. Both matter, but you only get the first one by finishing something entirely alone.\r\n\r\nYou'll find the app at [klinkn.no](https://klinkn.no). The code is [on GitHub](https://github.com/MarcusJenshaug1/klink). The code contains a surprise or two.","date_published":"2026-04-02T04:23:00+00:00","date_modified":"2026-04-22T14:30:16.224+00:00","tags":["nextjs","supabase","pwa","sideprosjekt"],"image":"https://dzfrsfecffomzwlhemzt.supabase.co/storage/v1/object/public/media/blog/8637523f-9af6-44eb-bc9f-09d646d966b1.png","language":"en-US"}]}