<?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: CodeFromScratch</title>
    <description>The latest articles on DEV Community by CodeFromScratch (@codefromscratch).</description>
    <link>https://dev.to/codefromscratch</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%2F4236990%2F3b6f5af8-8143-4b00-b7ff-40b874440447.png</url>
      <title>DEV Community: CodeFromScratch</title>
      <link>https://dev.to/codefromscratch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codefromscratch"/>
    <language>en</language>
    <item>
      <title>What Is Vibe Coding? Where It Works and Where It Breaks</title>
      <dc:creator>CodeFromScratch</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:08:58 +0000</pubDate>
      <link>https://dev.to/codefromscratch/what-is-vibe-coding-where-it-works-and-where-it-breaks-3mg9</link>
      <guid>https://dev.to/codefromscratch/what-is-vibe-coding-where-it-works-and-where-it-breaks-3mg9</guid>
      <description>&lt;p&gt;You describe an app to an AI, it writes the code, you run it, and it works. People call that vibe coding, and depending on who you ask it's either the end of programming or a disaster waiting to happen. I work as a web developer and I use an AI coding agent every day, so let me tell you what vibe coding actually is, where it holds up, where it falls apart, and show you the code that proves it.&lt;/p&gt;

&lt;p&gt;Here's the short answer first. Vibe coding is building software by describing what you want to an AI in plain language and accepting the code it writes without reading it closely. It works for prototypes, throwaway tools and learning by seeing. It breaks the moment other people, real money or real data depend on the result, because nobody checked the parts that matter.&lt;/p&gt;

&lt;p&gt;That's the whole article in four sentences. The rest is the part that matters: what "the parts that matter" are, why the AI skips exactly those, and what you do about it. If you're a beginner, read it top to bottom; I define every term the first time it shows up. If you already ship code with an agent every day, jump to the orders example and the live demo, because those two show the exact mistake I see most often in AI-written code. The section near the end about checking AI output is for anyone who reviews other people's pull requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is vibe coding, in plain words
&lt;/h2&gt;

&lt;p&gt;The term is young. Andrej Karpathy, one of the founders of OpenAI, used it in &lt;a href="https://x.com/karpathy/status/1886192184808149383" rel="noopener noreferrer"&gt;a post on X in February 2025&lt;/a&gt; to describe a way of working where you "forget that the code even exists". You talk to the AI, it writes, you run the result, and when something breaks you paste the error back and let it fix itself. Less than a year later &lt;a href="https://www.collinsdictionary.com/woty" rel="noopener noreferrer"&gt;Collins Dictionary made it their Word of the Year 2025&lt;/a&gt;. That tells you how fast this spread.&lt;/p&gt;

&lt;p&gt;Let me try a comparison from outside tech. Imagine you hire a builder who works unbelievably fast, speaks every language, never gets tired and never argues. You say "I want a kitchen with an island" and by the evening there's a kitchen with an island. The catch: now and then he fits a door that opens the wrong way, or leaves a gas pipe loose, and he doesn't notice, because he was busy doing what you asked. If you walk through the house before guests arrive, you catch it. If you don't, the guests catch it. Vibe coding is hiring that builder and skipping the walk-through.&lt;/p&gt;

&lt;p&gt;A few terms before we go on, because this topic is full of them. A model, or a large language model, is the AI itself: a program that was trained on an enormous amount of text and code and predicts what should come next. A prompt is what you type to it. An AI coding agent is a program built around a model that can read and write the files in your project, run commands and read the errors that come back, so it can keep going without you pasting things by hand. A pull request is a bundle of changes you ask someone to review before it goes into the real code. That's all you need for this article.&lt;/p&gt;

&lt;p&gt;Here's the thing most explanations miss. Vibe coding is not a tool. It's a relationship with the output. Two people can use the exact same agent on the exact same task. One reads what came back, asks why a line is there, runs the tests and changes two things. The other says "looks good" and moves on. Only the second one is vibe coding. The word describes what you do with the code, not what wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does vibe coding exist now
&lt;/h2&gt;

&lt;p&gt;Why did this appear in 2025 and not in 2015? Good question. Two things happened at once. The models got good enough at code that the loop actually works: describe, run, paste the error, run again, and most of the time you end up with something that runs. And agents arrived, so the AI could run the commands itself instead of waiting for you to copy and paste. Put those together and the time from "I have an idea" to "it runs on my laptop" collapsed from weeks to an afternoon.&lt;/p&gt;

&lt;p&gt;And then the marketing arrived. I'll be honest: the vibe coding story annoys me. Not because people are building things, that part is great. It annoys me because it tells people they can build anything with AI without understanding what sits behind the code, or how to fix it when it breaks. And it will break. That's not pessimism, that's every piece of software ever written.&lt;/p&gt;

&lt;p&gt;What the story leaves out is which part got easy. Making something work on your own laptop was never the hard part of programming. It was the part courses taught you, and the part you could always muddle through. The hard part was always what happens after: when a stranger types something you didn't expect, when someone tries to read data that isn't theirs, when the database goes down at two in the morning, when a thousand people have accounts and one of them forgot a password. AI made the easy part almost free. The hard part is exactly where it was.&lt;/p&gt;

&lt;p&gt;I know this because I did it myself, before any AI was involved. For a long time I built things from my head until they looked good. No plan, no sketch, no thought about security. I never once stopped to think about what happens with a thousand users with profiles: protecting their accounts from someone trying to break in, resetting passwords, helping them when my own bug hurt them. The thing worked, so I was done. Vibe coding is that same mistake with a much faster engine under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where vibe coding works
&lt;/h2&gt;

&lt;p&gt;Let's be fair to it, because the hype has a true core. There are plenty of situations where reading every line is overkill, and telling a beginner to review a hundred lines of a throwaway script is how you make them quit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A prototype to show someone an idea. You want to see whether the screen makes sense, not whether it survives a hacker. It will be thrown away or rebuilt.&lt;/li&gt;
&lt;li&gt;A one-off script. Rename four hundred files, convert a spreadsheet, pull some numbers out of a log. You run it once, you look at the result, done.&lt;/li&gt;
&lt;li&gt;A tool only you use. A little dashboard for your own notes, on your own machine. If it breaks, you lose an hour, not a customer.&lt;/li&gt;
&lt;li&gt;Trying a layout or a library. "Show me what this looks like with cards instead of a table." You're exploring, not shipping.&lt;/li&gt;
&lt;li&gt;Learning by seeing, with a catch I'll come back to. Watching an agent build something and then asking it to explain every line is a very good way to learn. Watching it build and asking nothing is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what all of these have in common. If the code is wrong, the only person who pays is you. I'll give you one word for this: blast radius. It's the number of people who get hurt when the thing breaks. When the blast radius is you, vibe away. The moment the blast radius includes anybody else, their data, their money or their time, you need a different rule, and the rest of this article is that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks: the distance between your laptop and real users
&lt;/h2&gt;

&lt;p&gt;Let me show you rather than tell you. Here's a route handler, which is a small piece of server code that answers one kind of request, in this case "give me the orders for a user". This is what you get when you ask an agent for exactly that sentence. I'm not quoting a particular session; this is the shape I see again and again, and I've type-checked it against Next.js 16 so you know it compiles.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/api/orders/route.ts (what you get for "show the orders for a user")&lt;/code&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"next/server"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@/lib/db"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Request&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;searchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="s2"&gt;"userId"&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;userId&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="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"userId is required"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userId&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&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;Line by line, because every piece matters here. The first two lines import NextResponse, a helper for sending JSON back, and db, which stands for your database client. The GET function runs whenever the browser asks for this address. Inside it, new URL(request.url) breaks the address into parts, and searchParams.get("userId") reads the userId from the end of the address, the part after the question mark. Then there's a check: if userId is missing, send back a 400 error, which means "your request was bad". Only then does it ask the database for every order whose userId matches, and send that list back as JSON.&lt;/p&gt;

&lt;p&gt;Look at that code for a second. It validated the input. It returns a proper error code when something's missing. It's short, it's tidy, and if you open it in your browser with your own id on the end, it shows your orders. By every test the agent ran, it works.&lt;/p&gt;

&lt;p&gt;Now I'll run it with a tiny fake database that holds one order for Emily and one for James.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;three requests against the handler above&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;GET /api/orders                -&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 400 &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;:&lt;span class="s2"&gt;"userId is required"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
GET /api/orders?userId=emily   -&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 200 &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;:&lt;span class="s2"&gt;"o1"&lt;/span&gt;,&lt;span class="s2"&gt;"userId"&lt;/span&gt;:&lt;span class="s2"&gt;"emily"&lt;/span&gt;,&lt;span class="s2"&gt;"total"&lt;/span&gt;:1200,...&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
GET /api/orders?userId=james   -&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 200 &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;:&lt;span class="s2"&gt;"o2"&lt;/span&gt;,&lt;span class="s2"&gt;"userId"&lt;/span&gt;:&lt;span class="s2"&gt;"james"&lt;/span&gt;,&lt;span class="s2"&gt;"total"&lt;/span&gt;:5900,...&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do you see it? Emily can type James's id into the address bar and read James's orders. The server never asked who is making the request. It only asked whose orders were requested, and it took the answer from the one place anyone can edit: the address.&lt;/p&gt;

