<?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: Kunj Hirapara</title>
    <description>The latest articles on DEV Community by Kunj Hirapara (@kunjhirapara).</description>
    <link>https://dev.to/kunjhirapara</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%2F4212759%2F391848ef-3806-4929-b7c4-89698fa9b3af.jpg</url>
      <title>DEV Community: Kunj Hirapara</title>
      <link>https://dev.to/kunjhirapara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunjhirapara"/>
    <language>en</language>
    <item>
      <title>Next.js 16, one year on: what broke when I upgraded a real site</title>
      <dc:creator>Kunj Hirapara</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:18:32 +0000</pubDate>
      <link>https://dev.to/kunjhirapara/nextjs-16-one-year-on-what-broke-when-i-upgraded-a-real-site-428a</link>
      <guid>https://dev.to/kunjhirapara/nextjs-16-one-year-on-what-broke-when-i-upgraded-a-real-site-428a</guid>
      <description>&lt;p&gt;Next.js 16 came out in October 2025. A year later it is the version most new projects start on, and the version a lot of working sites still have not moved to. This site moved this month. It is a real app, not a starter: the App Router, a Postgres database through TypeORM, a credentials-based admin panel, and a Docker image that runs on a small VPS.&lt;/p&gt;

&lt;p&gt;The official upgrade guide is good and the codemod does most of the typing. What the guide cannot tell you is which of its changes will hit your particular stack. Two problems broke this site's production build in ways a dev server never showed. This is what broke, why, and what I would check before you upgrade yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turbopack builds your app differently
&lt;/h2&gt;

&lt;p&gt;The headline change is that &lt;code&gt;next build&lt;/code&gt; uses Turbopack by default. Development already did; now production does too. For most code that is invisible. For code that relies on two routes sharing the same instance of a module, it is not.&lt;/p&gt;

&lt;p&gt;After the upgrade, the production build served every public page fine, and admin sign-in failed with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No metadata for "Admin" was found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause is how the database connection and the bundles meet. The TypeORM &lt;code&gt;DataSource&lt;/code&gt; is created once and shared through &lt;code&gt;globalThis&lt;/code&gt;, so whichever route connects first registers its entity classes. Turbopack gives each route its own copy of those classes. In production the first request is usually a public page, so the connection held the public page's &lt;code&gt;Admin&lt;/code&gt; class, and the auth route asked for a repository with its own, different &lt;code&gt;Admin&lt;/code&gt; class. TypeORM compares classes by identity, so as far as it was concerned that entity had never been registered.&lt;/p&gt;

&lt;p&gt;webpack, the builder Next.js 15 used for production, shared one module instance across routes, which is why the same code had worked for months.&lt;/p&gt;

&lt;p&gt;The fix is one line per lookup: ask for the repository by name instead of by class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Breaks when the route that connected first holds a different copy of the class&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Admin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Works from any route: TypeORM matches the entity by its name&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Admin&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public loaders had already been switched to name lookups months earlier, after the same error showed up in development. The auth route was the one place still passing a class, and nothing exercised it until the production build did.&lt;/p&gt;

&lt;p&gt;The lesson is broader than TypeORM. If anything in your app keeps state in a module-level singleton and compares classes or object identity across routes, test it on the production build. &lt;code&gt;next dev&lt;/code&gt; will not show you this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker image lost its database driver
&lt;/h2&gt;

&lt;p&gt;This one was not strictly Next.js. It came from upgrading TypeORM to 1.x in the same round, and it only surfaced because of how Next.js packages a standalone build. The two together are worth knowing about.&lt;/p&gt;

&lt;p&gt;The site ships as &lt;code&gt;output: 'standalone'&lt;/code&gt;. Next.js traces which files the server actually imports and copies only those into &lt;code&gt;.next/standalone&lt;/code&gt;, which keeps the Docker image small. TypeORM 1.x loads its database driver with a dynamic &lt;code&gt;require(name)&lt;/code&gt;. Output tracing cannot follow a require whose argument is a variable, so the trace quietly left &lt;code&gt;pg&lt;/code&gt; out. Every page that touched the database threw &lt;code&gt;DriverPackageNotInstalledError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to import the driver yourself and hand it over, which puts it back in the trace:&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="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never reached visitors, because CI builds the Docker image and runs a smoke test against it before anything deploys. It had already earned its place once, after an earlier standalone surprise: images uploaded at runtime into &lt;code&gt;public/&lt;/code&gt; were not served, and the test grew a check for that too. If your deploy is a standalone build and your CI does not boot it, I would add that before upgrading anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Middleware is now called proxy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt; is deprecated in favour of &lt;code&gt;proxy.ts&lt;/code&gt;, and the exported function is renamed to match. The codemod does the rename. One difference matters if you relied on the Edge runtime: &lt;code&gt;proxy&lt;/code&gt; always runs on Node.js, and the guide says to keep &lt;code&gt;middleware&lt;/code&gt; for now if you need Edge. The behaviour this site relies on, guarding &lt;code&gt;/admin&lt;/code&gt; before a request reaches the page, works the same.&lt;/p&gt;

