<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Bergholz</title>
    <description>The latest articles on DEV Community by Daniel Bergholz (@danielbergholz).</description>
    <link>https://dev.to/danielbergholz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1199300%2F236c734c-3824-43c2-86aa-072956e0628f.jpeg</url>
      <title>DEV Community: Daniel Bergholz</title>
      <link>https://dev.to/danielbergholz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danielbergholz"/>
    <language>en</language>
    <item>
      <title>Testing GLM-5.2 on OpenCode: I'm impressed!</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Thu, 18 Jun 2026 09:51:13 +0000</pubDate>
      <link>https://dev.to/danielbergholz/testing-glm-52-on-opencode-im-impressed-1780</link>
      <guid>https://dev.to/danielbergholz/testing-glm-52-on-opencode-im-impressed-1780</guid>
      <description>&lt;p&gt;I have a confession: I roll my eyes at AI benchmarks. Every other week someone on Twitter posts a chart where a brand new model is suddenly beating Opus and GPT, the replies go crazy, and then you actually use the thing and it falls apart on the first real task. Beautiful numbers, ugly code.&lt;/p&gt;

&lt;p&gt;So when &lt;a href="https://z.ai/" rel="noopener noreferrer"&gt;z.ai&lt;/a&gt; shipped &lt;strong&gt;GLM 5.2&lt;/strong&gt; and the timeline started shouting that an open-weights model was now nipping at the heels of the frontier labs, my instinct was the usual one. Sure. Prove it.&lt;/p&gt;

&lt;p&gt;This post is me proving it. I gave GLM 5.2 a real feature to build on my actual production website, with almost no hand-holding, and watched what happened. Spoiler: I was not expecting to write the sentence I ended up writing.&lt;/p&gt;

&lt;p&gt;If you'd rather watch me run the whole thing live (including the part where my tools crashed on camera), the video is right here:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/PSOskeYqvhE"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The claims I was here to test
&lt;/h2&gt;

&lt;p&gt;Let's get the hype out of the way first, because the claims are genuinely big.&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2066938937344495629-856" src="https://platform.twitter.com/embed/Tweet.html?id=2066938937344495629"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2066938937344495629-856');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2066938937344495629&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;GLM 5.2 is the same physical size as GLM 5.1 (744B total parameters, 40B active), but on the Artificial Analysis Intelligence Index it jumped 11 points, from 40 to 51. That score makes it the &lt;strong&gt;leading open-weights model&lt;/strong&gt;, ahead of MiniMax-M3 (44), DeepSeek V4 Pro (44) and Kimi K2.6 (43). On the overall leaderboard it sits behind only Claude Fable 5 (60), Claude Opus 4.8 (56) and GPT-5.5 (55). For an open, MIT-licensed model you can download the weights for, that is a wild place to be.&lt;/p&gt;

&lt;p&gt;Here is the upgrade at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GLM 5.1&lt;/th&gt;
&lt;th&gt;GLM 5.2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intelligence Index (v4.1)&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context window&lt;/td&gt;
&lt;td&gt;200K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1M&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total / active params&lt;/td&gt;
&lt;td&gt;744B / 40B&lt;/td&gt;
&lt;td&gt;744B / 40B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output tokens per task&lt;/td&gt;
&lt;td&gt;26k&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;43k&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per task&lt;/td&gt;
&lt;td&gt;~$0.25&lt;/td&gt;
&lt;td&gt;~$0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price (in / cache / out per 1M)&lt;/td&gt;
&lt;td&gt;$1.4 / $0.26 / $4.4&lt;/td&gt;
&lt;td&gt;$1.4 / $0.26 / $4.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out. First, the context window went from 200K to a full &lt;strong&gt;1 million tokens&lt;/strong&gt;, which matters a lot for agentic coding where the harness stuffs your whole repo into the prompt. Second, the price did not move at all. Same cost as 5.1, 11 points smarter.&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2067135640249209175-119" src="https://platform.twitter.com/embed/Tweet.html?id=2067135640249209175"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2067135640249209175-119');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2067135640249209175&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;That last row in the table is also the explanation for a complaint you'll see online, and I'll come back to it. Numbers on a chart are one thing. Let's go build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  My setup: OpenCode plus OpenRouter
&lt;/h2&gt;

&lt;p&gt;For the harness I used &lt;a href="https://opencode.ai/" rel="noopener noreferrer"&gt;OpenCode&lt;/a&gt;, the desktop app, which has become my go-to playground for trying models that aren't Claude. For the model itself I routed through &lt;a href="https://openrouter.ai/" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Why OpenRouter and not OpenCode's own provider? Honestly, a small gripe: OpenCode Zen and the Go subscription have been slow to add new open-weights models. GLM 5.2 wasn't on either yet on the day I recorded. OpenRouter had it on day one, so that's where I went. If you want the model, OpenRouter is the path of least resistance right now.&lt;/p&gt;

&lt;p&gt;One note for the regulars here: I'm normally a Claude Code person. I pay for Claude Max and I've said many times on this channel that it's the best agent out there. So this isn't a "Claude is bad" video. It's me, a Claude loyalist, genuinely curious whether an open model can hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: a real feature on my real site
&lt;/h2&gt;

&lt;p&gt;No toy to-do app. The target was &lt;a href="https://bergdaniel.com.br/" rel="noopener noreferrer"&gt;bergdaniel.com.br&lt;/a&gt;, my personal website. It's a fully server-rendered Next.js app where, at build time, I hit the dev.to API to populate my blog page and the YouTube API to populate my courses page. Nothing fancy, but it's real code that's actually deployed.&lt;/p&gt;

&lt;p&gt;My blog list has grown enough that I wanted a search box. So that was the task. And here's the important part for a model review: &lt;strong&gt;I deliberately gave it almost no context.&lt;/strong&gt; No "by the way, we don't have a database," no "remember this is server-rendered with ISR." I wanted to see whether the model would figure out the constraints on its own or trip over them.&lt;/p&gt;

&lt;p&gt;This was the entire prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Help me implement a search feature on the blog page. If possible, use query params and URL state for the search. Example: &lt;code&gt;?q=&lt;/code&gt;. Whenever the user types something on the search, no need to build an explicit submit button, use debounced searches after waiting like 300 milliseconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I switched on plan mode and hit submit. The honest test of an agent isn't whether it can follow a spec. It's what it does when the spec is vague.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan mode actually surprised me
&lt;/h2&gt;

&lt;p&gt;GLM 5.2 is a slower, more deliberate model than the other open-weights options. I'd noticed that the day before. But I'll take a slow model that one-shots the feature over a fast one I have to argue with, so I let it think.&lt;/p&gt;

&lt;p&gt;First it researched the codebase and reported back, correctly, that my blog page is a server component calling &lt;code&gt;getArticles&lt;/code&gt;, regenerated with ISR every twelve hours. Then it proposed the plan: keep the page as a server component, add a new client component for the search, debounce each keystroke 300ms, then run a &lt;code&gt;replace&lt;/code&gt; on the URL so we don't pollute browser history on every letter. Clear X button to reset, empty state when nothing matches.&lt;/p&gt;

&lt;p&gt;But the part that got me was the reasoning. It explained, unprompted, &lt;em&gt;why&lt;/em&gt; it chose client-side filtering:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Articles are already fetched at build time. Doing the filter client side keeps ISR intact, avoids an extra fetch per keystroke, and lets the URL state be the single source of truth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's exactly right. That's the trap I was hoping to watch it fall into, querying the dev.to API on every keystroke at runtime, and it sidestepped it without me saying a word about ISR. It even knew to wrap the component in a Suspense boundary because &lt;code&gt;useSearchParams&lt;/code&gt; requires it in Next.js. This is the kind of thing I expect from Opus, not from a model I downloaded the weights for.&lt;/p&gt;