&lt;p&gt;This is the gap between two words that sound similar and aren't. Validation asks "is this a userId?" Authorization asks "is this your userId?" The agent did the first one, which is why the code looks careful. It skipped the second one, which is the one that matters when the blast radius includes other people.&lt;/p&gt;

&lt;p&gt;This isn't a rare slip. It has a name, broken access control, and it sits at number one on the &lt;a href="https://top10.owasp.org/2025/A01_2025-Broken_Access_Control" rel="noopener noreferrer"&gt;OWASP Top 10:2025&lt;/a&gt;, the most used list of web security risks. The OWASP page says that every application tested had some form of it. Every one. That was true before AI wrote code, and the agents learned from the same code we wrote.&lt;/p&gt;

&lt;p&gt;Here's the same handler, fixed. Two changes and one line removed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/api/orders/route.ts (fixed)&lt;/code&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"next/server"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@/lib/auth"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@/lib/db"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;GET&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="n"&gt;session&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;auth&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;session&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="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Sign in first"&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userId&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"desc"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&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;Walk through it. GET now takes no request at all, because nothing in the address is allowed to influence whose orders come back. The first line inside calls auth(), which reads the session cookie the browser sent and tells you who is signed in. If nobody is, we return 401, which means "you need to sign in", a different thing from 400. Then the database query uses session.user.id, a value the server got from its own session store, not from the address. Emily can type whatever she wants after the question mark; the handler doesn't even look. The orderBy line just sorts newest first, which is what a real orders page wants anyway.&lt;/p&gt;

&lt;p&gt;That's the entire fix. If you remove the auth() call, you're back to the first version. If you take the id from the address again "just for admin users", you've reopened the hole with extra steps. The pattern of taking the user from the session and never from the request is the same one I use in &lt;a href="https://codefromscratch.org/blog/login-form-validation-client-and-server" rel="noopener noreferrer"&gt;the login form article&lt;/a&gt;, and if you want to see how the session itself is set up, &lt;a href="https://codefromscratch.org/blog/authentication-in-next-js-with-nextauth-js-v5" rel="noopener noreferrer"&gt;the NextAuth v5 guide&lt;/a&gt; builds it from zero.&lt;/p&gt;

&lt;p&gt;How often does AI-written code carry a problem like this? I'll give you one number with its source and its label. Veracode, a company that sells security scanning, so this is a vendor figure, tested more than 100 models and reported that &lt;a href="https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/" rel="noopener noreferrer"&gt;AI-generated code introduced security flaws in 45% of their tests&lt;/a&gt; in their 2025 report. I can't check their method, so don't quote it as gospel. But I don't need the exact number. The orders handler above is enough to make the point: the code that "works" and the code that's safe are two different things, and the agent only optimised for the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it happen: the price that came from the browser
&lt;/h2&gt;

&lt;p&gt;The second mistake is the same mistake wearing different clothes, and it's the one that costs money. Here's a tiny shop. Pick a product, pick a quantity, place the order. Then press the red button, which does what an attacker would do: edit the request before it reaches the server. Both "servers" in this demo are just functions in the page, so this is a front-end demo of a server-side problem; in a real app the request goes over the network, but the logic is identical.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Who decides the price: the browser or the server?. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/what-is-vibe-coding" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Place an order with attacker mode off and both servers charge the same amount. Now turn attacker mode on and order again. Server A charges a few cents. Server B charges the real price. Same request, same quantity, same product.&lt;/p&gt;

&lt;p&gt;Here's what each part of the code does. PRICES is the server's own price list, in cents, because storing money as decimals causes rounding bugs; in a real app this is a table in the database. buildRequest is the browser side: it packs up the product, the quantity and the price into one object, which is what a lot of shop pages genuinely do, because the price was right there on the screen. When attacker mode is on, it sets priceCents to 1 before sending, which is what anyone can do with the browser's developer tools or a one-line command. The browser is not yours. Whatever it sends, it sends because the person using it allowed it.&lt;/p&gt;

&lt;p&gt;serverA is the trusting server. It multiplies the price it received by the quantity it received and calls it a day. serverB ignores the price in the request completely. It takes the product id, looks the price up in its own list, and if the product doesn't exist it returns null, which the page shows as a rejection. Then render puts all three results on the screen, and the submit handler calls preventDefault so the form doesn't reload the page, which is the one thing every form demo needs.&lt;/p&gt;

&lt;p&gt;See the pattern? It's the orders handler again. The orders handler trusted the address for who you are. Server A trusts the request body for what things cost. The rule is the same both times: the server decides the things that matter, and the request only gets to say what it wants, never what it is entitled to. If you want the full version of this idea, with a schema that checks the shape of every request at the door, read &lt;a href="https://codefromscratch.org/blog/zod-at-trust-boundaries" rel="noopener noreferrer"&gt;Zod at the trust boundary&lt;/a&gt;. It's the article I'd hand to anyone who just shipped their first vibe-coded app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the AI keeps making this kind of mistake
&lt;/h2&gt;

&lt;p&gt;People read the two examples above and conclude the AI is bad at security. That's not quite it, and getting this right changes how you use the tool.&lt;/p&gt;

&lt;p&gt;The agent is optimising for the sentence you gave it and the errors you pasted back. "Show the orders for a user" doesn't contain the word session. "Build a checkout" doesn't contain the sentence "the browser lies". Nothing in the loop ever rewarded safe over works, so works is what you got. And the training data doesn't help, because a huge share of the code on the internet is tutorial code, and tutorials skip exactly these parts to keep the lesson short. The agent learned from that too.&lt;/p&gt;

&lt;p&gt;I check what the AI writes as far as I can, because it can hallucinate, which means confidently inventing a function or a package that doesn't exist, and because it assumes things it shouldn't. Mostly it assumes the happy path: the user is honest, the input is well formed, the network is fine. Those are the assumptions a beginner makes too, which is why beginner code and vibe-coded code fail in the same places.&lt;/p&gt;

&lt;p&gt;And here's a realisation that took me a while. When the output isn't what I imagined, most of the time I could have asked better. The AI is a machine running maths and algorithms. It can't know what's in my head, what I saw on some other site last week, or that in my project the user id always comes from the session. If I didn't say it and the project didn't show it, it doesn't exist. That reframes most "the AI got it wrong" moments into "I left a gap and it filled it with the most common thing".&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually work with an AI agent
&lt;/h2&gt;

&lt;p&gt;So you don't stop using the tool. You change your relationship with the output. This is my routine; it's not the only good one, but it's the one I can honestly say I use.&lt;/p&gt;

&lt;p&gt;Before any code, I talk the project through with the agent. What are we building, who uses it, what must never happen. We make a plan together and I read the plan, because a wrong plan is cheap to fix and wrong code is not. Then we go step by step: I give the next step, the agent writes the code, I run it and read what changed. One step at a time, not "build the whole app". It saves a lot of time. It also makes mistakes without meaning to, so without some programming knowledge you'll hit a wall sooner or later; that's not a reason to avoid it, it's a reason to learn alongside it.&lt;/p&gt;

&lt;p&gt;Compare two ways of asking for the orders route. The vague one is "add an API that returns a user's orders", and you've already seen what that gets you. The specific one says: add GET /api/orders that returns only the signed-in user's orders; take the user id from auth() in lib/auth.ts and never from the request; return 401 when there's no session; and add a test where a second user cannot see the first user's orders. Same agent, same model, completely different code, because the second prompt carried the two things the first one left in my head: who is asking, and what they must not be able to do.&lt;/p&gt;