&lt;p&gt;The name is more honest about what the file does: it sits in front of your routes and decides what to do with a request.&lt;/p&gt;

&lt;p&gt;I still check the session again inside every admin page and server action. A guard in front of the routes is a convenience, not the only lock on the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  next lint is gone
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next lint&lt;/code&gt; was removed. You run the ESLint CLI directly, with a flat config. The codemod writes one for you; this site's is core web vitals, TypeScript and Prettier in &lt;code&gt;eslint.config.mjs&lt;/code&gt;, and &lt;code&gt;npm run lint&lt;/code&gt; calls ESLint itself.&lt;/p&gt;

&lt;p&gt;The practical difference is that ESLint now lints whatever its config says, not what Next.js decided to point it at. The first run after the switch walked into tool folders that had never been linted before, and they needed adding to the ignores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small breaking changes
&lt;/h2&gt;

&lt;p&gt;Most of the remaining changes are mechanical, and the codemod handles them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;params&lt;/code&gt;, &lt;code&gt;searchParams&lt;/code&gt;, &lt;code&gt;cookies()&lt;/code&gt; and &lt;code&gt;headers()&lt;/code&gt; are async. Await them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;revalidateTag&lt;/code&gt; takes a second argument, a cache profile such as &lt;code&gt;'max'&lt;/code&gt;. The single-argument form is deprecated and fails type checking.&lt;/li&gt;
&lt;li&gt;The minimum Node.js is 20.9, and the supported browsers start at Safari 16.4 and Chrome 111.&lt;/li&gt;
&lt;li&gt;If you have a custom &lt;code&gt;webpack&lt;/code&gt; config, &lt;code&gt;next build&lt;/code&gt; fails on purpose rather than silently ignoring it. You either migrate it or build with &lt;code&gt;--webpack&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hard. They are just noisy, which is a good reason to land the upgrade on its own and not inside a feature branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The React Compiler is stable, and still a choice
&lt;/h2&gt;

&lt;p&gt;Next.js 16 made built-in support for the React Compiler stable, after the compiler's own 1.0 release. It memoizes components for you, so most hand-written &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt; calls become unnecessary.&lt;/p&gt;

&lt;p&gt;It is off on this site for now. If you turn it on, two things are worth knowing first. The upgrade guide warns that builds and dev compiles get slower, because the compiler runs through Babel. And eslint-plugin-react-hooks 7 ships rules that only make sense with the compiler on; this site has those three switched off, to be switched back on the day the compiler is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check before you upgrade
&lt;/h2&gt;

&lt;p&gt;If you are still on Next.js 15, this is the short list I would work through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test the production build, not dev.&lt;/strong&gt; Run &lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next start&lt;/code&gt;, then click through the parts of the app that only some users reach: sign-in, admin, webhooks, anything behind auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for shared singletons.&lt;/strong&gt; Database connections, caches and clients stored on &lt;code&gt;globalThis&lt;/code&gt; are fine. Comparing classes across routes is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot the standalone output in CI.&lt;/strong&gt; If you deploy &lt;code&gt;.next/standalone&lt;/code&gt;, start it and hit a few routes before you deploy. Dynamic requires are where tracing loses files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade one thing at a time.&lt;/strong&gt; The driver problem came from TypeORM, not Next.js, and it was easy to pin down only because the dependency bumps were separate commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the codemod, then read its diff.&lt;/strong&gt; It renames and rewrites correctly. It cannot know which of your routes depend on what it changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A year on, Next.js 16 is a better default than 15: faster builds, clearer names, and a cache API that says what it does. The upgrade itself is mostly typing. The work is in the few places where your app depended on how the old builder happened to behave, and the production build is the only place that will show you where those are.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on my site, &lt;a href="https://kunjdeveloper.com/blog/nextjs-16-one-year-on" rel="noopener noreferrer"&gt;kunjdeveloper.com&lt;/a&gt;, where I write about building and running full-stack web apps.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My portfolio was blank on every iPhone and BrowserStack said it was fine</title>
      <dc:creator>Kunj Hirapara</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:17:15 +0000</pubDate>
      <link>https://dev.to/kunjhirapara/my-portfolio-was-blank-on-every-iphone-and-browserstack-said-it-was-fine-1jip</link>
      <guid>https://dev.to/kunjhirapara/my-portfolio-was-blank-on-every-iphone-and-browserstack-said-it-was-fine-1jip</guid>
      <description>&lt;p&gt;I told my friends I'd finally pushed my portfolio live. One of them pulled it up on his phone right there on the bench.&lt;/p&gt;

