# Wonsuk Choi — Full Content > Full-stack engineer and founder building products in fragmented markets — healthcare, immigration, legal, and real estate — designed to grow organically through search. ## About Wonsuk Choi is a full-stack engineer and serial founder based in Seoul, South Korea. He builds web products in underserved verticals — immigration, healthcare, legal, and real estate — designed to grow through organic search rather than paid acquisition. He has shipped 13+ live products across multiple markets and languages, using a single Next.js + Supabase stack. Currently open to technical co-founder roles and consulting engagements on web product architecture, multilingual SEO, and growth infrastructure. ## Projects - [Expert Sapiens](https://expertsapiens.com): Expert marketplace built for the age of AI agents hiring humans - [Apostille First](https://apostillefirst.com): Apostille and document legalization service for Korea-issued documents - [Mr. Visa Korea](https://mrvisakorea.com): AI-powered visa guidance for navigating South Korean immigration - [Airlinkee](https://airlinkee.com): Professional link page platform for experts — share services, booking, and credentials from one page - [Seoul Homes](https://seoulhomes.kr): Seoul real estate listings and property search for foreign buyers and renters - [Medipaths](https://medipaths.co): Medical information and symptom checker - [Online Medical Tools](https://onlinemedicaltools.com): Free clinical calculators and tools - [MassageGo](https://massagego.co): Massage booking platform - [생활수리](https://생활수리.com): Home repair services marketplace (Korean) - [EngageOnX](https://engageonx.com): X (Twitter) engagement tool ## Pages - [Home](https://wonsukchoi.com/en) - [Blog](https://wonsukchoi.com/en/blog) - [Contact](https://wonsukchoi.com/en/contact) ## Contact - Email: wonsukchoi97@gmail.com - GitHub: https://github.com/wonsukchoi - X: https://x.com/wonsukchoi_ - LinkedIn: https://www.linkedin.com/in/wonsuk-choi-5514b460/ --- # Blog Posts ## [Sovereignty Through Ownership: Why It Matters More Now](https://wonsukchoi.com/en/blog/sovereignty-through-ownership) > Every platform you depend on can change its terms, raise its prices, or shut down tomorrow. The antidote isn't diversification, it's ownership. Here's what I learned running a portfolio of sites, a personal AI agent, and 130+ cron jobs on hardware I control. # Sovereignty Through Ownership: Why It Matters More Now Two years ago, I made a decision I didn't fully understand at the time: I stopped renting and started owning. Not real estate. Infrastructure. Every tool, every platform, every service that my work depends on, I asked one question: *if this company disappears tomorrow, or changes its terms next month, or triples its price in a year, what happens to me?* The answer, for most of my stack, used to be: "I'm screwed." Today it's: "I migrate." This article is about why ownership, of code, of infrastructure, of the systems that generate your income, matters more in 2026 than it ever has. And it's not a paranoia piece. It's a return-on-investment piece. ## What I Actually Own Let me be concrete about what "sovereignty through ownership" means in practice, because it sounds abstract until you see the receipts. **28 websites** that I built, whose code lives in git repos I control, deployable to any server at any time. **A personal AI agent** (Hermes) running on my own VPS, coordinating a small army of cron jobs: watchdogs, content scanners, SEO audits, health checks. If the agent company vanished tomorrow, the code is open source and the jobs run on my hardware. **A password manager** (Vaultwarden) self-hosted on my own server. My credentials aren't in a SaaS database that can be breached, sold, or locked. **A search engine** (SearXNG) self-hosted. No API keys, no rate limits, no "your quota is exhausted" emails. **A portfolio of sites** that generate traffic and income without me being present, because the automation runs whether I open a laptop or not. None of this required heroic engineering. It required a decision about *where* things live. ## The Rental Economy of the Internet Here's the uncomfortable truth: most people who "build online" are renters. They don't own their audience (platform algorithm does). They don't own their content (platform ToS does). They don't own their traffic (platform ranking does). They don't own their tooling (platform pricing does). Every one of those platforms is a landlord. And landlords raise rent. I've watched it happen my whole career: - SaaS products with reasonable pricing that got acquired and quadrupled their prices - APIs that were free, then rate-limited, then paid, then deprecated - Platforms that changed their algorithms overnight and eviscerated businesses built on them - Tools that were open, then closed, then discontinued - Companies that promised "we'll never charge for this" and then charged The pattern is always the same. You build on someone else's land, and one day the land changes hands or the rules change. You didn't do anything wrong. You just didn't own the ground. **Ownership isn't about being anti-platform. It's about never being *dependent* on a platform.** ## Why It Matters More Now This isn't a new idea, but 2026 is a genuinely different moment, for three reasons. ### 1. AI made the cost of building your own things collapse Five years ago, "self-host everything" meant a weekend of Docker configs and a month of maintenance. Today, I describe a system in plain language to an AI agent and it's built in hours. Claude Code writes the code. My agent runs the ops. I review and decide. The marginal cost of owning something vs renting it has collapsed. When building is cheap, renting is a bad deal, because you're paying a premium for convenience you no longer need. ### 2. AI models and platforms are the new landlords The hottest platforms right now are AI providers. And they're behaving exactly like every other platform: changing models, deprecating versions, re-pricing, re-scoping what's included. I run my agent on open models through providers I can swap in a config file. If one provider's prices double, I change one line and move on. The people who are most exposed right now are the ones who built their entire business on a single model's API, with no abstraction layer, no fallback, no ability to move. They're renting land that's actively being re-zoned. ### 3. The stakes are higher, because your work *is* your income When I was freelancing, losing a tool was an inconvenience. Now my sites generate income autonomously. The systems that produce that income are the most valuable assets I own. Leaving them on rented land, a single SaaS that can lock, a single hosting provider with no migration path, is a business risk, not a technical preference. ## What Ownership Actually Costs Let me be honest about the trade, because "self-host everything" as a religion is as dumb as "SaaS everything" as a religion. **Ownership costs:** - Your time upfront (building, migrating, configuring) - Occasional maintenance (updates, security patches, disk cleanup) - Some convenience (I don't have 47 integrations; I have a few good ones) - Responsibility (when something breaks, it's my problem) **Renting costs:** - Recurring fees forever (they compound) - Lock-in that grows every year - Terms that change without consent - The risk of sudden, total, unilateral removal The interesting thing is that after the upfront cost, my owned infrastructure got *cheaper to run* than the rented stack it replaced. My entire operation, 28 sites, the agent, the automation, runs on servers I control for a fraction of what the equivalent SaaS stack would cost monthly. And every month, the gap widens, because rents rise and ownership doesn't. **The real cost of ownership is attention, not money.** That's why it matters that the maintenance itself is automated. My watchdogs check my servers. My cron jobs fix issues before I wake up. My agent reports only what needs a human decision. Ownership that requires constant babysitting isn't sovereignty, it's a second job. Ownership that runs itself is freedom. ## The Three Tiers of Ownership When people hear "sovereignty through ownership," they picture a basement full of servers. They're wrong. Ownership is a spectrum, and you should own *in proportion to how much you depend on the thing*. **Tier 1, Own the income source.** Your business, your content, your audience relationship, your products. This is non-negotiable. If you don't own the thing that makes you money, you don't own anything. **Tier 2, Own the critical infrastructure.** The systems your income depends on: your code (git, portable), your data (exportable, in standard formats), your deployment (redeployable anywhere), your credentials (in your control). If a provider dies, you should be able to restore operations in hours, not months. **Tier 3, Rent the conveniences.** Things that genuinely don't matter if they vanish: notification services, minor analytics, niceties. Rent these freely, but know exactly which tier each dependency is in. The failure mode of most people is tier confusion: they rent their *income source* (their audience on a platform, their business on a marketplace) while over-owning conveniences (self-hosting a blog comment system nobody reads). Own what you depend on. Rent what you don't. ## Preparation, Not Paranoia People sometimes hear "what if this company disappears" and think it's fear. It's not. It's an insurance policy, and insurance is a normal business expense. I don't expect my providers to fail. I expect *something* to fail, and I've built so that whatever it is, the blast radius is small. That's the difference between paranoia (fear of specific outcomes) and preparation (accepting that outcomes are uncertain and building accordingly). Preparation looks like: - Code in git repos I control, not just on a platform's servers - Data in standard formats I can export - Config in files I can read, not only in dashboards I can't - Multiple paths for anything critical - The ability to migrate, tested (I've actually done it, moved projects off one host to another when the math stopped making sense) **Preparation is what lets me sleep through the night without checking dashboards.** The system handles the routine; I handle the decisions. That's the whole point of building it. ## The End Goal Here's the part I think about most. The end goal of all this building isn't the artifacts. It's not 28 sites, or a portfolio. It's **making yourself optional to your own system.** A system that survives your absence, that runs while you're on a bus with no WiFi, that generates income while you're on a trip, that alerts you only when a human decision is genuinely needed, that's not a business tool. That's freedom. The day the answer to "what should I do today?" is *whatever I want*, not *what the system needs*, that's the destination. And you get there by owning the ground you stand on. Build things that survive you. Own the systems that pay you. Rent the rest. That's sovereignty through ownership. And it matters more now than it ever has, because for the first time in history, the tools to achieve it are cheap enough that almost anyone can. The only question is whether you'll keep renting, or start owning. --- ## [Supabase Cost: One Project vs. Many — What Running 13 Sites Taught Me](https://wonsukchoi.com/en/blog/supabase-cost-shared-vs-per-tenant) > Supabase's pricing tiers aren't the real cost lever — how many projects your architecture needs is. Here's the shared-vs-per-tenant tradeoff from running 13 sites on 5 Supabase projects. ## Why This Post Exists I run 13 live sites — expertsapiens.com and its tenant sites, this portfolio, a handful of side projects — and every one of them touches Supabase somewhere. When people ask what Supabase actually costs to run in production, the honest answer isn't a single number. It's an architecture decision, and the decision matters more than the sticker price on any given tier. ## The Free Tier Is Genuinely Fine, Until It Isn't Supabase's free tier gives you a real production-grade Postgres database, auth, storage, and edge functions — not a crippled trial. For a single low-traffic project, it can run indefinitely at $0. The two things that push you off it are usually the same two things everyone hits: the database gets paused after a week of inactivity (fine for a side project, not fine for anything with real users), and you outgrow the row/storage/bandwidth ceilings once actual traffic shows up. Pro tier removes the pause-on-inactivity behavior and raises those ceilings, with additional cost scaling from there based on database compute size, storage, bandwidth, and any add-ons (point-in-time recovery, extra compute, read replicas). If you're mapping out a budget, Supabase's own pricing page has the current numbers — what I want to cover here is the decision that changes those numbers more than anything else: how many Supabase *projects* you actually need. ## The Real Lever: One Project vs. Many This is the part that isn't on the pricing page. If you're running more than one site or app, you have two options: - **One Supabase project per site.** Clean isolation, simplest mental model, and you pay a full Pro-tier bill (or more) per site the moment any one of them needs more than the free tier. - **One shared Supabase project across multiple sites**, with tenant isolation enforced by Row-Level Security instead of by separate databases. I run the second model. My own ops dashboard checks the health of all 13 of my live sites, and when it pings each site's Supabase instance, those 13 sites resolve down to just **5 unique Supabase projects** — because most of them share infrastructure. That's not a cost hack layered on top; it's the same [multi-tenant architecture](/en/blog/multi-tenant-saas-single-supabase-database) I use for the ExpertSapiens platform itself, where one codebase and one database serve multiple tenant domains, and tenant context resolves from the request hostname. The cost implication is straightforward: a shared project's paid-tier bill gets amortized across every site it serves, instead of each site independently crossing into its own paid tier. Six side projects on six separate free-tier projects is six separate pause-on-inactivity clocks and six separate ceilings to babysit. Six side projects sharing one well-configured Pro project is one bill, one set of ceilings, and — done right — one set of Row-Level Security policies doing the isolation work that separate databases would otherwise do for you. ## What You Give Up for That Savings This isn't a free lunch, and pretending otherwise would be dishonest. Sharing a project across tenants means: - **RLS has to be airtight.** Every table needs correct row-level policies, because the database boundary that used to separate two sites is gone. A missing or wrong policy on a shared project is a cross-tenant data leak, not just a bug. I wrote up the [production RLS patterns](/en/blog/supabase-rls-production-patterns) I actually use for this, because it's the part that has to be right, not just working. - **Noisy neighbors are real.** A traffic spike or a runaway query on one tenant can affect database performance for every other tenant sharing that project. Isolated-per-project setups don't have this problem. - **Blast radius grows.** A misconfiguration, an accidental migration, or downtime on a shared project affects every site on it at once, not just one. ## How I'd Decide, If I Were Starting Over - **A single serious app with real users:** its own project, full stop. Isolation is worth the extra cost the moment revenue or user trust is on the line. - **A handful of side projects, prototypes, or low-traffic tools:** share a project. The free-tier pause behavior and per-project ceilings hurt more than shared-tenant risk does at that scale, as long as your RLS policies are actually correct. - **A genuine multi-tenant product** (a SaaS with multiple customer organizations, or a directory platform with multiple tenant sites like ExpertSapiens): shared project, RLS-based isolation, from day one. Bolting this on after you've already shipped with per-tenant databases is a much harder migration than starting with it. ## The Actual Answer to "What Does Supabase Cost?" For one project: whatever the current free or Pro tier costs, which you can check directly on Supabase's pricing page. For running several projects or a multi-tenant product, the number that actually matters isn't the per-project price — it's how many projects your architecture requires you to pay for in the first place. That's the decision I'd spend time on before I spent time optimizing anything else. If you're deciding between per-tenant databases and a shared RLS-isolated project — or you've already picked one and it's starting to hurt — I've built both, and I'm glad to talk through what actually breaks at scale. **[Get in touch →](/contact)** --- ## [Hermes: My Personal AI Agent, and What It Actually Automates](https://wonsukchoi.com/en/blog/running-hermes-ai-ops-agent) > A markdown vault synced across devices, a cron-scheduled agent that runs weekly reflections on my own journal, and the line between script-only jobs and ones that need a real LLM. Here's what's actually running, and what it's caught. ## What Hermes Actually Is Hermes is a scheduled agent, not a chatbot I talk to. It runs on a cron, reads a plain markdown vault, and does specific jobs — some of them a shell script could do, some of them need an actual model reading the content. The vault itself is a PARA-style folder structure I call HQ: `inbox/` for unsorted dumps, `areas/` for ongoing life domains (finance, health, career, one folder per active project), `reference/` for lookup docs I rarely edit, `journal/` for daily notes named `YYYY-MM-DD.md`, `automations/` for the scripts and schedules themselves. One concern per file, dates in filenames so everything sorts chronologically. No database, no app — just markdown, synced with git. ## Two Kinds of Automation: no_agent vs Real LLM Every automation gets logged with what it does, when it runs, and how — and the "how" splits cleanly into two categories. **Script-only (`no_agent` mode).** Deterministic jobs that don't need a model at all. My git sync between devices runs every 2 hours this way: pull, push, done. Reminders for known dates (a bill due, a visa renewal) are the same — the script reads a date, compares it to today, sends a notification. No LLM needed because the logic is "if date == today, notify." Running these as full agent invocations would be pure token overhead for something `cron` + `bash` already does correctly. **Full agent (scheduled cloud routine).** Jobs that need judgment — reading unstructured text and deciding something. My weekly reflection runs every Sunday at 8pm: it reads the week's journal entries, looks for repetitive tasks that show up often enough to be worth automating, and cross-checks a goals file for milestone dates that have already passed without being marked hit. That last check isn't pattern-matching a regex — it's reading a date, reading today's date, and reasoning about whether a milestone should already have fired. It caught exactly that once: a milestone date had passed silently, un-flagged, until the reflection routine noticed and surfaced it for a manual re-date. The split matters because it's tempting to route everything through an LLM once you have one running on a schedule. Most of what I actually automate doesn't need one. ## The Failure Mode: an Agent That Reviews Itself The weekly reflection catching its own missed milestone is the part worth dwelling on. It's not a human noticing a bug and writing a test for it — it's a scheduled agent reading the same vault a human would read, applying the same "wait, this date already passed" logic a human would apply, and surfacing it before I would have noticed on my own. The automation isn't just running tasks, it's auditing whether other tasks — and other automations — are actually keeping up. That's a different design goal than most personal-automation setups, which tend to stop at "remind me of X on schedule Y." Building in a layer that periodically asks "is anything here stale, silently failed, or worth turning into its own automation" is what makes this feel less like a pile of cron jobs and more like an actual ops system. ## The Local Viewer Because the vault is just markdown files, I built a single-file, stdlib-only Python server (~700 lines, zero dependencies) to browse it without opening a folder tree in Finder. It walks the workspace lazily — it never descends into a ~38GB media folder unless I explicitly open it — renders markdown, previews images, aggregates open todos across every file, surfaces recently-edited notes, does full-text search, and lets me edit a note in place from the browser. One command, opens on `localhost:8787`, stays local to the machine. The point of keeping it stdlib-only: this is a personal tool I want to still work in five years without an `npm install` fighting dependency rot. Markdown files and a script that reads them age better than almost any framework choice. ## What I'd Change The git-sync-every-2-hours approach works but isn't real-time — if I edit a note on one device and need it on another immediately, I'm waiting up to 2 hours or triggering a manual sync. A file-watcher-triggered sync instead of a fixed interval is the obvious next step. The other gap: automations only get logged when I remember to log them. There's no enforcement that a new script actually gets an entry in the schedule log — it's a convention, not a constraint. For a system whose whole value is "I can see everything that's running," an unlogged automation is a blind spot in the exact thing it's supposed to prevent. --- ## [How I Added 13 Languages to My Next.js Site Without Wrecking SEO](https://wonsukchoi.com/en/blog/nextjs-multilingual-i18n-seo) > I built 13-language support into Next.js App Router for UX, not SEO. Here's the full setup: typed translations, ISR, hreflang, noindex strategy, and what actually happened to traffic. wonsukchoi.co supports 13 languages. Not because I expect to rank in 13 countries — most of my search traffic is in English. I built it because clients come from everywhere, and letting someone read a page in Korean, Vietnamese, or Arabic reduces friction before they reach out. Here's how the setup actually works, and the SEO decisions that matter. ## The 13 languages The list: English, Korean, Vietnamese, Japanese, Simplified Chinese, Traditional Chinese, Spanish, Thai, Arabic, Brazilian Portuguese, Hindi, Indonesian, French. I picked these based on spoken language population globally, not keyword research. The goal wasn't to chase search rankings in each market. It was to cover the largest possible share of potential clients who might land on the site and prefer to read in their native language. ## The architecture The site runs on Next.js App Router. The language lives in the URL as the first segment: ``` /en/blog/some-post /ko/blog/some-post /ja/blog/some-post ``` This is a `[lang]` dynamic segment at the top of the app directory. Every page receives `lang` as a param and uses it to look up the right translations. UI strings — nav labels, section headings, CTA text, form copy — live in a single typed `translations.ts` file: ```ts export const languages = [ 'en', 'ko', 'vi', 'ja', 'zh', 'zh-tw', 'es', 'th', 'ar', 'pt-br', 'hi', 'id', 'fr' ] as const; export type Lang = (typeof languages)[number]; export const t = { en: { nav: { blog: 'blog', hire: 'hire' }, ... }, ko: { nav: { blog: '블로그', hire: '의뢰' }, ... }, // ...all 13 }; ``` This keeps translations in-code and fully typed. If a key is missing for a language, TypeScript catches it at build time. ## How I handled translations For UI strings, I used AI. Short, structured text with clear context translates accurately — a nav label or a CTA button has almost no ambiguity. I translated the full `translations.ts` file once and occasionally update individual keys when copy changes. Blog posts are a different story. I keep all blog content in English and do not translate posts. The reason is simple: long-form translation quality degrades significantly, and publishing inaccurate translated posts hurts both credibility and SEO. Non-English blog URLs are noindexed so they don't compete with the English version or get flagged for thin content. ## The SEO decisions that matter ### hreflang Every page outputs hreflang alternates pointing to all 13 language versions: ```html ``` This tells Google these pages are alternate versions, not duplicates. Without it, you risk having 13 versions of the same content compete against each other. ### Noindex on untranslated content Any non-English page that has no actual translation returns `robots: { index: false }` in its metadata. This covers blog posts (all English-only) and any page where a translation hasn't been written yet. The rule is: if Google can't find meaningfully different content at a URL, don't let it index the URL. ### The root redirect Hitting `wonsukchoi.co` with no language prefix returns a 307 redirect to the appropriate language based on the `Accept-Language` header. If the browser signals Korean, you land on `/ko`. Default is `/en`. 307 (temporary) rather than 301 (permanent) is intentional — the destination changes per user, so it shouldn't be cached permanently. ### Sitemap The sitemap includes only pages with real indexable content. Blog posts appear only once under `/en/`, not under all 13 language paths, since non-English blog URLs are noindexed anyway. This keeps the sitemap clean and avoids inflating it with URLs that return noindex. ## ISR: one build, 13 cached versions Each page uses ISR with a 1-hour revalidation: ```ts export const revalidate = 3600; ``` `generateStaticParams` returns every language × slug combination so all 13 versions of each page are pre-built and edge-cached. A visitor hitting the Korean homepage gets a cached static page, not a server render. ## What actually happened The multilingual setup hasn't moved the needle on SEO. Most search traffic is in English, and English-language SEO effort has a much better return than trying to rank in 13 languages at once. The benefit is UX, not rankings. Clients from Japan, Korea, Vietnam, and the Middle East have reached out and mentioned reading the site in their language. That's the actual value — reducing the friction between a cold visit and a contact form submission. ## The honest setup advice If you're considering this for your own site: - Do it for UX, not SEO, unless you're specifically targeting non-English markets with localized content - Translate UI strings with AI — the accuracy is high enough for short copy - Do not auto-translate long-form content and index it — it's a quality and credibility problem - Get hreflang right before launching — it's easy to get wrong and hard to audit after the fact - Noindex any page that doesn't have real translated content - Keep one canonical language as your SEO priority and treat others as UX improvements --- ## [Coolify vs Vercel: I Moved 8 Projects — Here's the Real Cost Difference (2026)](https://wonsukchoi.com/en/blog/coolify-vs-vercel-real-cost) > I moved 8 apps from Vercel to Coolify — from $50+/mo to $30/mo. Full cost breakdown, what you actually give up, and who should stick with Vercel. ## Coolify vs Vercel: A Direct Comparison I moved 8 projects off Vercel Pro to Coolify on a single Hetzner server. Here's what I actually spent, what the switch cost, what broke, and what got faster — with real numbers from my setup. If you're modernizing the stack while you're at it, the hosting move and a [React to Next.js migration](/en/blog/migrate-react-to-nextjs) often happen together — the same teams doing one are usually overdue for the other. ## The Numbers, Side by Side | | Vercel Hobby | Vercel Pro | Coolify on Hetzner CX42 | |---|---|---|---| | Monthly cost | $0 | $20 + usage (per-site billing) | $29.99 + $3 volume = $32.99 | | Projects | 1 site | Billed per usage, per site | Unlimited | | Build minutes | 100/mo | 6,000 free, then $0.05/min | GitHub Actions (2,000 free/mo) | | Bandwidth | 100 GB | 1 TB, then $0.15/GB | Included | | Scaling cost | Free | Grows with every site and every deploy | Flat, always | | SSL + domains | Managed | Managed | Managed via Let's Encrypt | | Preview deployments | Automatic per branch | Automatic per branch | Requires config | | Setup time | Minutes | Minutes | A few weekends | | Ops overhead | None | None | Ongoing (yours) | The headline: **Hobby is genuinely free for one small site. Pro starts at $20/month and then bills per site.** My bill crossed $50/month with 8 active projects — and the comparison gets worse the more sites you add, because every additional Vercel site carries its own Pro price. My actual setup: one Hetzner CX42 (8 vCPU, 16 GB RAM, 320 GB disk, Nuremberg — $29.99/month) plus a 60 GB volume for Docker data ($3/month). **Total: $32.99/month, flat, for all 8 sites — and it doesn't change whether I add 2 more or 20 more.** ## The Migration Story: What I Moved Off Vercel I didn't plan this as a cost exercise — it started with one client site that kept hitting Vercel's bandwidth limits. By the time I'd paid the overage bill twice, the math was done. Here's how the migration actually went, project by project: **The first move (took a weekend):** pick the smallest, least critical site. Set up the Hetzner box, install Coolify, connect the GitHub repo, copy env vars, point the DNS record, wait for Let's Encrypt. First deploy works, you feel great. **What broke (and this is the part nobody writes about):** - **Edge functions and middleware rewrites.** Vercel's `vercel.json` rewrite rules don't map 1:1 to anything on Coolify. My multilingual site had locale rewrites in `vercel.json` that silently stopped working — I had to move them into Next.js's own `middleware.ts` (or `next.config.ts` rewrites) instead. This is the #1 migration gotcha. - **Cron jobs.** Vercel crons are scheduled functions. On Coolify they don't exist — but scheduled tasks (curl commands) replace them trivially. I now run my IndexNow pings and sitemap submissions as Coolify scheduled tasks. - **Preview deployments.** Vercel gives every PR a URL automatically. Coolify can do it (per-branch environments) but you have to configure it. For solo work I mostly skipped this — the tradeoff barely matters when you're the only reviewer. - **Database connections.** This was the fastest win. Vercel apps connect to Supabase over the public internet; the same app on the same server as nothing else changed. But once I started running other services on the box, co-locating Postgres-adjacent workloads became trivial. **What was faster (genuinely surprising):** - **Deploys stopped cold-starting.** Docker containers are always-on. No Lambda-style cold start, no function spin-up on first request. - **Background jobs became free-form.** Edge functions can't run arbitrary workers; a server can. My ops dashboard syncs, SEO checks, and backup scripts all run on the box now. - **The feedback loop tightened.** SSH in, `docker logs`, done. No waiting on a platform UI. **What cost real time:** the first server setup (a weekend, mostly learning), and one middleware migration that took an evening to debug. Everything after that was copy-paste: add site → connect repo → copy env vars → point DNS. ## Feature Comparison: Deploy Model, Databases, Cron, Multi-Site, Lock-In | Capability | Vercel | Coolify | |---|---|---| | Deploy model | Git push → build → serve | Git push → build → Docker container | | Builds | On Vercel's infra (6,000 free min/mo on Pro) | On your server, or offloaded to GitHub Actions | | Databases | External only (Supabase, Neon, etc.) | External, or co-located on the same box | | Cron / scheduled jobs | Edge functions only | Coolify scheduled tasks (curl) + full cron | | Background workers | Not really possible | Docker containers, workers, anything | | Multi-site | Per-site Pro billing | Unlimited on one server | | Lock-in | Next.js-optimized, but platform-specific features (edge, ISR tuning) | Portable Docker images, no platform lock | | Team workflow | Excellent (previews, roles, audit logs) | Basic — built for solo devs | ## Is Coolify Cheaper Than Vercel? Yes, once you're running more than 2-3 projects. Below that, Vercel's free tier is usually cheaper — server costs and your own time aren't free either. My break-even was around project 3: a flat $32.99/month Hetzner box got cheaper than Vercel's usage-based Pro billing the moment I added a third active site, and stayed flat as I added five more. The compounding part: on Vercel every new site is another Pro line item; on Coolify the ninth site costs exactly nothing. At my current scale the annual difference is roughly **$600/year on Vercel vs ~$396/year on Hetzner** — and the gap grows with every new deploy, every new client, every new overage. ## Can You Self-Host Vercel? Not really — Vercel is a hosted platform, there's no downloadable version to run on your own server. What people usually mean by "self-hosted Vercel" is a tool that replicates its developer experience (git push → deploy, environment variables, preview URLs, managed SSL) on infrastructure you control. Coolify, Dokku, and CapRover are the main options; Coolify is the closest match to Vercel's actual workflow of the three. ## What Vercel Gives You Vercel is a managed deployment platform built specifically for Next.js. Push code, it deploys. Build infrastructure, CDN, SSL, edge functions — all handled for you. The free plan is genuinely good for one or two personal projects. Once you're on Pro ($20/month base), usage charges stack on top: build minutes, edge function invocations, bandwidth. With multiple active projects, the bill grows independently per project — which is exactly the pricing model that made me leave. **Use Vercel if:** - You have 1-2 products and the free plan covers you - You're running a high-traffic app where the edge CDN gives a meaningful latency advantage - Server operations aren't something you want to think about ## What Coolify Gives You Coolify is an open-source platform you run on your own server. It replicates the core Vercel workflow — Git-connected deployments, environment variable management, SSL via Let's Encrypt, domain routing — but you manage the underlying server. The real unlock isn't the dashboard. It's that the server is *yours*: I run Umami analytics, my personal ops dashboard's supporting services, scheduled tasks, and backups all on the same $32.99 box that serves the sites. None of that was possible (or affordable) on Vercel. The [ops dashboard post](/en/blog/personal-ops-dashboard-nextjs-supabase) covers the architecture; the short version is that consolidating infrastructure onto one owned server changed how much I could automate. **Use Coolify if:** - You're managing 3+ projects (the economics flip quickly) - Hosting cost comes out of your margin (client work, agencies) - You have some Linux/Docker experience or you're willing to learn it ## What You Give Up With Coolify **No global edge network.** Vercel routes through a worldwide CDN. Coolify runs on one server in one region. Cloudflare as a proxy closes most of the gap for static assets, but it's not the same as Vercel's edge for dynamic routes. **No automatic preview deployments.** Vercel creates a preview URL per pull request automatically. Coolify can do it but needs configuration. **Real ops overhead.** Coolify is infrastructure with a dashboard — not a product. Docker networking, nginx proxy config, server updates: these become your problem. Budget a few weekends to get comfortable before going production. **Build CPU competes with serving.** I offload builds to GitHub Actions (2,000 free minutes/month on private repos) to keep server CPU free for live traffic. **Backups are on you.** Vercel backs up nothing you'd miss; a self-hosted server can lose everything in one bad `rm`. My backups are a scheduled task that dumps volumes off-box — set that up before you need it. ## Is Coolify Production Ready? Yes — with caveats. Coolify itself is stable and actively maintained. The production risk is running your own server: backups, monitoring, and uptime are on you. On Vercel, infrastructure issues are Vercel's problem. On Coolify, they're yours. For solo developers and agencies running client sites, this is a reasonable tradeoff. For products where downtime directly costs money (e-commerce, SaaS with SLAs), budget proper backup and monitoring setup before going production. One honest note on where my costs live now: the Supabase side of the stack is a separate bill, and how you structure it changes what "self-hosting" saves you. I wrote up the shared-vs-per-tenant Supabase decision in [Supabase Cost: One Project vs. Many](/en/blog/supabase-cost-shared-vs-per-tenant) — worth reading before you commit to a migration plan. ## FAQ ### Is Coolify really cheaper than Vercel? For one site on Vercel's free tier, no — free beats $32.99. The crossover is around 2-3 projects, and the gap widens with every additional site because Vercel Pro bills per project while Coolify's cost is flat. ### Is Vercel free tier enough for a personal site? Usually yes. 100 GB bandwidth and 100 build minutes per month cover a low-traffic personal blog. You only feel the limits when you run multiple projects or get real traffic. ### Does self-hosting with Coolify hurt performance? Measurably not, at solo-dev scale. SSR response times are comparable (~50-80ms vs ~50ms), and you eliminate cold starts entirely because containers are always running. The one real tradeoff is geographic: no global edge network — Cloudflare in front closes most of that gap. ### What about Supabase — does that change the cost math? Self-hosting the Next.js app removes the Vercel bill, but Supabase is a separate cost. The architecture decision (one shared project vs per-tenant) matters more than any tier price — details in the [Supabase cost post](/en/blog/supabase-cost-shared-vs-per-tenant). ### How long does the migration take? The first site takes a weekend (server setup + learning curve). Every site after that is roughly 30-60 minutes: connect repo, copy env vars, point DNS. The single biggest time sink is migrating `vercel.json` rewrite rules into Next.js middleware if you have them. ### Is Coolify safe for client work? Yes, with a backup and monitoring setup first. Agencies running multiple client sites on one server are arguably the strongest use case — flat cost, full control, and the client's hosting bill stops being a recurring margin leak. ## The Verdict **Vercel** if you have 1-2 products, you need zero ops overhead, or you're on the free plan. **Coolify** if you're managing 3+ projects, hosting cost comes out of your margin, and you're comfortable with some server administration. The math at my scale: roughly $600/year on Vercel vs ~$396/year on Hetzner. The savings paid for themselves in month one and stay flat while the Vercel bill would have grown with every new deploy and every new client. --- ## [My AI Coding Workflow in 2026: What I Actually Use and What I Skip](https://wonsukchoi.com/en/blog/ai-coding-workflow-2026) > I run more than 10 production apps as one developer. Here's how AI fits into my actual daily workflow: the tools, the patterns, what moved the needle, and what I stopped using. # My AI Coding Workflow in 2026: What I Actually Use and What I Skip A year ago I was skeptical of "AI coding tools will 10x your productivity" claims. Not because I thought the tools were bad (I'd been using them), but because productivity claims for developers usually ignore the part that's actually slow: thinking, not typing. I was wrong, but not in the way I expected. The gain isn't typing less. It's that certain categories of cognitive work, the boring-but-careful kind, got dramatically cheaper. And for a solo developer running more than 10 production apps, that's what actually mattered. Here's what my actual workflow looks like now, what changed, and what I still don't use AI for. ## The Setup I run everything through Claude Code in the terminal, integrated into my editor. I also use the Claude API directly in several of my products. That's basically it: no AI-powered IDE plugin, no Copilot. I'm not anti-Copilot. I tried it. For me, the inline autocomplete model creates a particular kind of friction: accepting suggestions at the character level slows me down more than it helps. Claude Code in agentic mode (where I describe a task and it operates across files) is a different category of tool. ## What I Actually Use It For ### Boilerplate and scaffolding I'd write from memory anyway If I'm adding a new Supabase table, I know the pattern: migration file, RLS policies, TypeScript types, server action, maybe a client hook. I've written this dozens of times. It's not hard, it's just repetitive and error-prone at the edges (forgetting to add a policy for the service role, off-by-one in a policy condition, etc.). Now I describe the table and the access rules once, Claude writes the full scaffold, and I audit it. The audit is the important part; I still read everything. But writing it myself offered no signal; I already knew what to write. The AI handles the transcription, I handle the judgment. This is probably where I save the most time across 10+ apps. ### Cross-file refactors "Rename this type everywhere, update all the callsites, fix the affected tests" is the kind of task that used to take 30 minutes of careful grep-and-replace. Now it takes two minutes plus a diff review. The risk here is real: a refactor that touches 20 files can introduce subtle bugs. So I always review the diff carefully and run tests before accepting. But the cognitive load of doing the actual work dropped to nearly zero. ### Writing tests for code I've already written I'm not disciplined enough to write tests first. I write the code, it works, and then I consider whether the logic is complex enough to warrant a test. When the answer is yes, AI is excellent at this: give it the function, describe the edge cases you care about, and it writes the test suite. The output isn't always perfect. It sometimes over-tests obvious cases or misses what I actually care about. But it's faster to edit a test suite than to write one from scratch, and the framework is usually right. ### Explaining code I'm about to delete I have a lot of code I wrote 18 months ago that I no longer remember clearly. Before I touch it or delete it, I ask Claude to explain what it does and why. This sounds trivial but it's genuinely useful: it's cheaper than re-reading carefully, and it sometimes surfaces edge cases I'd forgotten about. ### First drafts of SQL migrations Writing migrations is high-stakes and tedious. I describe what I want (add a column, create an index, backfill data), Claude writes the SQL, I check it against what I know about the data, then run it on staging first. This hasn't bitten me yet, but I still treat the review step as non-negotiable. ## What Moved the Needle Most The honest answer is the boring middle-of-the-implementation work. Not the architecture decisions. Not the tricky business logic. Not debugging weird production errors. The part that takes time but doesn't require judgment: converting an interface to a new shape, updating every page that imports a component, writing 15 nearly-identical test cases. For a solo developer managing 10+ products, the constraint isn't ideas or architecture. It's throughput on implementation work that doesn't require my specific attention. AI compressed that category significantly. ## What I Don't Use It For ### Debugging production issues When something breaks in production, I want to read the logs, trace the execution, and understand what actually happened, not have an AI guess. I've tried feeding error logs and context to Claude and getting an explanation. It's sometimes useful as a second opinion, but I don't rely on it. Production bugs usually involve state and timing that the AI doesn't have access to. ### Architecture decisions I talk through architecture with Claude sometimes. It's useful for pressure-testing an idea or listing tradeoffs I haven't considered. But I don't let it make the call. Architecture decisions have long tails; a wrong choice now costs months later. I want to own those. ### Anything involving real user data or credentials This should be obvious, but I don't paste customer data, API keys, or environment variables into any AI tool. I also don't let AI-generated code touch anything production-sensitive without me reading every line. ### Performance optimization "Why is this query slow?" is usually a question about your data distribution, your indexes, your specific query plan, not something an AI can reliably answer without running EXPLAIN ANALYZE on the actual database. I've seen AI-suggested indexes that would have made things worse. Benchmarks and query plans, not vibes. ## The Honest Productivity Number I've thought about how to quantify this and I'm not going to try. "10x" is a marketing claim. "Measurably faster at shipping" is accurate. What I can say: I shipped more features across my products in the last 12 months than in the 24 months before that, with roughly the same working hours. Some of that is experience. Some is better tooling overall. Some is AI. I can't isolate the AI contribution cleanly, and anyone who claims they can is probably rounding in their favor. What I do know: if you took away my current workflow, I wouldn't go back to the old one. That's a meaningful signal. ## The Part Nobody Talks About The biggest change isn't speed. It's that the cost of starting something dropped. When writing a new migration, a new server action, or a new API route has low enough friction, I just do it. I don't batch tasks or wait until I have a bigger block of time. The activation energy for small-but-correct changes got low enough that I make them when I notice them. That's the compounding effect. Not that any single task is much faster, but that the threshold for doing the right thing at all got lower. Over 10+ apps across 12 months, that adds up. ## What I'd Tell Someone Starting Don't evaluate AI coding tools on autocomplete. Evaluate them on agentic tasks: things that span multiple files, require reading context across a codebase, and produce a diff you can review. That's where the productivity gain is real. Keep the review step. Not because AI makes obvious mistakes, but because you need to understand what's in your codebase. The developer who lets AI write a feature and never reads the output is setting themselves up for a bad debugging session. Use it on the boring middle. Architecture is yours. Debugging is yours. The 45 minutes of careful-but-mechanical implementation work between those two things: that's where AI earns its keep. --- ## [Type-Safe Next.js App Router: Server Actions, Params, and Error Handling That Actually Scale](https://wonsukchoi.com/en/blog/type-safe-nextjs-app-router) > App Router introduced async params, server actions, and new patterns for forms and mutations — and TypeScript support for all of it has sharp edges. Here are the patterns I use to keep things type-safe without fighting the framework. App Router changed how data flows through a Next.js app. Server actions replaced API routes for most mutations. Params became async. `searchParams` arrived untyped. And TypeScript support for all of it has real sharp edges that aren't obvious until you hit them. These are the patterns I use across production apps to keep server actions, params, and error handling type-safe without fighting the framework. If you're still on Create React App or Vite, start with the [guide to migrating React to Next.js](/en/blog/migrate-react-to-nextjs) — these patterns all assume the App Router is already in place. ## Type Your Server Action Returns as a Discriminated Union Server actions that return `void` or throw on failure make form handling awkward. The pattern I use everywhere is a discriminated union: ```ts type ActionResult = | { success: true; data: T } | { success: false; error: string }; ``` Every server action returns this type, never throws: ```ts "use server"; export async function createItem( input: unknown ): Promise> { const parsed = CreateItemSchema.safeParse(input); if (!parsed.success) { return { success: false, error: parsed.error.errors[0].message }; } try { const item = await db.items.create(parsed.data); return { success: true, data: { id: item.id } }; } catch { return { success: false, error: "Failed to create item." }; } } ``` On the client, the result is always safe to destructure — no try/catch, no ambiguous state: ```ts const result = await createItem(formData); if (!result.success) { setError(result.error); return; } router.push(`/items/${result.data.id}`); ``` The discriminated union means TypeScript narrows the type for you. Inside the `!result.success` branch, `result.error` exists. Inside the success branch, `result.data` exists. No casting needed. ## Validate Server Action Input with Zod, Not TypeScript Types TypeScript types are erased at runtime. A server action that accepts `name: string` in its signature doesn't actually enforce that at the boundary — `name` could be anything. ```ts // Unsafe: TypeScript types don't protect you at runtime export async function createItem({ name }: { name: string }) { // name could be anything here } // Safe: Zod validates at runtime const CreateItemSchema = z.object({ name: z.string().min(1).max(100), }); export async function createItem(input: unknown): Promise { const parsed = CreateItemSchema.safeParse(input); if (!parsed.success) return { success: false, error: "Invalid input." }; // parsed.data is now fully typed } ``` Take `input: unknown` in every server action. Parse it with Zod before touching the data. This also gives you a single schema definition that serves as both the runtime validator and the inferred TypeScript type: ```ts type CreateItemInput = z.infer; ``` Share the schema between client and server — it lives in a `lib/schemas` file that both can import. The client uses it for form validation, the server uses it for runtime safety. ## Async Params Are Not Optional In Next.js 15+, `params` and `searchParams` are `Promise`s. This means any page or layout that destructures them synchronously will either type-error or fail silently. ```ts // Broken: params is a Promise export default function Page({ params }: { params: { id: string } }) { // params.id is undefined at render time } // Correct type Props = { params: Promise<{ id: string }> }; export default async function Page({ params }: Props) { const { id } = await params; // id is a string } ``` For `generateMetadata`, the same pattern applies: ```ts export async function generateMetadata({ params }: Props): Promise { const { id } = await params; const item = await getItem(id); return { title: item.name }; } ``` If you're migrating a Pages Router codebase, this is the most common source of silent bugs. Everything still renders — the params just come back as the raw Promise object instead of the resolved value. ## SearchParams Are Unknown — Parse Them `searchParams` in App Router are typed as `{ [key: string]: string | string[] | undefined }`. That's technically accurate, but it means every access requires a check before you can use the value safely. ```ts type Props = { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }; export default async function Page({ searchParams }: Props) { const raw = await searchParams; // raw.page is string | string[] | undefined // You need to coerce it before using it } ``` The pattern I use: a small helper that coerces a specific searchParam to its expected type: ```ts function getStringParam( params: { [key: string]: string | string[] | undefined }, key: string ): string | undefined { const val = params[key]; return typeof val === "string" ? val : Array.isArray(val) ? val[0] : undefined; } function getNumberParam( params: { [key: string]: string | string[] | undefined }, key: string, fallback: number ): number { const val = getStringParam(params, key); const n = Number(val); return Number.isFinite(n) ? n : fallback; } ``` For complex searchParams (filters, sorts, pagination), parse them with Zod the same way as server action inputs: ```ts const SearchSchema = z.object({ page: z.coerce.number().int().min(1).default(1), q: z.string().optional(), }); const search = SearchSchema.parse(await searchParams); // search.page is a number, search.q is string | undefined ``` `z.coerce.number()` handles the string→number conversion automatically. Invalid values fall back to the default. ## Error Handling in Catch Blocks TypeScript 4.0+ types caught errors as `unknown`, not `Error`. Code that does `catch (e) { return e.message }` doesn't compile without a cast. The right pattern: ```ts function toErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "An unexpected error occurred."; } try { await riskyOperation(); } catch (error) { return { success: false, error: toErrorMessage(error) }; } ``` This replaces every `catch (e: any)` cast and every `(e as Error).message` in server actions and API routes. One utility, used everywhere. ## useActionState with Correct Typing `useActionState` (formerly `useFormState`) takes an action function and an initial state. The types are tightly coupled — the action's return type has to match the state type. ```ts "use client"; import { useActionState } from "react"; import { createItem } from "./actions"; type State = ActionResult<{ id: string }> | null; const initialState: State = null; export function CreateItemForm() { const [state, action, isPending] = useActionState(createItem, initialState); return (
{state && !state.success && (

{state.error}

)}
); } ``` The action signature has to accept the previous state as its first argument: ```ts export async function createItem( _prevState: State, formData: FormData ): Promise { const input = Object.fromEntries(formData); // ... } ``` The `_prevState` parameter is required by `useActionState` — you can ignore it in most actions, but the type has to be there for the binding to work. ## How Do You Share Types Between Server and Client Components? Create a `lib/types` directory and export plain TypeScript types (no `"use server"` or `"use client"`). Both server and client components import from it freely. The rule: never import server-only modules (Supabase server client, server-only secrets) into client components. TypeScript won't catch this by default — use the `server-only` package to get a build error when you try: ```ts // lib/supabase/server.ts import "server-only"; import { createServerClient } from "@supabase/ssr"; // ... ``` Now any attempt to import `lib/supabase/server.ts` from a client component throws a build error instead of silently sending secrets to the browser. ## The Pattern That Ties It Together The end-to-end shape I use on every form in App Router: 1. `lib/schemas/item.ts` — Zod schema + inferred type, no server/client boundary 2. `app/actions/item.ts` — `"use server"`, accepts `unknown`, returns `ActionResult` 3. `app/components/ItemForm.tsx` — `"use client"`, `useActionState`, shows `state.error` inline No API routes. No try/catch on the client. No `any` casts. TypeScript narrows the result automatically at every step. --- ## [Supabase Multi-Tenancy: Schema, RLS, JWT Claims, and Production Gotchas](https://wonsukchoi.com/en/blog/supabase-multi-tenancy-production) > How to build multi-tenant SaaS on a single Supabase database — covering tenant_id schema design, RLS policies with JWT claims, middleware tenant resolution, and the production gotchas that cost the most time. Multi-tenancy is mostly a schema and RLS problem. The core idea is simple: every table gets a `tenant_id`, every policy filters by it, and the current tenant flows in from the request. The reality is full of edge cases — deleted tenants, cross-tenant admin queries, N+1 lookups, and migration pain nobody talks about until you're already in it. These are the patterns I use when building multi-tenant apps on Supabase, and the things that cost me time. ## One Project or Many? Before the schema, the real question is whether to put all tenants in one project or give each one their own. Separate projects give you hard isolation, no shared connection limits, and no risk of one tenant's slow query degrading another's. The downside is operational overhead — if you have 50 tenants, you have 50 Supabase projects to manage, update, and monitor. Row-level tenancy in a shared project makes sense when: - You have tens to hundreds of tenants with the same schema - Tenants share the same feature set and you want one codebase to serve all of them - The RLS complexity is worth the operational simplicity I use separate projects for my own apps — it's simpler at low tenant counts. For client SaaS work with many tenants, shared-project row-level tenancy is what I reach for. Everything below is about that pattern. ## The Schema Every table that holds tenant-specific data gets a `tenant_id` column: ```sql CREATE TABLE items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name text NOT NULL, created_at timestamptz DEFAULT now() ); ``` The index on `tenant_id` is not optional: ```sql CREATE INDEX ON items(tenant_id); ``` Without it, every RLS policy evaluation on this table is a sequential scan. On a small table you won't notice. On a table with 500k rows spread across 500 tenants, every query pays the full scan cost. The index is the difference between Postgres jumping to your tenant's rows and reading all of them. ## Putting the Tenant in the JWT The cleanest way to pass the current tenant to RLS policies is via the JWT `app_metadata`. Set it when the user authenticates or when they join a workspace: ```sql -- RLS policy reading from JWT CREATE POLICY "tenant_isolation" ON items FOR ALL TO authenticated USING ( tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid ); ``` Populate `app_metadata` from your backend using the Admin SDK: ```ts // server-side only — never expose the service role key to the client await supabaseAdmin.auth.admin.updateUserById(userId, { app_metadata: { tenant_id: tenantId }, }); ``` The caveat: JWT claims are baked at auth time. If a user's tenant changes mid-session, they need a new token. For most apps this is fine. If users switch between multiple tenants in one session, use request-time config instead. ## Request-Time Tenant Config For background jobs, service-role API calls, or multi-tenant switching, set the tenant as a Postgres config variable at the start of each request: ```sql CREATE OR REPLACE FUNCTION set_tenant(p_tenant_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN PERFORM set_config('app.current_tenant', p_tenant_id::text, true); END; $$; CREATE POLICY "tenant_isolation" ON items FOR ALL TO authenticated USING ( tenant_id = current_setting('app.current_tenant', true)::uuid ); ``` From your server action or route handler: ```ts await supabase.rpc("set_tenant", { p_tenant_id: tenantId }); // all subsequent queries in this request are scoped to that tenant ``` The `true` flag on `set_config` makes the setting transaction-scoped so it resets automatically. The `true` flag on `current_setting` returns `null` instead of throwing when the variable isn't set — safer default. ## Wrap JWT Functions in SELECT This is the same pattern from RLS basics but worth repeating because it's even more important in multi-tenant schemas where policies fire on every row. ```sql -- Slow: evaluated on every row USING (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid) -- Fast: evaluated once per query, result cached USING (tenant_id = ((SELECT auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid) ``` The `SELECT` wrapper tells Postgres to treat the function as an `initPlan` — computed once, reused for every row. On tables with thousands of rows per tenant this difference is measurable. ## Resolving the Tenant in Next.js Middleware In App Router, resolve the tenant once in middleware and attach it to a request header: ```ts // middleware.ts export async function middleware(request: NextRequest) { const host = request.headers.get("host") ?? ""; // subdomain: acme.yourapp.com → "acme" const tenantSlug = host.split(".")[0]; const response = NextResponse.next(); response.headers.set("x-tenant-slug", tenantSlug); return response; } ``` Then in any server component or action: ```ts import { headers } from "next/headers"; async function getCurrentTenant() { const slug = (await headers()).get("x-tenant-slug"); return getTenantBySlug(slug); // wrap with React cache() or unstable_cache } ``` Cache the slug→tenant lookup. Without caching you're hitting the DB on every page render. A 60-second TTL is usually fine — tenant records change infrequently, and a briefly stale lookup is harmless. ## The Gotchas That Cost Time **Tables without RLS** Every new table needs `ENABLE ROW LEVEL SECURITY` — and `FORCE ROW LEVEL SECURITY` so the `postgres` superuser doesn't bypass it silently. Easy to miss when you're moving fast. ```sql -- Find tables in public schema missing RLS SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = false; ``` Run this before every deploy. Anything unexpected means a tenant can read another tenant's rows. **UPDATE without a SELECT policy** PostgreSQL evaluates SELECT policies when processing UPDATEs. If there's no SELECT policy, the UPDATE returns 0 affected rows — no error, no warning, just silence. Always define both: ```sql CREATE POLICY "tenant_select" ON items FOR SELECT TO authenticated USING ((SELECT auth.jwt()) -> 'app_metadata' ->> 'tenant_id' = tenant_id::text); CREATE POLICY "tenant_update" ON items FOR UPDATE TO authenticated USING ((SELECT auth.jwt()) -> 'app_metadata' ->> 'tenant_id' = tenant_id::text); ``` **Cross-tenant admin queries** Your admin dashboard needs to see all tenants. RLS blocks it. Pattern: use the service role key for admin routes only, never expose it to the client, and set tenant context explicitly when narrowing to a specific tenant. **Deleting a tenant** Don't rely entirely on `ON DELETE CASCADE` for complex schemas — it's hard to debug when it fires in an unexpected order. Write an explicit deletion function: ```sql CREATE OR REPLACE FUNCTION delete_tenant(p_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN DELETE FROM items WHERE tenant_id = p_id; DELETE FROM workspaces WHERE tenant_id = p_id; DELETE FROM members WHERE tenant_id = p_id; DELETE FROM tenants WHERE id = p_id; END; $$; ``` ## How Do You Test Tenant Isolation? Create two test tenants, insert rows for each, then query as each tenant to verify they can't see each other's data. ```sql -- Simulate a request scoped to tenant A SET LOCAL app.current_tenant = ''; SELECT * FROM items; -- should only return tenant A rows SET LOCAL app.current_tenant = ''; SELECT * FROM items; -- should only return tenant B rows ``` Also run the `rowsecurity = false` check against your schema before every release. RLS misconfiguration is silent and the consequences are serious — one missing policy and every tenant can read all rows. ## When Should You Split a Tenant Into Its Own Project? When a tenant's load starts degrading everyone else, or when they need a different schema, a different region, or a contractual isolation guarantee, it's time to move them to their own project. The migration path: `COPY TO` for the data snapshot, restore to the new project with `COPY FROM`, sync rows written during migration, cut over the connection string. Budget a maintenance window. Build the export tooling early: a `export_tenant` function that snapshots a tenant's data to CSV is useful for debugging, migration, and churned tenants asking for their data. You don't want to be writing this under pressure when an enterprise client is waiting. --- For the RLS patterns that underpin the tenant isolation strategies in this post — including the `(SELECT auth.uid())` performance fix, SECURITY DEFINER functions, and view security — see [Supabase RLS in Production: jwt.claims, Tenant Isolation & Performance](/en/blog/supabase-rls-production-patterns). --- ## [Building a Knowledge Base Chat with Supabase and Claude](https://wonsukchoi.com/en/blog/knowledge-base-chat-supabase-claude) > A complete walkthrough of building a RAG-based knowledge base chat: pgvector schema, embedding models, the retrieval function, chunking strategy, and the Claude prompt pattern — including the gotchas that cause silent failures. The basic idea is simple: store your documents in Supabase, embed them as vectors, search for the relevant chunks when a user asks a question, and pass those chunks to Claude to generate an answer. The implementation has a handful of non-obvious decisions that determine whether the system actually works in production. This is the full setup — schema, embeddings, retrieval function, and the Claude prompt pattern — with the gotchas that cost the most time. ## When to Use This vs. Full-Context Injection If your knowledge base is small and curated — say, under 100 documents — you can often skip vector search entirely and inject all of it directly into the system prompt. Simpler, no embedding costs, and retrieval can't fail because there's no retrieval step. Vector search becomes worth it when the knowledge base is large enough that injecting everything would overflow the context window or produce worse answers from noise. The crossover is roughly when your documents stop fitting comfortably in 50–100k tokens. Below that threshold, consider whether RAG complexity is actually necessary. This post covers the RAG approach for when you genuinely need it. ## Schema Setup Enable pgvector and create your documents table. Use `halfvec` instead of `vector` — it stores embeddings as 16-bit floats rather than 32-bit, cutting storage in half with negligible quality loss, and it unlocks HNSW indexing for models with more than 2000 dimensions. ```sql create extension if not exists vector with schema extensions; create table documents ( id bigint primary key generated always as identity, title text not null, content text not null, embedding extensions.halfvec(1536), metadata jsonb, created_at timestamptz default now() ); create index on documents using hnsw (embedding extensions.halfvec_cosine_ops); ``` The HNSW index is the right default — unlike IVFFlat, it updates itself on write so you don't need to rebuild the index as you add documents. IVFFlat requires the table to have data before you create the index (its clusters are derived from the existing data distribution); build it on an empty table and the index is useless. ## Choosing an Embedding Model For most use cases, OpenAI's `text-embedding-3-small` at 1536 dimensions is the practical default: good quality, low cost, and straightforward to integrate. If accuracy is the priority, Voyage AI's `voyage-3.5-lite` consistently outperforms the OpenAI models on retrieval benchmarks and is the model Anthropic uses in their own RAG cookbook. The dimension you embed at must match the column type exactly. There is no implicit truncation — a 3072-dim vector inserted into a `halfvec(1536)` column fails with a type error. If you ever switch models, you need to alter the column, re-embed every row, and rebuild the index. OpenAI supports Matryoshka truncation, which lets you request a smaller dimension at embedding time and retain most of the quality: ```ts const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: text, dimensions: 512, // trade some quality for storage savings }); ``` At 512 dimensions the model retains ~95% of full-dimension quality while using 3x less storage. Match whatever `dimensions` you pass here to your Postgres column size. ## The Retrieval Function PostgREST does not expose pgvector's distance operators directly. You need a Postgres function and call it via `.rpc()`: ```sql create or replace function match_documents ( query_embedding extensions.halfvec(1536), match_threshold float, match_count int ) returns table ( id bigint, title text, content text, similarity float ) language sql stable as $$ select documents.id, documents.title, documents.content, 1 - (documents.embedding <=> query_embedding) as similarity from documents where 1 - (documents.embedding <=> query_embedding) > match_threshold order by documents.embedding <=> query_embedding asc limit match_count; $$; ``` Two things to get right here. The `<=>` operator returns cosine *distance* (0 = identical), so `1 - distance` gives cosine *similarity* (1 = identical) — that's what you compare against `match_threshold`. But the `ORDER BY` clause must use the raw distance, not the derived similarity, so the query planner uses the HNSW index. Using `ORDER BY similarity DESC` produces a sequential scan with no error message — just silent, full-table performance. Calling it from TypeScript: ```ts const { data: chunks } = await supabase.rpc('match_documents', { query_embedding: queryEmbedding, // number[] match_threshold: 0.78, // tune between 0.75–0.85 match_count: 8, }); ``` ## Chunking Documents Before embedding, split documents into chunks. The practical default: 512 tokens per chunk, 64-token overlap between adjacent chunks. Overlap preserves context across chunk boundaries — without it, sentences that span a split can lose meaning in both halves. Chunk too large and the embedding averages too many ideas, making it hard for any single query to score well. Chunk too small and the retrieved context is too fragmentary to be useful. 512 tokens is a reasonable starting point; tune based on your actual documents. Prepend the document title to each chunk before embedding — it anchors the embedding to the right topic: ```ts const textToEmbed = `# ${document.title}\n\n${chunk}`; ``` ## Writing to the Database At ingest time: chunk, embed, insert. ```ts async function ingestDocument(title: string, content: string) { const chunks = splitIntoChunks(content, { maxTokens: 512, overlap: 64 }); for (const chunk of chunks) { const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: `# ${title}\n\n${chunk}`, }); await supabase.from('documents').insert({ title, content: chunk, embedding: response.data[0].embedding, }); } } ``` If you update document content, re-generate the embedding — Supabase won't do it automatically unless you set up the pgmq trigger pipeline. Stale embeddings return stale results silently. ## Querying and Calling Claude At query time: embed the user's question, retrieve the relevant chunks, build a prompt, call Claude. ```ts async function answer(question: string): Promise { // 1. Embed the question const embeddingRes = await openai.embeddings.create({ model: 'text-embedding-3-small', input: question, }); const queryEmbedding = embeddingRes.data[0].embedding; // 2. Retrieve matching chunks const { data: chunks } = await supabase.rpc('match_documents', { query_embedding: queryEmbedding, match_threshold: 0.78, match_count: 8, }); if (!chunks?.length) { return "I couldn't find relevant information for that question."; } // 3. Build context const context = chunks .map((c) => `### ${c.title}\n\n${c.content}`) .join(' --- '); // 4. Call Claude const response = await anthropic.messages.create({ model: 'claude-sonnet-4-5', max_tokens: 1024, system: 'You are a helpful assistant. Answer using only the provided context. ' + 'If the context does not contain the answer, say so explicitly.', messages: [ { role: 'user', content: `\n${context}\n\n\n${question}`, }, ], }); return response.content[0].type === 'text' ? response.content[0].text : ''; } ``` Place the context before the question in the prompt. For large context blocks (20k+ tokens), Anthropic's benchmarks show up to 30% better response quality when documents come first and the question comes last. ## The Gotchas That Cost Time **Dimension mismatch crashes silently at query time, not setup time.** You can insert with the wrong dimension if you forget the column type, then discover the mismatch when the retrieval function errors. Lock the dimension in a constant and reuse it everywhere. **`ORDER BY similarity` breaks index usage.** As mentioned: always order by the raw distance expression, not the derived similarity column. Check with `EXPLAIN ANALYZE` if you suspect a sequential scan. **Context window creep.** Eight retrieved chunks at 512 tokens each is ~4k tokens before you add the system prompt and conversation history. That's fine, but verify your actual usage — verbose documents or high `match_count` values can quietly push you into expensive territory. Pre-filter with `match_threshold` to cut low-relevance results before they reach the prompt. **IVFFlat on an empty table.** If you chose IVFFlat instead of HNSW, build the index only after loading data. An empty-table IVFFlat index has no clusters and produces useless results with no warning. **Stale embeddings after content updates.** There is no automatic re-embedding unless you wire up the pgmq trigger pipeline. A simple safeguard: add an `embedding_updated_at` column and run a periodic check for rows where `updated_at > embedding_updated_at`. --- ## Securing Your Documents Table If you're building a per-user or multi-tenant knowledge base, you'll want RLS on the `documents` table so each user only retrieves their own chunks. [Supabase RLS in Production: jwt.claims, Tenant Isolation & Performance](/en/blog/supabase-rls-production-patterns) covers the exact policy patterns — including the `(SELECT auth.uid())` performance trick that makes a significant difference at scale. --- ## [How I Built a Personal AI That Knows My Entire Business](https://wonsukchoi.com/en/blog/personal-ai-knowledge-base) > I built an AI that can represent me accurately on my personal site — not with RAG or vector search, but with a simpler pattern that works better at this scale. Here's the architecture, the tradeoffs, and what building a real knowledge base about yourself actually involves. There's a version of "personal AI" that most people imagine: you upload your documents, it searches them with vectors, and you can ask questions. That's a valid architecture. It's also more complex than necessary for what I actually wanted. What I wanted was an AI that could represent me accurately on my personal site — not as a chatbot with canned responses, but as a version of me that speaks from genuine knowledge of how I think, what I've built, and what I actually believe. The simpler approach turned out to work better. ## The Problem With Generic AI on a Personal Site If you put a generic AI assistant on your personal site — even a good one — it will confidently fabricate details about you based on cultural inference, patterns from its training data, or whatever sounds plausible. Ask it where you grew up and it might guess correctly. Ask it about your approach to pricing a freelance project and it'll give you generic advice about freelancing rather than how you specifically think about it. This is the core problem. A model that doesn't know you specifically cannot represent you specifically. And "doesn't know" plus "confidently answers anyway" is worse than just not having a chatbot at all. The solution isn't a smarter model. It's a model that only knows what you've told it. ## The Architecture: Full-Context Injection My implementation is deliberately simple. There's no vector search, no embeddings, no chunking strategy. Instead, it works like this: 1. A Supabase table — `personal_ai_entries` — stores Q&A pairs. Each row is a question and my answer in my own words. 2. When a visitor opens the chat, the client fetches all entries from a public API route. 3. Those entries are injected wholesale into the system prompt before any conversation happens. 4. The model (Claude via OpenRouter) responds strictly from that context — explicitly instructed not to fill gaps with inference. The system prompt makes the constraint explicit: ```ts function buildSystemPrompt(entries: KBEntry[]): string { const kb = entries.map((e) => `Q: ${e.question}\nA: ${e.answer}`).join("\n\n"); return `You are Wonsuk Choi, responding to a visitor on your personal website. CRITICAL: The knowledgebase below is the ONLY source of truth about Wonsuk. Do NOT use your training data, assumptions, or cultural inference to fill in gaps. If the knowledgebase doesn't cover something, say "I haven't shared that yet." KNOWLEDGEBASE (Wonsuk's own words — treat this as ground truth): ${kb}`; } ``` The model gets one instruction it has to follow above everything else: if it's not in the knowledge base, say so. This prevents hallucination better than any prompt engineering trick I've tried, because it gives the model a clear fallback instead of leaving it to improvise. ## Why Not RAG? RAG (retrieval-augmented generation) is the standard approach for large knowledge bases — you embed documents, store vectors in a database, and at query time you retrieve only the relevant chunks to fit in the context window. For a personal knowledge base, this creates problems: **Retrieval misses matter more.** If the model retrieves the wrong chunks or fails to retrieve a relevant one, it still has to answer. Without the right context, it'll either hallucinate or give a generic response — the exact failure mode I was trying to avoid. With full-context injection, either the information is there or it isn't. **Chunk boundaries lose voice.** Splitting your own writing into 500-token chunks strips out the continuity that makes it sound like you. The model sees fragments rather than complete thoughts, and the responses feel assembled rather than genuine. **Latency and cost are acceptable at this scale.** A personal knowledge base of 50–100 Q&A pairs fits comfortably in a context window and costs fractions of a cent per conversation. The complexity of vector search isn't justified until the knowledge base is much larger. If the knowledge base grew to thousands of entries, I'd switch to RAG. At the scale of a personal site, full injection is simpler and more reliable. ## The Supabase Schema ```sql create table public.personal_ai_entries ( id uuid primary key default gen_random_uuid(), question text not null, answer text not null, created_at timestamptz not null default now() ); ``` Public read, authenticated write. The entries are fetched on the client side and passed to the chat API alongside the conversation history. ## The API Route The chat endpoint receives the messages and the preloaded knowledge base from the client: ```ts export async function POST(req: NextRequest) { const { messages, knowledgebase } = await req.json(); const systemPrompt = buildSystemPrompt(knowledgebase ?? []); const stream = await streamOpenRouter({ messages: [{ role: "system", content: systemPrompt }, ...messages], model: "anthropic/claude-sonnet-4-5", }); return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); } ``` Streaming keeps the response feeling fast. The client handles the SSE stream and renders incrementally. ## Building the Knowledge Base The technical setup takes a few hours. Building the knowledge base is the actual work. I approached it like a structured interview with myself. The categories I covered: how I work, what I've built and why, how I think about pricing and clients, what I believe about product development, things I've changed my mind about, what a typical week looks like. The entries that produce the best chat responses are the ones where I resisted the urge to sound good and just described things accurately. "I prefer working alone because collaboration overhead slows me down" is more useful than a diplomatic version that hedges. The model mirrors your voice — if you're vague, it's vague. The knowledge base also tells you something about yourself. Writing 80 honest Q&A pairs about how you think and work is a reasonably useful exercise independent of the AI application. ## What the "Business" Part Means Beyond the personal site chatbot, I extended the same pattern to my dashboard. The AI there has access to live context — pending todos, site statuses, recent journal entries, subscriber counts, active subscriptions across all seven products — injected alongside the same personal knowledge base. This creates something qualitatively different from a standard AI assistant: it knows the current state of everything I'm running, and it knows how I think about those things from the knowledge base. The combination is what makes it feel like talking to someone who understands the situation rather than explaining it from scratch every time. The architecture for that is the same: system prompt injection, no vectors, just context. The live data comes from the same Supabase tables the dashboard reads from. ## The Honest Limitation This only works as well as the knowledge base you build. A sparse or vague knowledge base produces a sparse and vague AI. The model can't add specificity it doesn't have. The other limitation: it's static in time. If my views on something change, the knowledge base needs to be updated manually. There's no mechanism for it to learn from conversations or update its own entries. For a personal site that I control, that's a feature — I decide what's in it. But it means maintenance is ongoing. --- ## [Running 7 Products as One Developer: What I Delegate to AI](https://wonsukchoi.com/en/blog/running-7-products-solo-ai-delegation) > The bottleneck in running multiple products solo isn't hours — it's attention. Here's what I actually delegate to AI across seven live products, what I don't, and what the real productivity gain looks like. Running multiple products solo sounds like a scheduling problem. It isn't. Scheduling is just logistics — you can optimize it with better habits and a calendar. The real problem is cognitive load: every product has its own domain knowledge, its own user context, its own open questions. Context-switching between them is expensive in a way that hours on a calendar don't capture. AI hasn't solved that problem, but it has dramatically reduced the cost of the parts that were burning the most time. Here's what I actually delegate, what I don't, and what I learned along the way. ## What Kills Solo Productivity Before getting into the AI tools, it's worth naming the actual bottleneck. Seven products means seven sets of users, seven codebases (in my case one codebase, but seven distinct configurations), seven support inboxes, and seven sets of decisions that require context to make well. The failure mode isn't running out of hours — it's running out of attention. You make worse decisions later in the day, especially on things that require holding a lot of context at once. The goal is to protect high-attention work and delegate everything else. AI is good at most things that don't require judgment. Judgment is the scarce resource. ## Writing First Drafts Every product needs content: landing page copy, email sequences, blog posts, support documentation, outreach messages. I used to batch this into dedicated writing sessions that would eat half a day. Now the workflow is different. I write a short brief — who this is for, what it needs to accomplish, what tone fits — and use AI to produce a first draft. The draft is never final, but starting from something is dramatically faster than starting from nothing. I edit rather than write, which is a different and cheaper cognitive mode. The key is writing a tight brief. Vague input produces vague output. "Write a landing page for my immigration app" produces generic marketing copy. "Write a hero section for an immigration visa tracking app targeting Korean nationals in Japan — direct tone, no hype, lead with the specific anxiety they have about missing deadlines" produces something editable. The brief takes five minutes. The edit takes fifteen. The old process took two hours. ## Outreach and Follow-Ups Cold outreach is mostly pattern matching at scale: find the right person, understand their context, explain what you can do for them. AI is good at all three parts individually, though the combination still needs human review. For each product I run outreach for, I have a system that scrapes basic context about a prospect (what they do, what their site says, recent activity), then generates a draft message grounded in that context. I review and edit before sending — I don't send AI output verbatim — but I've cut the time to craft a personalized message from fifteen minutes to three. The follow-up cadence is something I now handle almost entirely through templates AI helped write. The judgment call — when to follow up, when to drop a thread — is still mine. The actual writing isn't. ## Debugging and Code Review This is the highest-leverage AI use for an individual developer. Being able to paste a function, describe the unexpected behavior, and get a plausible explanation in ten seconds — that used to be a rubber duck problem that could stretch into an hour. I use AI for the first pass on any debugging session. Not because it always gets it right, but because it generates hypotheses quickly. Half the time the hypothesis is wrong and I learn something by figuring out why. The other half it's right and I've saved significant time. For code review on my own PRs, AI catches the categories of issues I'm most likely to miss when I'm too close to the code: missing edge cases, inconsistent error handling, things that are technically correct but will confuse the next reader (who is usually me, three months later). ## What I Don't Delegate **Product decisions.** What to build next, what to cut, how to price — these require knowing what users actually want, what the market looks like, and what I'm willing to maintain. AI can synthesize information, but it can't weigh the tradeoffs for my specific situation. **User conversations.** When a user has a problem or a question, I respond personally. This is where trust is built. Automating it would save an hour a week and cost far more in product insight. **Anything architectural.** Technical decisions that compound — database schema, authentication patterns, how state flows through the app — I make slowly and deliberately. Getting these wrong is expensive. AI suggestions here are a starting point, not a conclusion. **Judgment calls under uncertainty.** Any decision where the right answer depends on information that isn't in my context and can't be retrieved, I make myself. AI is confidently wrong in ways that are hard to detect, and the cases where you most need to be careful are the ones where it sounds most certain. ## The Actual Productivity Gain Across seven products, I'd estimate AI saves me eight to twelve hours a week — mostly in writing, research, and first-pass debugging. That's not magic; it's closer to having a fast junior collaborator who's good at tasks with clear inputs and outputs, bad at judgment, and never sleepy. The ceiling on solo product development isn't hours. It's the number of things you can hold in your head well enough to make good decisions about. AI doesn't raise that ceiling — nothing does. What it does is reduce the number of hours that get consumed before you hit it. --- ## [Supabase RLS in Production: jwt.claims, Tenant Isolation & the Silent Failures](https://wonsukchoi.com/en/blog/supabase-rls-production-patterns) > Battle-tested RLS from 7 production Supabase apps. Covers jwt.claims patterns that actually work, multi-tenant isolation without policy sprawl, and the silent data leaks most developers only find in prod. Row Level Security is Supabase's answer to data isolation — write a policy, and the database enforces it on every query without touching your application code. The concept is simple. The production reality is full of silent failures, performance traps, and behavior that looks correct until it isn't. These are the patterns I use across seven production apps, and the gotchas I've run into along the way. ## Always Use FORCE ROW LEVEL SECURITY When you run `ALTER TABLE t ENABLE ROW LEVEL SECURITY`, you're enabling RLS for API-level access. But the table owner — the `postgres` superuser — still bypasses it silently. This matters because Supabase's own backend jobs and some SDK operations run as `postgres`. The fix is one extra line: ```sql ALTER TABLE documents ENABLE ROW LEVEL SECURITY; ALTER TABLE documents FORCE ROW LEVEL SECURITY; ``` `FORCE ROW LEVEL SECURITY` makes RLS apply to the table owner as well. Without it, you may test as an API user and see correct behavior, then discover that something running as `postgres` is reading all rows without restriction. ## Always Specify the Role in Your Policy Without a `TO` clause, a policy applies to every role that has table-level `GRANT` access — including `anon`. This is a common source of unintended public access. ```sql -- Dangerous: applies to anon too if anon has SELECT grant CREATE POLICY "user_reads_own" ON documents USING (auth.uid() = user_id); -- Correct: only authenticated users CREATE POLICY "user_reads_own" ON documents FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); ``` The `TO authenticated` clause short-circuits policy evaluation for unauthenticated requests entirely. It's both a security guard and a performance optimization. ## Wrap auth.uid() in SELECT — Always This is the single highest-impact performance change you can make to existing RLS policies, and almost nobody does it by default. ```sql -- Slow: auth.uid() is evaluated on every row USING (auth.uid() = user_id) -- Fast: evaluated once per statement, result is cached USING ((SELECT auth.uid()) = user_id) ``` Without the `SELECT` wrapper, PostgreSQL calls `auth.uid()` once per row during the policy check. On a 100,000-row table, benchmarks show this difference: **171ms vs under 0.1ms**. The wrapping causes the planner to treat it as an `initPlan` — computed once, then reused. Apply the same pattern to `auth.jwt()` and any security definer function that returns a fixed value per query. Also index every column that appears in a policy `USING` clause: ```sql CREATE INDEX idx_documents_user_id ON documents(user_id); ``` Without the index, the policy turns every query into a sequential scan. ## UPDATE Policies Silently Fail Without SELECT This one wastes hours. PostgreSQL requires a SELECT policy to be in place for UPDATE to work. Without it, an UPDATE returns 0 affected rows — no error, no feedback, just silence. If your mutations appear to succeed but nothing changes in the database, check whether you have a SELECT policy. For UPDATE specifically, always supply both `USING` and `WITH CHECK`: ```sql CREATE POLICY "user_updates_own" ON documents FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id) -- which rows can I target? WITH CHECK ((SELECT auth.uid()) = user_id); -- is the result still mine? ``` `USING` filters the rows that can be targeted. `WITH CHECK` validates the new state after the update. Without `WITH CHECK`, a user could update a row's `user_id` to someone else's ID — transferring ownership of data they legitimately own. ## Fix Infinite Recursion With SECURITY DEFINER Functions If an RLS policy on a table references that same table in a subquery, PostgreSQL will infinitely recurse evaluating the policy. The classic case is checking a user's role from a `profiles` table inside a policy on `profiles` itself. ```sql -- BROKEN: infinite recursion CREATE POLICY "read_own" ON profiles FOR SELECT USING ( id = auth.uid() OR role = (SELECT role FROM profiles WHERE id = auth.uid()) -- recurses ); ``` The fix is a `SECURITY DEFINER` function. These functions run as the function owner (bypassing RLS on the tables they touch), breaking the cycle: ```sql CREATE OR REPLACE FUNCTION get_my_role() RETURNS text LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$ SELECT role FROM public.profiles WHERE id = (SELECT auth.uid()); $$; CREATE POLICY "read_own" ON profiles FOR SELECT USING ( id = (SELECT auth.uid()) OR role = (SELECT get_my_role()) ); ``` Two things to always do with `SECURITY DEFINER` functions: set `search_path = ''` (prevents search path injection), and don't expose them in Supabase's API schema (they can be called directly via RPC). ## Views Bypass RLS — Use security_invoker Standard PostgreSQL views run as the view owner (`postgres`), which bypasses RLS on their underlying tables. A view over a `documents` table with RLS will expose every row to anyone who can select from the view. In Postgres 15+ (which Supabase uses), the fix is one option: ```sql CREATE VIEW public.my_documents WITH (security_invoker = true) AS SELECT * FROM documents; ``` `security_invoker = true` makes the view run RLS as the calling role, not the view owner. Supabase's Security Advisor will flag any views missing this option. Check it regularly — it catches this and other common misconfigurations. ## Multi-Tenant Isolation With JWT Claims For multi-tenant apps where rows belong to an organization rather than a user, I store a `tenant_id` on every table and resolve the current tenant from the JWT. The safest place to embed tenant context is `app_metadata`, which is server-controlled and cannot be modified by the user: ```sql CREATE POLICY "tenant_isolation" ON orders FOR ALL TO authenticated USING ( tenant_id = ( (SELECT auth.jwt()) -> 'app_metadata' ->> 'org_id' )::uuid ) WITH CHECK ( tenant_id = ( (SELECT auth.jwt()) -> 'app_metadata' ->> 'org_id' )::uuid ); ``` Never use `raw_user_meta_data` for authorization — users can write to it themselves. Only `app_metadata` is safe for access control. To embed `org_id` in the JWT at login time, use Supabase's Custom Access Token Hook. This runs a function before the token is issued and lets you add custom claims — avoiding a database lookup on every request. One caveat: JWT claims are cached until the token refreshes (default one hour). If you revoke a user's org membership, that change won't take effect in RLS policies until their token expires. For time-sensitive revocations, you need to force a token refresh on the client. ## The Service Role Key Bypasses Everything The service role key grants Supabase's `service_role` Postgres role, which has `BYPASSRLS` privilege. Every query made with it ignores all RLS policies — unconditionally, regardless of what your policies say. Use it only in server-side code: API routes, Server Actions, cron jobs, backend scripts. Never in browser code, mobile apps, or any client-visible context. Exposing the service role key is equivalent to handing someone direct unrestricted database access. The setup is straightforward: ```ts import { createClient } from '@supabase/supabase-js'; // Server-only — never expose this key to clients const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); ``` Keep the service role key in server-side environment variables only. In Next.js, that means no `NEXT_PUBLIC_` prefix. ## The Debugging Pattern That Saves Time When a policy is blocking something it shouldn't (or allowing something it shouldn't), run the query directly in the Supabase SQL editor while impersonating the relevant role: ```sql SET LOCAL role = authenticated; SET LOCAL request.jwt.claims = '{"sub": "your-user-uuid", "role": "authenticated"}'; EXPLAIN ANALYZE SELECT * FROM documents; ``` This shows exactly which policies are being evaluated and whether the query is using indexes. Most RLS debugging dead-ends disappear once you can see the actual execution plan. ## What Does SET LOCAL request.jwt.claims Do in Supabase? `SET LOCAL request.jwt.claims` overrides the JWT claims Postgres sees for the rest of the current transaction — it's how you impersonate a role in the SQL editor without a real token. `SET LOCAL` scopes the change to the transaction, so it's automatically undone on `COMMIT` or `ROLLBACK`. This only works inside a superuser session (the SQL editor); it does nothing from your application's Supabase client, which sends real JWTs over the wire on every request. ```sql BEGIN; SET LOCAL role = authenticated; SET LOCAL request.jwt.claims = '{"sub": "your-user-uuid", "role": "authenticated"}'; SELECT * FROM documents; -- runs exactly as that user would see it ROLLBACK; ``` Wrap it in `BEGIN`/`ROLLBACK` so nothing is left mutated if you're testing writes, not just reads. ## How Do I Fix Supabase Lint 0012 (auth_allow_anonymous_sign_ins)? This warning means your project allows anonymous sign-ins, but at least one RLS policy grants access to `anon` in a way that isn't intentionally public. `anon` covers both real anonymous visitors and any anonymous-auth session you've created — a policy that doesn't distinguish between them can leak data past every user account. ```sql -- 1. Scope every policy to the roles that should actually have it CREATE POLICY "user_reads_own" ON documents FOR SELECT TO authenticated -- not "anon", not left unscoped USING ((SELECT auth.uid()) = user_id); -- 2. If a table genuinely should be readable pre-auth, make that -- explicit instead of relying on an unscoped policy CREATE POLICY "public_read" ON documents FOR SELECT TO anon, authenticated USING (is_public = true); ``` If you don't use anonymous sign-ins at all, disable them under Authentication → Providers — the lint warning goes away and removes an entire class of policy mistake. --- ## Related Reading If you're using RLS in a multi-tenant setup, [Building a Multi-Tenant SaaS on a Single Supabase Database](/en/blog/multi-tenant-saas-single-supabase-database) covers how to structure tenant configs, resolve tenants at the middleware layer, and filter rows with array columns and GIN indexes. For using Supabase as the vector store in an AI knowledge base, [Building a Knowledge Base Chat with Supabase and Claude](/en/blog/knowledge-base-chat-supabase-claude) walks through pgvector schema, chunking, retrieval, and the Claude prompt pattern — including how RLS keeps each user's documents isolated. For a comprehensive guide to the multi-tenant schema itself — tenant_id design, request-time config, middleware resolution, and the production gotchas — see [Supabase Multi-Tenancy: Schema, RLS, JWT Claims, and Production Gotchas](/en/blog/supabase-multi-tenancy-production). --- ## [AI SEO in 2026: What's Actually Working vs What's Hype](https://wonsukchoi.com/en/blog/ai-seo-2026-whats-working) > AI SEO is real and it matters — but most advice about it is wrong. Here's what's actually moving the needle across my sites in 2026: the signals that work, the tactics that don't, and what I actually do. AI SEO is real, it matters, and most of the advice about it is wrong. The confusion comes from conflating two separate problems: ranking in traditional search (Google, Bing) and being cited by AI systems (ChatGPT, Perplexity, Google's AI Overviews). These are related but not the same, and treating them as identical leads to bad decisions. Here's what's actually moving the needle across my sites in 2026, and what's noise. ## The Two-Track Reality Traditional search still drives the majority of organic traffic for most sites. Google hasn't been replaced — but the nature of clicks has shifted. Broad informational queries ("what is X", "how does Y work") increasingly get answered directly by AI Overviews. Users get the answer without clicking through. Specific, technical, and transactional queries still drive clicks. "How to set up Supabase RLS for a multi-tenant app" is not something an AI will answer completely — it'll summarize and link out. That traffic still lands. The practical implication: the content worth investing in is specific and technical, not broad and introductory. Writing "what is Next.js" in 2026 is almost certainly a waste of time. Writing "why your Next.js middleware is running twice in development" targets a specific problem that real users search when they're stuck, and that AI systems will cite when they can't fully resolve it themselves. ## What AI SEO tactics actually work in 2026? **Answer-shaped content.** AI systems prefer content that directly and cleanly answers a question. This isn't new advice dressed up as AI SEO — it's just good writing. What has changed: the bar is higher. A paragraph of hedging before the actual answer used to be fine. Now, if your H2 asks a question and the paragraph below doesn't answer it immediately, an AI will skip your content and cite whoever does. Practically: lead with the answer, then explain. If your section header is "How do I handle tag invalidation in Next.js?", the first sentence under it should answer that directly, not set up context for three more paragraphs. **Technical content with real code.** LLMs cite technical posts with working code examples at a significantly higher rate than posts without. This matches what I've observed in my own analytics — blog posts with substantial code blocks attract inbound links and AI citations at a higher rate than prose-only posts on similar topics. The reason is simple: code is verifiable and specific, which signals expertise. **Long-tail, problem-specific queries.** "SEO" is getting harder. "How to generate hreflang tags for a Next.js App Router site with 13 languages" is a query an AI can't fully resolve from its training data alone. If you have a post that answers it precisely, you'll get the traffic. The long tail is expanding, not shrinking. **Multilingual content.** This is where I see the most underutilized opportunity. The vast majority of web content is in English. AI-driven search in Korean, Japanese, Spanish, or Vietnamese has far less competition. A technically sound post translated and adapted for non-English speakers can outperform its English equivalent purely because the competition is thin. This isn't a hack — it requires real localization, not machine-translated garbage — but the return is real. **Structured data and clean HTML.** Schema markup still works. AI crawlers read JSON-LD. If you have a how-to post, mark it up as HowTo. If you have FAQ sections (real ones, not manufactured ones), mark them up. This isn't magic, but it lowers the friction for AI systems to extract and cite your content. **Brand mentions across sources.** LLMs build a model of who is authoritative on a topic based on where a name appears. If your name or site is referenced across GitHub, Hacker News, other blogs, and forums — not just your own content — that signal accumulates. You can't manufacture this directly, but publishing work worth referencing (open source projects, genuinely useful posts) creates the conditions for it. ## What AI SEO advice is hype or misleading? **"Optimize for AI Overviews" as a distinct strategy.** There is no separate optimization path for Google's AI Overviews. Google pulls from the same index, evaluates the same quality signals, and ranks the same content. The advice to "structure content for AIO" is just restating "write clearly and answer questions directly" in trendier language. **AI content at scale without a unique angle.** Publishing 500 AI-generated posts on generic topics is producing noise, not signal. AI systems are trained to recognize thin content, and so are the humans who might otherwise link to it. Volume without perspective is not an SEO strategy in 2026 — it's a way to get ignored by both algorithms and readers. **E-E-A-T as a lever you can pull.** Experience, Expertise, Authoritativeness, and Trust are real ranking factors, but they're outcomes, not inputs. You can't add an author bio and "demonstrate experience" — you demonstrate experience by writing things only someone with experience would write. The signal is in the content, not the metadata around it. **AI SEO tools that charge for basic advice.** There's a category of tool that packages standard on-page SEO checks, calls them "AI SEO audits," and charges accordingly. The underlying advice (fix your title tags, improve your page speed, get more backlinks) is the same as it's always been. The framing is new. The value is not. ## What do I actually do for AI SEO? Across seven sites, my approach hasn't changed dramatically from solid traditional SEO. What has changed is the emphasis: I write about specific problems from real production experience. Not "how to use Supabase" — "how I handle data isolation across seven tenants in a single Supabase project." Specific, experiential, verifiable. I maintain a `llms.txt` file that updates automatically as I publish new content, so AI crawlers always have a fresh sitemap of what's on the site. (I wrote about this separately — the static version becomes stale immediately, which defeats the purpose.) I publish in multiple languages. The multilingual infrastructure is already built; publishing translated versions of posts that perform well in English has a high return for minimal marginal effort. I keep page speed tight and use structured data on every post — Article schema, BreadcrumbList, FAQPage where the content warrants it. ## The Honest Summary Traditional SEO fundamentals — fast pages, clean structure, specific content, inbound links — still work and still matter. The sites winning AI citations are not doing something exotic. They're the ones with genuine depth on specific topics, clean technical implementation, and a track record of publishing things worth referencing. The shift in 2026 is not that the rules changed. It's that the margin for mediocre content has collapsed. Broad, shallow, well-optimized posts used to rank. Now they get summarized by an AI and never clicked. The posts that still drive traffic are the ones that go deep enough to be useful after the AI has given the overview. Write those. Everything else is noise. --- ## [How to Get Freelance Clients as a Developer Without Running Ads](https://wonsukchoi.com/en/blog/freelance-clients-without-ads) > Most advice about getting freelance clients involves spending money on ads or cold-pitching strangers. Here's what actually worked for me: the channels, the positioning, and the specific moves that bring in inbound work. Most advice about getting freelance clients involves either running ads or cold-pitching strangers on LinkedIn. Both work eventually, but they're expensive — in money or in dignity, depending on which you choose. I've never run an ad for my freelance work. Every client I've worked with came through inbound, referrals, or warm outreach. This is how that happened, and what I'd do if I had to build it from scratch. ## The Real Bottleneck Isn't Discovery The assumption behind running ads is that potential clients can't find you. That's usually not the problem. The problem is that when they do find you, they don't trust you enough to reach out. Trust is what converts a visitor into an inquiry. Ads accelerate discovery but don't create trust — and for solo developers, trust is almost entirely built through visible work. Things people can read, evaluate, and share. This is why a single well-written blog post about a problem you've actually solved is worth more than a month of LinkedIn connection requests. ## How much does positioning matter for freelancers? Most developer portfolios say something like "full stack developer available for freelance." That phrase competes with tens of thousands of other identical phrases and tells potential clients nothing about whether you can solve their specific problem. Specificity is the thing that makes you memorable and searchable. Compare: - "Full stack developer available for hire" - "I help startups migrate from React to Next.js without breaking what's working" The second version signals expertise in a specific problem. Someone Googling "how to migrate React app to Next.js" might find your blog post on the topic, read it, realize you know what you're talking about, and contact you. That's a warm inbound lead from someone who already believes you can help. The same logic applies to your website's hero text, your X bio, and how you describe yourself in any context. Lead with the problem you solve, not your job title. ## Which channels actually bring in freelance clients? Not all acquisition channels are equal for solo developers. Here's what I've found, ranked by conversion rate: **Warm network.** Former colleagues, past clients, people you've helped in Slack communities or on GitHub. These convert at the highest rate because the trust is already there. A message to someone you've worked with before saying "I have availability for a project in Q2" takes five minutes and can close in a week. **Personal website with SEO.** Slow to build, hard to measure, but it compounds. A blog post that ranks for "how to do X in Next.js" or "Supabase multi-tenant architecture" sends inbound traffic indefinitely. The people who find you this way are already pre-qualified — they have the exact problem you write about. **X (formerly Twitter).** Consistency matters more than volume here. Posting once a day about what you're building, what you're learning, or what problems you're solving builds an audience that trusts you before they ever need to hire someone. When they do need someone, you're already on the list. This is slow — plan for six months before it noticeably moves the needle. **GitHub.** Active, well-documented projects get discovered. If a potential client is evaluating whether you can build something, a public repo with real code is more convincing than anything you could write in a proposal. **Direct outreach to warm leads.** Not cold. If someone engages with your content — comments on a post, stars a repo, replies to a tweet — they've already signaled interest. A short follow-up message is not intrusive; it's appropriate. Cold outreach to people who've never heard of you is a numbers game with poor odds. ## How do you turn one client into three? The highest-leverage moment in freelance is right after you finish a project well. The client is happy, the work is fresh, and they know other people with similar problems. Two things to do at project end: Ask directly: "Do you know anyone else who might need this kind of help?" Most clients won't proactively refer you. If you ask, a meaningful percentage will. Write about the project. With the client's permission, a short case study or blog post — "how I migrated X from React to Next.js in three weeks" — does double duty. It's a referral for the client and a trust signal for future leads who find it via search. The second engagement is always easier than the first. A client who's worked with you once already trusts you. Staying in occasional contact — sharing something relevant, checking in after a launch — means you're top of mind when the next project comes up. ## What would I do starting from zero? If I had no portfolio, no audience, and needed my first freelance client within 60 days: **Week 1:** Fix the positioning. Update website, X bio, and LinkedIn headline to lead with the specific problem I solve, not my job title. Make the contact path obvious — one clear CTA, not a buried email. **Weeks 2–4:** Write three posts about specific technical problems I've solved. Not "intro to React" — something like "how to handle multi-tenant data isolation in Supabase" or "why your Next.js page is slower after upgrading to v15." These are the terms potential clients search when they have the problem and need someone who understands it. **Throughout:** Message ten warm contacts — former colleagues, people I've helped before, anyone who's seen my work. Not to pitch, just to let them know I'm available and what kind of work I'm looking for. This alone often produces a lead within the first month. **Ongoing:** Post on X three to five times a week. Show the work. Ask questions. Be useful in public. The audience compounds slowly, then quickly. The common thread: everything here builds trust before the client needs you. When they eventually do need someone, you're already a known quantity. That's worth more than any ad budget. --- ## [Next.js App Router Caching: How v15 Changed the Defaults and What Actually Breaks](https://wonsukchoi.com/en/blog/nextjs-app-router-caching) > Next.js v15 silently changed all caching defaults. Here's how the 4 caches work, what breaks without warning, and the patterns I use in every production app. If you've built anything with the Next.js App Router, you've hit this: you update data in the database, refresh the page, and nothing changes. Or the opposite — you expect a page to be fast and cached, but it's slow and rendering fresh on every request. App Router caching is the most powerful feature in Next.js and also the most misunderstood. Part of the confusion is that v15 flipped the defaults entirely. What was opt-out before is now opt-in. If you migrated without auditing, you probably have pages that are either stale or unexpectedly slow. This is what I've learned running it across seven production sites. (Still on Create React App or Vite? The [React to Next.js migration guide](/en/blog/migrate-react-to-nextjs) covers the App Router basics these caching rules build on.) ## The Four Caches (Quick Map) Next.js App Router has four distinct caches. They operate at different layers, and conflating them is where most bugs come from. **Request Memoization** — deduplicates identical `fetch` calls within a single render tree. If two Server Components both call `fetch('/api/user/123')`, the request only fires once. This is automatic and you don't control it. **Data Cache** — persists `fetch` results across requests and deployments. In v13/v14, this was on by default. In v15+, it's opt-in. **Full Route Cache** — stores the rendered HTML and RSC payload of a route on the server. Static routes are fully cached here until you revalidate. **Router Cache** — a client-side in-memory cache of RSC payloads. In v14, this cached Page components aggressively. In v15+, it no longer does by default. You'll mostly interact with the Data Cache and Full Route Cache. The others are automatic. ## What v15 Changed Before v15, `fetch` was cached by default. Every fetch inside a Server Component behaved like this: ```ts // v13/v14 implicit default — you never wrote this, Next.js applied it fetch('https://api.example.com/data', { cache: 'force-cache', }); ``` This made pages fast out of the box, but caused the most common bug in the ecosystem: stale data that refused to update no matter how many times you refreshed. Starting in v15, the default flipped: ```ts // v15+ implicit default fetch('https://api.example.com/data', { cache: 'no-store', }); ``` GET Route Handlers also stopped being cached by default. And `cookies()`, `headers()`, and `params` became async. The practical result: pages that used to be cached and fast are now dynamic and slower after migration, unless you explicitly opt them back in. ## What Breaks Silently **Stale data after mutations (v14 and earlier).** You update a row in your database via a Server Action. The page re-renders, but shows the old value. The Data Cache is still serving the previous fetch result. Without calling `revalidatePath` or `revalidateTag` after the mutation, the cache never knows anything changed. ```ts 'use server'; import { revalidatePath } from 'next/cache'; export async function updatePost(id: string, title: string) { await supabase.from('blog_posts').update({ title }).eq('id', id); revalidatePath('/blog'); } ``` **Pages going dynamic unexpectedly.** If any Server Component in a route uses `cookies()`, `headers()`, or reads `searchParams`, Next.js opts the entire route into dynamic rendering — bypassing the Full Route Cache, even if 95% of the page has nothing dynamic in it. ```ts // This one line makes the whole route dynamic const cookieStore = await cookies(); ``` If you only need the cookie value in one small part of the layout, push that logic into a child Server Component. The parent route stays static; only the leaf that actually reads the cookie goes dynamic. **Tag mismatches.** If you tag a fetch but the string doesn't match exactly when calling `revalidateTag`, nothing gets invalidated and you'll spend an hour wondering why your mutation isn't working. ```ts // Tagging the fetch const data = await fetch('/api/posts', { next: { tags: ['posts'] }, }); revalidateTag('posts'); // ✓ works revalidateTag('post'); // ✗ does nothing — exact match required ``` ## How I Handle It **For data that changes infrequently**, I use a `revalidate` value on the fetch or at the route segment level: ```ts // On a specific fetch const data = await fetch('/api/config', { next: { revalidate: 3600 }, }); // Or applied to all fetches in a route segment export const revalidate = 3600; ``` **For data that changes on user action**, I tag fetches and invalidate the tag in the Server Action. If the data source is Supabase (not a native `fetch`), I wrap it with `unstable_cache`: ```ts import { unstable_cache } from 'next/cache'; const getCachedPosts = unstable_cache( async () => { const { data } = await supabase .from('blog_posts') .select('*') .eq('published', true); return data; }, ['published-posts'], { tags: ['posts'], revalidate: 60 }, ); ``` ```ts 'use server'; import { revalidateTag } from 'next/cache'; export async function publishPost(id: string) { await supabase.from('blog_posts').update({ published: true }).eq('id', id); revalidateTag('posts'); } ``` **For truly dynamic data** — dashboards, user-specific pages, anything that must always be fresh — I don't fight the cache at all: ```ts export const dynamic = 'force-dynamic'; ``` This bypasses the Full Route Cache entirely. Use it for authenticated sections where per-user data makes caching useless anyway. ## The Pattern I Use in Every App Across all seven sites, the same structure holds: - **Marketing and content pages** (`/`, `/blog`, `/about`): `revalidate = 3600` or tag-based invalidation. Fast on first load, updates when content changes. - **Dynamic content routes** (`/blog/[slug]`): `generateStaticParams` to pre-render known paths at build time, `revalidate` for new posts. - **Authenticated routes** (`/dashboard`, `/settings`): `force-dynamic`. No point caching per-user data server-side. - **All Server Actions that mutate**: always call `revalidatePath` or `revalidateTag` at the end, without exception. Skipping this makes mutations look broken from the user's perspective even when they succeed. The mental shift that made this click: caching in the App Router isn't magic you turn on — it's a contract between your data layer and your routes. You define how fresh data needs to be, and you honor that contract when data changes. Once you think of it that way, the four caches stop feeling like gotchas and start feeling like knobs you actually want to turn. --- ## [Why Your Website Needs Multiple Languages — And How to Build It Right](https://wonsukchoi.com/en/blog/multilingual-seo-i18n-guide) > Multi-language websites unlock SEO traffic most sites never see. Here's the full picture: why it matters, how to implement it technically, and the infrastructure decision that most developers get wrong. Most websites are built in one language and never revisited. That's a significant missed opportunity — not just for inclusivity, but for organic search traffic. Search engines like Google index each language version of a page independently, which means a multilingual site can capture keyword demand across entirely separate markets with the same content, translated. This post covers why multilingual SEO matters, what the tech stack looks like in practice, and the infrastructure decision that's easy to get wrong but hard to change later. ## Why is multilingual SEO underrated? When someone in South Korea searches for "best productivity tools for remote teams," Google serves results in Korean — and those results compete separately from the English ones. If your site only exists in English, you're invisible to that query regardless of how strong your English SEO is. Multilingual SEO exploits this partition. By publishing the same content in multiple languages, each translated version gets its own indexable URL, its own keyword surface area, and its own ranking potential — all pointing back to the same product or service. The compounding effect is significant. A site with content in 10 languages doesn't get 10× the traffic, but it often gets 3–5× organically over time as each language version builds its own backlink profile and domain authority in its respective market. ## What tech stack do you need for i18n SEO? Modern i18n (internationalization) libraries handle the two core problems: routing (which URL maps to which language) and translation (which strings map to which locale). ### Routing For Next.js (App Router), the standard pattern is a `[lang]` dynamic segment at the top of the route tree: ``` app/ [lang]/ (site)/ page.tsx → wonsukchoi.co/en blog/ page.tsx → wonsukchoi.co/en/blog ``` Middleware intercepts bare requests (e.g. `/blog`) and redirects to the detected locale (`/en/blog`), falling back to the default language when detection fails. ### Translation Two main options: - **i18next / react-i18next** — the ecosystem standard. JSON-based translation files, namespace support, pluralization, interpolation. Works in both server and client components. - **next-intl** — purpose-built for Next.js App Router. Integrates cleanly with RSC and async server components. Handles locale detection, formatting, and timezone-aware dates out of the box. For smaller sites, a flat translations object works fine — a single `translations.ts` file keyed by locale, with typed access. No library needed until you hit a few thousand strings. ### Content For static strings (UI labels, navigation, CTAs), a translation file is sufficient. For dynamic content like blog posts, you have two options: 1. **Database-level localization** — a `locale` column or a `translations` JSONB field on each row. Simple but hard to manage at scale. 2. **Separate content entries per locale** — one row per language. More storage, but clean querying and independent publishing workflows. ## Which i18n URL structure should you choose? Before you write a line of i18n code, you need to decide your URL structure. This decision is hard to change later and has real SEO consequences. There are three main patterns: ### 1. Language-only: `/lang/path` ``` wonsukchoi.co/en/blog wonsukchoi.co/ko/blog wonsukchoi.co/ja/blog ``` Best for: personal sites, SaaS products, content sites targeting language groups rather than specific countries. Simple to implement. Hreflang uses language codes only (`en`, `ko`, `ja`). ### 2. Language + Country: `/lang-COUNTRY/path` ``` wonsukchoi.co/en-US/blog wonsukchoi.co/en-GB/blog wonsukchoi.co/fr-FR/blog ``` Best for: e-commerce, platforms with country-specific pricing or legal requirements. Allows you to serve `en-US` and `en-GB` as distinct pages with different spellings, currency, or legal copy. Hreflang uses `en-US`, `en-GB` etc. More complex to maintain. ### 3. Country-code top-level domains or subdomains ``` wonsukchoi.co.uk wonsukchoi.de uk.wonsukchoi.co ``` Best for: large enterprises with country-specific brand presence, dedicated local teams, or markets where ccTLDs carry strong trust signals (Germany, Japan). Strongest geo-targeting signal to Google, but the highest operational overhead — each domain or subdomain must build its own authority from scratch. ### Which One Should You Pick? If your platform doesn't have country-specific variants of the same language (no `en-US` vs `en-GB` difference in your content), use `/lang/path`. It's the simplest structure that works well for most sites. If you sell in multiple countries with different pricing, legal copy, or regional content, use `/lang-COUNTRY/path` — but commit to maintaining those distinctions. A `en-US` and `en-GB` page that show identical content waste crawl budget and invite duplicate content issues. Country-code TLDs are rarely the right call for new projects. The SEO benefit rarely outweighs the cost of splitting domain authority across multiple roots. ## What is hreflang and how do you set it up? Once your URL structure is set, you need to tell search engines which pages are translations of each other. This is done via `hreflang` tags — either in `` or in your sitemap. ```html ``` Every page must reference all its alternate versions, including itself. Missing or mismatched hreflang is one of the most common technical SEO issues on multilingual sites. ## What are the common multilingual SEO pitfalls? **Skipping `x-default`** — always include an `x-default` hreflang pointing to your fallback locale. This tells Google what to show users whose language has no match. **Auto-translating without editing** — machine-translated content ranks poorly and reads poorly. Use it as a draft baseline, not a final product. **Inconsistent URL structure** — mixing `/en/blog` and `/blog/en/` across different sections breaks crawler logic and confuses hreflang matching. **No locale in sitemap** — your XML sitemap should include all language variants with proper `` alternate entries so Google discovers every version. ## The Payoff Multilingual SEO is one of the highest-leverage investments a content-driven site can make. The same article, properly translated and structured, can index in 10+ language markets — each with its own traffic ceiling, its own keyword long-tail, and its own compounding backlink potential. The infrastructure decision (language vs. language+country vs. ccTLD) matters most at the beginning. Get that right, implement hreflang correctly, and the rest is execution. --- ## [llms.txt: Why Static Files Go Stale Immediately (And What to Use Instead)](https://wonsukchoi.com/en/blog/llms-txt-ai-seo) > Most llms.txt files go stale on publish. Here's the dynamic approach I switched to and what actually matters for AI search visibility in 2026. Before I understood what llms.txt was actually for, I added one to this site. It was a static text file sitting in `public/`, manually written, listing a few URLs. It had no description format, no blog posts, and it was already wrong by the time I published my next article. That's the version most sites have right now. Here's why it matters, what the spec actually says, and how I replaced it with something that stays in sync automatically. ## Why isn't traditional SEO enough for AI search? Googlebot crawls, indexes, and ranks. AI answer engines — Perplexity, ChatGPT, Google AI Overviews — synthesize. They don't return ten blue links; they return one answer, assembled from passages they've decided are credible enough to quote. The implication: **you can rank #1 on Google and still be invisible to AI search.** Relevance no longer lives at the page level — it lives at the sentence level. A page that clearly states a fact in its first paragraph gets cited. A page that buries its answer in keyword-padded prose gets skipped. `llms.txt` is one response to this shift. It's not a magic ranking signal. It's a structured map you give AI crawlers so they don't have to guess what your site is about and where the useful content lives. ## What is llms.txt? The spec was proposed by Jeremy Howard (founder of fast.ai and Answer.AI) in late 2024. It's a markdown file at `yourdomain.com/llms.txt` — similar to `robots.txt` in concept, but written for large language models rather than traditional crawlers. The format is deliberately simple: ``` # Your Name or Site > One-sentence description of who you are and what you publish. ## Blog - [Post Title](/en/blog/slug): Short description of this post. - [Another Post](/en/blog/other-slug): What this one covers. ## Projects - [Project Name](https://project.com): What it does. ``` The `> ` blockquote at the top is the canonical description LLMs may use verbatim when introducing you. The `##` sections group your content. Each line is a markdown link with a description. That's the whole spec. ## What was my original llms.txt doing wrong? My `public/llms.txt` looked like this: ``` # llms.txt for wonsukchoi.co Canonical: https://wonsukchoi.co Owner: Wonsuk Choi Type: Personal website and portfolio Updated: 2026-04-21 ## Priority URLs - Home: https://wonsukchoi.co/en - Blog index: https://wonsukchoi.co/en/blog ``` Three problems: 1. **Wrong format.** It used key-value pairs instead of the spec's `> description` and markdown link structure. An LLM scanning this gets metadata, not an understanding of what the site is about. 2. **No content links.** Pointing to `/en/blog` tells a crawler "there's a blog." Listing individual posts with descriptions tells it "here's what I've written and why each piece exists." Those are very different signals. 3. **Static.** Every new blog post I published meant this file was immediately out of date. And I was never going to remember to update it manually. ## How do you generate llms.txt from a dynamic route? In Next.js App Router, `public/` files are served as static assets and take priority over route handlers at the same path. So the first step was deleting `public/llms.txt`. Then I created `app/llms.txt/route.ts`: ```ts import { createClient } from "@supabase/supabase-js"; export const revalidate = 3600; const BASE = "https://wonsukchoi.co"; export async function GET() { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ); const { data: posts } = await supabase .from("blog_posts") .select("title, slug, excerpt") .eq("published", true) .order("created_at", { ascending: false }) .limit(30); const blogLines = (posts ?? []) .map((p) => { const desc = p.excerpt ? `: ${p.excerpt.slice(0, 120)}` : ""; return `- [${p.title}](${BASE}/en/blog/${p.slug})${desc}`; }) .join("\n"); const body = `# Wonsuk Choi > Full stack developer and AI product builder. I build practical products in healthcare, local services, and AI-enabled workflows. ## Blog ${blogLines} ## Projects - [Medipaths](https://medipaths.co): Medical information and symptom checker - [Online Medical Tools](https://onlinemedicaltools.com): Free clinical calculators and tools ## Pages - [Home](${BASE}/en) - [Blog](${BASE}/en/blog) - [Contact](${BASE}/en/contact) `; return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", }, }); } ``` About 40 lines. It rebuilds every hour via ISR, pulls every published post with its excerpt, and formats it to spec. Every new post I publish is in the file within an hour, automatically. The `revalidate = 3600` is the key detail — without it, the file would be generated once at build time and go stale again. With it, Next.js refreshes it in the background on the first request after the TTL expires. ## What else matters for AI citability `llms.txt` is one signal. These matter more: **Passage-level clarity.** LLMs extract sentences, not pages. Put the answer in the first paragraph, not paragraph four. "This reduced LCP from 4.2s to 1.8s" gets cited. "This can potentially improve certain performance characteristics" does not. **Structured data.** Schema.org markup — `Article`, `FAQPage`, `HowTo` — helps AI systems understand what type of content they're reading and extract structured answers. Add it if you haven't. **Topical depth.** A site with 20 posts on one subject gets cited before a general site with one post on the same subject. Consistent publication in a narrow area builds authority that compounds. **Server-rendered HTML.** JavaScript-heavy SPAs that need rendering to produce content are harder to crawl reliably. If your content is in the DOM on first load, you're easier to index. ## What is llms-full.txt? The spec also defines `llms-full.txt` — a single file containing the actual content of your key pages concatenated as markdown. Useful for sites where content is gated, JavaScript-rendered, or otherwise hard to crawl. For a public blog with server-rendered HTML, it's optional. But for a SaaS docs site or a knowledge base, generating one programmatically from your content source is worth doing. ## Does any of this actually move the needle? Honest answer: it's early. There's no public study showing a direct correlation between `llms.txt` compliance and AI citation rates. The major LLM providers haven't confirmed they actively use the file (though Perplexity has indicated awareness of the spec). What I can say: implementing a proper `llms.txt` took me less than an hour, including deleting the broken static version. The mindset shift it represents — writing for passage extraction, not keyword density — is the more significant change. And that shift is already measurable in traditional SEO too. If you're checking your AI referral traffic in GA4 (filter for sources containing "perplexity", "chatgpt", "bing"), you'll see it growing. The sites that adapt their content structure now will have a head start that's hard to close later. ## What should you set up this week? 1. Check if you have `llms.txt` at `yourdomain.com/llms.txt`. If not, add one. If you have a static version, check whether it follows the spec format. 2. If your site has dynamic content (a blog, a changelog, a docs site), replace the static file with a dynamic route that stays in sync automatically. 3. Audit your top 5 posts: does each one state its main claim in the first paragraph? 4. Check your structured data with [Google's Rich Results Test](https://search.google.com/test/rich-results). Fix anything broken. The rules of search are being rewritten. The spec exists, the tooling is trivial, and a stale static file is almost worse than nothing — it signals to AI systems that your site's content map doesn't match reality. --- ## [Building a Multi-Tenant SaaS on a Single Supabase Database](https://wonsukchoi.com/en/blog/multi-tenant-saas-single-supabase-database) > How I run seven branded sites — one Next.js codebase, one Supabase project. A practical walkthrough of hostname resolution, feature flags, data isolation with RLS, and tenant-aware routing. ## The Problem Multi-Tenant Architectures Solve The standard answer to "I need multiple branded sites" is multiple codebases — one repo per brand, each deployed separately. It works, but it scales poorly. Bug fixes get applied to three repos instead of one. A new feature requires four deployments. Shared logic drifts out of sync. The maintenance overhead compounds fast. The alternative is a single codebase that resolves its identity at runtime from the incoming request. One deployment, one database, one set of tests — but any number of distinct front-ends. I use this pattern across seven live production sites built on the ExpertSapiens platform: expertsapiens.com, mrvisakorea.com, apostillefirst.com, seoulhomes.kr, seoultranslate.com, airlinkee.com, and rehovica.com. Each looks, behaves, and ranks as its own brand. They share one Next.js codebase and one Supabase project. This post covers how it's built. ## Step 1: A Typed Tenant Config The foundation is a `TenantConfig` object — a fully typed description of what a tenant is and what it can do. Every piece of tenant-specific behavior flows from this object, not from environment variables or string comparisons scattered through the code. ```typescript export type TenantId = | "expertsapiens" | "apostillefirst" | "mrvisakorea" | "seoulhomes" | "seoultranslate" | "link" | "rehovica"; export interface TenantConfig { id: TenantId; name: string; baseUrl: string; tagline: string; categoryFilter: string[] | null; // null = show all categories siteKey: string | null; // used to filter DB rows locales: readonly string[]; gaId: string | null; features: TenantFeatures; // convenience booleans — computed, never set manually isExpertSapiens: boolean; isMrVisa: boolean; isApostille: boolean; // ... } ``` A `createTenant()` factory function merges per-tenant overrides with shared defaults and computes the convenience booleans automatically: ```typescript function createTenant(input: TenantInput): TenantConfig { return { ...input, features: { ...FEATURE_DEFAULTS, ...input.features }, isExpertSapiens: input.id === "expertsapiens", isMrVisa: input.id === "mrvisakorea", isApostille: input.id === "apostillefirst", // ... }; } ``` This means adding a new tenant is a config entry, not code changes. The type system enforces completeness — if you miss a required field, TypeScript tells you before the build finishes. ## Step 2: A Feature Flag System Each tenant exposes a distinct surface area. ExpertSapiens has the full feature set. MrVisaKorea shows visa guides and hides the property listings. ApostilleFirst has no expert directory — it's a document legalization service, not a marketplace. Rather than scattering `if (tenant.id === "mrvisakorea")` checks everywhere, I use a `TenantFeatures` interface with every flag defaulting to `false`. Each tenant only enables what it needs: ```typescript const FEATURE_DEFAULTS: TenantFeatures = { showVisaGuides: false, showApostilleGuides: false, showHireGuides: false, showExpertDirectory: false, showPropertyListings: false, showCurrencySelector: false, // ... all false by default }; // mrvisakorea overrides only what it needs: features: { showVisaGuides: true, showKVisaWidgets: true, showExpertDirectory: true, showGlossary: true, indexContentGuides: true, } ``` Components check `tenant.features.showVisaGuides` rather than the tenant ID. The flag name expresses intent; the ID is implementation detail. This also makes it easy to toggle features across tenants without touching component code. ## Step 3: Hostname Resolution at the Middleware Layer Tenant resolution happens once — in Next.js middleware — before any page renders. The middleware reads the request hostname and calls `getTenantByHostname()`: ```typescript export function getTenantByHostname(hostname: string): TenantConfig { if (matchesDomain(hostname, "apostillefirst.com")) return TENANTS.apostillefirst; if (matchesDomain(hostname, "mrvisakorea.com")) return TENANTS.mrvisakorea; if (matchesDomain(hostname, "seoulhomes.kr")) return TENANTS.seoulhomes; if (matchesDomain(hostname, "seoultranslate.com")) return TENANTS.seoultranslate; if (matchesDomain(hostname, "airlinkee.com")) return TENANTS.link; if (matchesDomain(hostname, "rehovica.com")) return TENANTS.rehovica; return TENANTS.expertsapiens; // default } ``` The resolved tenant is forwarded as an `x-tenant` header and picked up by server components via `headers()`. No prop drilling, no context providers wrapping the entire app — every server component can resolve its tenant in one line. For local development, a `NEXT_PUBLIC_TENANT_OVERRIDE` env var (or a cookie set by an in-app dev switcher) lets you browse any tenant on localhost without changing the hostname. ## Step 4: Data Isolation with a Single Database All tenants share one Supabase project. The `experts` table has a `sites` column — a `text[]` array of which tenant keys that expert belongs to. Tenant-scoped queries filter with `sites @> ARRAY['mrvisakorea']`, using a GIN index on the column for performance. ```sql -- Experts visible to MrVisaKorea SELECT * FROM experts WHERE sites @> ARRAY['mrvisakorea'] AND is_active = true; ``` This means a single expert can appear on multiple tenants — or none. The admin panel has per-expert toggles. No duplicated rows, no cross-tenant joins. Row-Level Security policies enforce the same isolation at the database layer: ```sql -- Users can only read their own data regardless of which tenant they're on CREATE POLICY "users_own_data" ON bookings FOR SELECT USING (client_id = auth.uid()); ``` RLS means even a bug in application code can't return another user's bookings. The enforcement happens in Postgres, not in a middleware function that could be bypassed. For a deeper look at RLS patterns — performance optimization, jwt.claims setup, multi-tenant isolation policies, and common gotchas — see [Supabase RLS in Production: jwt.claims, Tenant Isolation & Performance](/en/blog/supabase-rls-production-patterns). ## Step 5: Tenant-Aware Routing Some tenants have entirely different page universes. MrVisaKorea has visa guide pages that don't exist on ExpertSapiens. ApostilleFirst has apostille service pages. SeoulHomes has property listings. `categoryFilter` on the tenant config handles this at the routing layer — if a tenant has `categoryFilter: ["immigration", "legal"]`, then `/category/[slug]` returns 404 for any other slug. No accidental content leakage across tenant surfaces. For selecting per-tenant values in components, a `selectByTenant()` utility keeps the code clean: ```typescript const keywords = selectByTenant(tenant, { mrvisakorea: ["Korea visa consultant", "immigration lawyer Seoul"], apostillefirst: ["apostille Korea", "document legalization"], default: ["expert services", "professional directory"], }); ``` This is more readable than a switch statement and fully typed — TypeScript will error if you reference a `TenantId` that doesn't exist. ## What This Looks Like in Practice Seven live sites. One `vercel deploy`. When I fix a bug in the booking flow, all seven tenants get the fix simultaneously. When I add a new page type, I add one feature flag and enable it on the tenants that need it. The tradeoff is complexity in the config layer and careful discipline around data isolation. The config is verbose — each `TenantConfig` object is ~40 lines — but it's all in one file (`src/lib/tenant.ts`) and the TypeScript compiler catches any inconsistencies. For projects where each client needs meaningfully different behavior but shares a common platform, this pattern is significantly more maintainable than separate repositories. ## What This Architecture Requires - **Next.js App Router** — server components and middleware make tenant resolution clean and zero-overhead - **Supabase** — array column with GIN index for multi-tenant DB queries; RLS for security - **TypeScript** — the config system only works well with types enforcing completeness - **Disciplined feature flagging** — resist the urge to use `tenant.id` directly in components; flags are more refactorable --- If you're building a platform that needs to support multiple brands, clients, or subdomains from a single codebase — this is a pattern worth considering. I've used it across multiple production projects and it's held up well. **[Get in touch if you'd like to discuss your project →](/contact)** --- ## [How I Built a Personal Ops Dashboard with Next.js, Supabase, and AI](https://wonsukchoi.com/en/blog/personal-ops-dashboard-nextjs-supabase) > A look inside the private Next.js dashboard I built to manage 13 live sites — site status monitoring, personal AI, offline-capable notes, multi-tenant architecture, and more. ## Why I Built It When you're running multiple web projects simultaneously — each with its own domain, database, analytics, and user base — managing everything becomes its own part-time job. I was constantly switching between Vercel, Supabase, Google Analytics, email, and three different todo apps. Nothing was connected. So I built a private ops dashboard at wonsukchoi.co — a single Next.js app that centralizes everything I need to manage my projects. This post covers what's in it, the technical decisions behind it, and what I learned. ## What's Inside The dashboard is organized into five tab groups. Here's what each one does. ### System: Site Status Monitor The most immediately useful feature monitors all 13 of my live sites on a 30-second polling interval, checking two things per site: **HTTP reachability** — a HEAD request to each domain with a 5-second timeout. Any response is "up"; a timeout or network error is "down." **Supabase DB health** — a HEAD request to each project's PostgREST endpoint (`https://.supabase.co/rest/v1/`). Since multiple sites share the same Supabase instance, I deduplicate by project ref — 13 sites resolve to just 5 unique DB checks. Any HTTP response, even a 401, confirms the DB is reachable. The widget also tracks domain expiry dates with color-coded urgency: green beyond 60 days, yellow at 30–60, orange at 14–30, red under 14. Dates are editable inline and persisted to localStorage. ### Tools: Personal AI A private chat interface connected to OpenRouter — Claude Sonnet 4.5, Claude Opus 4.5, GPT-4o, GPT-4o Mini, and Gemini 2.0 Flash. Model switching is live mid-conversation. Responses stream token-by-token using the Vercel AI SDK. Having model selection in-context is genuinely useful: draft quickly with a fast model, switch to Opus for anything that needs deeper reasoning. ### Content: Journal and Notes **Journal** is an AI-assisted daily writing space. **Notes** is a persistent reference system — nested category hierarchy, optional per-note password protection, bulk move/delete, image uploads to Supabase Storage, and an offline sync queue so writes aren't lost when the network drops. Offline sync is handled by a custom `useOfflineSync` hook that queues mutations in IndexedDB and replays them on reconnect. It's the kind of feature that's invisible when it works — and was the hardest to get right. ### Work: Todos, Calendar, Subscriptions Todo tracks tasks with due dates, priorities, and categories. Overdue items surface in the tab badge. Calendar gives a monthly view of scheduled items across configurable timezones. Subscriptions is a lightweight financial tracker — cost per subscription, monthly vs annual, running total at a glance. ### Site: Messages, Blog, Subscribers, Analytics Centralizes the inbound side: contact form submissions, newsletter subscribers, and analytics. The Blog tab lets me write and publish posts — including this one — without opening a separate CMS. ## Technical Decisions ### Next.js App Router The dashboard uses the App Router with a deliberate split between server and client components. The initial page load is a server component that fetches the user's full data set from Supabase using the service role client — no loading spinners on first render, no client-side data fetching waterfalls. Mutations go through server actions: form submissions, note updates, todo toggles, blog publishes. This eliminates an entire layer of API route boilerplate and keeps the data layer co-located with the components that use it. ### Supabase for Auth, DB, and Storage Supabase handles authentication (email/password, session management), the primary PostgreSQL database, and file storage for note images and blog assets. Row-Level Security is the core of the auth model. Every table enforces read/write policies at the database layer — not in application code. Even if a server action has a bug, the DB won't return data it shouldn't. This is the right place to enforce authorization. Types are generated directly from the database schema via the Supabase CLI and imported throughout the app. A schema change immediately surfaces as a TypeScript error in consuming code — not a runtime crash in production. ### Multi-Tenant Architecture The same codebase that powers this dashboard is the foundation for the ExpertSapiens platform — a multi-tenant directory with 7 live tenant sites (expertsapiens.com, airlinkee.com, mrvisakorea.com, seoultranslate.com, and others). Tenant context is resolved from the request hostname at the middleware layer and flows through the app as a typed config object. Adding a new tenant requires a config entry, not a new codebase or deployment. This is the architecture pattern I use across all new projects now — single codebase, environment-resolved tenant, shared DB with RLS-based data isolation. ### TypeScript End-to-End Everything is typed: server actions, API routes, component props, Supabase query results. The discipline pays off most in refactors — rename a column in the DB, regenerate types, and every usage that's now broken is highlighted immediately. ## What I Would Do Differently **Split the dashboard shell earlier** — the main component grew past 6,000 lines. It works, but feature-level module splits from the start would make it far easier to navigate. **Service worker for offline sync** — the current IndexedDB-based approach works well but a service worker with background sync would handle more edge cases, especially on flaky mobile connections. **Time-series store for analytics** — basic Supabase queries are fine for low-traffic dashboards. For anything richer, a dedicated time-series store (Tinybird, ClickHouse) would be worth it. ## What This Demonstrates A production-quality private dashboard requires the same skill set as any client-facing SaaS: real-time data, auth, background jobs, file uploads, offline resilience, and clean UI. The difference is that the only user is me, which means faster iteration and more opinionated decisions. If you're looking for a developer who can take a project from blank repo to production — Next.js App Router, Supabase backend, TypeScript throughout, AI integrations, multi-tenant architecture — I'd be glad to talk. **[Get in touch →](/contact)** --- ## [How to Migrate React to Next.js in 2026: CRA, Vite & Custom Webpack Guide](https://wonsukchoi.com/en/blog/migrate-react-to-nextjs) > Step-by-step migration for CRA, Vite, and custom webpack: routing, SSR, data fetching, images, and the gotchas most guides skip. **Quick answer:** migrating React to Next.js means keeping your component code almost as-is and rebuilding the outer shell — file-based routing, Server Components for data fetching, `next/image`, and `generateMetadata`. A small CRA or Vite app (under 10 routes, no auth) takes a solo developer 1–2 weeks. Full walkthrough, checklist, and an AI-assisted conversion prompt below. ## What Changes When You Migrate React to Next.js React is a library. Next.js is a framework built on top of React that adds routing, server rendering, image optimization, and a metadata API. When you migrate, your component code stays almost entirely intact. What changes is the outer shell: - **File-based routing** replaces React Router - **[Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components)** replace client-side `useEffect` data fetching - **[`next/image`](https://nextjs.org/docs/app/api-reference/components/image)** replaces `` tags - **[`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata)** replaces react-helmet - **API routes** optionally replace a separate Express/Node backend The migration is a restructure, not a rewrite. Whether you are moving a Create React App, converting a Vite project, or shifting off a custom webpack setup, the steps below are the same — this guide covers react to next js migration for all three starting points. ## Before You Start: Migration Audit Checklist Map these out before writing a single line of code. The surprises you find in audit are much cheaper than the ones found mid-migration. **Routing:** - List every React Router route (v5 vs v6 matters — they have different APIs) - Identify nested routes and shared layouts - Note any programmatic navigation (`useNavigate`, `history.push`) **Data fetching:** - List every `useEffect` that fetches data - Note any Redux Thunk / React Query / SWR patterns - Identify which calls hit authenticated endpoints **Configuration:** - Note CRA customizations (CRACO, react-app-rewired, custom `.env` keys) - Note Vite config (aliases, plugins, `import.meta.env` usage) - Note any webpack plugins that have no Next.js equivalent **Auth:** - Is auth via `localStorage` tokens or `httpOnly` cookies? - Where does protected route logic live? **Third-party packages:** - Any packages that use `window` or `document` at import time — these crash in Server Components - Browser-only SDKs (analytics, Intercom, chat widgets, etc.) --- ## How Do I Migrate a Create React App (CRA) Project to Next.js? CRA is the most common starting point. Most of the migration is the same as any React-to-Next.js move, but CRA has specific issues to handle first. ### CRA-specific changes **Replace `react-scripts` commands.** CRA's `npm start` / `npm run build` become `next dev` / `next build`. Update `package.json`: ```json "scripts": { "dev": "next dev", "build": "next build", "start": "next start" } ``` **Rename `REACT_APP_` env variables to `NEXT_PUBLIC_`.** CRA uses `REACT_APP_FOO`; Next.js uses `NEXT_PUBLIC_FOO`. Rename every variable in your `.env` files and every reference in the codebase: ```bash # Before (CRA) REACT_APP_API_URL=https://api.example.com # After (Next.js) NEXT_PUBLIC_API_URL=https://api.example.com ``` **`PUBLIC_URL` is not needed.** CRA's `PUBLIC_URL` variable becomes unnecessary — Next.js serves static assets from the `public/` folder automatically with no path prefix required. **Delete `react-app-env.d.ts`.** It's CRA-specific and will cause TypeScript issues if left in. **Translate CRACO / react-app-rewired configs to `next.config.ts`.** Most use cases (aliased imports, SVG handling, custom Babel plugins) have direct Next.js equivalents. Don't copy the config over — identify what each rule was doing and find the Next.js idiomatic way. **Set up import aliases in `tsconfig.json`:** ```json { "compilerOptions": { "paths": { "@/*": ["./*"] } } } ``` After handling these, continue to the shared steps below. --- ## How Do I Migrate a Vite React App to Next.js? Vite projects are usually cleaner than CRA, but have their own migration points. ### Vite-specific changes **Replace `import.meta.env` with `process.env`.** Vite uses `import.meta.env.VITE_FOO`; Next.js uses `process.env.NEXT_PUBLIC_FOO`. Rename variables and update every usage: ```ts // Before (Vite) const apiUrl = import.meta.env.VITE_API_URL // After (Next.js) const apiUrl = process.env.NEXT_PUBLIC_API_URL ``` **Move Vite aliases to `tsconfig.json` paths.** If you used `resolve.alias` in `vite.config.ts`, move them to `tsconfig.json` — Next.js reads path aliases from there automatically: ```ts // vite.config.ts (what you had) resolve: { alias: { '@': path.resolve(__dirname, 'src') } } // tsconfig.json (what you need instead) // "paths": { "@/*": ["./src/*"] } ``` **Replace Vite-specific plugins:** - `vite-plugin-svgr` → use `@svgr/webpack` via `next.config.ts`, or inline SVGs as React components - `vite-plugin-pwa` → `next-pwa` - `@vitejs/plugin-react` → not needed; Next.js includes React support out of the box After handling these, continue to the shared steps below. --- ## How Do I Migrate a Custom Webpack React App to Next.js? This covers ejected CRA, hand-rolled webpack setups, or projects using older tooling. **Custom webpack configs rarely transfer 1:1.** Identify what each rule was doing and find the Next.js equivalent: | Webpack customization | Next.js equivalent | |---|---| | SVG as React component | `@svgr/webpack` in `next.config.ts` | | CSS Modules | Built-in — file must be named `*.module.css` | | Sass/SCSS | Install `sass`, works automatically | | Less | `next-with-less` plugin | | Custom Babel plugins | `babel.config.js` at project root | | Module path aliases | `tsconfig.json` paths | | Environment-specific builds | `.env.local`, `.env.production`, etc. | | Bundle analyzer | `@next/bundle-analyzer` | Don't try to port the entire webpack config. Go feature by feature — what is each rule accomplishing, and what's the idiomatic Next.js way to accomplish it? After handling these, continue to the shared steps below. --- ## Shared Migration Steps (All React Apps) These steps apply regardless of whether you're coming from CRA, Vite, or a custom setup. ### Step 1: Create the Next.js project Start fresh — don't try to convert in-place. Create a new project alongside the existing one: ```bash npx create-next-app@latest my-app-nextjs --typescript --app ``` Copy your `components/`, `hooks/`, `lib/`, and `utils/` directories into the new project. Component code is nearly all reusable. ### Step 2: Migrate routing Next.js uses a file-based router. Create a `page.tsx` for every route: ``` app/ page.tsx → / about/page.tsx → /about blog/[slug]/page.tsx → /blog/:slug dashboard/layout.tsx → shared layout for /dashboard/* ``` Every React Router `` maps to a file. It's mechanical work — the most time-consuming part for large apps. **Programmatic navigation:** Replace `useNavigate()` (React Router) with `useRouter()` from `next/navigation` inside a `'use client'` component. ### Step 3: Move data fetching to Server Components This is the biggest conceptual shift. Instead of `useEffect` + client-side API calls, you fetch data directly in async Server Components: ```tsx // Before: client-side fetch (React SPA) function BlogPage() { const [posts, setPosts] = useState([]) useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts) }, []) return } // After: Server Component (Next.js) export default async function BlogPage() { const posts = await db.query('SELECT * FROM posts WHERE published = true') return } ``` Result: no loading spinners on first render, server-rendered HTML for crawlers, no extra browser round-trip on page load. ### Step 4: Replace react-helmet with generateMetadata ```tsx // Before: react-helmet (client-rendered — crawlers often miss it) My Page // After: generateMetadata (server-rendered — crawlers see it immediately) export async function generateMetadata({ params }): Promise { return { title: 'My Page', description: '...', openGraph: { title: 'My Page', description: '...' }, } } ``` ### Step 5: Handle browser-only code Components that use `localStorage`, `window` events, or browser SDKs must be marked as Client Components: ```tsx 'use client' export function ThemeToggle() { const [dark, setDark] = useState(false) // safe to use browser APIs here } ``` For third-party packages that use `window` at import time, wrap with `dynamic`: ```tsx const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false }) ``` ### Step 6: Replace `` with `next/image` ```tsx // Before Hero // After import Image from 'next/image' Hero ``` `next/image` gives you automatic WebP conversion, lazy loading, and responsive sizing. Add `priority` to above-the-fold images to avoid LCP issues. ### Step 7: Test, then cut over Run both apps simultaneously and check for: - Hydration mismatch warnings in the browser console - Missing or misnamed environment variables - Auth flows that relied on immediate client-side state - Any package that crashes with `window is not defined` Deploy to Coolify, Vercel, Railway, or any Node-capable VPS. The first production deploy usually surfaces the last few edge cases. --- ## React to Next.js Migration Checklist **Setup** - [ ] Created fresh `create-next-app` project - [ ] Copied `components/`, `hooks/`, `lib/`, `utils/` directories - [ ] Renamed env variables (`REACT_APP_` / `VITE_` → `NEXT_PUBLIC_`) - [ ] Updated `package.json` scripts - [ ] Set up path aliases in `tsconfig.json` **Routing** - [ ] Every React Router route has a corresponding `page.tsx` - [ ] Dynamic routes converted to `[slug]/page.tsx` - [ ] Nested layouts moved to `layout.tsx` - [ ] Programmatic navigation updated to `useRouter()` from `next/navigation` **Data fetching** - [ ] `useEffect` data fetches moved to Server Components - [ ] Auth-gated data uses server-side cookie reading via `cookies()` - [ ] Client-only fetching (triggered by user actions) left as Client Components **Metadata** - [ ] `react-helmet` replaced with `generateMetadata` - [ ] Open Graph tags set per page **Client Components** - [ ] `'use client'` added to every component using `useState`, `useEffect`, or browser APIs - [ ] `dynamic(() => import(...), { ssr: false })` wrapping any SSR-crashing package **Images** - [ ] `` tags replaced with `next/image` - [ ] `priority` set on above-the-fold images **Testing** - [ ] No hydration mismatch errors in browser console - [ ] Auth flow works end to end - [ ] All env variables present in `.env.local` - [ ] Build passes (`next build`) with no type errors --- ## React to Next.js Conversion Prompt (for Claude, Cursor, or ChatGPT) Feeding an AI tool your whole codebase and saying "convert this to Next.js" produces messy, inconsistent output. It works much better broken into the same steps as this guide — one file or one concern at a time, explicit about which router and rendering model you're targeting. Copy this as a starting prompt, fill in the bracketed parts: ``` I'm migrating a React app (built with [CRA / Vite / custom webpack]) to Next.js [App Router]. Convert the following component/file, applying these rules: 1. If this component fetches data with useEffect + fetch/axios, and it doesn't need browser-only APIs (localStorage, window, event listeners), convert it to an async Server Component that fetches data directly — no useState/useEffect for the initial load. 2. If this component genuinely needs interactivity (useState, onClick, browser APIs), keep it a Client Component and add 'use client' at the top — but push it as deep in the tree as possible, don't mark parent layout/page files as client just because one child needs it. 3. Replace react-router-dom navigation (useNavigate, Link, useParams) with next/navigation equivalents (useRouter, next/link, useParams). 4. Replace tags with next/image, inferring width/height from context or asking me if unknown. 5. Replace react-helmet / manual tags with a generateMetadata export if this is a page-level file. 6. Flag anything that reads process.env.REACT_APP_* or import.meta.env.VITE_* so I can rename it to NEXT_PUBLIC_*. 7. Don't guess at file structure — tell me what new file path this should live at under app/, and ask if it's ambiguous. Here's the file: [paste component] ``` Feed it one route or shared component at a time, in the order from the [migration checklist](#react-to-nextjs-migration-checklist) above — routing first, then data fetching, then metadata, then images. Trying to convert an entire app in one prompt is exactly what produces the broken, half-migrated output most AI-assisted migrations end up with. ## How Long Does It Take? | App size | Solo developer, full-time | |---|---| | Small (< 10 routes, no auth) | 1–2 weeks | | Medium (10–30 routes, auth, 3rd-party APIs) | 3–6 weeks | | Large (30+ routes, complex state, multiple teams) | 2–4 months | Add 30–50% to these estimates if you're migrating while actively developing new features in the existing app. ## Common Gotchas **Cookie-based auth needs `cookies()`.** If you use `httpOnly` cookies, read them in Server Components via Next.js's [`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies) function. This is the single most common blocker in production migrations. **`window is not defined`.** Third-party packages that reference `window` at import time crash in Server Components. Fix with `dynamic(..., { ssr: false })`. **Hydration mismatches.** If server HTML doesn't match the first client render (common with `localStorage`-based themes or random IDs), Next.js warns and remounts. Move such logic inside `useEffect` or use `suppressHydrationWarning` on the specific element. **`useRouter()` changed.** In the App Router, [`useRouter`](https://nextjs.org/docs/app/api-reference/functions/use-router) comes from `next/navigation`, not `next/router`. The `query` object is gone — use `useParams()` for route params and `useSearchParams()` for query strings. **Server Components can't use hooks.** `useState`, `useEffect`, and React context don't work in Server Components. Add `'use client'` to any component that needs them. **Environment variables without `NEXT_PUBLIC_` are server-only.** They won't be in the browser bundle, which is good for secrets — but if you reference a non-prefixed variable client-side, it'll be `undefined`. Audit your env usage during the migration. --- If you're weighing whether this migration makes sense for your specific stack — the timeline, the cost, the risk — feel free to reach out. Happy to talk through it.