&lt;p&gt;For the review itself, I keep it to four questions, in this order, and I ask them of every change before I move on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is asking, and where did the code learn that? If the answer is "from the request", stop.&lt;/li&gt;
&lt;li&gt;What did the agent assume? I literally ask it: "List everything you assumed about the input, the user and the environment." It will tell you, and the list is usually where the bugs are.&lt;/li&gt;
&lt;li&gt;What breaks if I delete this line? Asking that about every check is the fastest way to learn what each line is for.&lt;/li&gt;
&lt;li&gt;Which paths move money, data or permissions, and do they have a test? Not everything needs a test. Those do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, before anybody else touches it, I go through the same list of eight questions I wrote up in &lt;a href="https://codefromscratch.org/blog/production-grade-code-checklist" rel="noopener noreferrer"&gt;the production-grade code article&lt;/a&gt;. An agent can do the mechanical part of that list in minutes: finding every place input enters, listing every endpoint without a rate limit, grepping for secrets. The judgment part, deciding what's acceptable for this app and these users, is still yours, and I don't see that changing soon.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Tested with: Claude Code, October 2026. The two route handlers were type-checked with TypeScript 5.9 in strict mode against Next.js 16.4, and the vulnerable one was run under Node 24 to produce the three responses shown. The live demo was run in a browser. The routine above doesn't depend on the tool; it works the same with any agent that can edit files and run commands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  If you're just starting: does this mean you can skip learning to code
&lt;/h2&gt;

&lt;p&gt;I get this question a lot, so here's a straight answer. No. But not for the reason you've been told.&lt;/p&gt;

&lt;p&gt;AI will not replace programmers. It changes how they work. Less of the job is weeks of typing and debugging, more of it is directing: deciding what to build, in what order, and checking that what came back is right. The people who fall behind are the ones who refuse to use it, and they'll fall behind fast, because this moves fast. But directing needs understanding. You cannot check what you cannot read. You cannot tell the agent "take the user from the session" if you don't know what a session is. The vibe coder and the beginner hit the same wall, and the wall is concepts, not typing speed.&lt;/p&gt;

&lt;p&gt;So what do you actually need to know first? Less than you fear. How a request travels from the browser to the server and back. What a database row is and what it means to look one up. What "who is asking" means, which is sessions and cookies. How to read an error message without panicking. That's the foundation, and I wrote &lt;a href="https://codefromscratch.org/blog/what-is-web-development-beginners-guide" rel="noopener noreferrer"&gt;the beginner's guide to web development&lt;/a&gt; to put those pieces in order, because when I started nobody connected them for me, and &lt;a href="https://codefromscratch.org/blog/why-courses-leave-you-feeling-like-you-know-nothing" rel="noopener noreferrer"&gt;courses left me feeling like I knew nothing&lt;/a&gt; even after I'd finished them.&lt;/p&gt;

&lt;p&gt;And here's the best news in this article. The agent is the most patient tutor you'll ever have. After it writes something, ask it to explain every line. Ask what would break if a line were removed. Ask it to write the same thing a second way and tell you the difference. That's learning by seeing done right, and it's the catch I promised earlier: watching it build teaches you nothing, interrogating what it built teaches you everything. One percent a day for a year beats a heroic weekend and then nothing. And if you don't know something yet, that's fine. Plenty of people working in companies right now don't know everything and ask for help; the difference is they know what to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  So that's all it is
&lt;/h2&gt;

&lt;p&gt;Vibe coding is the same tool, with the reading taken out. That's the whole definition, and once you see it that way the fear and the hype both shrink. The AI didn't make programming disappear. It made the part that was always easy nearly free, and left the part that was always hard, judgment, exactly where it was: with you.&lt;/p&gt;

&lt;p&gt;The one question that carries most of that judgment is this: who is asking, and what's the worst thing they could do with this? Ask it about every route, every form, every query. The orders handler fails that question. Server A fails it. Everything you'd be embarrassed to find in production fails it. That's not a security specialist's question. It's an older colleague's question, and after a few weeks you'll ask it without thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to be careful about, and the Plan B
&lt;/h2&gt;

&lt;p&gt;A few pitfalls I see in vibe-coded projects more than anywhere else, and what to do about each.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secrets in the code. An API key pasted straight into a file because the agent needed it to work, then committed. Keys go in environment variables, and if one was ever committed, rotate it, meaning generate a new one and delete the old one; removing it from the file isn't enough, it's still in the history.&lt;/li&gt;
&lt;li&gt;Packages you never asked for. Agents add dependencies freely. Each one is code you didn't write and have to maintain. Read the package list and remove what you can't explain.&lt;/li&gt;
&lt;li&gt;Fixes that delete the check. When a test fails, the quickest way to make it pass is to loosen the validation. Agents do this. If a "fix" removes an if statement, read it twice.&lt;/li&gt;
&lt;li&gt;Tests that test nothing. A test file that exists, runs green, and checks that true is true. Open the tests and make sure each one would fail if the feature broke.&lt;/li&gt;
&lt;li&gt;No limits on anything. No rate limit, no maximum input size, no timeout. The day you get traffic is the day you get abuse, and the limits are the subject of the rate limiting article linked below.&lt;/li&gt;
&lt;li&gt;One enormous file. Works on day one, unreadable on day thirty, when you need to find the one place a thing happens. Ask the agent to split by responsibility before that day, not after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plan B, if you've already shipped something vibe-coded and real people are using it: don't panic and don't rewrite. Rewrites are how you get the same bugs in a new shape. Instead, make two lists: every place data enters your app, and every place data is read or changed. For each item ask who is asking and where the code learned that. Fix those first, rotate every key that ever touched the code, and add &lt;a href="https://codefromscratch.org/blog/api-rate-limiting-nextjs" rel="noopener noreferrer"&gt;a rate limit on every public endpoint&lt;/a&gt;. Treat it as a prototype you're promoting to a product, one checked boundary at a time, not a product you're patching.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; The demo and the examples here are about the shape of the problem, not a complete security review. A real app has more trust boundaries than one route and one price: file uploads, webhooks, admin pages, background jobs. The method is the same for each, but you have to walk every one of them. Nothing in this article replaces that walk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;Pick the thing you built most recently, with or without an AI, and run the one question over it: who is asking, and what's the worst thing they could do? Then read &lt;a href="https://codefromscratch.org/blog/zod-at-trust-boundaries" rel="noopener noreferrer"&gt;Zod at the trust boundary&lt;/a&gt; to learn how to check every request at the door, and &lt;a href="https://codefromscratch.org/blog/production-grade-code-checklist" rel="noopener noreferrer"&gt;the production-grade code article&lt;/a&gt; for the full list of what separates a demo from something you'd put your name on. If tests are new to you, &lt;a href="https://codefromscratch.org/blog/testing-react-applications-a-complete-strategy" rel="noopener noreferrer"&gt;the React testing strategy&lt;/a&gt; shows where tests pay off and where they're overkill.&lt;/p&gt;

&lt;p&gt;Use the agent. Use it a lot. Just don't stop reading. You can do this, and honestly, you have to, because this is where the work is going. Don't give up at the first wall, and you'll have me on your side for the next one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article first appeared on &lt;a href="https://codefromscratch.org/blog/what-is-vibe-coding" rel="noopener noreferrer"&gt;CodeFromScratch&lt;/a&gt;, where the live demos run right in the page. If you want the next one in your inbox, &lt;a href="https://codefromscratch.org/newsletter" rel="noopener noreferrer"&gt;join the newsletter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Login Form Validation: Why You Check Twice</title>
      <dc:creator>CodeFromScratch</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:08:54 +0000</pubDate>
      <link>https://dev.to/codefromscratch/login-form-validation-why-you-check-twice-192o</link>
      <guid>https://dev.to/codefromscratch/login-form-validation-why-you-check-twice-192o</guid>
      <description>&lt;p&gt;You built a login form. It looks clean, it shows a red message when the email is wrong, and you feel done. Then someone sends your server a request your form would never allow, and it walks straight in. Today I'll show you why every form needs two checks, what each one is for, and how to build both so they stop fighting each other.&lt;/p&gt;

&lt;p&gt;Here's the short answer, so you have it before anything else. Form validation runs twice on purpose. The check in the browser is for the person filling in the form: fast, friendly feedback before anything is sent. The check on the server is for your app: it's the only one you can trust, because anyone can skip the browser and talk to your server directly.&lt;/p&gt;

&lt;p&gt;That's it. Everything else in this article is the why and the how. If you're a beginner, read it top to bottom. If you already write client-side validation every day, jump to the section about the browser check being a courtesy, not a lock. If you're the person who designs auth for a team, the later sections on error messages, password rules and old accounts are for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The form works, so why is that not enough
&lt;/h2&gt;

&lt;p&gt;Let me describe a form I think most of us have built. Two fields, email and password. A button. A bit of JavaScript that says "enter a valid email" when you type nonsense. You test it, the messages appear, and it feels finished.&lt;/p&gt;

&lt;p&gt;And it is finished, for the happy path. Your form is well behaved. It only ever sends what you allowed it to send. The problem is that your form is not the only thing that can talk to your server. A script can. A mobile app someone builds next year can. A curious person with a terminal can. None of them ever see your nice red messages.&lt;/p&gt;