&lt;p&gt;The header loaded. The grid behind the hero loaded. Then nothing. A big empty screen where the rest of my site was supposed to be.&lt;/p&gt;

&lt;p&gt;I got roasted. On a roadside bench, for a site I had announced moments earlier. It was funny. It was significantly funnier for them.&lt;/p&gt;

&lt;p&gt;The phone was an iPhone 13 Pro Max on the latest iOS, and I'm putting that up front because it kills the excuse I would otherwise have reached for. This was not a five-year-old budget phone dying on 2G. It was a flagship, on mobile data, and my site did not render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blaming Safari, then running out of things to blame
&lt;/h2&gt;

&lt;p&gt;First instinct: Safari. Blaming Safari is free and usually at least partly correct.&lt;/p&gt;

&lt;p&gt;So he opened it in Chrome. Same shit.&lt;/p&gt;

&lt;p&gt;That was the clue, and I completely missed it. Every browser on iOS is Safari wearing a different hat. Same WebKit engine underneath, whatever the icon says. "Try it in Chrome" isn't a second test on an iPhone, it's the first test again.&lt;/p&gt;

&lt;p&gt;Then another friend opened it on his Samsung. Perfect. Smooth, exactly like my laptop.&lt;/p&gt;

&lt;p&gt;Every iPhone broken. Every Android fine. My machine fine. Cool. Love that for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  A week of not being able to reproduce it
&lt;/h2&gt;

&lt;p&gt;This was the genuinely miserable part.&lt;/p&gt;

&lt;p&gt;I threw it at BrowserStack. BrowserStack told me the site was fine. Every iOS version I tried, fine, fine, fine.&lt;/p&gt;

&lt;p&gt;Then I borrowed my sister's phone. Broken. My cousins' phones. Broken. Every real iPhone I could physically get my hands on failed the same way, while the one service whose entire job is reproducing this stuff kept insisting I was imagining it.&lt;/p&gt;

&lt;p&gt;I still don't have a clean explanation for that gap. My best guess is that a fresh remote device on a fast connection never sits in the broken state long enough for you to catch it. Whatever the reason, the lesson stuck: when every real phone you can find disagrees with your testing service, believe the phones.&lt;/p&gt;

&lt;p&gt;So I did it the stupid way. Deploy, check on my sister's phone, change something, deploy again. For about a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually cracked it
&lt;/h2&gt;

&lt;p&gt;The next day I downloaded one of those apps that gives you a console on your phone. I genuinely don't remember which one, it was whatever came up first.&lt;/p&gt;

&lt;p&gt;Then it took about ten seconds.&lt;/p&gt;

&lt;p&gt;The content was there. All of it. Every paragraph sitting in the DOM at &lt;code&gt;opacity: 0&lt;/code&gt;, waiting for JavaScript to turn up and give it permission to exist.&lt;/p&gt;

&lt;p&gt;Nothing had failed to load. Nothing had crashed. My site had rendered perfectly and then hidden itself from everybody.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I had done to myself
&lt;/h2&gt;

&lt;p&gt;I wrote both of these on purpose, thinking I was being clever.&lt;/p&gt;