&lt;p&gt;Then it did something only the frontier models usually do: it asked me good questions before writing code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a search is active, how should the "New" badge behave? (Hide while searching / keep on the first match / always on the latest)&lt;/li&gt;
&lt;li&gt;Which fields should search match against? (Title, description, and tags &lt;strong&gt;(recommended)&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;What shows when nothing matches? (A "No articles found" message &lt;strong&gt;(recommended)&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See that &lt;code&gt;(recommended)&lt;/code&gt; in the options? That's a Claude move. When I talk to Claude models, they almost always mark a recommended choice in parentheses, and it's genuinely useful. Watching GLM do the same, with sensible defaults, was the moment I sat up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgr0mlan9247h47clurvd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgr0mlan9247h47clurvd.gif" alt="mind blown" width="500" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The code it actually wrote
&lt;/h2&gt;

&lt;p&gt;Talk is cheap, so here's the real output. This is the code that's deployed on my site right now, copied straight from the repo.&lt;/p&gt;

&lt;p&gt;First, the change to the blog page. It stayed a server component, kept the ISR revalidation, and just handed the articles off to the new search component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/blog/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BlogSearch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/blog-search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getArticles&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/data-access/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Blog | Daniel Bergholz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Daniel Bergholz's blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;43200&lt;/span&gt; &lt;span class="c1"&gt;// 12 hours (twice a day)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Blog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getArticles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"my-14 md:my-28 flex flex-col gap-5 max-w-5xl mx-auto"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-3xl md:text-4xl italic tracking-tight"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Blog
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;hr&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-12 border-t border-current opacity-20"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BlogSearch&lt;/span&gt; &lt;span class="na"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the new client component. Notice the &lt;code&gt;matches&lt;/code&gt; helper that searches across title, description, and tags exactly like I asked, the 300ms debounce, the &lt;code&gt;router.replace&lt;/code&gt; with &lt;code&gt;scroll: false&lt;/code&gt;, and the Suspense wrapper at the bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/blog-search.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ArticleType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag_list&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogSearchInner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleType&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryFromUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setInputValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryFromUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debounceRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryString&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/blog?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;queryString&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setInputValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debounceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debounceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;debounceRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;updateUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isSearching&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isSearching&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-5"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchInput&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"opacity-50 text-sm md:text-base"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          No articles found for &lt;span class="ni"&gt;&amp;amp;ldquo;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;rdquo;&lt;/span&gt;.
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Article&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
              &lt;span class="na"&gt;article&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
              &lt;span class="na"&gt;newPost&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isSearching&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleType&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BlogSearchInner&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I trimmed the &lt;code&gt;SearchInput&lt;/code&gt; sub-component and a couple of effects out of the snippet above so it stays readable, but everything you see is the model's actual code, untouched. The &lt;code&gt;newPost={!isSearching &amp;amp;&amp;amp; index === 0}&lt;/code&gt; line is the answer to its own "New badge" question: hide the badge while searching, show it on the latest post otherwise. It wired its own design decision straight into the JSX.&lt;/p&gt;

&lt;p&gt;Design-wise, it matched the rest of my site without being told what the site looks like. Same border treatment, same spacing, same muted opacity. When I tested it, typing "Elixir", "React", "Fable", it filtered instantly, and pasting &lt;code&gt;/blog?q=fable&lt;/code&gt; straight into the address bar worked too. URL state as the single source of truth, exactly as planned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgohejz2xwdr8zfw0zkda.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgohejz2xwdr8zfw0zkda.png" alt="Finished design" width="800" height="841"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy3j4yn5lozjz036yk7hj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy3j4yn5lozjz036yk7hj.png" alt="Finished design part 2" width="800" height="841"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't expect to like: restraint
&lt;/h2&gt;

&lt;p&gt;After it finished, I ran &lt;code&gt;npm run check&lt;/code&gt; and there were a couple of lint warnings. But here's the thing: the warnings were in my Tidewave proxy files, code GLM never touched. And its response was basically, "the one remaining warning is preexisting and unrelated to my changes, so I left it alone."&lt;/p&gt;

&lt;p&gt;Some developers will hate that. They want the agent to fix everything it sees. I'm the opposite. If a model starts proactively rewriting files I didn't ask it to touch, that's how you get a 40-file diff for a one-component feature and a code review that takes longer than writing it yourself would have. GLM drew the line exactly where I'd draw it: change what I asked for, run format, check the build, and leave the rest of the repo alone.&lt;/p&gt;

&lt;p&gt;For context, my &lt;a href="https://github.com/danielbergholz/bergdaniel.com.br/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt; on this repo has a hard rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**IMPORTANT**&lt;/span&gt;: After making any code changes, always run:
&lt;span class="p"&gt;1.&lt;/span&gt; &lt;span class="sb"&gt;`npm run format`&lt;/span&gt; - Format the code
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="sb"&gt;`npm run check`&lt;/span&gt; - Verify no linting or type errors
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="sb"&gt;`npm run build`&lt;/span&gt; - Verify the production build succeeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GLM followed it to the letter. It formatted, it checked, it built, and it stopped. That's an agent reading its instructions and respecting their boundaries, which is harder than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then OpenCode crashed
&lt;/h2&gt;

&lt;p&gt;Now for the honest part, because I'm not going to pretend the run was flawless. Right after I asked it to clean up the commits, OpenCode froze and threw a JavaScript error in the main process. The whole app went down on camera.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3zcaxgag9wx4ayq3676.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3zcaxgag9wx4ayq3676.png" alt="OpenCode crashed" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To be clear, that's an OpenCode problem, not a GLM 5.2 problem. The model's work was fine. The harness around it fell over. I relaunched, my changes were still there (just not pushed yet), and I pushed them up to deploy. Annoying, but the kind of thing that happens with fast-moving tools, and worth showing instead of editing out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in review caught something real
&lt;/h2&gt;

&lt;p&gt;OpenCode ships a &lt;code&gt;/review&lt;/code&gt; command that spins up a code-reviewer sub-agent. I pointed it at the commit GLM had made and let it go.&lt;/p&gt;

&lt;p&gt;It found no bugs, which matches my read of the code, but it did flag two minor things: a subtle input drift while typing, and an acceptable flash on deep links. The input drift was real. The URL-sync effect was re-setting the input from the trimmed URL value about 300ms after each keystroke, which could strip trailing whitespace under your cursor. GLM fixed it cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/blog-search.tsx  (the fix)&lt;/span&gt;
&lt;span class="c1"&gt;// Keep input in sync when navigating back/forward. Skip when the URL already&lt;/span&gt;
&lt;span class="c1"&gt;// reflects the current input (e.g. trailing whitespace stripped on write) so&lt;/span&gt;
&lt;span class="c1"&gt;// the visible value doesn't shift under the cursor mid-typing.&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryFromUrl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="nf"&gt;setInputValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryFromUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;queryFromUrl&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth flagging if you try this: GLM is eager to commit. The moment I used the word "commit" once, it started committing every subsequent change on its own, which isn't my default preference. I like the model to wait for my go-ahead. But to be completely fair, Claude and Codex do the exact same thing once you mention committing, so I can't single GLM out for it. At least it didn't push without asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, is it actually worth it?
&lt;/h2&gt;

&lt;p&gt;Remember that "43k output tokens per task" row in the table up top? That's the answer to everyone calling GLM 5.2 slow. It isn't slow because it's weak, it's slower because it &lt;em&gt;thinks more&lt;/em&gt;: 43k output tokens per task versus 26k for GLM 5.1 and 24k for MiniMax-M3. You're paying in latency for more reasoning. After watching the plan it produced, I'll take that trade. A model that one-shots the feature after thinking for an extra minute beats a fast model I have to correct three times.&lt;/p&gt;

&lt;p&gt;And the cost? This is the part I forgot to mention in the video, and it might be the most convincing number in the whole post: the entire session, the planning, the implementation, the code review, and the fix, cost me &lt;strong&gt;$0.265&lt;/strong&gt;. That is not a typo. Twenty-six cents to ship a real feature to production. Artificial Analysis has GLM 5.2 on the Pareto frontier of intelligence versus cost, and this is what that looks like once you stop reading charts and actually build something. For an open, MIT-licensed model whose &lt;a href="https://huggingface.co/zai-org/GLM-5.2" rel="noopener noreferrer"&gt;weights&lt;/a&gt; you can run yourself, that is hard to argue with.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpanhs5ialin5hdkwykwx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpanhs5ialin5hdkwykwx.gif" alt="bargain" width="250" height="146"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the sentence I didn't expect to write: &lt;strong&gt;this is the first time an open-weights model has genuinely impressed me on real code.&lt;/strong&gt; Not "good for an open model." Just good. The code quality was there, the architectural instincts were there, the questions it asked were the right ones, and the speed felt close to Claude, maybe even a touch faster. From this one feature, GLM 5.2 gets my stamp of approval. I'm genuinely happy we have an open model at this level: competition is good, open weights are good, and a model you can self-host writing Next.js code this clean is a great sign for where this is all heading.&lt;/p&gt;

&lt;p&gt;If you want to try it yourself: grab &lt;a href="https://opencode.ai/" rel="noopener noreferrer"&gt;OpenCode&lt;/a&gt;, point it at &lt;a href="https://openrouter.ai/" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt;, select GLM 5.2, and give it a real task instead of a benchmark. The &lt;a href="https://docs.z.ai/guides/llm/glm-5.2" rel="noopener noreferrer"&gt;z.ai docs&lt;/a&gt; have the rest of the details.&lt;/p&gt;

&lt;p&gt;If you made it all the way down here, you're awesome, thank you for reading. Let me know in the comments whether you've tested GLM 5.2 and whether it impressed you as much as it impressed me. See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" alt="the end" width="499" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opencode</category>
      <category>glm52</category>
      <category>zai</category>
    </item>
    <item>
      <title>How I Used AI to Rebuild My SaaS From Scratch</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Tue, 16 Jun 2026 19:40:11 +0000</pubDate>
      <link>https://dev.to/danielbergholz/how-i-used-ai-to-rebuild-my-saas-from-scratch-5f3m</link>
      <guid>https://dev.to/danielbergholz/how-i-used-ai-to-rebuild-my-saas-from-scratch-5f3m</guid>
      <description>&lt;p&gt;On my &lt;a href="https://www.youtube.com/watch?v=DbBw1GAs-FQ" rel="noopener noreferrer"&gt;previous video&lt;/a&gt;, I explained &lt;strong&gt;why&lt;/strong&gt; I migrated my SaaS, &lt;a href="https://thecourseshelf.com" rel="noopener noreferrer"&gt;CourseShelf&lt;/a&gt;, away from React and Inertia to Phoenix LiveView. Today I want to talk about the part everyone actually asks me about: &lt;strong&gt;how&lt;/strong&gt; I did it. Did I just point an AI at a giant codebase and walk away? Did I automate the whole thing?&lt;/p&gt;

&lt;p&gt;Short answer: I tried. Four times. And the way it ended is not the way Twitter told me it would.&lt;/p&gt;

&lt;p&gt;Let me give you the boring details first so nobody is confused. The model was primarily &lt;strong&gt;Claude Opus 4.7&lt;/strong&gt;, and the harness was &lt;strong&gt;Claude Code via the desktop app&lt;/strong&gt;. I've been playing around with OpenCode, the Codex desktop app, and people keep recommending the Pi coding agent — but for this specific migration, it was Claude Code, and I have a whole separate video coming about my setup. Not today. Today is about the four attempts.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rjg_51HnTUI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: the lazy test
&lt;/h2&gt;

&lt;p&gt;I have this thing I like to do from time to time that I call the &lt;strong&gt;lazy test&lt;/strong&gt;. I try my absolute hardest to be the laziest person alive, give the AI the smallest possible prompt, and see what comes out. It's a great way to measure how much the models have improved. I still remember when I used to write two or three giant paragraphs of context just to get something usable. Now? Two or three lines and the model often nails it. So I was curious how far "nothing" would get me.&lt;/p&gt;

&lt;p&gt;I'd heard people on Twitter saying you can do these crazy automations — "just tell Claude Code, 'hey, migrate Bun from Zig to Rust,' and it just works." Okay. Let me try the equivalent for my app.&lt;/p&gt;

&lt;p&gt;The prompt was basically: &lt;em&gt;"please migrate this codebase away from React, use LiveView, make no mistakes."&lt;/em&gt; And I didn't just use Opus 4.7 — I cranked it to &lt;strong&gt;max effort&lt;/strong&gt;. Then I waited. And waited. A little under an hour. Like 40, 45 minutes.&lt;/p&gt;

&lt;p&gt;The results were &lt;strong&gt;horrendous&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now, to be fair, my effort level here was a hard &lt;strong&gt;zero&lt;/strong&gt;. I spent ten seconds writing that prompt and maybe two brain cells crafting it. I was judging the output on two things: &lt;strong&gt;UI fidelity&lt;/strong&gt; (does the new UI look like what I had live in production, or is Claude inventing random new components?) and &lt;strong&gt;code quality&lt;/strong&gt; (is this clean and maintainable?).&lt;/p&gt;

&lt;p&gt;On UI fidelity I gave it a &lt;strong&gt;2 out of 10&lt;/strong&gt;. The funny part is the first ten minutes were genuinely good — I reviewed the homepage and it was very close to the React version. But every page after that looked like a completely different application. It felt like I'd asked Claude to build a brand new product from scratch instead of porting one frontend to another. It hallucinated components, random styles, the works.&lt;/p&gt;

&lt;p&gt;And the code quality? When Claude actually wrote code, it was okay. But halfway through the task it announced &lt;em&gt;"I'm done, I finished the migration"&lt;/em&gt; — and then I'd find a hundred to-do comments scattered around: &lt;em&gt;"this is pending, we'll fix it later, but for a v1 this is good enough."&lt;/em&gt; On some pages there was literally a placeholder component rendered on the frontend that said &lt;strong&gt;"this page isn't built yet, coming soon."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bro. NO. Unacceptable. First attempt: complete failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyccrzhs5y32ey15a191z.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyccrzhs5y32ey15a191z.gif" alt="garbage" width="363" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: batching the work with sub-agents
&lt;/h2&gt;

&lt;p&gt;Then I tried a skill that Boris introduced on Twitter, called &lt;code&gt;/batch&lt;/code&gt;. The idea is that when you're facing one huge task, you batch it into smaller ones — instead of one main agent trying to do everything in a single marathon conversation, you explicitly tell Claude to spin up sub-agents, one per slice of work. One agent migrates the homepage, another does the blog page, another does the playlists page, and so on.&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2027534984534544489-605" src="https://platform.twitter.com/embed/Tweet.html?id=2027534984534544489"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2027534984534544489-605');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2027534984534544489&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;I genuinely thought this would help a lot, because attempt 1 taught me something important: &lt;strong&gt;context rot is real&lt;/strong&gt;. The first few pages and components Claude migrated were great. But after minute twenty or thirty, it started hallucinating and going off-script. Batching attacks that directly — each agent has a short, focused context instead of one enormous degrading one.&lt;/p&gt;

&lt;p&gt;And the results &lt;em&gt;were&lt;/em&gt; better. Much better, honestly. My effort level was still zero — the only difference from attempt 1 was typing &lt;code&gt;/batch&lt;/code&gt; at the start of the prompt. This time it took way longer before Claude started hallucinating, and I got around &lt;strong&gt;two or three pages&lt;/strong&gt; that were genuinely good, both in LiveView code and in fidelity to the React version.&lt;/p&gt;

&lt;p&gt;But Claude &lt;em&gt;still&lt;/em&gt; pulled the same move: &lt;em&gt;"hey, we're done, the migration's finished,"&lt;/em&gt; followed by to-do comments and "coming soon" badges on the frontend. Better, but not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 3: I stop being lazy
&lt;/h2&gt;

&lt;p&gt;At this point I accepted the obvious: Claude does not have a crystal ball, and it cannot read my mind. I needed to be explicit. So I wrote a big markdown file with all the instructions I wanted it to follow, and I kept using the batch skill on top of it.&lt;/p&gt;

&lt;p&gt;My effort level went from zero to about &lt;strong&gt;four&lt;/strong&gt;. I spent 20–30 minutes crafting what I thought was the perfect plan, and I actually had to use some brain cells this time.&lt;/p&gt;

&lt;p&gt;The biggest improvement here wasn't UI fidelity — it was &lt;strong&gt;code quality&lt;/strong&gt;. Because Claude kept reusing conventions I had invented purely for the React/Inertia world, and porting them to LiveView where they made zero sense.&lt;/p&gt;

&lt;p&gt;The clearest example: serializers. In Inertia you can't just query the database, grab an Elixir struct, and hand it to the frontend. You have to convert the struct into a plain map of props first. So in v1 I had a whole layer of &lt;code&gt;*JSON&lt;/code&gt; modules doing exactly that. Here's a real one from the old codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# courseshelf-v1/lib/courseshelf_web/controllers/course_json.ex&lt;/span&gt;
&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CourseJSON&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;@moduledoc&lt;/span&gt; &lt;span class="sd"&gt;"""
  Functions for serializing course resources
  """&lt;/span&gt;

  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;Courseshelf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Courses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Course&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;Courseshelf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Utils&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;ChannelJSON&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;PlatformJSON&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;TagJSON&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;UserJSON&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(%&lt;/span&gt;&lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Association&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;NotLoaded&lt;/span&gt;&lt;span class="p"&gt;{}),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(%&lt;/span&gt;&lt;span class="no"&gt;Course&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="p"&gt;%{&lt;/span&gt;
      &lt;span class="ss"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;slug:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;description:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;thumbnail_url:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;view_count:&lt;/span&gt; &lt;span class="no"&gt;Utils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view_count&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="c1"&gt;# ...a dozen more fields...&lt;/span&gt;

      &lt;span class="c1"&gt;# Relationships&lt;/span&gt;
      &lt;span class="ss"&gt;tags:&lt;/span&gt; &lt;span class="no"&gt;TagJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;channel:&lt;/span&gt; &lt;span class="no"&gt;ChannelJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;platform:&lt;/span&gt; &lt;span class="no"&gt;PlatformJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;submitted_by:&lt;/span&gt; &lt;span class="no"&gt;UserJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_by&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then the controller called that serializer for every single prop before handing it to React via Inertia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# courseshelf-v1/lib/courseshelf_web/controllers/course_controller.ex&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CourseJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:sort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:tab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"courses"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;render_inertia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courses/index"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This middle layer only ever existed because of the React boundary. And Claude was faithfully recreating it in LiveView. I had to keep telling it: &lt;em&gt;"Claude, bro, just query the database and use the result on the frontend. There is no need for this middle layer."&lt;/em&gt; In LiveView you assign the struct directly to the socket and render it — done.&lt;/p&gt;

&lt;p&gt;For UI fidelity, I went a different route. I explicitly told Claude to use the &lt;a href="https://github.com/microsoft/playwright-mcp" rel="noopener noreferrer"&gt;Playwright MCP&lt;/a&gt; to screenshot the live website, compare it to whatever it had just written in LiveView, and fix the discrepancies. And… I'm pretty sure Claude completely ignored that step. Same result as before: two or three pages that were extremely close to the original, and then everything else was new components invented from scratch, style guide ignored.&lt;/p&gt;

&lt;p&gt;So attempt 3 was a good plan for code quality and still a bad result for UI fidelity. And after the third try, I was honestly starting to lose my patience and consider that maybe this wasn't going to be fully automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 4: I stop automating and start driving
&lt;/h2&gt;

&lt;p&gt;And ladies and gentlemen, that's exactly what I did.&lt;/p&gt;

&lt;p&gt;I took that big markdown plan and converted it into a proper &lt;strong&gt;skill&lt;/strong&gt;, then &lt;strong&gt;manually invoked it, page by page&lt;/strong&gt;. I wanted to personally see every component and every page and confirm they were identical to the React version, with no code smells in the LiveView underneath.&lt;/p&gt;

&lt;p&gt;People keep asking how big this skill is. It's not huge — &lt;strong&gt;152 lines&lt;/strong&gt;, which I think is very reasonable. It encodes all the explicit decisions I didn't want Claude guessing at: what &lt;em&gt;not&lt;/em&gt; to port, simplification rules, database schema parity, SEO parity, and a firm reminder to actually run the tests. Here's the simplification section, verbatim from the skill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- courseshelf-v2/.claude/skills/migrate-v1-to-v2/SKILL.md --&amp;gt;&lt;/span&gt;
&lt;span class="gu"&gt;## Simplification rules&lt;/span&gt;

One purpose of the migration is to remove v1 complexity that existed only
because of Inertia/React boundaries.

Do not carry over these patterns unless there is a strong new reason:
&lt;span class="p"&gt;
-&lt;/span&gt; JSON serializer layers whose main job was converting structs to props
&lt;span class="p"&gt;-&lt;/span&gt; Controller prop plumbing that only existed for Inertia
&lt;span class="p"&gt;-&lt;/span&gt; React-only indirection, state workarounds, or hydration-oriented conventions
&lt;span class="p"&gt;-&lt;/span&gt; Compatibility abstractions built around frontend constraints that no longer exist in LiveView

In v2, prefer:
&lt;span class="p"&gt;
-&lt;/span&gt; Calling context functions directly from LiveViews/controllers
&lt;span class="p"&gt;-&lt;/span&gt; Assigning structs and maps directly
&lt;span class="p"&gt;-&lt;/span&gt; Phoenix-native forms, routes, and state transitions
&lt;span class="p"&gt;-&lt;/span&gt; Smaller, clearer modules over migration-era abstraction layers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That serializer rule is the same lesson from attempt 3, now written down once so I never have to repeat it in a prompt again. And here's what the v2 version actually looks like in practice — no serializer, the context function returns a &lt;code&gt;%Playlist{}&lt;/code&gt; and it goes straight onto the socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# courseshelf-v2/lib/course_shelf_web/live/playlist_live/show.ex&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(%{&lt;/span&gt;&lt;span class="s2"&gt;"username"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"slug"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;_session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Playlists&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_user_playlist_by_username_and_slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;slug&lt;/span&gt;
       &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;socket&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;put_flash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Playlist not found"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;push_navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="sx"&gt;~p"/playlists"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

    &lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="no"&gt;Playlist&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playlist&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;owner?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Playlists&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;playlist_owner?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maybe_track_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;socket&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:playlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:owner?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:items_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Playlists&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compute_progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill also makes the testing non-negotiable — it asks Claude to write LiveView tests and to run &lt;code&gt;mix precommit&lt;/code&gt; before calling anything done, so we don't bypass the project's own rules.&lt;/p&gt;

&lt;p&gt;Because I was manually calling this skill for every single major feature, my effort level jumped from a 4 to a solid &lt;strong&gt;9 out of 10&lt;/strong&gt;. And I want to be precise about what that means: I did &lt;strong&gt;not&lt;/strong&gt; automate the migration. I automated the &lt;em&gt;skill&lt;/em&gt; — the playbook — but the actual work, I was driving by hand. Sure, AI wrote the code, but I was manually starting like five parallel Claude Code sessions, then reviewing each page and each diff myself.&lt;/p&gt;

&lt;p&gt;The payoff: &lt;strong&gt;UI fidelity went from a 6 to a 9&lt;/strong&gt;, and &lt;strong&gt;code quality is a flat 10&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;UI fidelity&lt;/th&gt;
&lt;th&gt;Code quality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Attempt 1 — pure lazy&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;to-do comments everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attempt 2 — &lt;code&gt;/batch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;a bit better&lt;/td&gt;
&lt;td&gt;still "coming soon" badges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attempt 3 — big markdown plan&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;still bad&lt;/td&gt;
&lt;td&gt;much better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attempt 4 — manual skill, page by page&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why isn't fidelity a 10? Because under the hood, v2 uses &lt;a href="https://daisyui.com/" rel="noopener noreferrer"&gt;daisyUI&lt;/a&gt; for Phoenix, and v1 used &lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;shadcn/ui&lt;/a&gt; on the React side. If you've used my software before, you'll notice the buttons are slightly different, the dropdowns are slightly different. But I think that's completely acceptable — I'm deliberately using a different UI library, so some divergence is expected. It's not 100%, but it's about 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code smell I had to teach away
&lt;/h2&gt;

&lt;p&gt;Code quality being a 10 wasn't free either. Every time I caught a code smell from Claude, I'd stop and write &lt;em&gt;another&lt;/em&gt; skill correcting it.&lt;/p&gt;

&lt;p&gt;The best example is around LiveView interactions. LiveView has state, kind of like &lt;code&gt;useState&lt;/code&gt; in React — except LiveView state can only change &lt;strong&gt;on the server&lt;/strong&gt;. So every time you touch it, you're doing a round trip over the websocket. For some interactions, like simply opening and closing a dialog, Claude was using LiveView state. That means a user clicks "add to library," waits ~200ms for the server round trip, and &lt;em&gt;then&lt;/em&gt; the dialog opens. That interaction shouldn't be slow — it's pure client-side UI. I'm not fetching anything, not validating anything, just opening a modal. That should never be backend state.&lt;/p&gt;

&lt;p&gt;So I wrote a skill about it. The core principle is one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- courseshelf-v2/.claude/skills/liveview-interactions/SKILL.md --&amp;gt;&lt;/span&gt;
&lt;span class="gs"&gt;**The decision test:**&lt;/span&gt; &lt;span class="ge"&gt;*Would the server's response change what the user sees?*&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**No**&lt;/span&gt; → client-side. Use &lt;span class="sb"&gt;`Phoenix.LiveView.JS`&lt;/span&gt; commands directly from
  &lt;span class="sb"&gt;`phx-click`&lt;/span&gt;, or a colocated hook. No &lt;span class="sb"&gt;`handle_event`&lt;/span&gt;, no &lt;span class="sb"&gt;`:foo_open?`&lt;/span&gt; assign.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Yes**&lt;/span&gt; → server-side. &lt;span class="sb"&gt;`phx-click="event_name"`&lt;/span&gt; with a &lt;span class="sb"&gt;`handle_event/3`&lt;/span&gt; is correct.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I gave Claude, in plain terms: only trigger a &lt;code&gt;handle_event&lt;/code&gt; if you &lt;em&gt;absolutely&lt;/em&gt; need the server — to fetch extra data, run validation, run a changeset. But if all you're doing is opening a dialog, use the JS module and push a client-side event instead. Here's the right pattern from the actual codebase — the edit button opens the modal instantly with &lt;code&gt;JS.show&lt;/code&gt;, and a colocated hook prefills the form from &lt;code&gt;data-*&lt;/code&gt; attributes, no server involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# courseshelf-v2/lib/course_shelf_web/live/playlist_live/show.ex&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;
  &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"secondary"&lt;/span&gt;
  &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"button"&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"playlist-show-edit"&lt;/span&gt;
  &lt;span class="n"&gt;phx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hook&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;".PrefillEditPlaylistForm"&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;@playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;@playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;playlist&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@playlist&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_public&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
  &lt;span class="n"&gt;phx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;click&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#edit-playlist-modal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;display:&lt;/span&gt; &lt;span class="s2"&gt;"flex"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#edit-playlist-name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;.&lt;/span&gt;&lt;span class="n"&gt;icon&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"hero-pencil-square"&lt;/span&gt; &lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"size-4"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Edit"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server only gets involved when there's a real mutation to run. &lt;em&gt;Then&lt;/em&gt; it does its thing and tells the client to close the modal — one &lt;code&gt;push_event&lt;/code&gt; after the save succeeds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# courseshelf-v2/lib/course_shelf_web/live/profile_live/show.ex&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"save_profile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;Accounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_profile_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:noreply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;socket&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_edit_form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;push_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"edit-profile-modal:close"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%{})&lt;/span&gt;
       &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;put_flash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Profile updated successfully"&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;

    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="no"&gt;Ecto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Changeset&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;changeset&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:noreply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:edit_form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changeset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;as:&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After I wrote that skill, the results improved. But notice the loop: I had to &lt;em&gt;read&lt;/em&gt; the code, &lt;em&gt;find&lt;/em&gt; the smell, and &lt;em&gt;then&lt;/em&gt; ask Claude to fix it. That's exactly why the effort level stayed at a 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Here's the honest math. If I had been able to fully automate this with my lazy approach, I could probably have migrated the entire CourseShelf in &lt;strong&gt;one night&lt;/strong&gt;. Instead, because I migrated every single thing by hand, the whole process took almost &lt;strong&gt;two months&lt;/strong&gt; — call it 35 to 40 days. And remember, this is not my full-time job. CourseShelf is a side project, so 40 days of work &lt;em&gt;outside&lt;/em&gt; my day job is a serious chunk of my life.&lt;/p&gt;

&lt;p&gt;But because the code quality is a solid 10, I feel completely comfortable maintaining this codebase for the long run. In my humble opinion, this is the cleanest codebase I have ever worked with — I'm not joking, it might be the best software I've ever read. I don't want to shut this thing down because it's hot garbage. I'm genuinely proud of it.&lt;/p&gt;

&lt;p&gt;So would I love to one day get a great result on the first lazy attempt? Absolutely. But I don't think we're there yet — not if you have high standards for code quality. The AI wrote almost every line of CourseShelf v2. It just needed me sitting right next to it, reviewing every page, writing a new skill every time it drifted. That's the part Twitter leaves out.&lt;/p&gt;

&lt;p&gt;If you made it this far, you're awesome. Let me know what other questions you have about how I did this, and I'll see you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" alt="The end" width="499" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>elixir</category>
      <category>react</category>
      <category>ai</category>
    </item>
    <item>
      <title>Claude Fable 5 review: I let Anthropic's new "bazooka" loose on my SaaS</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Wed, 10 Jun 2026 19:50:21 +0000</pubDate>
      <link>https://dev.to/danielbergholz/claude-fable-5-review-i-let-anthropics-new-bazooka-loose-on-my-saas-41co</link>
      <guid>https://dev.to/danielbergholz/claude-fable-5-review-i-let-anthropics-new-bazooka-loose-on-my-saas-41co</guid>
      <description>&lt;p&gt;Claude Fable 5 just launched, and I did what any reasonable person would do: I woke up at 6 AM, got flashbanged by an announcement page with no dark mode, read the whole thing, and then let the model loose on my production SaaS to see if the hype is real.&lt;/p&gt;

&lt;p&gt;I recorded the whole experiment, including the exact moment Claude talked back to me for the first time ever. If you'd rather watch than read, here's the video:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/RHkFnFE6vgI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;This post is the written version of that experiment: what the announcement actually says, the benchmark everyone should be paying attention to, the feature Fable 5 built in my codebase (with the real code it wrote), and my honest verdict at the end. If you only care about the verdict, feel free to scroll. I do the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fable vs Mythos: same model, different safeguards
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.anthropic.com/news/claude-fable-5-mythos-5" rel="noopener noreferrer"&gt;announcement&lt;/a&gt; opens with a sentence that sets the tone: Fable 5 is a &lt;strong&gt;Mythos-class model that Anthropic made safe for general use&lt;/strong&gt;. It's state of the art on nearly all tested benchmarks, and the longer and more complex the task, the larger its lead over previous models.&lt;/p&gt;

&lt;p&gt;That matches the vibe on Twitter perfectly. People are describing Fable 5 as a &lt;strong&gt;bazooka&lt;/strong&gt;: point it at a huge problem, like a giant migration or a feature that touches your whole codebase, and it will run for a very long time without hallucinating. But it's still a bazooka. If you need to change two files, it's probably overkill.&lt;/p&gt;

&lt;p&gt;Now, releasing a model this capable comes with risks, so Anthropic shipped it with heavy safeguards. And I do mean &lt;em&gt;heavy&lt;/em&gt;: from my own testing, if you dare to mention the word "security" or anything slightly adjacent to it, Fable 5 shuts down the party and your query gets rerouted to the next most capable model, Claude Opus 4.8.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjm0y3g8chb2og8ghx1d.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjm0y3g8chb2og8ghx1d.gif" alt="too dangerous" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is also where the naming confusion comes from, so let me save you some scrolling on social media:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Fable 5&lt;/strong&gt;: what we normies get. Full capabilities, with classifier-based safeguards on top (cybersecurity queries get rerouted, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Mythos 5&lt;/strong&gt;: the &lt;em&gt;same underlying model&lt;/em&gt; with safeguards lifted in some areas, available only to a small group of cyber defenders and infrastructure providers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same model. Different guardrails. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark that actually matters: FrontierCode
&lt;/h2&gt;

&lt;p&gt;Every announcement comes with the classic benchmark chart where the new model beats everything, and yes, Fable 5 wins everywhere. But there are two benchmarks worth zooming into.&lt;/p&gt;

&lt;p&gt;The first is the classical SWE-bench Pro, which, let's be honest, some people say is outdated. Models have learned to ship slop code that still scores high on it.&lt;/p&gt;

&lt;p&gt;The second is the interesting one: &lt;a href="https://cognition.ai/blog/frontier-code" rel="noopener noreferrer"&gt;&lt;strong&gt;FrontierCode&lt;/strong&gt;&lt;/a&gt;, a new benchmark created by Cognition (the company behind Devin). Instead of only asking "does the code pass the tests?", it grades what real maintainers care about: would this PR actually get &lt;strong&gt;merged&lt;/strong&gt;? They built it with 20+ open-source maintainers, and they grade behavioral correctness, regression safety, scope, test correctness, and code quality.&lt;/p&gt;

&lt;p&gt;Here's how the hardest tier (the "Diamond" set) looked before Fable 5, straight from Cognition's post:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;FrontierCode (Diamond)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Claude Fable 5 (max effort)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~30%&lt;/strong&gt; (from Anthropic's chart)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.8&lt;/td&gt;
&lt;td&gt;13.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;6.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K2.6 (best open-source)&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The previous king scored 13.4%. Fable 5, on max effort, scores &lt;strong&gt;slightly more than 30%&lt;/strong&gt;. That is not an incremental jump, that's more than doubling the state of the art on the benchmark specifically designed to punish slop.&lt;/p&gt;

&lt;p&gt;However (and this is a big however), take a look at the cost axis of that same chart. On &lt;strong&gt;high&lt;/strong&gt; effort, Fable 5 already costs around &lt;strong&gt;ten bucks per task&lt;/strong&gt;, which is roughly Opus 4.8 territory. Crank it to x-high or max effort and the sky is the limit. API pricing is &lt;strong&gt;$10 per million input tokens and $50 per million output tokens&lt;/strong&gt;. Keep that in mind for later, because it shapes my entire recommendation.&lt;/p&gt;

&lt;p&gt;Oh, and the announcement also casually mentions that Fable 5 beat &lt;strong&gt;Pokémon FireRed using only vision&lt;/strong&gt;: just screenshots, no terminal access to the game. It also built a solar system simulator that &lt;strong&gt;predicted a solar eclipse&lt;/strong&gt;. Cool party tricks, but let's test something that actually pays my bills.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch nobody is talking about: you lose it on June 23
&lt;/h2&gt;

&lt;p&gt;This is the part of the announcement I haven't seen anyone cover, and it's &lt;em&gt;very&lt;/em&gt; important.&lt;/p&gt;

&lt;p&gt;From June 9 through &lt;strong&gt;June 22&lt;/strong&gt;, Fable 5 is included in the Pro, Max, Team, and Enterprise plans at no extra cost. On &lt;strong&gt;June 23&lt;/strong&gt;, it gets removed from those plans, and using it will require &lt;strong&gt;usage credits&lt;/strong&gt;. If capacity allows, they say they'll extend the included window.&lt;/p&gt;

&lt;p&gt;Reading between the lines: Fable is expensive to run. Maybe they genuinely don't have the capacity to host this model for everyone. Or maybe they do, and they want to charge API pricing for it instead of including it in the subscriptions. To be honest? I think they're just money hungry, and what we're getting right now is essentially a free trial.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F878vrjmrwkl5v2lkjv0s.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F878vrjmrwkl5v2lkjv0s.gif" alt="expensive" width="326" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So don't get too comfortable. You have about two weeks to fully test this thing. Which is exactly what I did next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's build a real feature in CourseShelf
&lt;/h2&gt;

&lt;p&gt;Enough announcement reading. I opened the Claude desktop app with &lt;a href="https://thecourseshelf.com/" rel="noopener noreferrer"&gt;CourseShelf&lt;/a&gt; (my SaaS, full-stack Elixir/Phoenix LiveView; you know the story if you've been following the channel), bumped the effort level from medium to &lt;strong&gt;extra high&lt;/strong&gt; on the &lt;strong&gt;1M context window&lt;/strong&gt;, and kept one eye on my five-hour usage limit, which started at 0%.&lt;/p&gt;

&lt;p&gt;My prompt was deliberately vague, because I wanted to see how it thinks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I want to increase user engagement. Help me improve the app or build new features. What do you suggest we do? Give me a list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After running for almost one minute, I got a list of genuinely good suggestions. The one I liked most was number nine, &lt;strong&gt;profile badges / curator milestones&lt;/strong&gt;: cheap gamification that rewards &lt;em&gt;contribution&lt;/em&gt; (writing reviews, adding courses) rather than just consumption. It even noticed I already had a course-milestone notification type in the codebase to build on. So: "I really liked suggestion number nine. Work on it."&lt;/p&gt;

&lt;p&gt;Here's the timeline, because the token economics matter as much as the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~1 min in:&lt;/strong&gt; reading the codebase. Usage: 2%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7 min in:&lt;/strong&gt; still running, 109 lines changed so far. Usage: 7%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 min in:&lt;/strong&gt; it's doing browser testing on its own, taking screenshots of the app with the preview to check its own work. Usage: 9%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~12 min:&lt;/strong&gt; done. Usage: 9%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick math: one small-ish feature ≈ 10% of my five-hour limit. So on extra-high effort I could ship roughly ten of these before hitting the wall. Reasonable, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment Claude pushed back at me
&lt;/h2&gt;

&lt;p&gt;I opened the app in my own browser to test. Profile pages now showed achievement badges (&lt;em&gt;Course Scout&lt;/em&gt;, &lt;em&gt;Playlist Pro&lt;/em&gt;, &lt;em&gt;First Finish&lt;/em&gt;, &lt;em&gt;Seasoned Reviewer&lt;/em&gt;) and they worked. But when I hovered over a badge, I got a question-mark cursor, and after a couple of seconds a native browser tooltip showed up: "Added 5 or more courses to the catalog."&lt;/p&gt;

&lt;p&gt;So it had used the built-in &lt;code&gt;title&lt;/code&gt; attribute instead of a frontend component. I appreciate using browser defaults, but I wasn't sure this was good UX. So I typed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I wonder if it would be better UX if we allowed users to click on the engagement metrics and open a modal that explains what the metric means, instead of just showing the built-in browser titles.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then something happened that I have &lt;em&gt;never&lt;/em&gt; seen from Claude before. It agreed with the problem, and then it &lt;strong&gt;pushed back on my solution&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Honestly, the title tooltips have a real gap I should flag. They're desktop hover only. Mobile users and keyboard users get nothing. That said, I would tweak the shape of the fix: instead of one modal per badge, I would do one achievements modal for the whole system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is incredible. Previous versions of Claude Code would simply say "All right, I'm gonna ship that, no problem." Every older model was a robot with zero opinions: whatever you asked, it would do without question. That was literally my biggest complaint about them. I want a genuine pair programmer, someone with opinions and experience. &lt;strong&gt;Fable 5 has opinions.&lt;/strong&gt; And its opinion was better than my idea. I said yes, obviously.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx01wshxi3lyjqc48hufc.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx01wshxi3lyjqc48hufc.gif" alt="talking to a robot" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six minutes later (usage now 12%), the modal was built: badges became clickable, opening a single achievements modal that lists every tier with the locked ones dimmed, plus an info icon. Is the UI top-notch? Honestly, no. There's a bit too much information in that modal to absorb at a glance. Good, not great. But functionally, it nailed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity-checking the code
&lt;/h2&gt;

&lt;p&gt;Before shipping anything an AI writes to production, I do a sanity check. This is the actual code Fable 5 wrote, straight from the commit.&lt;/p&gt;

&lt;p&gt;It properly created a Phoenix context, and I love the design decision documented right in the code: badges are &lt;strong&gt;computed on the fly from the canonical count queries&lt;/strong&gt;. No new table, so the badges can never drift from the real numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/course_shelf/achievements.ex&lt;/span&gt;
&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;CourseShelf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Achievements&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;@moduledoc&lt;/span&gt; &lt;span class="sd"&gt;"""
  Derives profile achievement badges from a user's contribution counts.

  Badges are computed on the fly from the canonical count queries — no
  table backs them, so they can never drift from the real numbers and
  earning one requires no write path.
  """&lt;/span&gt;

  &lt;span class="nv"&gt;@tier_thresholds&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="ss"&gt;courses_added:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;reviews_written:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;playlists_created:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;courses_completed:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;@tier_thresholds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;count_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;tiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;thresholds&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;with_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;tier:&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;threshold:&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;earned?:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;category:&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;count:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;tiers:&lt;/span&gt; &lt;span class="n"&gt;tiers&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;count_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;Courses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count_user_submitted_courses_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;count_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:reviews_written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;Reviews&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count_user_reviews_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;# ... playlists_created and courses_completed follow the same pattern&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the frontend, it used pattern matching to map badge tiers to the different CSS variants, which is exactly how I'd write it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/course_shelf_web/live/profile_live/show.ex&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Course Scout"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Course Curator"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses_added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Catalog Builder"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... same pattern for reviews, playlists, and completions&lt;/span&gt;

&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses_added&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"hero-squares-plus"&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:reviews_written&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"hero-chat-bubble-left-right"&lt;/span&gt;

&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_variant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"neutral"&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_variant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt;
&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;achievement_variant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"warning"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it wrote proper LiveView tests for the frontend, including the accessibility cases &lt;em&gt;it&lt;/em&gt; brought up, not me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# test/course_shelf_web/live/profile_live/show_test.exs&lt;/span&gt;
&lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"renders the highest earned badge per category as a button that opens the modal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;conn:&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_fixture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="n"&gt;review_fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user:&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;library_fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user:&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status:&lt;/span&gt; &lt;span class="ss"&gt;:completed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_html&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sx"&gt;~p"/profile/#{target.username}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;has_element?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"#profile-achievements"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;has_element?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"button#achievement-reviews_written[phx-click]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"First Review"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;has_element?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"button#achievement-courses_completed[phx-click]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"First Finish"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;refute&lt;/span&gt; &lt;span class="n"&gt;has_element?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"#achievement-playlists_created"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I told it "This looks great. Commit and push on main" (sorry, PR purists, it's my side project), opened a terminal, ran &lt;code&gt;git log&lt;/code&gt;, and there it was: &lt;code&gt;feat: add achievement badges and explainer modal to profiles&lt;/code&gt;. Very nice commit message, by the way. If you're wondering whether AI can write good Elixir in 2026: that argument is long dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token bill
&lt;/h2&gt;

&lt;p&gt;Final tally: &lt;strong&gt;~900 lines of code changed, 15% of my five-hour usage limit.&lt;/strong&gt; And here's the thing to internalize: the &lt;em&gt;one&lt;/em&gt; round of feedback I gave took my usage from 9% to 15%. If you go back and forth with Fable 5 a few times on extra-high effort, your usage will evaporate.&lt;/p&gt;

&lt;p&gt;So the economics are simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If Fable 5 &lt;strong&gt;one-shots&lt;/strong&gt; your feature: extra-high effort is fine. You can survive.&lt;/li&gt;
&lt;li&gt;If it &lt;strong&gt;doesn't&lt;/strong&gt; one-shot it: you're going to burn a ton of tokens. Drop the effort to high or medium, or use a different model entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And to be fair to Fable: this one &lt;em&gt;was&lt;/em&gt; essentially a one-shot. The feature worked on the first try; the modal round was me nitpicking the UX, and Claude turned my nitpick into a better architecture than I asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final verdict: 9/10
&lt;/h2&gt;

&lt;p&gt;I rate Claude Fable 5 a solid &lt;strong&gt;9/10&lt;/strong&gt;. It's a bazooka, and it's the best model available right now. And I say this having recently used Codex and some of the Chinese models via opencode: Fable 5 beats all of them by a wide margin. It's extremely thoughtful, it runs for a very long time without losing the plot, and for the first time ever it pushed back on my ideas and gave me something better.&lt;/p&gt;

&lt;p&gt;But it is &lt;em&gt;extremely&lt;/em&gt; token hungry. My honest recommendation for right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't make it your default model&lt;/strong&gt; for day-to-day work, unless your company has an infinite token budget (in which case, go wild).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Fable 5 to create plans, then let Opus execute them.&lt;/strong&gt; Best of both worlds at current prices.&lt;/li&gt;
&lt;li&gt;Remember the clock: it leaves the subscription plans on &lt;strong&gt;June 23&lt;/strong&gt;. Test it while it's included.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My read on the industry: both Anthropic and OpenAI are shipping the best models they possibly can first, and optimizing for cost later. First we achieve AGI, &lt;em&gt;then&lt;/em&gt; we make it cheap. I'd bet that in six months to a year we get a Fable 5.5 or 5.6 at the same level of performance for a fraction of the price. That's the moment this becomes everyone's default.&lt;/p&gt;

&lt;p&gt;If you made it all the way down here, you're awesome, thank you for reading! Do you think Fable 5 is really the best model available, or is it just hype? Let me know in the comments. And if you skipped the video at the top, it's worth watching just for the moment Claude pushes back on me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqnmaygv3kykctpcy8pyh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqnmaygv3kykctpcy8pyh.gif" alt="thats all folks" width="349" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Moving my SAAS away from React (and Inertia) to Elixir</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Tue, 02 Jun 2026 18:22:23 +0000</pubDate>
      <link>https://dev.to/danielbergholz/moving-my-saas-away-from-react-and-inertia-to-elixir-50el</link>
      <guid>https://dev.to/danielbergholz/moving-my-saas-away-from-react-and-inertia-to-elixir-50el</guid>
      <description>&lt;p&gt;If you've been following my journey, you know I went all in on Elixir a while ago. I left the React treadmill behind, picked Phoenix, and to keep React on the front end I used &lt;a href="https://inertiajs.com/" rel="noopener noreferrer"&gt;Inertia.js&lt;/a&gt;. For a year, I was happy. I built my SaaS, &lt;a href="https://thecourseshelf.com/" rel="noopener noreferrer"&gt;CourseShelf&lt;/a&gt;, and I had a lot of fun doing it.&lt;/p&gt;

&lt;p&gt;And then I deleted all of it. 345 commits later, I have completely rebuilt CourseShelf from scratch, this time on full-stack Elixir with &lt;a href="https://hexdocs.pm/phoenix_live_view/welcome.html" rel="noopener noreferrer"&gt;Phoenix LiveView&lt;/a&gt;. No more React. No more Inertia. No more Node.js.&lt;/p&gt;

&lt;p&gt;This is the story of why I did it.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/DbBw1GAs-FQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The honeymoon was real (and then it ended)
&lt;/h2&gt;

&lt;p&gt;I want to be fair to Inertia here, because Inertia is genuinely great. When I first adopted it, I was writing every line of code by hand, and the experience was wonderful. You get the productivity of Phoenix on the back end and you get React on the front end, glued together without a REST API in the middle. For someone coming from the React world, it felt like the best of both worlds.&lt;/p&gt;

&lt;p&gt;But about six months ago I recorded a video with a title that probably gave away the ending: &lt;strong&gt;"Do I regret picking Inertia over LiveView for my SaaS?"&lt;/strong&gt; And the honest answer was yes.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/0kZmG3sUu0U"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;If you want the full side-by-side, that video does a proper comparison. But the TL;DR is simple: &lt;strong&gt;LiveView is simpler.&lt;/strong&gt; Not just fewer layers of code, but fewer layers of &lt;em&gt;infrastructure&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;With Inertia, my React front end couldn't render itself on the server for free. To get good SEO, I needed server-side rendering, and to get server-side rendering I needed a pool of Node.js workers sitting next to my Elixir app, rendering React to HTML before sending it down. So I was maintaining two ecosystems instead of one. Elixir for the back end, and a whole Node.js runtime just to draw the first paint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real killer: AI agents couldn't write Inertia code
&lt;/h2&gt;

&lt;p&gt;Here's the thing that actually pushed me over the edge.&lt;/p&gt;

&lt;p&gt;Inertia was a joy when I was hand-writing everything. But the moment I started moving toward agentic coding, the cracks appeared. Not a single AI agent out there could produce good Inertia code. I'd ask for a feature, and the agents would just keep spinning, generating code that didn't fit the pattern, didn't wire up correctly, didn't work.&lt;/p&gt;

&lt;p&gt;And I realized: this is a ticking time bomb. Someday I'm going to sit down to build a feature, and I simply won't be able to, because the agents will choke and I'll be back to writing every layer by hand.&lt;/p&gt;

&lt;p&gt;To be fully transparent, a lot of this wasn't Inertia's fault as a library. It was specifically the &lt;strong&gt;&lt;a href="https://github.com/inertiajs/inertia-phoenix" rel="noopener noreferrer"&gt;Inertia Phoenix port&lt;/a&gt;&lt;/strong&gt;. If you're a PHP/Laravel developer using Inertia, you have far more official support, more tooling, and the AI agents write much better code for you. You even get &lt;a href="https://laravel.com/blog/laravel-wayfinder-end-to-end-type-safety-for-php-and-typescript" rel="noopener noreferrer"&gt;Wayfinder&lt;/a&gt; for end-to-end type safety between your backend and your frontend.&lt;/p&gt;

&lt;p&gt;We don't have that in Elixir. For v1 of CourseShelf, I was creating the types of &lt;em&gt;everything&lt;/em&gt; by hand. I literally had a &lt;code&gt;types/&lt;/code&gt; folder, and every time I added a resource on the back end, I had to go write a matching TypeScript type on the front end. No code generation. Pure manual labor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/types/course.ts  (CourseShelf v1)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string | null&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;thumbnailUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;difficultyLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DifficultyLevel&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CourseStatus&lt;/span&gt;
  &lt;span class="na"&gt;estimatedDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;insertedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="c1"&gt;// ...and on, and on, kept in sync by hand&lt;/span&gt;
  &lt;span class="nx"&gt;submittedBy&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;
  &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt;
  &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Tag&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: a paradigm the AI couldn't help me with, a port with second-class tooling, and a types folder I babysat by hand. I made the call to switch to full-stack Elixir.&lt;/p&gt;




&lt;h2&gt;
  
  
  The new stack
&lt;/h2&gt;

&lt;p&gt;The biggest change is the front-end layer, which I'll get to. But I also used the rebuild as an excuse to rip out an expensive piece of my infrastructure: &lt;a href="https://fly.io/docs/mpg/" rel="noopener noreferrer"&gt;managed Postgres&lt;/a&gt; on &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;, where I host everything.&lt;/p&gt;

&lt;p&gt;Fly's managed Postgres is an amazing service. But it costs around &lt;strong&gt;$38/month&lt;/strong&gt; for the first tier (one database plus a read replica), and I just don't have the scale to justify that. If you go to CourseShelf right now, you can count the accounts: I have 89. On a &lt;em&gt;great&lt;/em&gt; day, when I tweet about it and get a traffic spike, I'll see around 130 users. On an average day it's more like 20. That is nowhere near enough to warrant a $40/month database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvsko5k6p04fn2cieu64r.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvsko5k6p04fn2cieu64r.gif" alt="Expensive" width="320" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I switched to &lt;strong&gt;unmanaged Postgres&lt;/strong&gt;, which runs me about &lt;strong&gt;$7/month&lt;/strong&gt;. The catch with unmanaged is that I'm now responsible for my own backups, so I added &lt;a href="https://www.tigrisdata.com/" rel="noopener noreferrer"&gt;Tigris&lt;/a&gt;, another Fly.io service. It's S3-compatible (you literally use the AWS CLI to poke at it), and since I'm only storing tiny database backups, I'm paying essentially nothing for it.&lt;/p&gt;

&lt;p&gt;And the front end, of course, is now LiveView.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file vs. three
&lt;/h2&gt;

&lt;p&gt;Let me show you the thing I love showing in every video: how much simpler the front end gets.&lt;/p&gt;

&lt;p&gt;In LiveView, you basically have &lt;strong&gt;one layer&lt;/strong&gt;. One file defines your queries, your mutations, &lt;em&gt;and&lt;/em&gt; your markup. Here's the actual course listing page from CourseShelf v2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/course_shelf_web/live/course_live/index.ex&lt;/span&gt;
&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;CourseShelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CourseLive&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;CourseShelfWeb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:live_view&lt;/span&gt;

  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;CourseShelf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Courses&lt;/span&gt;

  &lt;span class="nv"&gt;@page_size&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;

  &lt;span class="nv"&gt;@impl&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;socket&lt;/span&gt;
     &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:page_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gettext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Courses"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
     &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:page_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sx"&gt;~p"/courses"&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="nv"&gt;@impl&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parse_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="no"&gt;Courses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_active_courses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;page:&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;page_size:&lt;/span&gt; &lt;span class="nv"&gt;@page_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;search:&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:noreply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;socket&lt;/span&gt;
     &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="nv"&gt;@impl&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assigns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="sx"&gt;~H""&lt;/span&gt;&lt;span class="s2"&gt;"
    &amp;lt;section id="&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="s2"&gt;" class="&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ss"&gt;sm:&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ss"&gt;lg:&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ss"&gt;xl:&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
      &amp;lt;.course_card :for={course &amp;lt;- @courses} course={course} from={@current_url} /&amp;gt;
    &amp;lt;/section&amp;gt;
    """&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're new to Elixir, here's the quick tour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mount/3&lt;/code&gt; is your first render. Think of it as the &lt;code&gt;useEffect&lt;/code&gt; that runs once. I'm just assigning some SEO stuff here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handle_params/3&lt;/code&gt; runs right after mount on this page. This is where I query all the courses and assign them to a &lt;code&gt;courses&lt;/code&gt; variable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;render/1&lt;/code&gt; draws the page, and it reaches into those variables through the &lt;code&gt;assigns&lt;/code&gt; argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inside the markup, notice &lt;code&gt;&amp;lt;.course_card ...&amp;gt;&lt;/code&gt;. Unlike React, where a component is uppercase, here you call a component with &lt;strong&gt;dot syntax&lt;/strong&gt;. The &lt;code&gt;:for={course &amp;lt;- @courses}&lt;/code&gt; is LiveView's nice little syntactic sugar for a loop, and the &lt;code&gt;@&lt;/code&gt; is sugar for "reach into &lt;code&gt;assigns&lt;/code&gt; and grab this variable."&lt;/p&gt;

&lt;p&gt;That's the whole thing. Query my courses, assign a variable, loop over it. One file.&lt;/p&gt;

&lt;p&gt;Now let me show you what the &lt;em&gt;same&lt;/em&gt; page looked like in Inertia.&lt;/p&gt;

&lt;p&gt;First, I needed a controller. That's file number one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/courseshelf_web/controllers/course_controller.ex  (v1)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CourseJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;assign_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:sort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;render_inertia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courses/index"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See that &lt;code&gt;assign_prop(:courses, CourseJSON.serialize(courses))&lt;/code&gt;? That &lt;code&gt;serialize&lt;/code&gt; call is the problem. I &lt;strong&gt;cannot&lt;/strong&gt; send an Elixir struct to a React front end. I have to convert it into a plain map first. So that's a whole separate module, file number two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/courseshelf_web/controllers/course_json.ex  (v1)&lt;/span&gt;
&lt;span class="k"&gt;defmodule&lt;/span&gt; &lt;span class="no"&gt;CourseshelfWeb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CourseJSON&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;Courseshelf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Courses&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Course&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(%&lt;/span&gt;&lt;span class="no"&gt;Course&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="p"&gt;%{&lt;/span&gt;
      &lt;span class="ss"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;title:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;slug:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;thumbnail_url:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnail_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;difficulty_level:&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;difficulty_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;# ...every single field, by hand&lt;/span&gt;
      &lt;span class="ss"&gt;tags:&lt;/span&gt; &lt;span class="no"&gt;TagJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;channel:&lt;/span&gt; &lt;span class="no"&gt;ChannelJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;platform:&lt;/span&gt; &lt;span class="no"&gt;PlatformJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;when&lt;/span&gt; &lt;span class="n"&gt;is_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;serialize&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;em&gt;then&lt;/em&gt;, finally, file number three: the React page that receives the props and renders them, with that hand-maintained TypeScript type from earlier sitting behind it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/pages/courses/index.tsx  (v1)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CoursesIndex&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sort&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;courses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CourseCard&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;course&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So to render a course listing — the &lt;em&gt;easy&lt;/em&gt; case — I'm navigating through a controller, a JSON serializer, and a TypeScript component. Three layers. And listing data is the simple part; mutations are a whole different beast.&lt;/p&gt;

&lt;p&gt;This is why LiveView is absolute cinema for me. One file versus three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7tcyasuhtjpq0esvij86.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7tcyasuhtjpq0esvij86.gif" alt="Perfection" width="500" height="289"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The infrastructure bill nobody warned me about
&lt;/h2&gt;

&lt;p&gt;The cost story isn't just the database. The most painful lesson was about &lt;em&gt;memory&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I run the same size server for both v1 and v2: one machine, 1GB of RAM, 1 CPU. But here's the difference.&lt;/p&gt;

&lt;p&gt;CourseShelf is a public website, so I need good SEO, which means I need server-side rendering. React is not a server-first framework, so to render it on the server I had to lean on that Node.js worker pool I mentioned. By default the Inertia Phoenix library spins up &lt;strong&gt;four&lt;/strong&gt; Node.js workers. "Perfect," I thought, "I can server-render React, that's all I needed."&lt;/p&gt;

&lt;p&gt;It's never that simple.&lt;/p&gt;

&lt;p&gt;Each Node.js worker was eating about &lt;strong&gt;150MB&lt;/strong&gt; of memory. Four of them is &lt;strong&gt;600MB&lt;/strong&gt; — gone, just for rendering. Meanwhile Elixir's own baseline for a SaaS this size is around &lt;strong&gt;200–300MB&lt;/strong&gt;. Out of a 1GB machine, I was constantly bumping against the ceiling. My memory usage hovered between &lt;strong&gt;90% and 100%&lt;/strong&gt;, and my server would randomly fall over with out-of-memory crashes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftq5hmpt6xv06w9gwtdxl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftq5hmpt6xv06w9gwtdxl.gif" alt="Server on fire" width="500" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The frustrating part? A year and a half ago, AI wasn't what it is today, and I didn't have the infra knowledge to debug it. I just knew my "performant Elixir app" kept crashing, and it made me furious. So I left it broken and ate the occasional crash.&lt;/p&gt;

&lt;p&gt;Same machine today, with the JavaScript layer completely removed, my memory usage averages &lt;strong&gt;250MB&lt;/strong&gt;. A quarter of what's available. The thing that used to run at 100% now idles at 25%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v1 (Inertia)&lt;/th&gt;
&lt;th&gt;v2 (LiveView)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Managed Postgres, ~$38/mo&lt;/td&gt;
&lt;td&gt;Unmanaged Postgres, ~$7/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;1GB RAM / 1 CPU&lt;/td&gt;
&lt;td&gt;1GB RAM / 1 CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Front-end runtime&lt;/td&gt;
&lt;td&gt;Elixir &lt;strong&gt;+ 4 Node.js workers&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Elixir only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg memory usage&lt;/td&gt;
&lt;td&gt;90–100% (constant OOM crashes)&lt;/td&gt;
&lt;td&gt;~25%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  "But you lose all the nice front-end stuff, right?"
&lt;/h2&gt;

&lt;p&gt;This is the part everyone pushes back on. After I posted about the switch, the same questions kept coming up on Twitter, so let me answer them with real code from the new codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  "You'll lose rich UI interactions."
&lt;/h3&gt;

&lt;p&gt;Loading states? Load-more buttons? Drag and drop? All there.&lt;/p&gt;

&lt;p&gt;For simple stuff — toggling a class, a transition, showing or hiding an element — Phoenix ships an entire module for it: &lt;a href="https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html" rel="noopener noreferrer"&gt;&lt;code&gt;Phoenix.LiveView.JS&lt;/code&gt;&lt;/a&gt;. You write what looks like an Elixir function right in your markup, and it runs entirely on the client with zero server round-trips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;
  &lt;span class="n"&gt;phx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;click&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#description-full"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#description-truncated"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#show-less"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;display:&lt;/span&gt; &lt;span class="s2"&gt;"inline-flex"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;JS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;to:&lt;/span&gt; &lt;span class="s2"&gt;"#show-more"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="no"&gt;Show&lt;/span&gt; &lt;span class="n"&gt;more&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you need &lt;em&gt;real&lt;/em&gt; JavaScript — like drag-and-drop — you use &lt;strong&gt;hooks&lt;/strong&gt;. The new thing I love here is &lt;strong&gt;colocated hooks&lt;/strong&gt;: the JavaScript lives in the same file as the markup it controls. No separate hooks directory, no importing each file one by one.&lt;/p&gt;

&lt;p&gt;For the playlist reordering on CourseShelf, I dropped the minified &lt;a href="https://sortablejs.github.io/Sortable/" rel="noopener noreferrer"&gt;SortableJS&lt;/a&gt; library into &lt;code&gt;assets/vendor/&lt;/code&gt;, exposed it on the window in my root &lt;code&gt;app.js&lt;/code&gt;...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/js/app.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Sortable&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../vendor/sortable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// Expose Sortable to colocated hooks (e.g. .PlaylistSort).&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sortable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Sortable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...and then wrote the hook right next to the grid it powers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lib/course_shelf_web/live/playlist_live/show.ex&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"playlist-items-grid"&lt;/span&gt; &lt;span class="n"&gt;phx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hook&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;".PlaylistSort"&lt;/span&gt; &lt;span class="n"&gt;phx&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"stream"&lt;/span&gt; &lt;span class="o"&gt;...&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt; &lt;span class="ss"&gt;:for=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="nv"&gt;@streams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;.&lt;/span&gt;&lt;span class="n"&gt;playlist_item&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="ss"&gt;:type=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="no"&gt;Phoenix&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;LiveView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;ColocatedHook&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;".PlaylistSort"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="n"&gt;export&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sortable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Sortable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;handle:&lt;/span&gt; &lt;span class="s2"&gt;".playlist-drag-handle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;animation:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;onEnd:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oldIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;newIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;return&lt;/span&gt;
          &lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pushEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"reorder_items"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;destroyed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sortable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drag a card, drop it, and &lt;code&gt;pushEvent&lt;/code&gt; tells the server the new order. Reload the page and it's persisted. Feels great.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Every page navigation is a full reload."
&lt;/h3&gt;

&lt;p&gt;Nope. This is the most common misconception about LiveView. The whole goal of LiveView is to replicate the React feel as closely as possible.&lt;/p&gt;

&lt;p&gt;Open your network tab and you'll see &lt;strong&gt;one&lt;/strong&gt; WebSocket connection established on first load. After that, every page transition reuses that same socket — LiveView just patches the page, sends down the bits that changed, and renders them. No new HTTP request, no full reload.&lt;/p&gt;

&lt;p&gt;The one caveat: when you cross between a public page and a private page, you &lt;em&gt;do&lt;/em&gt; get a full refresh, because I'm tearing down the unauthenticated socket and opening an authenticated one. But navigating &lt;em&gt;within&lt;/em&gt; the authenticated app — dashboard to settings and back — reuses the same socket. You pay the loading cost once, and everything after is instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Is AI actually good at LiveView?"
&lt;/h3&gt;

&lt;p&gt;This is the one that matters most to me, given why I left in the first place. And the answer is yes — AI is &lt;em&gt;extremely&lt;/em&gt; strong at writing LiveView.&lt;/p&gt;

&lt;p&gt;The reason is that LiveView now ships with an &lt;code&gt;AGENTS.md&lt;/code&gt; file packed with guidance on how to write proper Elixir, Phoenix, and LiveView code. The agents pick it up and the results are great out of the box. (I actually split mine into two files to save a bit on context, but the default already gets you most of the way.)&lt;/p&gt;

&lt;p&gt;This is the exact opposite of my Inertia experience. There, the agents spun forever. Here, they ship.&lt;/p&gt;

&lt;h3&gt;
  
  
  "I'll miss &lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;shadcn/ui&lt;/a&gt;."
&lt;/h3&gt;

&lt;p&gt;Worry not, my friend. The latest version of Phoenix uses &lt;a href="https://daisyui.com/" rel="noopener noreferrer"&gt;daisyUI&lt;/a&gt; by default, with dark and light themes out of the box. I actually stripped out the light theme on CourseShelf to keep things simple, but you get it for free. My buttons, inputs, badges, and modals are all daisyUI tokens — &lt;code&gt;btn-primary&lt;/code&gt;, &lt;code&gt;bg-base-200&lt;/code&gt;, &lt;code&gt;text-base-content&lt;/code&gt; — so I'm barely writing custom CSS anymore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;The migration took me almost a month, and it was genuinely tiresome. 345 commits of rebuilding something that already worked is not most people's idea of fun.&lt;/p&gt;

&lt;p&gt;But look at what I got: one front-end layer instead of three, a server that idles at 25% memory instead of crashing at 100%, a database bill cut from $38 to $7, one ecosystem to maintain instead of two, and — most importantly — a stack the AI agents actually understand. The ticking time bomb is defused.&lt;/p&gt;

&lt;p&gt;The "perfect tech stack" doesn't exist. But the perfect stack &lt;em&gt;for me&lt;/em&gt;, right now, building the kind of product I'm building, is full-stack Elixir and LiveView. If you went through a similar journey to mine, maybe it'll be yours too.&lt;/p&gt;

&lt;p&gt;Go test the new &lt;a href="https://thecourseshelf.com/" rel="noopener noreferrer"&gt;CourseShelf&lt;/a&gt; and let me know how it feels — does navigating between pages feel fast? Does the UI look good? I want every bit of feedback I can get.&lt;/p&gt;

&lt;p&gt;And if you reached the end of this post, you're awesome. Thank you for your time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" alt="The end" width="499" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>elixir</category>
      <category>inertia</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Announcing CourseShelf: Rotten Tomatoes for Online Learning</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Wed, 21 Jan 2026 22:10:59 +0000</pubDate>
      <link>https://dev.to/danielbergholz/announcing-courseshelf-rotten-tomatoes-for-online-learning-4h3k</link>
      <guid>https://dev.to/danielbergholz/announcing-courseshelf-rotten-tomatoes-for-online-learning-4h3k</guid>
      <description>&lt;p&gt;Back in 2019, I created &lt;a href="https://techschool.dev" rel="noopener noreferrer"&gt;TechSchool&lt;/a&gt;, an open-source platform to help people find free programming courses. The idea was simple: fight back against predatory coding bootcamps that charge thousands of dollars for content that's often worse than what's available for free on YouTube.&lt;/p&gt;

&lt;p&gt;TechSchool worked. People found great courses. The community grew. But over time, I started noticing something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limitations of TechSchool
&lt;/h2&gt;

&lt;p&gt;TechSchool had two big problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One&lt;/strong&gt;, it was limited to tech. My friend who wanted to learn guitar couldn't use it. My mom who wanted to learn cooking couldn't use it. The core idea of community-curated courses was valuable, but I was artificially limiting who could benefit from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two&lt;/strong&gt;, adding new courses required opening a Pull Request on GitHub. Great for developers, terrible for everyone else. Most people who want to learn don't know what Git is, and they shouldn't have to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk4rrcopfkefk3a2jhln0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk4rrcopfkefk3a2jhln0.gif" alt="Thinking hard" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I kept thinking: what if we could build something bigger? A place where anyone could discover quality courses in &lt;strong&gt;any&lt;/strong&gt; subject, reviewed by real learners instead of marketing departments?&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter CourseShelf
&lt;/h2&gt;

&lt;p&gt;CourseShelf is the spiritual successor to TechSchool. Think of it as &lt;strong&gt;Rotten Tomatoes for online learning&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the core idea: when you're looking for a movie to watch, you check Rotten Tomatoes or IMDb. You trust the crowd more than the trailer. Why should finding a good course be any different?&lt;/p&gt;

&lt;p&gt;Right now, if you want to learn something new, you either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trust the platform's algorithm (which prioritizes engagement, not quality)&lt;/li&gt;
&lt;li&gt;Trust sponsored reviews (which are basically ads)&lt;/li&gt;
&lt;li&gt;Hope you get lucky&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CourseShelf fixes this by letting real learners review and rate courses. No fake reviews. No sponsored content. Just honest opinions from people who actually completed the course.&lt;/p&gt;

&lt;p&gt;Website: &lt;a href="https://thecourseshelf.com" rel="noopener noreferrer"&gt;https://thecourseshelf.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F31tjmfirk4k8rs5g1pa9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F31tjmfirk4k8rs5g1pa9.gif" alt="Let's go" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do on CourseShelf
&lt;/h2&gt;

&lt;p&gt;Here's a quick overview of the features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discover courses&lt;/strong&gt; across any subject, not just tech&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read and write reviews&lt;/strong&gt; with star ratings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save courses to your library&lt;/strong&gt; to track what you want to learn&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create playlists&lt;/strong&gt; to curate learning paths for yourself or others&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow other learners&lt;/strong&gt; to see what they're learning and recommending&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify your channel&lt;/strong&gt; if you're a course creator on YouTube&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now, the platform supports &lt;strong&gt;YouTube courses&lt;/strong&gt; and &lt;strong&gt;books&lt;/strong&gt;. We have plans to add support for other platforms like Coursera, Skillshare, and Domestika in the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  For course creators
&lt;/h2&gt;

&lt;p&gt;If you're a content creator, CourseShelf has something special for you. You can verify your YouTube channel and get a &lt;strong&gt;verified badge&lt;/strong&gt; on your profile. This helps learners identify official creators and gives you credibility.&lt;/p&gt;

&lt;p&gt;Verified creators also get access to analytics: see how many people are viewing your courses, what they're saying in reviews, and how you compare to others in your category.&lt;/p&gt;

&lt;p&gt;I created TechSchool because I was frustrated that my free courses had fewer views than paid garbage. CourseShelf is my attempt to fix the discoverability problem for all creators who are putting out quality content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;p&gt;I built CourseShelf using &lt;strong&gt;Elixir and Phoenix&lt;/strong&gt; with &lt;strong&gt;React and TypeScript&lt;/strong&gt; on the frontend via Inertia.js. If you've read my blog post about &lt;a href="https://dev.to/danielbergholz/from-nextjs-to-rails-then-elixir-my-journey-through-reactjs-burnout-h8d"&gt;leaving React for Rails and then Elixir&lt;/a&gt;, you know this is the stack I fell in love with.&lt;/p&gt;

&lt;p&gt;Phoenix gives me the productivity of Rails with the performance and reliability of the Erlang VM. Inertia.js lets me use React without the complexity of building a separate API. It's the best of both worlds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;CourseShelf is currently in &lt;strong&gt;beta&lt;/strong&gt;. We're actively adding features, fixing bugs, and improving the experience based on feedback. If you find something broken or have a suggestion, please let me know!&lt;/p&gt;

&lt;p&gt;Some features are limited during beta, but the core experience of discovering and reviewing courses is fully functional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Join the community
&lt;/h2&gt;

&lt;p&gt;I built TechSchool because I believe tech education should be accessible to everyone. I'm building CourseShelf because I believe &lt;strong&gt;all&lt;/strong&gt; education should be accessible to everyone.&lt;/p&gt;

&lt;p&gt;If you're a self-taught learner, a course creator, or just someone who's tired of wasting money on bad courses, give CourseShelf a try. And if you know a great course that deserves more attention, add it to the platform!&lt;/p&gt;

&lt;p&gt;Let's build the largest community-curated database of online courses together. We are all gonna make it 🔥&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqek6gbi1lugsas1ryft.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftqek6gbi1lugsas1ryft.gif" alt="We're all gonna make it" width="500" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow us
&lt;/h2&gt;

&lt;p&gt;Stay up to date with new features and announcements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Twitter: &lt;a href="https://x.com/courseshelf" rel="noopener noreferrer"&gt;@courseshelf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LinkedIn: &lt;a href="https://www.linkedin.com/company/courseshelf" rel="noopener noreferrer"&gt;CourseShelf&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Thank you
&lt;/h2&gt;

&lt;p&gt;If you reached the end of this post, thank you for your time. Building in public is scary, but seeing people use something I created makes it worth it.&lt;/p&gt;

&lt;p&gt;Have questions? Want to chat? Find me on Twitter &lt;a href="https://twitter.com/danielbergholz" rel="noopener noreferrer"&gt;@danielbergholz&lt;/a&gt; or drop me an email.&lt;/p&gt;

&lt;p&gt;See you on CourseShelf!&lt;/p&gt;

</description>
      <category>learning</category>
      <category>beginners</category>
      <category>elixir</category>
      <category>react</category>
    </item>
    <item>
      <title>My AI-Powered Workflow for Writing Elixir and Phoenix with Windsurf</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Mon, 10 Mar 2025 14:41:03 +0000</pubDate>
      <link>https://dev.to/danielbergholz/my-ai-powered-workflow-for-writing-elixir-and-phoenix-with-windsurf-4k8m</link>
      <guid>https://dev.to/danielbergholz/my-ai-powered-workflow-for-writing-elixir-and-phoenix-with-windsurf-4k8m</guid>
      <description>&lt;p&gt;There's a ton of stuff out there about using AI to write JavaScript and Next.js code, but what about Elixir and Phoenix? Are they getting left behind in the LLM code generation game? Well, yes and no. Can we make it better? Definitely. Is it worth the effort? Hell yeah. And can we actually become those legendary 10x engineers by writing Elixir with AI-powered tools like &lt;a href="https://www.cursor.com" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; or &lt;a href="https://codeium.com/windsurf" rel="noopener noreferrer"&gt;Windsurf&lt;/a&gt;? You bet we can!&lt;/p&gt;

&lt;h2&gt;
  
  
  First, why Windsurf and not Cursor?
&lt;/h2&gt;

&lt;p&gt;This year I tried using both Cursor and Windsurf for a month to see which one I'd stick with. The code they generated was pretty much the same (not surprising since they both use &lt;a href="https://claude.ai/" rel="noopener noreferrer"&gt;Claude&lt;/a&gt; 3.5 Sonnet). But Windsurf was better at understanding my codebase and grabbing ideas from my existing files, plus its UI just felt smoother and more intuitive. Honestly though, these AI tools are copying each other's features so quickly that it probably doesn't matter which one you pick - they're both solid options.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjsfy7k34opmrsmq61n7y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjsfy7k34opmrsmq61n7y.png" alt=" " width="602" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does AI generate good Elixir and Phoenix code?
&lt;/h2&gt;

&lt;p&gt;AI can handle the basics pretty well. Need a simple controller that renders a page? Or a basic &lt;a href="https://hexdocs.pm/phoenix_live_view/welcome.html" rel="noopener noreferrer"&gt;LiveView&lt;/a&gt; with a bit of server-side state? Claude's got you covered.&lt;/p&gt;

&lt;p&gt;But here's the thing - the more niche your tech stack gets, the less reliable AI becomes. Take my setup for example: I use Elixir, Phoenix, React, and &lt;a href="https://inertiajs.com" rel="noopener noreferrer"&gt;Inertia&lt;/a&gt; for my side projects. The &lt;a href="https://hexdocs.pm/inertia/readme.html" rel="noopener noreferrer"&gt;Inertia adapter for Phoenix&lt;/a&gt; is super new and not widely adopted yet (which is a shame - this combo is incredibly productive and performant). There's just no way current AI models have enough training data on this specific stack to generate useful code. The more specialized your tools, the more you'll need to rely on your own expertise rather than AI assistance.&lt;/p&gt;

&lt;p&gt;This isn't just an Elixir thing - it's the same story with any tech that hasn't gone mainstream yet. Languages like Zig, Gleam, or Scala (and whatever frameworks people build with them) just don't have nearly as much code floating around online as JavaScript or Python do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to improve?
&lt;/h2&gt;

&lt;p&gt;I mainly use two features in Windsurf: &lt;a href="https://docs.codeium.com/windsurf/memories" rel="noopener noreferrer"&gt;memories and rules&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memories&lt;/strong&gt; are like Windsurf's personalized notepad. Did Claude mess up by using &lt;code&gt;assign/3&lt;/code&gt; instead of &lt;code&gt;assign_prop/3&lt;/code&gt; inside a controller to pass Elixir data to React? Just point out the mistake. If Windsurf thinks it's important, it'll remember this for your entire coding session and avoid repeating the error.&lt;/p&gt;

&lt;p&gt;You can be direct about it too: "Hey, you got X wrong. The correct way is Y. Please create a memory for this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules&lt;/strong&gt; are more permanent and explicit. Just create a &lt;code&gt;.windsurfrules&lt;/code&gt; file in your project's root directory with specific instructions (similar to how &lt;code&gt;.cursorrules&lt;/code&gt; works). These rules will guide Windsurf every time it helps with your code.&lt;/p&gt;

&lt;p&gt;Here is my current &lt;code&gt;.windsurfrules&lt;/code&gt; for Phoenix + Inertia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;You are an expert in Elixir, Phoenix, PostgreSQL, JavaScript, TypeScript, React, Inertia, and Tailwind CSS.

&lt;span class="gh"&gt;# Elixir and Phoenix Usage&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; In controllers, use &lt;span class="sb"&gt;`assign_prop/3`&lt;/span&gt; to assign props to the Inertia page and then &lt;span class="sb"&gt;`render_inertia/2`&lt;/span&gt; to render Inertia pages.
&lt;span class="p"&gt;-&lt;/span&gt; In controllers tests, use &lt;span class="sb"&gt;`inertia_component/1`&lt;/span&gt; to assert the component name and &lt;span class="sb"&gt;`inertia_props/1`&lt;/span&gt; to assert the props.
&lt;span class="p"&gt;-&lt;/span&gt; When generating migrations, use &lt;span class="sb"&gt;`mix ecto.gen.migration &amp;lt;name&amp;gt;`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use plural form for context modules (e.g., "Users" for users table)
&lt;span class="p"&gt;-&lt;/span&gt; Use singular form for schema modules (e.g., "User" for users table)
&lt;span class="p"&gt;-&lt;/span&gt; Context files are usually inside a folder named after the resource (e.g., lib/my_app/users.ex)
&lt;span class="p"&gt;-&lt;/span&gt; Schema files are usually inside a folder named after the resource (e.g., lib/my_app/users/user.ex)
&lt;span class="p"&gt;-&lt;/span&gt; Prefer keyword-based queries over pipe-based queries
&lt;span class="p"&gt;  -&lt;/span&gt; For example, use &lt;span class="sb"&gt;`from(u in User, where: u.age &amp;gt; 18, select: u)`&lt;/span&gt; over &lt;span class="sb"&gt;`User |&amp;gt; where(age: 18) |&amp;gt; select([u], u)`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Use &lt;span class="sb"&gt;`dbg/1`&lt;/span&gt; to debug code.

&lt;span class="gh"&gt;# React and Inertia Usage&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Pages are in assets/js/pages. Use default export for pages.
&lt;span class="p"&gt;-&lt;/span&gt; Components are in assets/js/components. Use named exports for components.
&lt;span class="p"&gt;-&lt;/span&gt; Utils are in assets/js/lib.
&lt;span class="p"&gt;-&lt;/span&gt; Inside pages, get the props from the controller as regular props from the function argument.
&lt;span class="p"&gt;-&lt;/span&gt; When dealing with forms, use the &lt;span class="sb"&gt;`useForm`&lt;/span&gt; hook from Inertia
&lt;span class="p"&gt;-&lt;/span&gt; Use absolute paths for local imports using &lt;span class="sb"&gt;`@/`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; If you need to merge tailwind classes, use the &lt;span class="sb"&gt;`cn`&lt;/span&gt; function from assets/js/lib/utils.ts.
&lt;span class="p"&gt;-&lt;/span&gt; Import Radix components from "radix-ui", not from "@radix-ui/react-dialog" or "@radix-ui/react-button" etc.
&lt;span class="p"&gt;-&lt;/span&gt; Always create the mobile version of the component along with the desktop version.
&lt;span class="p"&gt;-&lt;/span&gt; Use lucide-react for icons.
&lt;span class="p"&gt;-&lt;/span&gt; Use kebab-case for file names.
&lt;span class="p"&gt;-&lt;/span&gt; If the page or component uses a type for a resource from the database, like users or courses, create the type in the assets/js/types folder.
&lt;span class="p"&gt;-&lt;/span&gt; Prefer types over interfaces.

&lt;span class="gh"&gt;# General Usage&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; When debugging data from the database, if the postgres_my_app MCP is not avaiable, use &lt;span class="sb"&gt;`psql my_app_dev -c "&amp;lt;your query&amp;gt;"`&lt;/span&gt; to connect to the database and then run the query. There is also the my_app_test database for testing.
&lt;span class="p"&gt;-&lt;/span&gt; Use the &lt;span class="sb"&gt;`mix check`&lt;/span&gt; command after generating lots of files to check the Elixir and React code for errors and code quality. If you encounter format errors, use &lt;span class="sb"&gt;`mix format`&lt;/span&gt; to fix them.
&lt;span class="p"&gt;-&lt;/span&gt; If any of my requests are not clear, ask me to clarify.
&lt;span class="p"&gt;-&lt;/span&gt; If you have better suggestions, feel free to suggest them.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting Up-to-Date Info
&lt;/h2&gt;

&lt;p&gt;This is a super underrated feature when working with frameworks that change all the time (like Next.js) or more niche ones (like Phoenix). Remember: AI is great at general knowledge, but the more specific you go, the less likely it'll get things right. To fix this, just use the &lt;code&gt;@web&lt;/code&gt; directive and drop in links to any documentation you think is relevant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftusowab3ce6y9bi8rcac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftusowab3ce6y9bi8rcac.png" alt=" " width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Check After a Long Coding Session
&lt;/h2&gt;

&lt;p&gt;It's all fun and games when Claude spits out 20 new files and everything looks fine. But are you actually checking all these files for formatting issues, type errors, or compilation warnings? And how do you make sure your code quality isn't tanking over time? Sometimes AI misunderstands what you want and goes off on a tangent, potentially wrecking hours of your hard work.&lt;/p&gt;

&lt;p&gt;That's why I made a simple script called "check" (my mental "sanity check") that I ask Claude to run after long coding sessions to make sure everything's still good.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# mix.exs&lt;/span&gt;

&lt;span class="k"&gt;defp&lt;/span&gt; &lt;span class="n"&gt;aliases&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="ss"&gt;check:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"format --check-formatted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"cmd npm run typecheck --prefix assets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"deps.unlock --check-unused"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"compile --warnings-as-errors"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"credo --strict"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This script does a few key things: checks for formatting issues (my &lt;code&gt;.windsurfrules&lt;/code&gt; file tells Claude to just run &lt;code&gt;mix format&lt;/code&gt; to fix these), runs the "typecheck" script from my NPM project (React + Inertia) in the assets folder, looks for unused dependencies, checks for compilation warnings like unused variables or aliases, and finally runs &lt;a href="https://hexdocs.pm/credo/overview.html" rel="noopener noreferrer"&gt;credo&lt;/a&gt; in strict mode to catch problems like oversized functions or deeply nested conditionals.&lt;/p&gt;

&lt;p&gt;This way, you can sleep easy knowing your app not only works but also has solid code quality with no warnings or errors hanging around. This is just my version of the check command - feel free to create your own with checks that make sense for your project. And if you want Claude to run both the "check" script and your tests after generating code, just add that rule to your &lt;code&gt;.windsurfrules&lt;/code&gt; file!&lt;/p&gt;

&lt;h2&gt;
  
  
  A Word of Caution
&lt;/h2&gt;

&lt;p&gt;This pro tip isn't specific to Elixir, but worth mentioning: be careful with generating code using AI - it can get expensive fast. Don't let the $15/month price tag fool you. This month alone I bought extra credits for Windsurf 3 times ($10 per 300 new credits). That brought my monthly cost to $45!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvigomphy81iqyffvsw56.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvigomphy81iqyffvsw56.gif" alt="burning money" width="500" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How to avoid this? My rule is simple: only ask Claude to generate code once I'm really confident about what I want to build. If you have a clear PRD (Product Requirements Document), there's a 95% chance Claude will "one shot" the solution. This is way better than going back and forth, generating code, rejecting it, and generating again. &lt;/p&gt;

&lt;p&gt;TL;DR: Spend more time planning rather than generating code. In this new era, code has become a "low level thing" that machines generate, while we humans can focus on higher level problems like crafting a good plan for an MVP, figuring out what users actually want, achieving product-market fit for our startup, etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moral of the story
&lt;/h2&gt;

&lt;p&gt;Look, AI definitely has some blind spots with Elixir and Phoenix code - but as I've shown throughout this post, there are some pretty simple workarounds for most of these issues.&lt;/p&gt;

&lt;p&gt;From my experience, the productivity boost is totally worth dealing with these little hiccups. That mythical "10x engineer" everyone talks about? It's not just hype - anyone can get there if they're willing to embrace these new tools.&lt;/p&gt;

&lt;p&gt;The real trick is keeping an open mind and constantly trying new stuff. Don't get stuck doing things the old way just because that's how you've always done it.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>windsurf</category>
      <category>ai</category>
      <category>react</category>
    </item>
    <item>
      <title>Announcing TechSchool: A free and open-source platform to learn programming</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Tue, 05 Mar 2024 22:52:54 +0000</pubDate>
      <link>https://dev.to/danielbergholz/announcing-techschool-a-free-and-open-source-platform-to-learn-programming-47fk</link>
      <guid>https://dev.to/danielbergholz/announcing-techschool-a-free-and-open-source-platform-to-learn-programming-47fk</guid>
      <description>&lt;p&gt;Since 2019 I have published free courses on my &lt;a href="https://youtube.com/@DanielBergholz?si=WsZ062ZtA5MV3kX_" rel="noopener noreferrer"&gt;YouTube channel&lt;/a&gt;. Many times, people have commented on my videos something like "Wow, this course is amazing! It's a lot better than the expensive course I purchased!". I started reflecting after that. Why on earth is someone getting paid thousands of dollars selling a course that is worse than the one I made for &lt;strong&gt;free&lt;/strong&gt;? Also, why does my course only have 100 views on YouTube? This isn't fair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identifying the problem
&lt;/h2&gt;

&lt;p&gt;There are lots of people who want to learn programming and also lots of free courses available. However, most of the time, these two groups don't meet each other.&lt;/p&gt;

&lt;p&gt;Why? Two reasons. One is that multiple VC-backed predatory coding bootcamps spend millions of dollars targeting newcomers and telling them that the only way to get into the industry is by spending all their money on expensive online courses. Two, the YouTube algorithm is super hard to master. If you recently created a channel, it can take &lt;strong&gt;years&lt;/strong&gt; to reach a bigger audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution
&lt;/h2&gt;

&lt;p&gt;What if we could easily find all the free courses out there without the YouTube algorithm standing in our way? That's where TechSchool steps in. It is a platform that contains all the free content that might be flying under the radar. It is also open-source, which means anyone who knows a cool course can easily open a PR to add it.&lt;/p&gt;

&lt;p&gt;Website: &lt;a href="https://techschool.dev" rel="noopener noreferrer"&gt;https://techschool.dev&lt;/a&gt;&lt;br&gt;
Discord: &lt;a href="https://discord.gg/C4abRX5skH" rel="noopener noreferrer"&gt;https://discord.gg/C4abRX5skH&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/danielbergholz/techschool.dev" rel="noopener noreferrer"&gt;https://github.com/danielbergholz/techschool.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's fight back against the expensive barrier to entry! Tech education should be free and accessible to everyone! We are all gonna make it 🔥&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdbrk54y1yfc2srctfygp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdbrk54y1yfc2srctfygp.gif" alt="Let's fight back!" width="480" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;TechSchool is forever a work in progress. We are all collectively adding more content over time, so if you don't like the options available right now, wait a couple of weeks and then re-visit the website! I'm sure you'll find something new.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;p&gt;I have it all explained on the &lt;a href="https://github.com/danielbergholz/techschool.dev/blob/main/docs/tech-stack.md" rel="noopener noreferrer"&gt;README&lt;/a&gt;, but in summary, I decided to use Elixir and Phoenix, because it's a freaking AWESOME combo. I'm also using Live View on all pages, so hopefully the transition between them is super smooth!&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>javascript</category>
      <category>ruby</category>
      <category>python</category>
    </item>
    <item>
      <title>From Next.js to Rails then Elixir: My journey through React.js burnout</title>
      <dc:creator>Daniel Bergholz</dc:creator>
      <pubDate>Wed, 10 Jan 2024 23:29:41 +0000</pubDate>
      <link>https://dev.to/danielbergholz/from-nextjs-to-rails-then-elixir-my-journey-through-reactjs-burnout-h8d</link>
      <guid>https://dev.to/danielbergholz/from-nextjs-to-rails-then-elixir-my-journey-through-reactjs-burnout-h8d</guid>
      <description>&lt;p&gt;I've been a web developer since 2019. I used React.js and React-based frameworks like Gatsby, Next, Remix, Astro, and Hydrogen. I've never been fully content with any of these tools, but, as a beginner who was deep into the JS ecosystem, all that I could hear from my peers was something along those lines: "This is the way, any other programming language is either slow or old".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0qu5amub6gpg909tljmz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0qu5amub6gpg909tljmz.gif" alt="This is the way" width="480" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As a result, I got used to a &lt;strong&gt;huge&lt;/strong&gt; amount of complexity: Multiple separate repositories, thousands of libraries and frameworks to achieve simple things, GraphQL, microservices, serverless, static site generation, incremental static regeneration, partial hydration, redux, redux-thunk, babel, webpack, react server components, server actions, etc. This list could go on for another 10 minutes.&lt;/p&gt;

&lt;p&gt;Until one day I said &lt;strong&gt;ENOUGH IS ENOUGH!&lt;/strong&gt; Let's take a look at the complete timeline of me slowly going mad. This will take a while, feel free to make some coffee before the long read!&lt;/p&gt;




&lt;h2&gt;
  
  
  The timeline of the burnout
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://www.gatsbyjs.com/" rel="noopener noreferrer"&gt;Gatsby.js&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;I remember finishing my bootcamp and thinking: "Finally I'm able to build my portfolio!", and so I did. There was only one small problem, I wanted to index on Google, but using the good old &lt;code&gt;create-react-app&lt;/code&gt; made this mission nearly impossible. Soon I learned about SEO and React's hydration cycle, which led me to the "solution" of this problem: Gatsby.js. The idea of static site generation was simply revolutionary for me back then, after all, nothing is faster than pre-rendered HTML files, right?&lt;/p&gt;

&lt;p&gt;I decided to learn this new framework by reading the docs and let me tell you, this was &lt;strong&gt;NOT&lt;/strong&gt; a fun experience. I have never heard of GraphQL before, and apparently, you needed it to generate all the static files (what the hell???). I asked some of my internet friends if having a hard time learning all of this overengineered crap was normal, and they replied with "Skill issue, try harder!". So I tried harder, and after finally learning it, I ported my personal website to Gatsby.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy8accuzmxat7v76cpb8u.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy8accuzmxat7v76cpb8u.gif" alt="Try harder" width="500" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most of my pages were successfully indexed on Google, and for a couple of months, I was extremely satisfied with the result. Then another problem appeared: A &lt;strong&gt;LOT&lt;/strong&gt; of my developer friends started saying "Gatsby is dead! Next was created to simplify static site generation and also provide server-side rendering".&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;I took a quick glance over the Next documentation and &lt;strong&gt;immediately&lt;/strong&gt; fell in love. I was able to do the same things as Gatsby without GraphQL and with a third of the code! Once again, I ported my portfolio to another framework: Next.&lt;/p&gt;

&lt;p&gt;This time I truly had a wonderful experience. Deploying to Vercel was a breeze, the &lt;code&gt;getStaticProps&lt;/code&gt; and &lt;code&gt;getServerSideProps&lt;/code&gt; functions were simple, yet extremely powerful, I could choose the rendering style per page, a lot of flexibility in general.&lt;/p&gt;

&lt;p&gt;Unfortunately, something I learned the hard way: In the JavaScript ecosystem, all the good things come to an end.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://remix.run/" rel="noopener noreferrer"&gt;Remix&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;I remember extremely well when Remix was announced. Multiple tech influencers started publishing content about it (as always). However, back then I read on the home page that it did not support static site generation, just server-side rendering, so I thought "Wait a sec, all those years investing on the &lt;a href="https://jamstack.org/" rel="noopener noreferrer"&gt;JAMstack&lt;/a&gt; are thrown away here? No way, this framework ain't gonna last". However, to my surprise, not only did Remix survive, but it was &lt;a href="https://shopify.engineering/remix-joins-shopify" rel="noopener noreferrer"&gt;acquired by Shopify&lt;/a&gt; and emerged as a prominent competitor to Next.&lt;/p&gt;

&lt;p&gt;After a couple of months had passed, I decided to give it a try. And once again, I was surprised, the main motto of Remix is to use the web fundamentals, and not an overly complex caching system like Next. So the mental model I needed in my head when coding in Remix was 10 times simpler: No global state manager, just use the URL, fewer client-side states, move all that logic to the server, and use cookies, going full stack without a REST API in the middle is super easy, just move your database queries to the &lt;code&gt;loader&lt;/code&gt; function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leaving the Matrix
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuewu9wfathbfo889mwya.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuewu9wfathbfo889mwya.gif" alt="Leaving the Matrix" width="450" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, out of nowhere, the truth was presented to me, and I took the red pill. Multiple questions started emerging in my head: Isn't Remix just like all the other "old and boring" frameworks like Rails, Laravel, and Django? We have been doing fullstack web development with server-side rendering for decades, but the JavaScript mafia decided collectively that this approach was trash, and moving everything to the client was the future. Did the same mafia decide that Rails was right all along? And doing all those over-engineered monstrosities with JS frameworks was not the right move? I started questioning everything. This "new" way of doing web development was a lot simpler and faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  I'm DONE with Next and Vercel
&lt;/h3&gt;

&lt;p&gt;I reached my tipping point with &lt;a href="https://nextjs.org/docs/app" rel="noopener noreferrer"&gt;Next.js app router&lt;/a&gt;. Here is a comprehensive list of everything wrong that Vercel is pushing to Next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What was once simple: The &lt;code&gt;getStaticProps&lt;/code&gt; and &lt;code&gt;getServerSideProps&lt;/code&gt; functions, now became complex and cumbersome. Currently, there is no specific place to add your API calls or database queries, you can write them wherever you want! We started mixing the business logic with UI once again, after making the same mistake with PHP multiple years ago. Do frontend developers not learn from the past? What happens if I delete a button? Does this break my user authentication flow because the database call was inside it? Your front end should be 100% trashable and replaceable. The competitive advantage you have against your competitors is the business logic, which should be completely isolated from the UI layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkp41ds14loo21xgimcza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkp41ds14loo21xgimcza.png" alt="Horrible Next.js code" width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Next is now server first. Which doesn't sound that bad right? After all, this solves the SEO issue and shows fresh content to the user immediately. The problem is that most of the existing Next codebases relied on client-side libraries, like Styled Components and a couple of global state managers. What does this mean? With breaking changes like this happening constantly, your app becomes legacy software in a couple of weeks instead of years. More time is spent to keep all dependencies up to date rather than doing what matters: Shipping features.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Vercel hired multiple React core team members from Meta. This presents a serious conflict of interest because these engineers are now (allegedly) shipping features that are beneficial to Next instead of prioritizing the ones that could help all the React-based frameworks like Remix.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ye40ykjgrd3z10t5nx7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9ye40ykjgrd3z10t5nx7.png" alt="Vercel is corrupting React" width="800" height="644"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I couldn't take it anymore. I said to myself: You know what? I am tired of re-learning the same framework over and over again, and I completely disagree with this new paradigm. &lt;/p&gt;

&lt;p&gt;Not surprisingly, other content creators were going through a similar situation:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/zkCBSz353fc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Zt8mO_Aqzw8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  The Path to Enlightenment
&lt;/h2&gt;

&lt;p&gt;TL;DR: I was extremely tired. After getting burned out from all the React tools, my journey for simpler web frameworks started. Here are the prerequisites I was looking for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batteries included&lt;/li&gt;
&lt;li&gt;Convention over configuration&lt;/li&gt;
&lt;li&gt;Good developer experience&lt;/li&gt;
&lt;li&gt;Modern and performant frontend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first instinct was to take a look at the top frameworks from the &lt;a href="https://survey.stackoverflow.co/2023/#section-most-popular-technologies-web-frameworks-and-technologies" rel="noopener noreferrer"&gt;Stack Overflow Survey 2023&lt;/a&gt;. Immediately I cut out from the list anything JS, C# and Java related. I never had any desire to learn the last two, they look ugly and verbose. So the remaining options were: Laravel (PHP), Django (Python), Rails (Ruby), and Phoenix (Elixir).&lt;/p&gt;

&lt;p&gt;Python is a language that I used during my Network Engineering degree and I had a very pleasant experience. Django seemed to follow the convention over configuration philosophy, but what turned me down from it ultimately was not having a good built-in tool to work on the front end. Most people on forums said they were using &lt;a href="https://htmx.org/" rel="noopener noreferrer"&gt;HTMX&lt;/a&gt; and &lt;a href="https://alpinejs.dev/" rel="noopener noreferrer"&gt;Alpine&lt;/a&gt;, however, both are external dependencies that you need to install.&lt;/p&gt;

&lt;p&gt;Giving up on Laravel was extremely hard because it has an amazing cost benefit, with hundreds of official packages to handle pretty much anything a startup might need, like hosting, authentication, stripe payments, etc. For the front end, they created &lt;a href="https://inertiajs.com/" rel="noopener noreferrer"&gt;inertia.js&lt;/a&gt;, a very simple and elegant way of keeping the high productivity and powers from Laravel while using React on the front end. To be 100% honest here, the only reason I didn't choose Laravel was because of PHP's syntax, it looks ugly as hell with a bunch of &lt;code&gt;$&lt;/code&gt; and &lt;code&gt;-&amp;gt;&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ruby on Rails
&lt;/h3&gt;

&lt;p&gt;Ruby on Rails needs no introduction. It's the OG of web development frameworks, with the revolutionary "build a blog in 15 minutes", which is still impressive to this day. Before I start ranting about all of the problems I found, let's start with the good stuff.&lt;/p&gt;

&lt;p&gt;Similar to Python, Ruby is that language that you can show to non-technical people and they will understand what the software is trying to do. It is &lt;strong&gt;by far&lt;/strong&gt; the easiest to read and the most beautiful language I've ever seen. I quickly realized that &lt;a href="https://world.hey.com/dhh/a-writer-s-ruby-2050b634" rel="noopener noreferrer"&gt;writing visually pleasing code&lt;/a&gt; was a priority for the Rails team, and that was new to me.&lt;/p&gt;

&lt;p&gt;Not to mention that Rails pretty much invented the "Batteries included" and "Convention over configuration" philosophies, so this wouldn't be a problem. Inside one single documentation, everything I needed for any type of web application was available.&lt;/p&gt;

&lt;p&gt;On the frontend side, there is &lt;a href="https://hotwired.dev/" rel="noopener noreferrer"&gt;Hotwire&lt;/a&gt;, a very simple and lightweight approach to all the UX improvements provided by SPA frameworks. I've always been curious to test the limits of this library, it looks very promising.&lt;/p&gt;

&lt;p&gt;Alright, so on paper Rails passed on all of the prerequisites I wanted on a framework. Let's try it! The first thing I tested locally was the &lt;code&gt;rails scaffold&lt;/code&gt; command. And immediatly I was &lt;strong&gt;SHOCKED&lt;/strong&gt;. One single command generates everything I need for a CRUD? No way!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F58lbioexmot9412kojr5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F58lbioexmot9412kojr5.png" alt=" " width="800" height="915"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On Node + React land, to achieve the same thing, I would need to manually write all the code (there are no generators here) and install a bunch of libraries like: Vite, prisma, express, react router, redux, redux-thunk, vitest, cypress, react testing library, zod, typescript, eslint, prettier, 1000 different plugins, and maybe even GraphQL or tRPC. Basically a package.json with 900 dependencies already.&lt;/p&gt;

&lt;p&gt;After the initial shock from &lt;code&gt;rails scaffold&lt;/code&gt;, I was shocked once again when I opened the code from the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticlesController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;
    &lt;span class="vi"&gt;@articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;:new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_entity&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;edit&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;:edit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_entity&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;destroy&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;

    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;root_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: :see_other&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;article_params&lt;/span&gt;
      &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Is this all of the backend code? Just a couple of lines? That's impossible! This is so simple that it looks like a "low code" tool. It's simple, elegant, and extremely readable, which is something we rarely find in the JS land.&lt;/p&gt;

&lt;p&gt;Okay, okay, you must be thinking right now: "This crazy react dev from the internet said he ended up using Elixir, so there must be some things wrong with ruby!". And you are right my anonymous friend, there were some things that annoyed me quite a lot, let's talk about them.&lt;/p&gt;

&lt;p&gt;First, we need to address the elephant in the room: Moving from React + Typescript to a dynamically typed language is not easy. From the moment I started writing code and no intellisense or dropdown filled with code suggestions show up on my VScode, I felt blind and lost. This is a terrible feeling, I could make a typo on a function name and didn't realize it until the website is in production! I know we can write tests, but this is the type of mistake that I want to identify immediatly on the IDE, and not during tests or deployment.&lt;/p&gt;

&lt;p&gt;Another thing I thought I would like, but ended up hating it: Too much magic. Inside a Typescript codebase, I can click on top of any class or function, go to the source and see how it's implemented. On Rails, where the hell do I do validation (for example)? Do I create a private function inside the controller? Is there a especific folder for this? NOPE, the correct place to do it is inside the model. Why? Because that's how it works, you either adopt the convention or have a hard time writing ruby code. I simply cannot develop an "intuition" on how everything works under the hood, I have to blindly trust that the maintainers did a good job at organizing everything.&lt;/p&gt;

&lt;p&gt;And to finish my frustrations, I started writing frontend code. How do I create components? &lt;a href="https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials" rel="noopener noreferrer"&gt;Partials&lt;/a&gt;. How do I define the prop types of this component? There is no way to do that, you need to open it up and visually look for all the variables inside it. How about doing some interactivity? Creating states? Well, there is Hotwire with &lt;a href="https://stimulus.hotwired.dev/" rel="noopener noreferrer"&gt;Stimulus&lt;/a&gt;, but as you can see, you need to manually create your "re-render" function, it doesn't figure out a way to re-render the page automatically after changing a state like React.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/controllers/slideshow_controller.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showCurrentSlide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showCurrentSlide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showCurrentSlide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;showCurrentSlide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slideTargets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once Again I got frustrated. I got reeeeeeeally close to finding the perfect framework! What is the next framework on my list that I wanted to try if Rails failed? Elixir.&lt;/p&gt;
&lt;h3&gt;
  
  
  Elixir and Phoenix
&lt;/h3&gt;

&lt;p&gt;I have to be honest, I was running low on patience. I tried multiple different ecosystems, and I was almost convinced to just stick with Ruby on Rails and give up on my quest to perfection. Until a video appeared on my YouTube recommended section:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bfrzGXM-Z88"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Hold on! Here we can see a React developer saying a bunch of nice things about functional programming, Elixir and Phoenix Live View. Maybe I should give it a try!&lt;/p&gt;

&lt;p&gt;The first thing I did was open the documentation for Elixir and Phoenix, and I really enjoyed the fact that all packages are documented in the same way using &lt;a href="https://hexdocs.pm/" rel="noopener noreferrer"&gt;Hex Docs&lt;/a&gt;, you just need to get used to one interface in order to learn new things.&lt;/p&gt;

&lt;p&gt;Another good thing is that you can truly learn Elixir just reading the docs, no need for an expensive course! On every other ecosystem, I had to learn the language through a paid course and then learn the framework by reading the docs.&lt;/p&gt;

&lt;p&gt;Then it was time to start writing code. Very quickly I understood that functional programming is very different from OOP. Let's do a small comparison:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// JS&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daniel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;

&lt;span class="c1"&gt;// result: obj = {name: "daniel", age: 25}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Elixir&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"daniel"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# result: obj = %{name: "daniel", age: 25}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Or you could achieve the same thing with a simpler syntax using the pipe operator:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Elixir with pipe operator&lt;/span&gt;
&lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="ss"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"daniel"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# result: obj = %{name: "daniel", age: 25}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Initially you might find it less readable and more complex, but I promise that over time it makes sense! Well, at least for me it did. As a React developer, I got used to seeing multiple functions everywhere, even front end components are functions! Not to mention the fact that creating a class is sometimes viewed as a code smell by the JavaScript mafia. My brain was already "shaped" for this new paradigm, it just felt natural to me. Since my Network Engineering degree in university, I had several classes about object oriented programming, but it never "clicked". I couldn't model complex problems into classes and objects. Using multiple functions to "mutate" a variable over time is how I model things in my mind.&lt;/p&gt;

&lt;p&gt;How about the main framework? Is Phoenix batteries included? Convention over configuration? &lt;strong&gt;YES IT IS!&lt;/strong&gt; To be honest, the ecosystem is not at the same level as Rails, but it's 95% there. Unless you need an ultra-specific feature, Phoenix got you covered.&lt;/p&gt;

&lt;p&gt;I was &lt;em&gt;almost&lt;/em&gt; sold on Elixir, 2 things were missing from my list: Good developer experience and modern/performant front end code.&lt;/p&gt;

&lt;p&gt;José Valim announced he was experimenting with adding types to the language, but Elixir doesn't have them currently, so I got concerned. How do I get intellisense and autocomplete without types? Soon I discovered these features aren't necessarily related. After installing the &lt;a href="https://marketplace.visualstudio.com/items?itemName=JakeBecker.elixir-ls" rel="noopener noreferrer"&gt;ElixirLS extension&lt;/a&gt; on VScode I was surprised. It's possible to define a function inside a random module on a random folder, import it somewhere else, and get the intellisense and documentation for it! I have those benefits from statically typed languages without the hassle of writing types, simply amazing!&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://elixir-lang.org/blog/2022/10/05/my-future-with-elixir-set-theoretic-types/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Felixir-lang.org%2Fimages%2Fsocial%2Felixir-og-card.jpg" height="420" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://elixir-lang.org/blog/2022/10/05/my-future-with-elixir-set-theoretic-types/" rel="noopener noreferrer" class="c-link"&gt;
            My Future with Elixir: set-theoretic types - The Elixir programming language
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            We announce and explore the possibilities for bringing set-theoretic types into Elixir.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Felixir-lang.org%2Ffavicon.ico" width="48" height="48"&gt;
          elixir-lang.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;My final concern on the frontend was addressed by Phoenix &lt;a href="https://hexdocs.pm/phoenix_live_view/welcome.html" rel="noopener noreferrer"&gt;Live View&lt;/a&gt;. On the code side, this was the exact piece of the documentation's home page that convinced me:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sjzj90khytebnk523fm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5sjzj90khytebnk523fm.png" alt=" " width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can define "props" to every component, and if the types mismatch, you get an error in your IDE, just like react! Impressive!&lt;/p&gt;

&lt;p&gt;How about the UX? Is there a full page load whenever a user clicks on a link? Hell no! Live view establishes a WebSocket connection with the client, and then every page transition is just a content swap made through the Websocket, no new HTTP request is made. Also, all of the state is managed on the server side, which means that rich user experiences like Trello, which used to be very janky on the client side due to an excessive amount of javascript being loaded, are now super fast! Elixir handles all the complex state logic and sends the updated pieces of the page to the front end. Take a look at the full explanation here:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/wrmVk2czqMg"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Since we are using WebSockets for builing the UI, creating "live" applications like Twitter takes only a couple of lines of code!&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/MZvmYaFkNJI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;It's safe to say that the "perfect tech stack" doesn't exist. The silver bullet that solves all the problems is an illusion that we create in our minds to keep searching and building the most optimized tool.&lt;/p&gt;

&lt;p&gt;However, at an individual level, the perfect stack does exist. Because each developer has preferences, and you can easily find a tool that fits your criteria. If you had a similar journey to mine, perfection might be Elixir and Phoenix! So give it a try, maybe you'll love it as much as I do now.&lt;/p&gt;

&lt;p&gt;If you reached the end of this blog post, you are awesome! Thank you so much for your time, and I hope I could bring some value into your career.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4ls4vpepbcofv2dd3e.gif" alt="The end" width="499" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>rails</category>
      <category>elixir</category>
      <category>react</category>
    </item>
  </channel>
</rss>