&lt;p&gt;When I started, I didn't think about any of this. I built things from my head until they looked right on screen, and "looks right" meant the form behaved when I clicked around in it. I never asked what happens with a thousand real users, and what happens when one of them is not friendly. That question is the whole point of today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why validation exists in the first place
&lt;/h2&gt;

&lt;p&gt;Validation means checking that data has the shape you expect before you do anything with it. Is the email field filled in? Does it look like an email? Is the password long enough to be a real password? You check the shape first, and only then you ask the expensive questions, like whether this person actually has an account.&lt;/p&gt;

&lt;p&gt;Think about a post office counter. The clerk looks at your envelope before you leave: no address, no stamp, they hand it back right away, and you fix it in ten seconds. That's helpful. But the sorting center still checks every letter again, because letters also arrive from street mailboxes, where no clerk ever looked at them. The counter check is a service for you. The sorting center check is what protects the system.&lt;/p&gt;

&lt;p&gt;Your browser is the counter. Your server is the sorting center. Both checks are real, both are useful, and they have completely different jobs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser check is about the person. It saves them a round trip, tells them exactly what's wrong next to the field, and keeps the experience calm.&lt;/li&gt;
&lt;li&gt;The server check is about your data and your users' safety. It decides what is allowed into your database and into your auth logic. Nothing gets past it.&lt;/li&gt;
&lt;li&gt;If you have to pick only one, pick the server. A form without browser checks is annoying. A server without checks is a hole.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Terms in plain words. Client means the browser, the part running on the user's device. Server means your code running on a machine you control. A request is the message the browser sends when the form is submitted. Validation is checking the shape of data. Authentication is checking who someone is. They are different jobs, and mixing them up causes a lot of the bugs in this article.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Build the browser check: a login form you can try right now
&lt;/h2&gt;

&lt;p&gt;Before reading the code, play with it. Try each of these five things in order: submit with both fields empty, type an email like emily@ with nothing after it, type a password shorter than eight characters, use a real-looking email with the wrong password, and finally sign in with the demo account shown in the form.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Login form with validation (front-end demo). It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/login-form-validation-client-and-server" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You saw five different outcomes, and each one has a reason. Let's walk through the code tab by tab.&lt;/p&gt;

&lt;h3&gt;
  
  
  The HTML does more work than you think
&lt;/h3&gt;

&lt;p&gt;Look at the HTML tab. Every input has a label tied to it with the for and id attributes. That's not decoration. Click the word Email and the cursor jumps into the field, and a screen reader announces the label when the field gets focus. Remove the label and both of those break.&lt;/p&gt;

&lt;p&gt;The email input uses type="email". That one word gives you three things for free: phones show a keyboard with the @ sign, password managers know what goes there, and the browser itself can judge whether the text looks like an email. The required attribute marks the field as mandatory, and minlength="8" on the password says the same thing about length.&lt;/p&gt;

&lt;p&gt;The autocomplete values matter more than people think. autocomplete="username" on the email and autocomplete="current-password" on the password tell password managers exactly what this form is: a sign-in form, not a sign-up form. On a sign-up form you would use new-password instead, so the manager offers to generate one. If you get these wrong, the manager guesses, and it often guesses badly.&lt;/p&gt;

&lt;p&gt;Then there's novalidate on the form. This one confuses people, so slow down here. It does not switch validation off. It switches off the browser's own popup bubbles, the grey tooltips that look different in every browser, so that our JavaScript can show its own messages under each field. The rules from type, required and minlength are still there, and our code still reads them.&lt;/p&gt;

&lt;p&gt;Each input also has aria-describedby pointing at the paragraph under it, where the error will appear. A screen reader reads that paragraph together with the field, so a blind user hears the error message along with the field name, instead of just "Email" and silence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The JavaScript, one piece at a time
&lt;/h3&gt;

&lt;p&gt;Now the JS tab. The first five lines just grab the elements we need: the form, the two inputs, the status line at the bottom, and the button. Nothing clever.&lt;/p&gt;

&lt;p&gt;checkEmail is a small function that returns a message, or an empty string when everything is fine. First it trims the value and checks for empty, because a field with three spaces in it is still an empty field. Then it asks email.validity.typeMismatch. That's the browser telling us whether the text matches its idea of an email address. We don't write our own email regex, and that's on purpose: the browser's rule comes from the HTML standard, it's the same rule everywhere, and it's simple enough to accept odd but real addresses.&lt;/p&gt;

&lt;p&gt;checkPassword does the same for the password: empty first, then the length. You might ask why I don't use password.validity.tooShort here too. Good question. Browsers only raise tooShort after the user has edited the field, not when a script filled in the value. Checking the length directly is one line and behaves the same every time.&lt;/p&gt;

&lt;p&gt;show is the function that touches the page. It writes the message into the right error paragraph, sets aria-invalid to true or false, and returns true when the field is fine. The aria-invalid attribute does double duty: screen readers announce it, and our CSS uses it to paint the border red. One source of truth for both.&lt;/p&gt;

&lt;p&gt;Then come four event listeners. On blur, which means when you leave a field, we check it, but only if you actually typed something. Nothing is more annoying than tabbing through a form and getting yelled at before you've even started. On input, which fires on every keystroke, we re-check only if the field is already marked invalid. So the red message appears when you leave the field, and it disappears the moment you fix it. That rhythm, late to complain and quick to forgive, is what makes a form feel kind.&lt;/p&gt;

&lt;p&gt;The submit handler starts with event.preventDefault(). Without it the browser does its default thing, which is to send the form and reload the page, and all our checks would flash and vanish. Then it clears the old status, runs both checks, and if anything failed it moves the keyboard focus to the first broken field, so the user lands exactly where they need to type.&lt;/p&gt;

&lt;p&gt;If both checks pass, we disable the button and change its text to "Signing in...". That stops double clicks from sending two requests, and it tells the user something is happening. Then we wait for the answer and switch the button back.&lt;/p&gt;

&lt;p&gt;Now notice fakeServerLogin. It's labelled as a front-end demo for a reason: checking a password in the browser is not authentication, it's theatre. In this demo it stands in for the request your real app sends to the server. The real check, the one that looks up the account and compares a hashed password, can only ever happen on the server. Which brings us to the most important part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser check is a courtesy, not a lock
&lt;/h2&gt;

&lt;p&gt;Here's the moment that changes how you see forms. Everything we just built lives on the user's machine. They can open developer tools and delete novalidate, required and the whole script. Or they can skip the page entirely. This is all it takes to send your server a login request with no form at all:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terminal&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -X POST http://localhost:3000/api/login \
  -H &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; \
  -d &lt;span class="s1"&gt;'{"email":"not-an-email","password":"1"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it line by line. curl is a command-line tool that sends HTTP requests, the same kind your browser sends. -X POST says which method to use, the same as method="post" on a form. The -H line adds a header telling the server the body is JSON. The -d line is the body itself: an email that is not an email and a one-character password. Your form would never send this. curl doesn't care about your form.&lt;/p&gt;

&lt;p&gt;So what's the point of the browser check, if it can be skipped this easily? The point is the person. Honest users never try to break your form, they just make typos. The browser check helps them. The server check handles everyone else. MDN, the reference most of us learn from, says it directly on its page about &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation" rel="noopener noreferrer"&gt;constraint validation&lt;/a&gt;: browser validation doesn't remove the need for validation on the server side.&lt;/p&gt;

&lt;p&gt;So that's all it is. Two checks, two audiences. The browser check is a polite clerk. The server check is the lock on the door. You need the lock even if the clerk is excellent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the server check so it says the same thing
&lt;/h2&gt;

&lt;p&gt;Now the real check. I'll use Zod, a library that describes the shape of data and then checks real values against that description. If you've never met it, &lt;a href="https://codefromscratch.org/blog/zod-at-trust-boundaries" rel="noopener noreferrer"&gt;the article on validating data where it enters your app&lt;/a&gt; explains why it belongs at every boundary, not just forms. Here we only need a small piece of it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lib/login-schema.ts&lt;/code&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"zod"&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="nc"&gt;LoginSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&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="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="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="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Enter a valid email address."&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;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&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="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Password must be at least 8 characters."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Password is too long."&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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;LoginInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;infer&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="nc"&gt;LoginSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line by line. The import brings in z, which is how Zod is used. LoginSchema describes an object with two fields. For email, we start with z.string(), so anything that isn't text fails right away: numbers, objects, missing values. Then .trim() removes spaces at the ends, and .toLowerCase() makes &lt;a href="mailto:Emily@Example.com"&gt;Emily@Example.com&lt;/a&gt; and &lt;a href="mailto:emily@example.com"&gt;emily@example.com&lt;/a&gt; the same account. Then .pipe(z.email(...)) checks the cleaned-up text against Zod's email format and gives our own friendly message when it fails.&lt;/p&gt;