&lt;p&gt;The first was the scroll reveal wrapped around every section:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt;
  &lt;span class="na"&gt;whileInView&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"show"&lt;/span&gt;
  &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;show&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;initial="hidden"&lt;/code&gt; is the killer. It bakes &lt;code&gt;opacity: 0&lt;/code&gt; straight into the server-rendered markup. The element doesn't come back until the bundle has downloaded, parsed, hydrated and hooked up an IntersectionObserver, and every single link in that chain is something that can be slow.&lt;/p&gt;

&lt;p&gt;The second one was dumber. My hero entrance was a GSAP timeline, and to stop everything flashing in its final position before GSAP grabbed it, I'd written the obvious guard:&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;.hero-reveal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;GSAP cleared that on load. So if GSAP didn't run, nothing cleared it, and the hero stayed invisible. Permanently. There's no route back from that state. I had written a line of CSS whose only job was to hide my own content, and then trusted a third-party library to undo it.&lt;/p&gt;

&lt;p&gt;Which explains exactly what my friend saw on that bench. The header and the hero grid were plain HTML and plain CSS, so they painted. Everything else in the page was sitting behind a script that hadn't arrived yet.&lt;/p&gt;

&lt;p&gt;I'd built a site where the decorative background was more reliable than the writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why iOS specifically
&lt;/h2&gt;

&lt;p&gt;Honestly, none of this is iOS-specific, and that's the part worth taking away. Any browser anywhere will show you a blank page if the content is gated behind a script that hasn't run. The only variable is how long the gap lasts.&lt;/p&gt;

&lt;p&gt;On my laptop that gap was short enough that I never once saw it while building the entire site. On that iPhone it was long enough to look like a broken website, and occasionally long enough that people gave up and started roasting me instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that wasn't
&lt;/h2&gt;

&lt;p&gt;My first attempt was &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt;, wired up through JavaScript. Reasonable idea: if someone doesn't want motion, skip the animation and show everything.&lt;/p&gt;

&lt;p&gt;It didn't help at all. The JavaScript still had to load before anything could decide anything, so you got the same blank wait, and then the entire page appeared at once in a single ugly slab.&lt;/p&gt;

&lt;p&gt;I hadn't fixed the problem. I'd moved it. Same gate, different key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the hero: no JavaScript at all
&lt;/h2&gt;

&lt;p&gt;The hero entrance is a staggered fade and rise. There was never a reason for that to be JavaScript. CSS has done this natively for years, so I deleted the timeline, deleted the guard, and wrote it properly:&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="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;hero-fade-up&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no-preference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-badge&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-cta&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-terminal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hero-fade-up&lt;/span&gt; &lt;span class="m"&gt;0.6s&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.hero-badge&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-terminal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-sub&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500ms&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;Two things in there matter more than they look.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;both&lt;/code&gt; as the fill mode. Without it you can't stagger with delays, because each element sits at its natural opacity until its delay runs out and then snaps into place. &lt;code&gt;both&lt;/code&gt; tells the browser to apply the &lt;code&gt;from&lt;/code&gt; state during the delay and hold the &lt;code&gt;to&lt;/code&gt; state afterwards. The useful side effect is that the hidden state now lives inside the animation instead of in a rule of its own. Take the animation away and the element is just visible.&lt;/p&gt;

&lt;p&gt;Then the &lt;code&gt;no-preference&lt;/code&gt; wrapper, which is the direct fix for my failed attempt. Instead of animating by default and switching it off under &lt;code&gt;reduce&lt;/code&gt;, the animation only exists inside &lt;code&gt;no-preference&lt;/code&gt;. The plain stylesheet now describes a page that's already finished and static, and motion is the thing being added on top. Reduced motion stopped being something JavaScript had to decide and became the default I opt out of.&lt;/p&gt;

&lt;p&gt;Visually it's the same entrance. Same easing, same delays. It just happens at first paint now, before the browser has even asked for the JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scroll reveals, which do need JavaScript
&lt;/h2&gt;

&lt;p&gt;These actually depend on scroll position, so I couldn't just delete the JS. What I could change was which direction the logic ran. Instead of rendering hidden and letting JavaScript release it, render visible and let JavaScript arm it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useInViewReveal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;once&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useInView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;once&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-80px&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;hydrated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHydrated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setHydrated&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;show&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hydrated&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;inView&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;show&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;animate&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;The &lt;code&gt;hydrated&lt;/code&gt; flag is the whole trick. On the server it's &lt;code&gt;false&lt;/code&gt;, so &lt;code&gt;animate&lt;/code&gt; is &lt;code&gt;'show'&lt;/code&gt;, so the markup that ships is visible. Pair it with &lt;code&gt;initial={false}&lt;/code&gt; and Framer stops writing an initial state of its own:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;animate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's now no moment where JavaScript hasn't run and the content is hidden, because "JavaScript hasn't run" and "visible" are the same state.&lt;/p&gt;