&lt;p&gt;For password we check that it's a string of at least 8 and at most 128 characters. Notice that we do not trim the password. A space can be a real part of someone's password, and silently removing it means they can never sign in again. The maximum is there so nobody can send you a ten-megabyte password and make your server spend time hashing it.&lt;/p&gt;

&lt;p&gt;The last line, z.infer, turns the schema into a TypeScript type. You describe the shape once, and you get both the runtime check and the type. If you change the schema, every place that uses LoginInput updates with it.&lt;/p&gt;

&lt;p&gt;Now the route handler that uses it, in a Next.js App Router project:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/api/login/route.ts&lt;/code&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"zod"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;LoginSchema&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@/lib/login-schema"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;verifyCredentials&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@/lib/auth"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Request&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="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;catch&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;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoginSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;success&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="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"VALIDATION_ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flattenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fieldErrors&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;user&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;verifyCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;user&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="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Email or password is incorrect."&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's go through it. POST is the function Next.js calls when a POST request arrives at /api/login. The first line reads the body as JSON. The .catch(() =&amp;gt; null) part matters: if someone sends garbage that is not JSON at all, request.json() throws, and without the catch your server would answer with a 500 error. With it, garbage simply becomes null, and null fails validation like any other bad input.&lt;/p&gt;

&lt;p&gt;safeParse runs the schema. Unlike parse, it never throws. It hands back an object with success set to true or false, so we decide what happens instead of an exception deciding for us. When it fails, we answer with status 400, which means bad request, and z.flattenError turns Zod's detailed error into a simple object: field name, list of messages. Here is what the server answered when I sent it the curl request from above:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;response (400)&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"VALIDATION_ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Enter a valid email address."&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Password must be at least 8 characters."&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;Those are the same messages the browser shows. That's not a coincidence, it's the design. The person and the server say the same thing in the same words, so when the browser check is skipped or out of date, the user still gets an answer that makes sense. In a bigger app you would import LoginSchema in the client code too, so both sides read the rules from one file. If you change the minimum length, you change it once.&lt;/p&gt;

&lt;p&gt;Only after the shape is valid do we call verifyCredentials. That's where the real authentication happens: find the user by email, compare the password against the stored hash with a slow hashing function like bcrypt or Argon2, return the user or nothing. I'm keeping that function out of this article on purpose, because it belongs to your auth setup. If you use Auth.js, &lt;a href="https://codefromscratch.org/blog/authentication-in-next-js-with-nextauth-js-v5" rel="noopener noreferrer"&gt;the NextAuth v5 guide&lt;/a&gt; shows where it lives, and &lt;a href="https://codefromscratch.org/blog/nextauth-vs-lucia-vs-better-auth" rel="noopener noreferrer"&gt;the comparison of NextAuth, Lucia and Better Auth&lt;/a&gt; helps you pick a library in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Order matters. Validate the shape first, then do the expensive work. A request with a broken email should never reach your database or your password hashing. Cheap checks first, expensive checks last. That's how you keep bad requests from costing you anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A wrong password is a different kind of error
&lt;/h2&gt;

&lt;p&gt;Go back to the preview and look at the fourth case, the wrong password. The message was "Email or password is incorrect." Not "this email doesn't exist", not "wrong password". That vagueness is deliberate, and it's one of those details that separates a form that works from a form that's safe.&lt;/p&gt;

&lt;p&gt;If your login says "no account with this email", you just built a free tool that tells anyone whether a given person has an account on your site. Feed it a list of emails and it sorts them for you. That's called user enumeration. The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Authentication Cheat Sheet&lt;/a&gt; tells you to answer with a generic message whether the email is unknown, the password is wrong, or the account is locked, so an attacker can't tell the cases apart.&lt;/p&gt;

&lt;p&gt;So there are two kinds of errors in a login form, and they're treated differently on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validation errors are about the shape of what you typed: empty field, not an email, too short. They are specific, they sit next to the field, and they're safe to show, because they only describe what the user already typed.&lt;/li&gt;
&lt;li&gt;Authentication errors are about whether the account and the password match. They are generic, they sit in one place for the whole form, and they never reveal which half was wrong. The server answers 401, which means not authenticated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice one more small thing in the demo: after a failed sign-in, the password field is cleared and gets focus, while the email stays. The user most likely mistyped the password, so we put them right back where they need to type, without making them retype their email.&lt;/p&gt;

&lt;p&gt;And honestly, a generic message is only half of it. Someone can still try a thousand passwords for one account. That's why a real login also needs rate limiting, which slows down repeated attempts from the same source. &lt;a href="https://codefromscratch.org/blog/api-rate-limiting-nextjs" rel="noopener noreferrer"&gt;The rate limiting article&lt;/a&gt; shows how to add it to exactly this kind of route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes I see most, and the Plan B
&lt;/h2&gt;

&lt;p&gt;This is where forms usually go wrong once they meet real people. I learned most of what I know by trying things and getting them wrong first, and forms were no exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules that are stricter than they need to be
&lt;/h3&gt;

&lt;p&gt;The email check should be loose. People have addresses with plus signs, long domains, dots in odd places. If your own regex rejects a real address, that person can't sign in and they'll never tell you why they left. Let the browser and the library do the format check, and treat the email as proven only when the user clicks a confirmation link you sent to it.&lt;/p&gt;

&lt;p&gt;Passwords are similar. Rules like "one uppercase, one number, one symbol" feel secure but mostly produce passwords like Password1 with a symbol stuck on the end. The current US government guidance for digital identity, &lt;a href="https://pages.nist.gov/800-63-4/sp800-63b.html" rel="noopener noreferrer"&gt;NIST SP 800-63B&lt;/a&gt;, says verifiers shall not impose those composition rules. What it asks for instead is length, a check against lists of common and leaked passwords, and room for long passwords: at least 64 characters should be allowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Changing the rule and locking out old accounts
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; This one is for the people designing auth. The current revision of NIST SP 800-63B asks for at least 15 characters when a password is the only factor, and at least 8 when it's part of multi-factor sign-in. If you raise your sign-up rule from 8 to 15, do NOT raise the login check with it. Every account created under the old rule still has a shorter password, and a login form that rejects it before it reaches the server has just locked those users out. Length rules belong on sign-up and password change. On login, only check what can never be a valid password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's also why the demo checks 8 characters, not 15. Think of it as the oldest rule any account in that app was ever created under.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validation that only exists in one place
&lt;/h3&gt;

&lt;p&gt;Browser-only validation is the hole we covered. But the opposite also happens: a team writes the rules twice, once in the form and once on the server, by hand, in different words. A month later somebody changes one side. Now the browser says 8 and the server says 10, and users get a green form followed by a server error they can't understand. One schema, imported on both sides, is the cure.&lt;/p&gt;

&lt;h3&gt;
  
  
  When AI writes the form for you
&lt;/h3&gt;

&lt;p&gt;If you generate forms with an AI assistant, look for exactly these things. I check everything an AI writes for me as far as I can, and forms are where I look hardest. It's very common to get a beautiful form with careful browser checks and a route handler that trusts the body completely, or one that answers "user not found" because that's what the prompt literally asked for. The AI isn't being careless. It can't know your security rules unless you tell it. Ask for server validation and a generic login error explicitly, then read the route before you ship it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plan B: when JavaScript doesn't run
&lt;/h3&gt;

&lt;p&gt;Scripts fail. A slow network, a browser extension, a bug in an unrelated part of your bundle. When the script is gone, the form falls back to plain HTML behavior, and here's the good news: because the server checks everything, nothing unsafe gets through. The user just gets a less pleasant answer. In Next.js you can go further and submit the form with a server action, so it works even without JavaScript, but that's a separate topic. The point is, if your plan B is "hope the script loads", that's not a plan B. The server check is the plan B.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing both sides takes ten minutes
&lt;/h2&gt;

&lt;p&gt;You saw me try five cases in the preview. Those same five cases are your test list, and they're worth automating, because forms break quietly. For the server, call the POST function directly with a Request object and check the status code and body for each case: empty, bad email, short password, wrong password, correct. For the browser side, a component test that types into the fields and checks the messages covers the rest. &lt;a href="https://codefromscratch.org/blog/testing-react-applications-a-complete-strategy" rel="noopener noreferrer"&gt;The guide to testing React applications&lt;/a&gt; shows how to set that up.&lt;/p&gt;

&lt;p&gt;Add one test that a lot of people forget: a body that isn't JSON at all. It should get a 400, not a 500. That single case catches a missing catch around request.json(), and it's the kind of bug that only shows up when someone is poking at your API on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;Take a form you already have, any form, and ask it two questions. First, if I delete the browser checks, what does my server accept? Run the curl command against it and find out. Second, do the browser and the server say the same thing in the same words? If they don't, move the rules into one schema and import it on both sides.&lt;/p&gt;