&lt;p&gt;Once it hydrates, anything already on screen stays put, so nothing flashes and nothing re-animates a paragraph you're halfway through reading. Anything off screen drops to hidden and reveals on scroll like before. On a decent connection you cannot tell that any of this changed.&lt;/p&gt;

&lt;p&gt;One thing I got wrong on the first pass: I'd made the staggering container a &lt;code&gt;motion&lt;/code&gt; element too, with its own hidden variant. Doesn't matter how careful the children are if the parent can still hide them. &lt;code&gt;RevealGroup&lt;/code&gt; is a plain &lt;code&gt;div&lt;/code&gt; now that hands each child an incremental &lt;code&gt;delay&lt;/code&gt; and has no opinion about visibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else went, once I started looking
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lenis is lazy-loaded and skipped completely on touch devices. Native iOS scrolling beats anything I'd bolt on top, and not running a permanent &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop during hydration helps exactly the phones that need help.&lt;/li&gt;
&lt;li&gt;The stacked project cards went back to &lt;code&gt;position: sticky&lt;/code&gt; and became a server component. One less scroll library, and under reduced motion it collapses to a normal vertical stack for free.&lt;/li&gt;
&lt;li&gt;The timeline progress rail is a small throttled scroll handler instead of a plugin.&lt;/li&gt;
&lt;li&gt;The skills marquee waits for idle before mounting rather than shipping dozens of inline SVG paths in the initial HTML. Its height is reserved in CSS so nothing jumps when it arrives.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;overflow-x: clip&lt;/code&gt; on &lt;code&gt;body&lt;/code&gt; went back to &lt;code&gt;hidden&lt;/code&gt;. &lt;code&gt;clip&lt;/code&gt; is the better property and I'd rather use it, but Safari didn't ship it until 16, and an &lt;code&gt;overflow&lt;/code&gt; value an older iPhone doesn't understand is not a bug you find quickly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Modern CSS" and "CSS that works on the phone your friend is holding" are not the same list.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to catch this without the bench
&lt;/h2&gt;

&lt;p&gt;None of it is hard to find once you know to look. It's just completely invisible from where I was standing.&lt;/p&gt;

&lt;p&gt;The check that would have saved me a week: turn JavaScript off and reload. DevTools, command palette, Disable JavaScript. If the page still reads top to bottom you're fine. If it's blank, congratulations, you have my bug. Four seconds.&lt;/p&gt;

&lt;p&gt;Then throttle properly, and watch the first two seconds rather than the finished state. The finished state always looks great. That's exactly why nobody catches this.&lt;/p&gt;

&lt;p&gt;Read the SSR HTML instead of the DOM, too. The inspector shows you the page after hydration, which is precisely the version that's lying to you. &lt;code&gt;curl&lt;/code&gt; the URL and grep it for &lt;code&gt;opacity:0&lt;/code&gt;. Anything sitting on real content is a problem waiting for a worse connection.&lt;/p&gt;

&lt;p&gt;And test on a real phone. Not a simulator, not a remote device farm that will cheerfully tell you everything is fine. A real one, in your hand, on cellular.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually stands
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this ended with a perfect Lighthouse score and a lesson learned. It's still not as fast as I want. The entrance is all hand-written CSS now with no motion library involved at all, and I'm still going back and forth on whether to do reduced motion through a proper hook instead of by hand.&lt;/p&gt;

&lt;p&gt;But it renders. Immediately, on every phone I can get hold of, whether or not the JavaScript ever turns up. That's a low bar, and I had somehow built a site that couldn't clear it.&lt;/p&gt;

&lt;p&gt;The thing had been live and broken for about a week before that bench. I have no idea who else opened it in that window. Probably nobody. I've decided to believe it was nobody.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post first appeared on my site, &lt;a href="https://kunjdeveloper.com/blog/blank-on-every-iphone" rel="noopener noreferrer"&gt;kunjdeveloper.com&lt;/a&gt;, where I write about building and running full-stack web apps.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>css</category>
      <category>ios</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