&lt;p&gt;Then go one level deeper. Read &lt;a href="https://codefromscratch.org/blog/zod-at-trust-boundaries" rel="noopener noreferrer"&gt;Zod at the trust boundary&lt;/a&gt; to see that forms are only one of the places where outside data enters your app, and look at &lt;a href="https://codefromscratch.org/blog/production-grade-code-checklist" rel="noopener noreferrer"&gt;the production-grade code checklist&lt;/a&gt; for the rest of the things that separate "works on my machine" from "safe with real users".&lt;/p&gt;

&lt;p&gt;A login form looks like the simplest thing on a website. It's also the front door. Build it like one. You can do this, and once you've built one properly, you won't want to go back to the old way.&lt;/p&gt;

&lt;p&gt;Tested with: Node.js 24, Zod 4.3 and a current Chromium-based browser, October 2026.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article first appeared on &lt;a href="https://codefromscratch.org/blog/login-form-validation-client-and-server" rel="noopener noreferrer"&gt;CodeFromScratch&lt;/a&gt;, where the live demos run right in the page. If you want the next one in your inbox, &lt;a href="https://codefromscratch.org/newsletter" rel="noopener noreferrer"&gt;join the newsletter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Flexbox vs Grid: How to Choose, on Real Layouts</title>
      <dc:creator>CodeFromScratch</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:08:50 +0000</pubDate>
      <link>https://dev.to/codefromscratch/flexbox-vs-grid-how-to-choose-on-real-layouts-51l6</link>
      <guid>https://dev.to/codefromscratch/flexbox-vs-grid-how-to-choose-on-real-layouts-51l6</guid>
      <description>&lt;p&gt;You've got a row of cards and you're staring at your CSS. Flexbox or grid? You try one, it almost works, you add a few more lines, and now it looks right but you couldn't say why. Today I'll give you the one question that settles it, and we'll run both on the same real layouts so you can watch the difference yourself.&lt;/p&gt;

&lt;p&gt;Here's the short answer, so you have it before anything else. Use flexbox when the content should decide the size of things, like a row of tags or a navigation bar. Use grid when the layout should decide the size of things, like a wall of cards or a whole page. Most real pages use both: grid outside, flexbox inside.&lt;/p&gt;

&lt;p&gt;That's the whole idea. The rest of this article is you seeing it with your own eyes. Every example below is live, so click the buttons. If you're a beginner, read it top to bottom. If you already use both every day, jump to the section about content that won't shrink, because that's the bug that costs people whole afternoons. The later sections on keyboard order, subgrid and checking AI-written layouts are for those of you who review other people's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this choice feels harder than it should
&lt;/h2&gt;

&lt;p&gt;Look, there's a reason you're confused, and it isn't you. Most material teaches these two as a list of properties. Here's justify-content, here's align-items, here's grid-template-columns, now make these colored boxes move around. You finish the exercise, and then you open a real project and nobody told you which tool goes where. It's the same thing I complain about in &lt;a href="https://codefromscratch.org/blog/why-courses-leave-you-feeling-like-you-know-nothing" rel="noopener noreferrer"&gt;why courses leave you feeling like you know nothing&lt;/a&gt;: you get the pieces, but nobody connects them to a real page.&lt;/p&gt;

&lt;p&gt;I've got my own version of this. For a long time I built things from my head until they looked good. No plan, no sketch. That works right up until it doesn't, and with layout it stops working the moment the content changes. You add one more card, or someone's name is longer than you expected, and the page falls apart, because you never decided how it was supposed to behave. You only decided how it should look today.&lt;/p&gt;

&lt;p&gt;So let's fix the actual problem. You don't need to memorize more properties. You need to know what each tool is for, and then the properties mostly make sense on their own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Terms in plain words. A container is the parent element you put display: flex or display: grid on. Items are its direct children, and only direct children: grandchildren don't take part. Gap is the space between items. A track is one row or one column of a grid. Viewport is the visible area of the browser window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one question: who decides the size?
&lt;/h2&gt;

&lt;p&gt;Picture a park bench. People sit down one after another, and each person takes up as much room as they need. A big guy in a winter coat takes more, a kid takes less. When the bench is full, the next person goes to the next bench, and the people on the second bench don't line up with the people on the first one. Why would they? Nobody planned any of it. The bench just fits whoever showed up.&lt;/p&gt;

&lt;p&gt;That's flexbox. The items come first, and the layout forms around them.&lt;/p&gt;

&lt;p&gt;Now picture an egg carton. The slots exist before there's a single egg in it. Every egg goes into a slot, the slots line up perfectly in rows and columns, and if you only have seven eggs, you've got seven full slots and some empty ones. A small egg doesn't make its slot smaller.&lt;/p&gt;

&lt;p&gt;That's grid. The layout comes first, and the items go into it.&lt;/p&gt;

&lt;p&gt;MDN puts it in almost the same terms: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Relationship_with_other_layout_methods" rel="noopener noreferrer"&gt;flexbox works from the content out, and grid works from the layout in&lt;/a&gt;. You'll also hear the older rule, and it's true too: flexbox is one-dimensional, it thinks about one row or one column at a time, while grid is two-dimensional, it thinks about rows and columns together. I covered that in &lt;a href="https://codefromscratch.org/blog/css-grid-layout-the-complete-visual-guide" rel="noopener noreferrer"&gt;the CSS Grid guide&lt;/a&gt;. But that rule alone can trick you. A wrapped row of tags looks two-dimensional on screen and it's still a flexbox job. Three equal columns in a single row look one-dimensional and grid may still be the better tool. So when you're stuck, ask the other question: should the size of this thing come from what's inside it, or from the layout around it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Tags: the content decides, so flexbox wins
&lt;/h2&gt;

&lt;p&gt;A list of tags under an article. The labels have different lengths, and you want each pill to be exactly as wide as its word. Same HTML below, two layouts. It starts on flexbox. Then click Grid and watch what happens.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Tags: same HTML, flexbox or grid. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On flexbox it looks right, and it took very little code. On grid, every tag is forced into a column of the same width. The short ones float in too much space, "Web performance" gets squeezed onto two lines, which makes its whole row taller, and "Authentication" doesn't even fit inside its pill. Nothing is broken, it's just the wrong tool. You've got words of different lengths and you're putting them in an egg carton.&lt;/p&gt;

&lt;p&gt;Now the code, line by line. Open the CSS tab if you want to follow along.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;display: flex on the list makes its children sit next to each other in a row. That's the only line that really changes the layout mode.&lt;/li&gt;
&lt;li&gt;flex-wrap: wrap allows a new line when the row is full. Take it away and the browser tries to keep everything on one line: the tags shrink as far as their text allows, and after that they stick out of the container.&lt;/li&gt;
&lt;li&gt;gap: 8px puts space between items. It's there instead of margins, because a margin on each tag would also add space you don't want at the start or the end of a line.&lt;/li&gt;
&lt;li&gt;Notice what's missing: no width anywhere. Each tag is as wide as its text plus its padding. That's the content deciding the size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JavaScript does nothing clever. It swaps the class name on the list, and that's what makes this a fair comparison: same HTML, only the CSS rule changes. The grid line will make sense in the next example, so don't worry about it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cards: the layout decides, so grid wins
&lt;/h2&gt;

&lt;p&gt;Now the opposite. A team page with seven people on it. You want a neat wall of cards, and you want it to adapt to the screen. Same two buttons, same HTML. This time look at the last row.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Cards: same HTML, flexbox or grid. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;See it? On flexbox the last row doesn't match the rows above it. With a frame around 700 pixels wide, the top cards are 158 pixels and the bottom three stretch to 215. On a phone-sized frame the lonely last card takes the full width, double the others. Click Grid and every card is the same size, with an empty space where the missing cards would go. That's the egg carton doing its job.&lt;/p&gt;

&lt;p&gt;So why does flexbox do this? The line flex: 1 1 140px is three numbers in one. The first means "you may grow if there's free space". The second means "you may shrink if there isn't". The third is the starting size. Each line on the bench shares out its own free space, and it has no idea what the line above it did. So a line with three cards gives each card more room than a line with four. Flexbox is doing exactly what you asked. You just asked the wrong thing.&lt;/p&gt;

&lt;p&gt;And here's the grid line that fixes it, piece by piece, because this one line is worth knowing by heart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;grid-template-columns says what the columns are. The cards don't get a vote.&lt;/li&gt;
&lt;li&gt;minmax(140px, 1fr) means each column is at least 140 pixels and can grow to one equal share of the leftover space. The fr unit is exactly that, a share.&lt;/li&gt;
&lt;li&gt;repeat(auto-fill, ...) means make as many of those columns as fit in the available width. Wide screen, more columns. Narrow screen, fewer columns.&lt;/li&gt;
&lt;li&gt;Notice what you didn't write: no media query. The layout adapts on its own, because you described the rule and not the result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'll also see auto-fit in the same spot, and the difference is small but real. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/repeat" rel="noopener noreferrer"&gt;Both make as many columns as fit, but auto-fit then collapses the empty ones&lt;/a&gt;. So if you only have two cards in that same 700-pixel frame, auto-fill keeps them at 158 pixels and leaves the empty columns standing, while auto-fit lets those two cards stretch to 328 pixels each and fill the row. Pick auto-fill when you want cards to stay the same size no matter how many there are. Pick auto-fit when you want a few items to fill the space.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Can you fix the last row in flexbox? Sort of. You can remove the growing with flex: 0 1 140px, but then the rows stop filling the container. Or you can calculate exact widths with calc() for every screen size. The moment you're doing that, you're rebuilding grid by hand, badly. That's your signal to switch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A navigation bar is where flexbox feels at home
&lt;/h2&gt;

&lt;p&gt;A logo, a couple of links, and a login button pushed to the far right. Every site has one. The links should be as wide as their words, and you don't want to count columns. So: flexbox.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; A navigation bar with flexbox. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most of this you've seen. Here's what's new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;align-items: center lines everything up on the middle of the row. Without it, items stretch to the height of the row and their text sits at the top, so a bigger logo and smaller links don't line up.&lt;/li&gt;
&lt;li&gt;margin-left: auto on the login link is the trick of the whole example. An auto margin takes all the free space on that side. So the free space goes between About and Log in, and the button lands on the far right.&lt;/li&gt;
&lt;li&gt;flex-wrap: wrap is the Plan B. On a very narrow screen the button drops to a new line instead of pushing out of the page, and it stays on the right because the auto margin still works there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So that's all it is. One auto margin. A lot of people reach for justify-content: space-between here, and it looks fine with two items. Add two more links and it spreads all of them evenly across the bar, which isn't what you wanted. The auto margin says exactly what you mean: this one item goes to the far end.&lt;/p&gt;

&lt;p&gt;Could you do this with grid? Yes. Something like grid-template-columns: auto auto auto 1fr, with the button aligned to the end of the last column. It works, right until someone adds a fourth link and the button lands in the wrong column, because you counted the items in your CSS. When the number of things can change, and each one sizes itself, you want the bench.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real components use both: grid outside, flexbox inside
&lt;/h2&gt;

&lt;p&gt;Here's the reason "flexbox or grid" is a slightly wrong question: real components use both, each at its own level. Look at three cards with different amounts of text. The links at the bottom start out uneven, which you've seen on plenty of websites. Click the button.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Grid outside, flexbox inside. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two things are happening, on two levels.&lt;/p&gt;

&lt;p&gt;The outer level is grid. The cards container uses repeat(auto-fit, minmax(170px, 1fr)), which you already know. There's a quiet bonus here: items in the same grid row stretch to the same height by default. That's why all three cards are the same height even though the text isn't. You didn't write any height at all.&lt;/p&gt;

&lt;p&gt;The inner level is flexbox. Each card has display: flex and flex-direction: column, which stacks its title, text and link top to bottom. Now each card is a small column of things with free space at the bottom. And the same auto margin trick from the navbar works vertically: margin-top: auto on the link eats all the free space above it, and the link drops to the bottom of the card. One line.&lt;/p&gt;

&lt;p&gt;If you're on a phone, the cards stack in one column, each only as tall as its own text. There's no free space and nothing to line up, so the button won't change anything you can see. That's correct. Layouts should behave differently when the job is different. On a bigger screen it's the difference between a page that looks finished and one that doesn't.&lt;/p&gt;

&lt;p&gt;So when you look at a real design, stop asking which of the two to use for the page. Go level by level. The wall of cards: who decides the size? The layout, so grid. Inside a card: the content is stacked and sized by what it holds, so flexbox. Every level gets its own answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whole pages: draw the layout, then let it change
&lt;/h2&gt;

&lt;p&gt;A page with a header, main content, a sidebar and a footer is the most layout-decides thing there is, and grid has a feature that reads almost like a drawing. Drag the slider to pretend the screen is getting smaller.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; A page layout with grid areas (drag the slider). It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read grid-template-areas in the CSS tab like a little picture of the page. Each quoted line is a row, each word is a column. Every part of the page then claims its name: grid-area: header, grid-area: main and so on. Want the sidebar somewhere else? You change the picture, and you don't touch the HTML. If you remove the grid-area lines, the picture stops meaning anything and the items just fill the grid in order, so those lines aren't optional.&lt;/p&gt;

&lt;p&gt;The slider is there so you can see the change in this small frame, and it's why the demo uses a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Containment/Container_queries" rel="noopener noreferrer"&gt;container query&lt;/a&gt;: the @container rule looks at the width of the box around it, instead of the whole browser window. That's what container-type: inline-size on the outer box is for. Remove it and the rule has nothing to measure, so the page stays stacked. On a real page you'd usually write the same thing with &lt;a class="mentioned-user" href="https://dev.to/media"&gt;@media&lt;/a&gt; (min-width: 768px), and the inside doesn't change at all. The rule is the same either way. Start with the phone layout, one column, then add the bigger layout when there's room for it.&lt;/p&gt;

&lt;p&gt;If you're just starting out, that's plenty for page layouts. If you want to place items by exact rows and columns, or build something like a magazine front page, &lt;a href="https://codefromscratch.org/blog/css-grid-layout-the-complete-visual-guide" rel="noopener noreferrer"&gt;the CSS Grid guide&lt;/a&gt; goes much further. And if the words header, main and aside are new to you, read &lt;a href="https://codefromscratch.org/blog/html-css-beginners-build-first-web-page" rel="noopener noreferrer"&gt;the HTML and CSS beginners article&lt;/a&gt; first, because layout sits on top of good HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that makes people hate both: content that won't shrink
&lt;/h2&gt;

&lt;p&gt;This one gets everyone sooner or later. You've got a sidebar and a main area, you've written the layout correctly, and then a long web address or a code block shows up and the whole page gets a sideways scrollbar. You stare at it. Your CSS says the main area takes the remaining space, so why is it wider than the screen? Here it is, both versions, on purpose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; The overflow bug and its one-line fix. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The main area burst straight through the dashed frame. Click the button and the long line gets its own little scrollbar, which is what you wanted in the first place.&lt;/p&gt;

&lt;p&gt;Why does it happen? It's the same reason in both tools. By default, a flex or grid item refuses to get smaller than its content. The browser calls this the automatic minimum size, and it's in both specs: &lt;a href="https://www.w3.org/TR/css-flexbox-1/#min-size-auto" rel="noopener noreferrer"&gt;flexbox&lt;/a&gt; and &lt;a href="https://www.w3.org/TR/css-grid-1/#min-size-auto" rel="noopener noreferrer"&gt;grid&lt;/a&gt;. The default exists for a good reason, because it stops your text from being crushed into something unreadable. But a long line with no spaces can't wrap, so its minimum width is its entire length, and the item grows to fit it. It doesn't matter that you wrote overflow-x: auto on the inner pre element. That element never gets squeezed, because its parent keeps growing to hold it.&lt;/p&gt;

&lt;p&gt;And 1fr doesn't save you, even though it sounds like it should. Written alone, 1fr secretly means minmax(auto, 1fr), and that auto is the same "not smaller than my content" rule.&lt;/p&gt;

&lt;p&gt;So the fix is to give the item permission to be small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In grid: grid-template-columns: 80px minmax(0, 1fr). The zero replaces the automatic minimum.&lt;/li&gt;
&lt;li&gt;In flexbox: min-width: 0 on the item that needs to shrink. It's the same idea in a different place.&lt;/li&gt;
&lt;li&gt;Either way, something inside still has to handle the overflow, and in this demo that's overflow-x: auto on the pre. Without it, the line just sticks out of its now-smaller box.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Advanced: this bug nests. If a flex item sits inside another flex item, every level between the long content and the top of the layout can need its own min-width: 0, because one level with the automatic minimum still grows. When the fix seems not to work, select the elements in your browser's dev tools going upward from the content, and find the first one wider than you expect. That's where it goes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keep this one in mind when you test. If a layout has only ever seen your neat sample text, you haven't tested it. Put in a long word, a long email address, or a long code line, and see what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  order and reversing look fine and quietly break keyboards
&lt;/h2&gt;

&lt;p&gt;Both tools let you show things in a different order than the HTML. It's tempting, because it's so easy. Click into the frame below and press the Tab key a few times.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; Press Tab through these links. It runs right in the page, so &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;try it in the original article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The red outline jumps from right to left, backwards through what you see, because the keyboard follows the HTML and not the screen. The order property only moves the picture. A screen reader will read the HTML order too. The grid spec is direct about this: &lt;a href="https://www.w3.org/TR/css-grid-1/#order-accessibility" rel="noopener noreferrer"&gt;use these features only for visual reordering, never for logical reordering of content&lt;/a&gt;, and &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/order" rel="noopener noreferrer"&gt;MDN warns about the same disconnect&lt;/a&gt;. WCAG has success criteria for this, on &lt;a href="https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html" rel="noopener noreferrer"&gt;focus order&lt;/a&gt; and on &lt;a href="https://www.w3.org/WAI/WCAG21/Understanding/meaningful-sequence.html" rel="noopener noreferrer"&gt;meaningful sequence&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Grid placement does the same thing, by the way, so a pretty grid-template-areas picture can scramble the reading order too. Look back at the page layout above: the main content comes before the sidebar in the HTML and on the screen, in both layouts, so the order is honest on purpose.&lt;/p&gt;

&lt;p&gt;The Plan B is simple. If the order matters for understanding, or if there's something you can focus on in there, change the HTML and don't fake it in CSS. Use order for things like which decorative tile sits where, where the order carries no meaning. Then test it: put your mouse away and walk through the page with the Tab key. It takes less than a minute and you'll find things.&lt;/p&gt;

&lt;h2&gt;
  
  
  For the advanced readers: subgrid, and the question I can't answer
&lt;/h2&gt;

&lt;p&gt;Remember the three cards? We lined up their links with an auto margin. But what if the titles have different lengths too, and you want the text in all three cards to start on the same line? The inner parts of separate cards don't know about each other. That's what &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Subgrid" rel="noopener noreferrer"&gt;subgrid&lt;/a&gt; is for: a nested grid takes its tracks from its parent instead of making its own. MDN shows it as widely available across browsers since September 2023.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cards.css&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.cards&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;repeat&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="mi"&gt;1fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;grid-row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subgrid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8px&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;ul&gt;
&lt;li&gt;grid-row: span 3 makes each card occupy three rows of the outer grid, one each for title, text and link. It needs to match how many children the card really has. Add a fourth and it falls into an extra row you didn't plan for.&lt;/li&gt;
&lt;li&gt;grid-template-rows: subgrid is the key line. The card stops creating its own rows and uses the parent's three. Now the tallest title in that row sets the height for all the titles.&lt;/li&gt;
&lt;li&gt;display: grid on the card is required, because only a grid can have a subgrid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With one short title and one long one, all three titles get the same row height, and all the text and all the links start on the same line. The auto margin from before only solves the last item. This solves every item.&lt;/p&gt;

&lt;p&gt;Now the question people always ask: which one is faster? I don't have a number I trust, so I won't give you one. I wouldn't pick between them for performance on an ordinary page. Pick the one that says what you mean, because the real cost shows up later, in the person who has to change this layout in six months. That's often you. A grid with a clear picture in it is easy to change. A pile of calc() widths nobody wants to touch isn't. Thinking about the next change, not just today's screen, is the same habit behind &lt;a href="https://codefromscratch.org/blog/production-grade-code-checklist" rel="noopener noreferrer"&gt;the production-grade code checklist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you use Tailwind, nothing here changes
&lt;/h2&gt;

&lt;p&gt;Tailwind doesn't give you a third layout system. Those classes are the same CSS with shorter names, so the question stays the same. Here's how the three patterns you just saw look in a React component:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;components/layouts.tsx&lt;/code&gt;&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;Tags&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ul&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"flex flex-wrap gap-2"&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;li&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="no"&gt;CSS&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;li&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;li&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Accessibility&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;li&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;ul&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;;&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="nc"&gt;Cards&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ul&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-4"&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;li&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Emily&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;li&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;li&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;James&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;li&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;ul&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;;&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="nc"&gt;Navbar&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;nav&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"flex flex-wrap items-center gap-4"&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;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Acme&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&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;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/login"&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ml-auto"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Log&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&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;nav&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;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;flex flex-wrap gap-2 is the tag row: display: flex, wrapping allowed, a small gap.&lt;/li&gt;
&lt;li&gt;grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] is the card wall, written as one arbitrary value. There are no spaces inside the brackets, because a space would make it two separate classes and it would silently do nothing.&lt;/li&gt;
&lt;li&gt;ml-auto is the same margin-left: auto trick. The min-w-0 class is your fix for the overflow bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're setting Tailwind up, &lt;a href="https://codefromscratch.org/blog/tailwind-css-v4-what-s-new-and-how-to-migrate" rel="noopener noreferrer"&gt;the Tailwind v4 article&lt;/a&gt; walks through it. If you don't know the underlying CSS, learn that first. Otherwise you'll be typing class names for things you can't debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  When AI writes your layout, this is how you check it
&lt;/h2&gt;

&lt;p&gt;I use AI for a lot of my work, and I check what it writes as far as I can, because it makes mistakes without meaning to. Layout is a good example of why you still need the concept. AI will happily give you something that looks right in the one size you tried. It has no idea what's in your head. When the result isn't what I imagined, it's usually because I could have asked better, and now you know what asking better sounds like: "A wall of cards, minimum 14rem each, using grid with auto-fill. Inside each card, a flex column with the link pushed to the bottom. No fixed widths." That's a real instruction, and it only exists because you understand the two tools.&lt;/p&gt;

&lt;p&gt;Then don't trust it, test it. This is the routine, and it works on layouts you wrote yourself too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask who decides the size, and see if the code agrees. Flexbox with calculated percentage widths on a card wall is the first thing to question.&lt;/li&gt;
&lt;li&gt;Add a card and remove a card. Does the last row still look intended?&lt;/li&gt;
&lt;li&gt;Paste in a long word or a long web address. Does the page get a sideways scrollbar?&lt;/li&gt;
&lt;li&gt;Shrink the window down to a phone width, about 320 pixels. Does anything stick out?&lt;/li&gt;
&lt;li&gt;Put the mouse away and press Tab. Does the focus move in the order you read?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five small checks, and they catch every bug in this article. If a layout is important enough to protect, ordinary unit tests won't do it, because they don't really draw the page. You need a test that runs in a real browser, and &lt;a href="https://codefromscratch.org/blog/testing-react-applications-a-complete-strategy" rel="noopener noreferrer"&gt;the React testing article&lt;/a&gt; explains where that kind of test is worth its cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule, and what to do next
&lt;/h2&gt;

&lt;p&gt;Here's the whole article as something you can actually use. Go level by level through your design, and for each level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose flexbox when the items should size themselves: tags, buttons, navigation bars, toolbars, a form row, or stacking things inside a card.&lt;/li&gt;
&lt;li&gt;Choose grid when the layout should size the items: card walls, galleries, whole page structure, or anything that has to line up in both directions.&lt;/li&gt;
&lt;li&gt;Choose neither when normal page flow already does the job. A heading above a paragraph doesn't need a layout tool. Blocks stack on their own.&lt;/li&gt;
&lt;li&gt;If you're writing calc() widths or counting items in your CSS, you've probably picked the wrong one. Try the other tool before adding more code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now go do this. It takes ten minutes, and it's worth more than another tutorial. Open a website you use every day, right-click a part of it, and choose Inspect. In the dev tools of Chrome, Edge and Firefox, elements using these layouts carry a small flex or grid label next to them in the HTML view. Before you look, guess: flexbox or grid here? Then check. Sometimes you'll be wrong, and sometimes the site's author made a different call. Ask why. That's how you get the judgment that no property list gives you.&lt;/p&gt;

&lt;p&gt;After that, build one thing on your own: a card wall with a link at the bottom of each card, and a navigation bar above it. You've now seen every line you need. If you get stuck, that's normal and it isn't a sign of anything. I learned most of this by trying it and getting it wrong first, and many people working in companies still look this stuff up. Don't give up on it. Then go deeper with &lt;a href="https://codefromscratch.org/blog/css-grid-layout-the-complete-visual-guide" rel="noopener noreferrer"&gt;the CSS Grid guide&lt;/a&gt;, which picks up where this leaves off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article first appeared on &lt;a href="https://codefromscratch.org/blog/css-flexbox-vs-grid-real-layouts" rel="noopener noreferrer"&gt;CodeFromScratch&lt;/a&gt;, where the live demos run right in the page. If you want the next one in your inbox, &lt;a href="https://codefromscratch.org/newsletter" rel="noopener noreferrer"&gt;join the newsletter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>html</category>
      <category>design</category>
    </item>
  </channel>
</rss>
