<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged nextjs</title>
<link>https://coderlegion.com/tag/nextjs</link>
<description>Powered by Question2Answer</description>
<item>
<title>Your Next.js redirect() Silently Does Nothing Because It's Inside a try/catch</title>
<link>https://coderlegion.com/33072/your-next-js-redirect-silently-does-nothing-because-its-inside-a-try-catch</link>
<description>&lt;p&gt;A user fills in your form and clicks &quot;Create post&quot;. The post is saved in the database. And the page shows &quot;Could not create post&quot;.&lt;/p&gt;
&lt;p&gt;They try again. Now there are two posts.&lt;/p&gt;
&lt;p&gt;Nothing is wrong with your database. Your error handling caught something it was never supposed to catch.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-causes-it&quot;&gt;The Code That Causes It&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/actions.ts
'use server';

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  try {
    const post = await db.post.create({
      data: { title: String(formData.get('title')) },
    });

    redirect(`/posts/${post.id}`);
  } catch (err) {
    return { error: 'Could not create post' };
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It reads perfectly. Save the post, go to its page, and if anything fails, return an error. A tidy, defensive pattern that you have probably written a hundred times in other frameworks.&lt;/p&gt;
&lt;h3 id=&quot;why-it-breaks&quot;&gt;Why It Breaks&lt;/h3&gt;
&lt;p&gt;In Next.js, &lt;code&gt;redirect()&lt;/code&gt; does not return. It &lt;strong&gt;throws a special error&lt;/strong&gt;, and the framework catches that error higher up and turns it into a redirect response.&lt;/p&gt;
&lt;p&gt;That means your &lt;code&gt;catch&lt;/code&gt; block receives it first. Your code swallows the redirect, returns &lt;code&gt;{ error: ... }&lt;/code&gt;, and the framework never sees it.&lt;/p&gt;
&lt;p&gt;The same is true for &lt;code&gt;notFound()&lt;/code&gt;, which also works by throwing. Both are written to stop execution immediately, which is why TypeScript treats them as functions that never return.&lt;/p&gt;
&lt;h3 id=&quot;why-it-is-hard-to-catch&quot;&gt;Why It Is Hard to Catch&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The data is saved.&lt;/strong&gt; The only visible symptom is a wrong error message, so it looks like a UI bug, not a logic bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is inconsistent in your head.&lt;/strong&gt; The same &lt;code&gt;redirect()&lt;/code&gt; works fine outside a &lt;code&gt;try&lt;/code&gt;, so you assume it is fine everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your logs are confusing.&lt;/strong&gt; If you log the caught error, you will see a strange error with a &lt;code&gt;NEXT_REDIRECT&lt;/code&gt; digest and think something crashed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It tempts you to retry.&lt;/strong&gt; Users see an error, submit again and create duplicates.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;fix-1-keep-redirect-outside-the-try-block&quot;&gt;Fix 1: Keep redirect() Outside the try Block&lt;/h3&gt;
&lt;p&gt;Only wrap the code that can genuinely fail, and redirect afterwards:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function createPost(formData: FormData) {
  let postId: string;

  try {
    const post = await db.post.create({
      data: { title: String(formData.get('title')) },
    });
    postId = post.id;
  } catch (err) {
    return { error: 'Could not create post' };
  }

  redirect(`/posts/${postId}`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;try&lt;/code&gt; now covers only the database call. If it fails, you return the error. If it succeeds, execution continues to &lt;code&gt;redirect()&lt;/code&gt;, which is outside any &lt;code&gt;catch&lt;/code&gt;, so it can do its job.&lt;/p&gt;
&lt;p&gt;This is the cleanest option, and it is the one I use by default.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-rethrow-if-you-must-catch-everything&quot;&gt;Fix 2: Rethrow If You Must Catch Everything&lt;/h3&gt;
&lt;p&gt;Sometimes the &lt;code&gt;redirect()&lt;/code&gt; sits deep inside a helper, and restructuring is awkward. Then let the framework's own errors pass through your &lt;code&gt;catch&lt;/code&gt;. Newer Next.js versions provide a helper for exactly this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { redirect, unstable_rethrow } from 'next/navigation';

try {
  // ...work that may call redirect() or notFound() somewhere inside
} catch (err) {
  unstable_rethrow(err); // lets redirect/notFound errors continue upward
  return { error: 'Something went wrong' };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The name says it is not final, so check the documentation for the version you use. If it is not available in yours, structure your code as in Fix 1.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-do-not-use-catch-all-to-control-flow&quot;&gt;Fix 3: Do Not Use Catch-All to Control Flow&lt;/h3&gt;
&lt;p&gt;A broader habit helps here. A &lt;code&gt;catch (err)&lt;/code&gt; with no check is a very wide net. Catch what you expect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;catch (err) {
  if (err instanceof DuplicateTitleError) {
    return { error: 'You already have a post with that title' };
  }
  throw err; // anything else is not yours to handle here
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rethrowing what you do not recognize means the framework's own signals, real crashes and bugs all reach the place that is meant to handle them.&lt;/p&gt;
&lt;h3 id=&quot;where-else-this-bites&quot;&gt;Where Else This Bites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;notFound()&lt;/code&gt; inside &lt;code&gt;try&lt;/code&gt;.&lt;/strong&gt; A missing record becomes your generic error instead of a 404 page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Route handlers and Server Components.&lt;/strong&gt; The same rule applies: any &lt;code&gt;try/catch&lt;/code&gt; around a call that can redirect or 404 will swallow it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.catch()&lt;/code&gt; on a promise.&lt;/strong&gt; &lt;code&gt;somePromise.catch(() =&amp;gt; null)&lt;/code&gt; has the same effect if the promise's code calls &lt;code&gt;redirect()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrapper functions.&lt;/strong&gt; A helper like &lt;code&gt;withErrorHandling(fn)&lt;/code&gt; that catches everything will break redirects in every function it wraps.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;quick-checklist&quot;&gt;Quick Checklist&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;What to do&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;redirect()&lt;/code&gt; after a database write &lt;/td&gt;&lt;td&gt;Put the write in &lt;code&gt;try&lt;/code&gt;, &lt;code&gt;redirect()&lt;/code&gt; after it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;notFound()&lt;/code&gt; when a record is missing &lt;/td&gt;&lt;td&gt;Call it outside the &lt;code&gt;try&lt;/code&gt;, or rethrow&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A shared error-handling wrapper &lt;/td&gt;&lt;td&gt;Make it rethrow framework errors&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unsure &lt;/td&gt;&lt;td&gt;Catch specific error types, rethrow the rest&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-rule&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;redirect()&lt;/code&gt; and &lt;code&gt;notFound()&lt;/code&gt; signal by throwing, so a &lt;code&gt;catch&lt;/code&gt; around them will eat the signal.&lt;/strong&gt; Keep them outside &lt;code&gt;try&lt;/code&gt; blocks, or rethrow what you do not recognize.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;redirect(&lt;/code&gt; and &lt;code&gt;notFound(&lt;/code&gt;. For each hit, look upward. If it sits inside a &lt;code&gt;try&lt;/code&gt;, you have either a bug now or one waiting for the day the code path is used.&lt;/p&gt;
&lt;p&gt;If you want a Next.js starting point where forms and actions are set up sensibly from day one, you can browse my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you ever shipped a form that showed an error after it had succeeded? Tell me in the comments how long it took you to find it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33072/your-next-js-redirect-silently-does-nothing-because-its-inside-a-try-catch</guid>
<pubDate>Fri, 30 Oct 2026 10:08:42 +0000</pubDate>
</item>
<item>
<title>You Deleted the .env File From Git. Your Secrets Are Still Public.</title>
<link>https://coderlegion.com/32783/you-deleted-the-env-file-from-git-your-secrets-are-still-public</link>
<description>&lt;p&gt;You notice it during a code review: &lt;code&gt;.env&lt;/code&gt; is in the repository. Your stomach drops. You run &lt;code&gt;git rm .env&lt;/code&gt;, commit with the message &quot;remove env file&quot;, push, and feel the relief of a problem solved.&lt;/p&gt;
&lt;p&gt;It is not solved. The file is gone from your latest commit. It is sitting, in full, in every commit before that one, and anyone who can read the repository can read it.&lt;/p&gt;
&lt;h3 id=&quot;why-deleting-doesn-t-delete&quot;&gt;Why Deleting Doesn't Delete&lt;/h3&gt;
&lt;p&gt;Git is a history book. A new commit adds a page saying &quot;the file no longer exists&quot;. It does not tear out the old pages.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git log --all --full-history -- .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that prints any commits, your file is in history. And anyone can go back to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git show &amp;lt;old-commit&amp;gt;:.env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That works for a public repository on GitHub, for a coworker's clone, for a fork, and for any tool that scans old commits. Automated bots scan public repositories for exactly this, so a leaked key can be found quickly.&lt;/p&gt;
&lt;h3 id=&quot;step-1-rotate-everything-first&quot;&gt;Step 1: Rotate Everything First&lt;/h3&gt;
&lt;p&gt;This is the part people skip, and it is the only part that actually matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat every secret that was ever in that file as compromised, and replace it:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Database passwords and connection strings&lt;/li&gt;
&lt;li&gt;API keys (payment, email, storage, maps, AI)&lt;/li&gt;
&lt;li&gt;Auth secrets such as &lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt; or JWT signing keys&lt;/li&gt;
&lt;li&gt;OAuth client secrets&lt;/li&gt;
&lt;li&gt;Webhook signing secrets&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Create the new value, update your hosting environment, deploy, and then revoke the old one. Cleaning the history comes after, because cleaning cannot take back what someone already copied.&lt;/p&gt;
&lt;p&gt;Rotating a signing secret logs users out, which is the right outcome. It means the old secret can no longer be used to forge sessions.&lt;/p&gt;
&lt;h3 id=&quot;step-2-find-out-what-else-was-committed&quot;&gt;Step 2: Find Out What Else Was Committed&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; is not the only way secrets end up in Git. Search the whole history for the values themselves:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git log -p --all -S'sk_live' | head -100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-S&lt;/code&gt; finds commits where a string was added or removed. Try the prefixes of keys you use. A dedicated scanner is faster and more thorough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gitleaks detect --source . --log-opts=&quot;--all&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It checks every commit for patterns that look like keys and tokens, and prints where it found them.&lt;/p&gt;
&lt;h3 id=&quot;step-3-remove-it-from-history&quot;&gt;Step 3: Remove It From History&lt;/h3&gt;
&lt;p&gt;Once the secrets are rotated, you can clean the history so the old file stops being a trap. The current recommended tool is &lt;code&gt;git filter-repo&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git filter-repo --path .env --invert-paths
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This rewrites every commit as if &lt;code&gt;.env&lt;/code&gt; had never been added. Because it changes commit IDs, you then force push:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git push --force --all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Be honest about the limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Everyone else must re-clone&lt;/strong&gt; or reset, or they will push the old history back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forks and existing clones keep the old data.&lt;/strong&gt; You cannot reach into them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hosting platforms may keep cached copies&lt;/strong&gt; of old commits for a while. Check their guidance on removing sensitive data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why step 1 comes first. History rewriting is cleanup, and rotation is the fix.&lt;/p&gt;
&lt;h3 id=&quot;the-leak-that-has-nothing-to-do-with-git&quot;&gt;The Leak That Has Nothing to Do With Git&lt;/h3&gt;
&lt;p&gt;Next.js developers have a second way to leak a secret, and no commit is involved:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any variable that starts with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is &lt;strong&gt;inlined into the JavaScript sent to the browser&lt;/strong&gt; at build time. Anyone can open DevTools, read your bundle and find the value. The prefix means &quot;this is safe to publish&quot;, and it does exactly that.&lt;/p&gt;
&lt;p&gt;Rules that keep you safe:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only values that are &lt;strong&gt;meant to be public&lt;/strong&gt; get the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, such as a public analytics ID or a publishable key.&lt;/li&gt;
&lt;li&gt;Secret keys have &lt;strong&gt;no prefix&lt;/strong&gt; and are read only in Server Components, Route Handlers and Server Actions.&lt;/li&gt;
&lt;li&gt;If a secret ever got that prefix, assume it is exposed and rotate it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;make-it-hard-to-repeat&quot;&gt;Make It Hard to Repeat&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Ignore the file before the first commit.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gitignore&quot;&gt;.env
.env.*
!.env.example
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Commit a &lt;code&gt;.env.example&lt;/code&gt; with the variable names and fake values, so teammates know what to set, and nothing real is ever in the repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scan before commits leave your machine.&lt;/strong&gt; A pre-commit hook with a scanner like gitleaks blocks a commit that contains something key-shaped. Hosting your code on GitHub also gives you secret scanning and push protection for many known key formats. Turn both on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fail loudly when a variable is missing.&lt;/strong&gt; Check required variables at startup, so a missing secret becomes an error message, not a silent fallback to a hardcoded value that someone then commits.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-1&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Once a secret has been in a commit, it is public forever. The fix is rotation, not deletion.&lt;/strong&gt; Rotate first, clean the history second, and add a scanner so the next mistake is caught before it leaves your laptop.&lt;/p&gt;
&lt;p&gt;Quick audit, right now, in your own project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git log --all --full-history -- .env .env.local .env.production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that prints anything, you have found a rotation task.&lt;/p&gt;
&lt;p&gt;If you are starting a new Next.js project and want the structure set up sensibly from day one, you can browse my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Have you ever committed a secret by accident, and how fast did you notice? Tell me in the comments, and no judgment, this happens to nearly everyone once.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32783/you-deleted-the-env-file-from-git-your-secrets-are-still-public</guid>
<pubDate>Wed, 28 Oct 2026 08:06:21 +0000</pubDate>
</item>
<item>
<title>You Upgraded to Next.js 15 and Your Site Got Slower. The Caching Defaults Flipped</title>
<link>https://coderlegion.com/32719/you-upgraded-to-next-js-15-and-your-site-got-slower-the-caching-defaults-flipped</link>
<description>&lt;p&gt;You upgrade from Next.js 14 to 15. The app compiles, the tests pass, nothing visibly breaks. A week later your database bill is higher, your pages feel slower, and your Time to First Byte graph has a step in it that lines up exactly with the deploy.&lt;/p&gt;
&lt;p&gt;Nothing is broken. Something that used to happen for free stopped happening.&lt;/p&gt;
&lt;h3 id=&quot;what-changed&quot;&gt;What Changed&lt;/h3&gt;
&lt;p&gt;In Next.js 14, data fetching was cached &lt;strong&gt;by default&lt;/strong&gt;. A &lt;code&gt;fetch&lt;/code&gt; in a Server Component ran once, the result was stored, and later requests reused it. You had to opt &lt;em&gt;out&lt;/em&gt; of caching.&lt;/p&gt;
&lt;p&gt;In Next.js 15, the default flipped. Per the upgrade notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt; requests are no longer cached by default.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;GET&lt;/code&gt; route handlers are no longer cached by default.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The client-side router cache no longer holds page segments between navigations by default.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So code you wrote once and never touched now behaves differently:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/products/page.tsx
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();
  return &amp;lt;ProductList products={products} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On 14, this fetched the data once and served the page from cache. On 15, &lt;strong&gt;every visitor triggers the API call&lt;/strong&gt;, and because the page now depends on data fetched at request time, the route is rendered dynamically.&lt;/p&gt;
&lt;h3 id=&quot;why-it-is-easy-to-miss&quot;&gt;Why It Is Easy to Miss&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nothing errors.&lt;/strong&gt; The code is valid. It just does more work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dev mode hides it.&lt;/strong&gt; In &lt;code&gt;next dev&lt;/code&gt;, nearly everything is rendered per request anyway.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The slowdown is gradual.&lt;/strong&gt; With low traffic, an extra API call per request is invisible. It shows up when the traffic does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Third-party limits bite later.&lt;/strong&gt; If the API you call has rate limits or per-request billing, you find out from the invoice.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-to-see-it-in-30-seconds&quot;&gt;How to See It in 30 Seconds&lt;/h3&gt;
&lt;p&gt;Run a production build and read the route table at the end:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;next build
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Route (app)
┌ ○ /                 (Static)
├ ƒ /products         (Dynamic)
└ ƒ /blog/[slug]      (Dynamic)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;○&lt;/code&gt; means the page is pre-rendered once at build time. &lt;code&gt;ƒ&lt;/code&gt; means it runs on the server for every request. A page that used to be static and is now &lt;code&gt;ƒ&lt;/code&gt; is exactly what this upgrade can cause. Compare the table before and after the upgrade.&lt;/p&gt;
&lt;h3 id=&quot;fix-1-cache-one-request-on-purpose&quot;&gt;Fix 1: Cache One Request on Purpose&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const res = await fetch('https://api.example.com/products', {
  cache: 'force-cache',
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This restores the old behavior for that single request. Use it for data that does not change per user and rarely changes at all.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-cache-with-an-expiry&quot;&gt;Fix 2: Cache With an Expiry&lt;/h3&gt;
&lt;p&gt;Most data is not permanent, just slow-changing. Revalidate it on a timer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const res = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }, // seconds
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Visitors get the cached copy, and after an hour the next request refreshes it in the background. This is the right default for product lists, blog posts and prices.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-set-it-once-for-the-whole-page&quot;&gt;Fix 3: Set It Once for the Whole Page&lt;/h3&gt;
&lt;p&gt;If several fetches on a page should share the same freshness, declare it at the route level:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/products/page.tsx
export const revalidate = 3600;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything that page renders is treated as cacheable for an hour, without repeating options on each call.&lt;/p&gt;
&lt;h3 id=&quot;fix-4-route-handlers-need-their-own-opt-in&quot;&gt;Fix 4: Route Handlers Need Their Own Opt-In&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;GET&lt;/code&gt; route handler that returns the same data for everyone used to be cached automatically. Now you must say so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/products/route.ts
export const dynamic = 'force-static';

export async function GET() {
  const products = await getProducts();
  return Response.json(products);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without that line, the handler runs on every request.&lt;/p&gt;
&lt;h3 id=&quot;the-part-people-forget-your-database-was-never-covered&quot;&gt;The Part People Forget: Your Database Was Never Covered&lt;/h3&gt;
&lt;p&gt;Here is the subtle thing. The &lt;code&gt;fetch&lt;/code&gt; cache only ever applied to &lt;code&gt;fetch&lt;/code&gt;. If you read from MongoDB or Postgres directly with an ORM, this change did not touch those calls. Depending on whether the page used request data such as cookies, it was either rendered once at build time with the data frozen in, or rendered on every request. Neither is controlled by the fetch cache, so check your build table for those pages too.&lt;/p&gt;
&lt;p&gt;To cache a database call at runtime, wrap it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { unstable_cache } from 'next/cache';

const getProducts = unstable_cache(
  async () =&amp;gt; Product.find().lean(),
  ['products'],
  { revalidate: 3600, tags: ['products'] }
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;tags&lt;/code&gt; option lets you clear that cache on demand with &lt;code&gt;revalidateTag('products')&lt;/code&gt; after an edit. I wrote about why &lt;code&gt;revalidateTag&lt;/code&gt; appears to do nothing when the data is not tagged in the first place. Newer Next.js versions keep moving toward explicit opt-in caching, so check the docs for the version you are on before copying any of this.&lt;/p&gt;
&lt;h3 id=&quot;should-you-cache-everything-again-&quot;&gt;Should You Cache Everything Again?&lt;/h3&gt;
&lt;p&gt;No. The new default exists because stale data surprised people. Show a user their own cart, balance or notifications from a shared cache and you have a bug, or a data leak.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Data &lt;/th&gt;&lt;th&gt;Choice&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Public, rarely changes (marketing pages, docs) &lt;/td&gt;&lt;td&gt;Cache it, long expiry&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Public, changes often (product list, prices) &lt;/td&gt;&lt;td&gt;Cache with a short &lt;code&gt;revalidate&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Per-user (dashboard, cart, account) &lt;/td&gt;&lt;td&gt;Do not cache&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Admin views &lt;/td&gt;&lt;td&gt;Do not cache&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-rule-2&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;When a framework changes a default, your old code changes behavior without a single line changing.&lt;/strong&gt; After any major upgrade, run &lt;code&gt;next build&lt;/code&gt; before and after and compare which routes are static (&lt;code&gt;○&lt;/code&gt;) and which are dynamic (&lt;code&gt;ƒ&lt;/code&gt;). Then opt back in on purpose, for the data that deserves it.&lt;/p&gt;
&lt;p&gt;Slow server responses hurt the metrics Google measures. I break those down in plain language in my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/core-web-vitals-explained&quot;&gt;guide to Core Web Vitals&lt;/a&gt;, and if you want a Next.js starting point, you can browse my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Did a framework upgrade ever quietly change your app's behavior? Tell me what it was in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32719/you-upgraded-to-next-js-15-and-your-site-got-slower-the-caching-defaults-flipped</guid>
<pubDate>Tue, 27 Oct 2026 16:13:23 +0000</pubDate>
</item>
<item>
<title>&quot;'use client' Doesn't Turn Your Whole App Into a Client Component (The Children Trick Nobody Explain</title>
<link>https://coderlegion.com/32265/use-client-doesnt-turn-your-whole-app-into-client-component-children-trick-nobody-explain</link>
<description>&lt;p&gt;You need a theme provider, so you create a file, add &lt;code&gt;'use client'&lt;/code&gt; at the top, and wrap your app in it.&lt;/p&gt;
&lt;p&gt;Then a nagging thought arrives: &quot;I just wrapped my entire layout in a client component. Did I turn the whole app into client-side React?&quot;&lt;/p&gt;
&lt;p&gt;Most developers either panic and avoid providers, or give up and put &lt;code&gt;'use client'&lt;/code&gt; at the top of every file &quot;just to be safe&quot;. Both are the wrong response, and the actual rule is simpler than you expect.&lt;/p&gt;
&lt;h3 id=&quot;the-setup&quot;&gt;The Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/ThemeProvider.tsx
'use client';

import { createContext, useState } from 'react';

export const ThemeContext = createContext('light');

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme] = useState('light');
  return &amp;lt;ThemeContext value={theme}&amp;gt;{children}&amp;lt;/ThemeContext&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx  (a Server Component)
import { ThemeProvider } from '@/components/ThemeProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;ThemeProvider&amp;gt;{children}&amp;lt;/ThemeProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ThemeProvider&lt;/code&gt; is a Client Component. It wraps every page. So are all the pages client components now?&lt;/p&gt;
&lt;p&gt;No.&lt;/p&gt;
&lt;h3 id=&quot;what-use-client-actually-does&quot;&gt;What 'use client' Actually Does&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;'use client'&lt;/code&gt; marks a &lt;strong&gt;boundary&lt;/strong&gt;. It says: &quot;this file, and everything this file &lt;em&gt;imports&lt;/em&gt;, is part of the JavaScript sent to the browser.&quot;&lt;/p&gt;
&lt;p&gt;The key word is &lt;em&gt;imports&lt;/em&gt;. The boundary follows the import graph, not the position in the rendered tree.&lt;/p&gt;
&lt;p&gt;In the layout above, &lt;code&gt;children&lt;/code&gt; is not imported by &lt;code&gt;ThemeProvider&lt;/code&gt;. It is &lt;strong&gt;passed in as a prop&lt;/strong&gt; by the layout, which is a Server Component. The server renders the page first, and hands the finished result to the client component as a slot to fill.&lt;/p&gt;
&lt;p&gt;So your pages stay Server Components. They can still fetch data on the server, use secrets, and ship no JavaScript for their static parts.&lt;/p&gt;
&lt;h3 id=&quot;the-mistake-that-does-change-things&quot;&gt;The Mistake That Does Change Things&lt;/h3&gt;
&lt;p&gt;Now look at this version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/ThemeProvider.tsx
'use client';

import { Dashboard } from './Dashboard'; // imported, not passed in

export function ThemeProvider() {
  // ...
  return &amp;lt;Dashboard /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here the client file &lt;strong&gt;imports&lt;/strong&gt; &lt;code&gt;Dashboard&lt;/code&gt;, so &lt;code&gt;Dashboard&lt;/code&gt; joins the client bundle, along with everything &lt;em&gt;it&lt;/em&gt; imports. If it was a Server Component that queried your database, it is no longer one. It will either break the build or quietly ship heavy code to every visitor.&lt;/p&gt;
&lt;p&gt;The difference between the two examples is a single word: &lt;strong&gt;import&lt;/strong&gt; versus &lt;strong&gt;prop&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-pattern-pass-server-components-through-as-children&quot;&gt;The Pattern: Pass Server Components Through as Children&lt;/h3&gt;
&lt;p&gt;Any time a client component needs to wrap server content, use a slot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/Modal.tsx
'use client';

import { useState } from 'react';

export function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    &amp;lt;&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setOpen(true)}&amp;gt;Show stats&amp;lt;/button&amp;gt;
      {open &amp;amp;&amp;amp; &amp;lt;div role=&quot;dialog&quot;&amp;gt;{children}&amp;lt;/div&amp;gt;}
    &amp;lt;/&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/page.tsx  (Server Component)
import { Modal } from '@/components/Modal';
import { Stats } from '@/components/Stats'; // reads the database

export default function Page() {
  return (
    &amp;lt;Modal&amp;gt;
      &amp;lt;Stats /&amp;gt;
    &amp;lt;/Modal&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Stats&lt;/code&gt; runs on the server, even though it is rendered &lt;em&gt;inside&lt;/em&gt; a client component's JSX. The button and the toggle are interactive in the browser. The data never leaves the server except as finished markup.&lt;/p&gt;
&lt;p&gt;This works for any prop that holds JSX, not only &lt;code&gt;children&lt;/code&gt;. A &lt;code&gt;sidebar&lt;/code&gt; prop or a &lt;code&gt;header&lt;/code&gt; prop behaves the same way.&lt;/p&gt;
&lt;h3 id=&quot;rule-of-thumb-push-the-boundary-down&quot;&gt;Rule of Thumb: Push the Boundary Down&lt;/h3&gt;
&lt;p&gt;Put &lt;code&gt;'use client'&lt;/code&gt; on the smallest piece that needs interactivity, not on the page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Bad: the whole page becomes client code
'use client';
export default function ProductPage() { /* ...200 lines... */ }

// Better: only the button is client code
export default function ProductPage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;ProductDetails /&amp;gt;   {/* server */}
      &amp;lt;AddToCartButton /&amp;gt;  {/* 'use client' lives here */}
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A page that is mostly text and images, with one button, should send JavaScript for the button, not for the page. Less JavaScript sent means a faster load, which is what I walk through in &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/core-web-vitals-explained&quot;&gt;this plain-language guide to Core Web Vitals&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;two-things-that-still-bite&quot;&gt;Two Things That Still Bite&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Props crossing the boundary must be serializable.&lt;/strong&gt; Data goes from server to client as text, so you can pass strings, numbers, plain objects, arrays and dates. You cannot pass a regular function. (Server Actions are the exception, because they are designed to cross.)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Error: functions cannot be passed to Client Components
&amp;lt;ClientButton onClick={() =&amp;gt; console.log('hi')} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Define the handler inside the client component instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. A server-only module can leak into the client by import.&lt;/strong&gt; If a client file imports a helper that touches your database or secrets, the build either fails or bundles code you never meant to ship. The &lt;code&gt;server-only&lt;/code&gt; package turns that into a loud build error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/db.ts
import 'server-only';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Import this file from a client component and the build fails, instead of shipping the connection code.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-3&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;'use client'&lt;/code&gt; marks an import boundary, not a rendering zone. Whatever a client file imports goes to the browser. Whatever you pass to it as props or children can stay on the server.&lt;/strong&gt; Keep the directive on small interactive leaves, and use the children slot for everything else.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;'use client'&lt;/code&gt; and look at each file. If it is a page or a layout, ask whether it really needs state or browser APIs. If it is a wrapper, check that it receives its content as &lt;code&gt;children&lt;/code&gt; and does not import it.&lt;/p&gt;
&lt;p&gt;If you want a Next.js starting point built around the App Router, you can browse my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Where in your project is &lt;code&gt;'use client'&lt;/code&gt; doing more than it needs to? Drop it in the comments, and tell me if the children trick surprised you.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32265/use-client-doesnt-turn-your-whole-app-into-client-component-children-trick-nobody-explain</guid>
<pubDate>Sat, 24 Oct 2026 11:28:48 +0000</pubDate>
</item>
<item>
<title>How to Protect a Next.js App with a WAF Before You Change DNS</title>
<link>https://coderlegion.com/32136/how-to-protect-a-next-js-app-with-a-waf-before-you-change-dns</link>
<description>&lt;p&gt;A Next.js app is still a web application that speaks HTTP. That is the only placement fact you need before you look for a framework plugin. The SafeLine documents used here do not describe a Next.js SDK, App Router hook, or hosting-platform add-on. Treat protection as a reverse-proxy decision, then walk a checklist before you move DNS.&lt;/p&gt;
&lt;p&gt;SafeLine is a self-hosted web application firewall. Its documentation says it filters and monitors HTTP traffic between a web application and the Internet, and that it works as a reverse proxy so clients reach the WAF before they reach the origin server (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). The application guide says SafeLine is developed based on nginx, receives traffic for the original website, cleans malicious attack traffic, and forwards safe traffic to the upstream application (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). For a Next.js deployment, the upstream is the address that actually serves the app, such as a Node process, a container, or another proxy you already operate. Do not add an undocumented Next.js middleware path and treat that path as the firewall.&lt;/p&gt;
&lt;h3 id=&quot;placement-checklist&quot;&gt;Placement checklist&lt;/h3&gt;
&lt;p&gt;Start with exposure. If users already reach the app by a domain over HTTP or HTTPS, you can put SafeLine on that path. In the Web Admin Console, open Applications, choose Add Application, and enter Domain, Port, and Upstream. Domain can be a domain name, hostname, or IP address. Port is the port SafeLine listens on, such as 80 or 443, and HTTPS apps should use the SSL option. Upstream is the real address SafeLine will forward to (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). After those fields are saved, resolve the domain to the IP address of the server where SafeLine runs. Until that DNS change is correct, and until you have written down the previous origin address for rollback, the checklist is unfinished.&lt;/p&gt;
&lt;p&gt;Confirm the host can run the proxy. The install guide requires Linux, x86_64 or arm64, Docker 20.10.14 or above, Docker Compose 2.0.0 or above, and at least 1 CPU core, 1 GB memory, and 5 GB disk. The CPU must support ssse3 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). On ARM, the same guide says SafeLine requires a Pro license to work properly and that the Personal Edition is not supported. If the machine in front of Next.js is ARM and you expected the Personal Edition to run there, stop and choose another architecture or a licensed Pro install. The guide also says the LTS line will no longer be maintained starting from 9.1.0-LTS (14 August 2025), and it recommends the latest version if you have concerns about staying on that line.&lt;/p&gt;
&lt;p&gt;Choose controls from the documented list only. The product page lists defenses for web attacks, proactive bot abused defense, HTML and JS code encryption, IP-based rate limiting, and a web access control list (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Named coverage includes SQL injection, XSS, code injection, os command injection, CRLF injection, ldap injection, xpath injection, RCE, XXE, SSRF, path traversal, backdoor, bruteforce, http-flood, and bot abused. Rate limiting throttles traffic that exceeds defined limits, including DoS attacks, bruteforce attempts, traffic surges, and other abuse. A CAPTCHA challenge is described as allowing human users through while blocking crawlers and bots. An authentication challenge, when turned on, requires visitors to enter the password or be blocked. Dynamic protection, when turned on, dynamically encrypts HTML and JS code on each visit.&lt;/p&gt;
&lt;p&gt;Those controls are relevant because a Next.js app still emits HTML and JavaScript and still accepts HTTP requests on pages, route handlers, and other endpoints. They do not replace validation, authorization, or secret handling in application code. Nothing in these sources says dynamic encryption understands a particular Next.js rendering mode, or that it replaces a policy you set in the app. There is also no documented promise that a strict policy will ignore harmless JSON, file uploads, or query strings that your routes legitimately use. That is why the checklist includes a real-user retest, not only attack samples.&lt;/p&gt;
&lt;p&gt;As of September 30, 2026, SafeLine surpassed 700,000 installations worldwide. SafeLine Pro annual promotional pricing is $650-$880 versus the $1,000 regular price. The documentation also calls the product production-ready, protecting over 1,000,000 websites and handling over 30,000,000,000 HTTP requests daily (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Read those scale statements as product context, not as a forecast for your own site. The same page reports an effect evaluation on 33669 samples: SafeLine, Balance showed 71.65% detection, 0.07% false positives, and 99.45% accuracy, while SafeLine, Strict showed 76.17% detection, 0.22% false positives, and 99.38% accuracy. Match the stricter profile to a site that can tolerate more false positives, and retest real Next.js routes before you leave it on.&lt;/p&gt;
&lt;h3 id=&quot;verification-checklist&quot;&gt;Verification checklist&lt;/h3&gt;
&lt;p&gt;After the application is proxied, use the test cases in the application guide and replace the sample domain with yours. The guide lists SQL injection, XSS, path traversal, code injection, and XXE-style query examples (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). A blocking page means SafeLine stopped that request. Then open the console attack list and use the detail view. Also request a normal page, a client-side navigation, and one legitimate POST that your users need. A proxy that stops the samples but breaks a real form is not done.&lt;/p&gt;
&lt;p&gt;Keep the management console off the public app hostname unless you intend to expose it. The install guide opens the console at &lt;a rel=&quot;nofollow&quot; href=&quot;https://&amp;lt;safeline-ip&amp;gt;:9443/&quot;&gt;https://&amp;lt;safeline-ip&amp;gt;:9443/&lt;/a&gt; and creates the first administrator with docker exec safeline-mgt resetadmin (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). Store that initial password outside the Next.js repository. Record the upstream URL, the listen port, whether SSL is enabled, and the DNS rollback value in the same change note.&lt;/p&gt;
&lt;h4 id=&quot;does-safeline-ship-a-next-js-integration-&quot;&gt;Does SafeLine ship a Next.js integration?&lt;/h4&gt;
&lt;p&gt;No. The install guide, the application guide, and the product overview used here describe a reverse proxy and an application record. They do not describe a Next.js plugin, middleware package, or platform add-on. The working record is domain, listen port, SSL when needed, and upstream.&lt;/p&gt;
&lt;h4 id=&quot;can-the-next-js-process-keep-its-current-port-&quot;&gt;Can the Next.js process keep its current port?&lt;/h4&gt;
&lt;p&gt;Yes, if that port is the upstream SafeLine forwards to. SafeLine listens on the port you configure, commonly 80 or 443, while the origin can remain on an internal port. The public domain must resolve to the SafeLine server, which is the change you should not make until the checklist above is filled in.&lt;/p&gt;
&lt;h4 id=&quot;which-next-js-defects-does-this-checklist-remove-&quot;&gt;Which Next.js defects does this checklist remove?&lt;/h4&gt;
&lt;p&gt;It does not patch framework defects. It can filter HTTP attack classes named in the product documentation, throttle traffic over defined limits, challenge bots, require a password gate, and encrypt HTML and JS when dynamic protection is enabled. Authorization inside route handlers remains an application problem.&lt;/p&gt;
&lt;h4 id=&quot;should-every-challenge-be-enabled-on-the-first-day-&quot;&gt;Should every challenge be enabled on the first day?&lt;/h4&gt;
&lt;p&gt;No. CAPTCHA, the authentication challenge, rate limiting, and dynamic HTML and JS encryption are separate features. Turn on the ones that match abuse you can name, then repeat the legitimate page and POST checks. A public marketing route and a signed-in app route do not need the same gate.&lt;/p&gt;
&lt;h4 id=&quot;which-host-choice-blocks-the-personal-edition-&quot;&gt;Which host choice blocks the Personal Edition?&lt;/h4&gt;
&lt;p&gt;ARM does. The deploy guide says a Pro license is required on ARM and that the Personal Edition is not supported there. Before you call the checklist complete, also confirm Docker, Docker Compose, disk, memory, and the ssse3 instruction on the SafeLine host, and prefer a maintained release over the unmaintained LTS line.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32136/how-to-protect-a-next-js-app-with-a-waf-before-you-change-dns</guid>
<pubDate>Fri, 23 Oct 2026 04:02:28 +0000</pubDate>
</item>
<item>
<title>How to Protect a Next.js App When No Framework Plugin Exists</title>
<link>https://coderlegion.com/32137/how-to-protect-a-next-js-app-when-no-framework-plugin-exists</link>
<description>&lt;p&gt;If you are asking how to protect a Next.js app with a WAF, start by rejecting the idea of a hidden framework connector. The SafeLine documents supplied here never mention Next.js, React Server Components, a middleware file, or a platform add-on. They describe a self-hosted web application firewall that sits in front of a web application and filters HTTP traffic. The honest guide is a decision checklist: either your Next.js origin can sit behind that reverse proxy, or this product path does not fit. Do not fill the gap with an integration the sources do not describe.&lt;/p&gt;
&lt;p&gt;SafeLine protects web apps by filtering and monitoring HTTP traffic between the application and the Internet. Deployed in front of the app, it is a reverse proxy: clients pass through it before they reach the server, and it blocks malicious HTTP and HTTPS traffic according to policy (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). The application guide adds that SafeLine is developed based on nginx, receives traffic for the original site, cleans malicious attack traffic, and forwards safe traffic to the upstream application (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). A Next.js server is in scope only as that upstream. Page routes, API routes, and form posts are protected to the extent they arrive as HTTP requests the proxy can see.&lt;/p&gt;
&lt;h3 id=&quot;run-the-fit-checklist-before-dns-cutover&quot;&gt;Run the fit checklist before DNS cutover&lt;/h3&gt;
&lt;p&gt;Work through these gates in order. A later control cannot repair a failed gate.&lt;/p&gt;
&lt;p&gt;First, confirm you own an upstream address. In the console you add an application with a Domain, a listen Port, and an Upstream. Domain may be a domain name, a hostname, or an IP address. Port is what SafeLine listens on, such as 80 or 443, and HTTPS applications should use the SSL option. Upstream is the real address of the original app (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). For Next.js, that upstream is the host and port of the Node process or container you actually run. If the app is hosted where you cannot place your own reverse proxy in front of it, stop. These pages do not document another mode.&lt;/p&gt;
&lt;p&gt;Second, plan the client path. After the application record exists, you resolve the domain to the IP of the SafeLine server and reach the protected app through that domain. If visitors can still open the Next.js port directly, the WAF is optional and the exposure remains. Write the public port and the upstream port on two lines so Upstream never points back at SafeLine itself.&lt;/p&gt;
&lt;p&gt;Third, check the WAF host against the deploy guide before you promise a launch date. SafeLine expects Linux on x86_64 or arm64, Docker 20.10.14 or above, Docker Compose 2.0.0 or above, and at least 1 CPU core, 1 GB of memory, and 5 GB of disk. The CPU also needs the ssse3 instruction (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). An ARM install requires a Pro license to work properly. The Personal Edition is not supported on ARM. The LTS release is no longer maintained starting from 9.1.0-LTS on 14 August 2025, and the guide strongly recommends moving to the latest version, or waiting about a month after a new release if you want a calmer upgrade.&lt;/p&gt;
&lt;p&gt;Fourth, separate the management plane from the app plane. The web console is served at &lt;a rel=&quot;nofollow&quot; href=&quot;https://&amp;lt;safeline-ip&amp;gt;:9443/&quot;&gt;https://&amp;lt;safeline-ip&amp;gt;:9443/&lt;/a&gt;. The initial administrator account is created with docker exec safeline-mgt resetadmin, which prints the initial username and password. Store that password. Do not assume the console is protected just because the Next.js site is.&lt;/p&gt;
&lt;p&gt;Fifth, decide which documented controls match this app, and leave the rest off until you have evidence. The core list is defenses for web attacks, proactive bot abused defense, HTML and JS code encryption, IP-based rate limiting, and a web access control list (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Rate limiting is documented against DoS attacks, brute-force attempts, traffic surges, and other abuse. A CAPTCHA challenge is documented to allow human users and block crawlers and bots. An authentication challenge requires a password before the visitor continues. Dynamic protection, when turned on, dynamically encrypts HTML and JS from the web server on each visit.&lt;/p&gt;
&lt;h3 id=&quot;map-those-controls-onto-next-js-without-inventing-a-parser&quot;&gt;Map those controls onto Next.js without inventing a parser&lt;/h3&gt;
&lt;p&gt;Next.js does not get a special inspection profile in these documents. What you can say is narrower and still useful. A production Next.js app usually publishes HTML, JavaScript, and JSON over HTTP. Those are the surfaces the documented feature list talks about. The attack categories named on the product home page include SQL injection, XSS, code injection, OS command injection, CRLF injection, LDAP injection, XPath injection, RCE, XXE, SSRF, path traversal, backdoor, brute force, HTTP flood, and bot abuse, among others. The application guide uses query-string trial cases for SQL injection, XSS, path traversal, code injection, and XXE, then expects a blocking page and an entry in the attack list with a detail view.&lt;/p&gt;
&lt;p&gt;Use that as a verification method, not as a claim that every Next.js bug class is covered. Replay the guide sample cases against your domain after DNS points at SafeLine. Confirm the block page. Open the attack detail. Then exercise a normal page load, a normal API call, and a normal form post so you can see whether a strict policy collides with legitimate traffic. The home page publishes an evaluation table, but this checklist does not treat that table as a sizing tool for your app.&lt;/p&gt;
&lt;p&gt;Rate limiting belongs in the first tuning pass if the Next.js app has a login, a search box, or any route that triggers expensive server work. Bot and CAPTCHA controls belong next if anonymous clients scrape or hammer the site. The authentication challenge is a site gate, not your session implementation. Dynamic HTML and JS encryption is a documented option for pages that serve HTML and JS, which Next.js does, but the docs do not say it is required, and they do not say it is aware of client rendering. Turn it on only after a page-level test.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-claims-inside-the-sources&quot;&gt;Keep the claims inside the sources&lt;/h3&gt;
&lt;p&gt;SafeLine states that it is production-ready, that it protects over 1,000,000 websites, and that it handles over 30,000,000,000 HTTP requests daily (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). As of September 30, 2026, SafeLine surpassed 700,000 installations worldwide. SafeLine Pro annual promotional pricing is $650-$880 versus the $1,000 regular price. Those figures help a budget conversation. They do not change the install floor, and they do not prove that a minimal 1 core and 1 GB host will carry your traffic. The deploy guide gives a minimum, not a capacity plan.&lt;/p&gt;
&lt;p&gt;I would also refuse three common overclaims. There is no documented command that installs SafeLine inside a Next.js repository. There is no documented connector for a hosted frontend platform that will not let you set an upstream. There is no documented promise that proxy filtering replaces authorization, output encoding, or dependency patching inside the app. The WAF can filter, monitor, and block malicious HTTP traffic, and the home page also says it prevents unauthorized data from leaving the app by applying policy. That is a proxy control. It is not a code review.&lt;/p&gt;
&lt;p&gt;If the checklist fails at upstream ownership or DNS placement, do not publish a protected label. A request that never passes through SafeLine is outside every feature named above.&lt;/p&gt;
&lt;h4 id=&quot;does-safeline-document-a-next-js-integration-&quot;&gt;Does SafeLine document a Next.js integration?&lt;/h4&gt;
&lt;p&gt;No. The install and application guides describe a reverse proxy and an application record. They do not document a Next.js package, config plugin, middleware hook, or server-action mode. If you cannot proxy the origin, this setup is the wrong fit.&lt;/p&gt;
&lt;h4 id=&quot;which-fields-matter-when-the-upstream-is-a-next-js-server-&quot;&gt;Which fields matter when the upstream is a Next.js server?&lt;/h4&gt;
&lt;p&gt;Domain, Port, and Upstream. Domain is the name clients use. Port is the SafeLine listen port, with SSL checked for HTTPS. Upstream is the real Next.js address. After saving, resolve that domain to the SafeLine server (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;).&lt;/p&gt;
&lt;h4 id=&quot;what-should-i-test-on-the-first-day-&quot;&gt;What should I test on the first day?&lt;/h4&gt;
&lt;p&gt;Use the application guide sample cases for SQL injection, XSS, path traversal, code injection, and XXE, with your domain in place of the sample host. Expect the blocking page, then check the attack list and the detail view. Also send ordinary page, API, and form traffic so a policy problem is visible before users are.&lt;/p&gt;
&lt;h4 id=&quot;when-is-dynamic-protection-a-reasonable-option-&quot;&gt;When is dynamic protection a reasonable option?&lt;/h4&gt;
&lt;p&gt;When you want the documented behavior: HTML and JS encrypted dynamically on each visit. It is listed with the other main features, not as a Next.js switch (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Test real pages first. Do not treat it as a substitute for safe rendering in the app.&lt;/p&gt;
&lt;h4 id=&quot;what-host-and-license-facts-should-a-buyer-check-&quot;&gt;What host and license facts should a buyer check?&lt;/h4&gt;
&lt;p&gt;Linux, x86_64 or arm64, current Docker and Compose versions from the deploy guide, ssse3, and at least 1 core, 1 GB of memory, and 5 GB of disk. ARM needs a Pro license; the Personal Edition is not supported on ARM (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). As of September 30, 2026, SafeLine surpassed 700,000 installations worldwide, and SafeLine Pro annual promotional pricing is $650-$880 versus the $1,000 regular price.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32137/how-to-protect-a-next-js-app-when-no-framework-plugin-exists</guid>
<pubDate>Fri, 23 Oct 2026 04:01:56 +0000</pubDate>
</item>
<item>
<title>Your Next.js Dates Are Wrong for Half Your Users (and the Hydration Warning Is Only the Start)</title>
<link>https://coderlegion.com/31957/your-next-js-dates-are-wrong-for-half-your-users-and-the-hydration-warning-is-only-the-start</link>
<description>&lt;p&gt;You format a date in a component. It looks right on your laptop. Then a user in another country opens the page and either sees a console full of hydration errors, or worse, sees nothing wrong at all and reads the wrong time.&lt;/p&gt;
&lt;p&gt;I live in a timezone that is five hours away from where most servers run, so this one finds me every time.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-looks-fine&quot;&gt;The Code That Looks Fine&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/OrderDate.tsx
export function OrderDate({ date }: { date: string }) {
  return &amp;lt;p&amp;gt;Ordered on {new Date(date).toLocaleString()}&amp;lt;/p&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No hooks, no browser APIs, nothing that looks risky. It even works in &lt;code&gt;next dev&lt;/code&gt;, because on your machine the server and the browser are the same computer.&lt;/p&gt;
&lt;h3 id=&quot;why-it-breaks-1&quot;&gt;Why It Breaks&lt;/h3&gt;
&lt;p&gt;In Next.js, this component runs &lt;strong&gt;twice&lt;/strong&gt;: once on the server to produce the HTML, and again in the browser to hydrate it. &lt;code&gt;toLocaleString()&lt;/code&gt; with no arguments uses the &lt;strong&gt;timezone and language of whatever machine runs it&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The server is often in UTC, with a default locale such as &lt;code&gt;en-US&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your user's browser might be in Karachi, with &lt;code&gt;en-GB&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;Server HTML:   Ordered on 10/20/2026, 10:15:00 AM
Browser render: Ordered on 20/10/2026, 15:15:00
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;React compares the two, finds that they differ, and logs a hydration error. In development, you see it. In production, React quietly tries to recover by re-rendering on the client, which costs time and can cause a visible flash.&lt;/p&gt;
&lt;h3 id=&quot;why-it-is-hard-to-catch-1&quot;&gt;Why It Is Hard to Catch&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It never fails where you test.&lt;/strong&gt; Same machine, same timezone, same locale: the two outputs are identical.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Many users never see an error.&lt;/strong&gt; The page just shows one format for a split second and then another.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It often hides inside other pieces of data.&lt;/strong&gt; &lt;code&gt;Date.now()&lt;/code&gt;, &lt;code&gt;Math.random()&lt;/code&gt;, &lt;code&gt;new Date().getFullYear()&lt;/code&gt; and anything that reads &lt;code&gt;window&lt;/code&gt; or &lt;code&gt;localStorage&lt;/code&gt; can all produce a different value on the server and in the browser.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-fix-that-is-tempting-and-wrong&quot;&gt;The Fix That Is Tempting, and Wrong&lt;/h3&gt;
&lt;p&gt;You will find this advice everywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;p suppressHydrationWarning&amp;gt;
  Ordered on {new Date(date).toLocaleString()}
&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It does silence the warning. But per React's documentation, it only suppresses the warning on that one element and does not patch up mismatched text. The user can end up looking at the &lt;strong&gt;server's&lt;/strong&gt; version of the time, which is in the wrong timezone.&lt;/p&gt;
&lt;p&gt;You did not fix the bug, you removed the alarm for it.&lt;/p&gt;
&lt;h3 id=&quot;fix-1-make-the-output-the-same-on-both-sides&quot;&gt;Fix 1: Make the Output the Same on Both Sides&lt;/h3&gt;
&lt;p&gt;Pass an explicit locale and timezone, so the server and the browser calculate the same string:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export function OrderDate({ date }: { date: string }) {
  const text = new Date(date).toLocaleString('en-US', {
    timeZone: 'UTC',
    dateStyle: 'medium',
    timeStyle: 'short',
  });

  return &amp;lt;time dateTime={date}&amp;gt;{text} UTC&amp;lt;/time&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is deterministic, so there is nothing to mismatch. It is the right choice for logs, admin tables and anything where showing UTC is acceptable. Label it as UTC so nobody reads it as local time.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-show-the-local-time-after-the-page-loads&quot;&gt;Fix 2: Show the Local Time After the Page Loads&lt;/h3&gt;
&lt;p&gt;If users should see their own timezone, only compute it in the browser:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';

import { useEffect, useState } from 'react';

export function LocalDate({ date }: { date: string }) {
  const [local, setLocal] = useState&amp;lt;string | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    setLocal(
      new Date(date).toLocaleString(undefined, {
        dateStyle: 'medium',
        timeStyle: 'short',
      })
    );
  }, [date]);

  return (
    &amp;lt;time dateTime={date}&amp;gt;
      {local ?? new Date(date).toISOString().slice(0, 10)}
    &amp;lt;/time&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first render, on the server and in the browser, shows a stable fallback (the ISO date). Right after hydration, &lt;code&gt;useEffect&lt;/code&gt; runs only in the browser and swaps in the local time. Nothing mismatches, and the user ends up with the right value.&lt;/p&gt;
&lt;p&gt;The cost is a brief change from the fallback to the local string. Keep the fallback short so the layout does not jump.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-store-and-send-dates-as-iso-strings&quot;&gt;Fix 3: Store and Send Dates as ISO Strings&lt;/h3&gt;
&lt;p&gt;Whichever display option you pick, pass dates around as ISO strings (&lt;code&gt;2026-10-20T10:15:00.000Z&lt;/code&gt;) and format them only at the edge, in one place. An ISO string is plain text, so it means the same thing on the server, in the browser and in your database.&lt;/p&gt;
&lt;h3 id=&quot;which-one-when&quot;&gt;Which One When&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Admin tables, logs, reports &lt;/td&gt;&lt;td&gt;Fixed locale and &lt;code&gt;timeZone: 'UTC'&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Customer-facing times (orders, bookings, messages) &lt;/td&gt;&lt;td&gt;Format in the browser after mount&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A date that never changes (blog post date) &lt;/td&gt;&lt;td&gt;Fixed locale and timezone, since the author's date is what matters&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-rule-4&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any value that depends on where the code runs, such as timezone, locale, the current time or a random number, will differ between the server and the browser.&lt;/strong&gt; Either make it identical on both sides, or compute it only in the browser after hydration. Do not suppress the warning and hope.&lt;/p&gt;
&lt;p&gt;Quick audit: search your project for &lt;code&gt;toLocaleString(&lt;/code&gt;, &lt;code&gt;toLocaleDateString(&lt;/code&gt;, &lt;code&gt;Date.now()&lt;/code&gt; and &lt;code&gt;Math.random()&lt;/code&gt; inside components that render on the server. Each one is either deterministic on purpose, or a mismatch that has not met a user in another timezone yet.&lt;/p&gt;
&lt;p&gt;Hydration mistakes also slow pages down, because React has to redo work in the browser. I explain what that does to real-world speed scores in &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/core-web-vitals-explained&quot;&gt;this plain-language guide to Core Web Vitals&lt;/a&gt;, and I keep every date in my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js templates&lt;/a&gt; formatted through one small helper so the server and the browser always agree.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Which timezone bug has bitten you hardest? Drop it in the comments, especially if it only showed up in production.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31957/your-next-js-dates-are-wrong-for-half-your-users-and-the-hydration-warning-is-only-the-start</guid>
<pubDate>Wed, 21 Oct 2026 11:48:27 +0000</pubDate>
</item>
<item>
<title>Fix: useSearchParams() Should Be Wrapped in a Suspense Boundary (and Why It Only Breaks at Build</title>
<link>https://coderlegion.com/31610/fix-usesearchparams-should-be-wrapped-in-suspense-boundary-and-why-it-only-breaks-at-build</link>
<description>&lt;p&gt;You add a small search box to your navbar. It reads the query string, so you reach for &lt;code&gt;useSearchParams()&lt;/code&gt;. Everything works in &lt;code&gt;next dev&lt;/code&gt;. You push, the deploy fails, and the build log says:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;useSearchParams() should be wrapped in a suspense boundary at page &quot;/about&quot;.
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the page name. You did not touch &lt;code&gt;/about&lt;/code&gt;. That detail is the whole story.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-triggers-it&quot;&gt;The Code That Triggers It&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/SearchBox.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export function SearchBox() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q') ?? '';

  return &amp;lt;input defaultValue={query} placeholder=&quot;Search...&quot; /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { SearchBox } from '@/components/SearchBox';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;nav&amp;gt;
          &amp;lt;SearchBox /&amp;gt;
        &amp;lt;/nav&amp;gt;
        {children}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing here looks wrong, and &lt;code&gt;next dev&lt;/code&gt; agrees with you.&lt;/p&gt;
&lt;h3 id=&quot;why-only-the-build-complains&quot;&gt;Why Only the Build Complains&lt;/h3&gt;
&lt;p&gt;At build time, Next.js tries to pre-render every page that does not obviously need request data into static HTML. That is the fast path, and it is the default.&lt;/p&gt;
&lt;p&gt;But a static page is built once, before any visitor exists, so it has no query string. &lt;code&gt;useSearchParams()&lt;/code&gt; needs one. Next.js handles this by &lt;strong&gt;bailing out of static rendering for that part of the tree&lt;/strong&gt;: the component is skipped on the server and rendered in the browser instead.&lt;/p&gt;
&lt;p&gt;The catch is how far the bail-out spreads. Without a &lt;code&gt;Suspense&lt;/code&gt; boundary around the component, the nearest boundary is the page itself, which would turn the whole page into client-side rendering and quietly throw away the static HTML you were about to ship. Instead of letting that happen silently, the build fails and asks you to say where the boundary should be.&lt;/p&gt;
&lt;p&gt;Dev mode renders every page on demand, so it never has to make that decision, which is why the error only shows up when you build.&lt;/p&gt;
&lt;h3 id=&quot;why-it-hits-every-page-at-once&quot;&gt;Why It Hits Every Page at Once&lt;/h3&gt;
&lt;p&gt;I put the component in &lt;code&gt;layout.tsx&lt;/code&gt; on purpose. A shared layout wraps every page, so one unguarded &lt;code&gt;useSearchParams()&lt;/code&gt; in a navbar breaks the build for &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/pricing&lt;/code&gt; and every other static route at the same time.&lt;/p&gt;
&lt;p&gt;That is also why the error names a page you never edited. The problem lives in a shared component.&lt;/p&gt;
&lt;h3 id=&quot;fix-1-wrap-the-component-in-suspense&quot;&gt;Fix 1: Wrap the Component in Suspense&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { Suspense } from 'react';
import { SearchBox } from '@/components/SearchBox';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;nav&amp;gt;
          &amp;lt;Suspense fallback={&amp;lt;input disabled placeholder=&quot;Search...&quot; /&amp;gt;}&amp;gt;
            &amp;lt;SearchBox /&amp;gt;
          &amp;lt;/Suspense&amp;gt;
        &amp;lt;/nav&amp;gt;
        {children}
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now only the search box is rendered in the browser. Everything else stays static HTML. The fallback is what visitors see until the client takes over, so make it the same size as the real component to avoid the layout jumping when it swaps in.&lt;/p&gt;
&lt;p&gt;This is the fix to reach for first. It keeps your pages static and limits the client-side part to the one component that needs it.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-read-the-params-on-the-server-and-pass-them-down&quot;&gt;Fix 2: Read the Params on the Server and Pass Them Down&lt;/h3&gt;
&lt;p&gt;If the value is only needed on one page, skip the hook entirely. Pages receive &lt;code&gt;searchParams&lt;/code&gt; as a prop:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise&amp;lt;{ q?: string }&amp;gt;;
}) {
  const { q } = await searchParams;
  return &amp;lt;SearchResults query={q ?? ''} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Next.js 15, &lt;code&gt;searchParams&lt;/code&gt; is a Promise, so it has to be awaited. Reading it marks that page as dynamic, so it renders per request instead of at build time. For a real search results page that is exactly what you want.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-force-the-route-to-be-dynamic&quot;&gt;Fix 3: Force the Route to Be Dynamic&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const dynamic = 'force-dynamic';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This also makes the error disappear, but it does so by giving up static rendering for the route. Use it when the page genuinely depends on the request, not as a way to silence the build. On a marketing page it trades speed for nothing.&lt;/p&gt;
&lt;h3 id=&quot;which-fix-when&quot;&gt;Which Fix When&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Situation &lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Small interactive component reads the URL (navbar search, filters, tabs) &lt;/td&gt;&lt;td&gt;Suspense around that component&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;An entire page depends on the query string &lt;/td&gt;&lt;td&gt;Read &lt;code&gt;searchParams&lt;/code&gt; in the page&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The page really is per-request &lt;/td&gt;&lt;td&gt;&lt;code&gt;force-dynamic&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;a-detail-worth-knowing-about-the-fallback&quot;&gt;A Detail Worth Knowing About the Fallback&lt;/h3&gt;
&lt;p&gt;Whatever sits inside the Suspense boundary is client-rendered, so it will not be in the initial HTML. For a search input that does not matter. For content you want indexed, keep it outside the boundary. A fallback that does not match the final size also adds layout shift, which counts against your Core Web Vitals. I cover what those metrics measure in &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com/blog/core-web-vitals-explained&quot;&gt;this plain-language guide&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-5&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A client component that reads request-time data needs a Suspense boundary, so Next.js knows how much of the page to make dynamic.&lt;/strong&gt; Put the boundary as close to the component as you can, and the rest of the page stays static.&lt;/p&gt;
&lt;p&gt;I wrap every URL-driven component this way across my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js templates&lt;/a&gt;, so the production build never depends on where a navbar or filter bar ends up in the layout.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Search your project for &lt;code&gt;useSearchParams&lt;/code&gt; and check that every use sits inside a &lt;code&gt;Suspense&lt;/code&gt; boundary, especially anything imported by &lt;code&gt;layout.tsx&lt;/code&gt;. If you have hit a different build-only error, drop it in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31610/fix-usesearchparams-should-be-wrapped-in-suspense-boundary-and-why-it-only-breaks-at-build</guid>
<pubDate>Sun, 18 Oct 2026 12:23:34 +0000</pubDate>
</item>
<item>
<title>Your Server Action Error Messages Vanish in Production, and Throwing Is the Wrong Tool</title>
<link>https://coderlegion.com/31506/your-server-action-error-messages-vanish-in-production-and-throwing-is-the-wrong-tool</link>
<description>&lt;p&gt;Here is a bug report you will eventually get: &quot;The signup form told me what was wrong on your machine, but on the live site it just says something went wrong.&quot;&lt;/p&gt;
&lt;p&gt;Nothing is broken. Next.js is doing this on purpose. But the code that triggers it looks completely normal, and it is in a lot of Server Actions tutorials, including a version in my own earlier post on replacing API routes.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-works-locally&quot;&gt;The Code That Works Locally&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// actions/signup.ts
'use server';

export async function signup(formData: FormData) {
  const email = formData.get('email') as string;

  const existing = await User.findOne({ email });
  if (existing) {
    throw new Error('That email is already registered');
  }

  await User.create({ email });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/SignupForm.tsx
'use client';

export function SignupForm() {
  const [error, setError] = useState('');

  async function handleSubmit(formData: FormData) {
    try {
      await signup(formData);
    } catch (e) {
      setError((e as Error).message);
    }
  }

  return (
    &amp;lt;form action={handleSubmit}&amp;gt;
      &amp;lt;input name=&quot;email&quot; type=&quot;email&quot; /&amp;gt;
      &amp;lt;button type=&quot;submit&quot;&amp;gt;Sign up&amp;lt;/button&amp;gt;
      {error &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{error}&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In development, a duplicate email shows &quot;That email is already registered&quot;. After &lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next start&lt;/code&gt;, or on Vercel, the same submission shows a generic message about an error occurring on the server, with a short digest string instead of your text.&lt;/p&gt;
&lt;h3 id=&quot;why-next-js-hides-it&quot;&gt;Why Next.js Hides It&lt;/h3&gt;
&lt;p&gt;An error thrown on the server can carry anything: a database connection string inside a driver message, a file path, a query, a stack trace. If Next.js forwarded every thrown message to the browser, a single unexpected failure could leak internal details to anyone watching the network tab.&lt;/p&gt;
&lt;p&gt;So in production builds, Next.js replaces the message of a thrown error with a generic one and attaches a &lt;strong&gt;digest&lt;/strong&gt;, a short hash you can search for in your server logs. In development it shows you the real message because you are the only one looking.&lt;/p&gt;
&lt;p&gt;That is the correct default. The mistake is using &lt;code&gt;throw&lt;/code&gt; for something that is not an unexpected failure.&lt;/p&gt;
&lt;h3 id=&quot;two-kinds-of-errors-two-different-tools&quot;&gt;Two Kinds of Errors, Two Different Tools&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Expected errors&lt;/strong&gt; are things that will happen during normal use: a duplicate email, a wrong password, a failed validation, a card that was declined. The user should see a specific message and fix it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unexpected errors&lt;/strong&gt; are things that should not happen: the database is down, a bug, a null that should not be null. The user should see a safe generic message and you should see the details in your logs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;throw&lt;/code&gt; is the right tool for the second kind. It is the wrong tool for the first, because the framework treats every thrown error as potentially sensitive and strips it.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-return-expected-errors-as-data&quot;&gt;The Fix: Return Expected Errors as Data&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/signup.ts
'use server';

import { z } from 'zod';

const SignupSchema = z.object({
  email: z.string().email('Enter a valid email'),
});

export type SignupState = {
  success: boolean;
  message?: string;
  fieldErrors?: Record&amp;lt;string, string[]&amp;gt;;
};

export async function signup(
  prevState: SignupState | null,
  formData: FormData
): Promise&amp;lt;SignupState&amp;gt; {
  const parsed = SignupSchema.safeParse({
    email: formData.get('email'),
  });

  if (!parsed.success) {
    return {
      success: false,
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  const existing = await User.findOne({ email: parsed.data.email });
  if (existing) {
    return { success: false, message: 'That email is already registered' };
  }

  await User.create({ email: parsed.data.email });
  return { success: true, message: 'Account created' };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/SignupForm.tsx
'use client';

import { useActionState } from 'react';
import { signup } from '@/actions/signup';

export function SignupForm() {
  const [state, action, isPending] = useActionState(signup, null);

  return (
    &amp;lt;form action={action}&amp;gt;
      &amp;lt;input name=&quot;email&quot; type=&quot;email&quot; /&amp;gt;
      {state?.fieldErrors?.email &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{state.fieldErrors.email[0]}&amp;lt;/p&amp;gt;}
      &amp;lt;button type=&quot;submit&quot; disabled={isPending}&amp;gt;
        {isPending ? 'Creating account...' : 'Sign up'}
      &amp;lt;/button&amp;gt;
      {state?.message &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{state.message}&amp;lt;/p&amp;gt;}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A returned value is just data. Next.js does not treat it as sensitive, so the exact same message reaches the user in development and in production. You also get &lt;code&gt;useActionState&lt;/code&gt; handling pending state and the previous result for free, with no &lt;code&gt;try/catch&lt;/code&gt; in the component.&lt;/p&gt;
&lt;h3 id=&quot;keep-throw-for-the-real-failures&quot;&gt;Keep &lt;code&gt;throw&lt;/code&gt; for the Real Failures&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function signup(prevState, formData) {
  // ...expected errors returned as data above...

  try {
    await User.create({ email: parsed.data.email });
  } catch (error) {
    console.error('signup failed', error); // full details stay in your logs
    throw error; // user gets the safe generic message
  }

  return { success: true, message: 'Account created' };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When this throws in production, the user sees the generic message and an error boundary can take over. You search your logs for the digest and find the real error. Nothing sensitive reaches the browser.&lt;/p&gt;
&lt;h3 id=&quot;why-this-survives-testing&quot;&gt;Why This Survives Testing&lt;/h3&gt;
&lt;p&gt;Every manual test happens in &lt;code&gt;next dev&lt;/code&gt;, where thrown messages are shown in full. The code looks perfect right up until the first production build, which is usually when real users start signing up.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-6&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Return expected errors as data, throw only for unexpected failures.&lt;/strong&gt; If a message is meant for the user to read, it should travel as a returned value. If it is meant for you to debug, it should go to your logs and surface to the user as something generic.&lt;/p&gt;
&lt;p&gt;I use this return-a-state pattern in the forms and dashboard actions across my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js templates&lt;/a&gt;, so validation messages behave identically in development and on the live site.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Search your own codebase for &lt;code&gt;throw new Error&lt;/code&gt; inside files marked &lt;code&gt;'use server'&lt;/code&gt;. For each one, ask whether a user is supposed to read that message. If yes, it is probably already invisible in production. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31506/your-server-action-error-messages-vanish-in-production-and-throwing-is-the-wrong-tool</guid>
<pubDate>Sat, 17 Oct 2026 09:48:36 +0000</pubDate>
</item>
<item>
<title>Why Your Tailwind Classes Work in Development and Silently Vanish in Production</title>
<link>https://coderlegion.com/31201/why-your-tailwind-classes-work-in-development-and-silently-vanish-in-production</link>
<description>&lt;p&gt;Almost every Tailwind developer hits this exact bug once, usually right after a deploy, and the confusing part is that nothing about it looks wrong in the code, nothing throws an error, and it worked completely fine five minutes earlier on the same machine.&lt;/p&gt;
&lt;h3 id=&quot;the-code-that-looks-completely-reasonable&quot;&gt;The Code That Looks Completely Reasonable&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;function StatusBadge({ status }: { status: 'success' | 'warning' | 'error' }) {
  const color = status === 'success' ? 'green' : status === 'warning' ? 'yellow' : 'red';

  return (
    &amp;lt;span className={`bg-${color}-100 text-${color}-800 px-2 py-1 rounded`}&amp;gt;
      {status}
    &amp;lt;/span&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In development, this renders exactly as expected, colored badges for each status, built dynamically from a single reusable component instead of three separate hardcoded ones. Then you deploy, and every badge renders as a plain, unstyled span. No color, no padding in some setups, nothing, depending on exactly what else got purged alongside it.&lt;/p&gt;
&lt;h3 id=&quot;why-this-happens-tailwind-doesn-t-actually-execute-your-code&quot;&gt;Why This Happens: Tailwind Doesn't Actually Execute Your Code&lt;/h3&gt;
&lt;p&gt;Tailwind doesn't run your JavaScript to figure out which classes you're using. It scans your source files as plain text, looking for strings that match known utility class patterns, and only generates CSS for the classes it actually finds written out, literally, somewhere in that scanned text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;className={`bg-${color}-100 text-${color}-800`}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tailwind's scanner sees the literal text &lt;code&gt;bg-${color}-100&lt;/code&gt;, not &lt;code&gt;bg-green-100&lt;/code&gt;, &lt;code&gt;bg-yellow-100&lt;/code&gt;, or &lt;code&gt;bg-red-100&lt;/code&gt;. Template literal interpolation happens at runtime, in the browser or on your server, long after Tailwind's build step has already finished scanning your source files and generating the final CSS. Since none of the three actual color classes your component needs ever appears as literal, complete text anywhere in your codebase, Tailwind never generates CSS for any of them, and the class names that do get applied to the DOM at runtime simply don't exist in your stylesheet at all.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specifically-survives-local-development&quot;&gt;Why This Specifically Survives Local Development&lt;/h3&gt;
&lt;p&gt;This is the part that makes the bug genuinely sneaky rather than just a misunderstanding: in development mode, especially with certain dev server configurations or cached builds from before the dynamic class was introduced, you can sometimes see the correct styling anyway, either because an earlier full build still has the class generated from some other literal usage elsewhere in the project, or because of how some dev tooling handles incremental rebuilds. The failure shows up specifically in a clean production build, which scans and purges from scratch, with nothing left over from earlier development sessions to accidentally mask the problem.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-always-write-complete-class-names-literally&quot;&gt;The Fix: Always Write Complete Class Names, Literally&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;function StatusBadge({ status }: { status: 'success' | 'warning' | 'error' }) {
  const styles = {
    success: 'bg-green-100 text-green-800',
    warning: 'bg-yellow-100 text-yellow-800',
    error: 'bg-red-100 text-red-800',
  };

  return (
    &amp;lt;span className={`${styles[status]} px-2 py-1 rounded`}&amp;gt;
      {status}
    &amp;lt;/span&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every complete, literal class name Tailwind needs now appears as actual text somewhere in the source file, &lt;code&gt;bg-green-100&lt;/code&gt;, &lt;code&gt;bg-yellow-100&lt;/code&gt;, &lt;code&gt;bg-red-100&lt;/code&gt;, &lt;code&gt;text-green-800&lt;/code&gt;, and so on, even though the specific one that gets applied is still chosen dynamically at runtime based on &lt;code&gt;status&lt;/code&gt;. Tailwind's scanner finds all three as literal strings during the build, generates CSS for all three, and the correct one gets applied correctly regardless of which branch of the mapping object actually executes.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-that-generalizes-beyond-this-one-example&quot;&gt;The Rule That Generalizes Beyond This One Example&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Tailwind only generates CSS for class names that exist as complete, literal text somewhere in your scanned source files, string concatenation and template literal interpolation don't count, no matter how confident the resulting string looks at runtime.&lt;/strong&gt; This applies to any dynamic styling pattern, not just color variants, conditionally built spacing classes, responsive variants built from a variable, state-based classes constructed from a prop, all of them need the complete literal class name written out somewhere Tailwind's scanner can see it, usually in a lookup object exactly like the one above, rather than assembled through string interpolation.&lt;/p&gt;
&lt;p&gt;I keep every dynamic variant mapped out explicitly like this across the component libraries in my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js and Tailwind templates&lt;/a&gt;, specifically because this exact bug is so easy to reintroduce months later when someone, often future-you, refactors a component back toward the more compact-looking interpolated version without remembering why it was written the longer way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own codebase for &lt;code&gt;className={&lt;/code&gt;...${variable}...&lt;code&gt;}&lt;/code&gt; patterns building Tailwind classes dynamically. If you find one, do a clean production build and actually look at it, don't trust how it renders in dev. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31201/why-your-tailwind-classes-work-in-development-and-silently-vanish-in-production</guid>
<pubDate>Thu, 15 Oct 2026 12:05:37 +0000</pubDate>
</item>
<item>
<title>Vercel Was Too Expensive, So I Migrated My Next.js App to Cloudflare Workers</title>
<link>https://coderlegion.com/31124/vercel-was-too-expensive-so-i-migrated-my-next-js-app-to-cloudflare-workers</link>
<description>&lt;p&gt;I recently moved production traffic for &lt;a rel=&quot;nofollow&quot; href=&quot;https://galaxymovies.app&quot;&gt;Galaxy Movies&lt;/a&gt;, my Next.js movie/TV discovery site, off Vercel and onto Cloudflare Workers. Nothing against Vercel. Vercel is a great platform, and the app ran happily there for a long time. But I was paying for a Pro plan for what is ultimately a side project, my DNS was already on Cloudflare, and the entire backend surface is fetch-based (Upstash REST, OpenAI, Resend, TMDB); no databases, no TCP. That's about as close to a best-case scenario for running Next.js on workers as you can get.&lt;/p&gt;
&lt;p&gt;The site is Next.js 16 Pages Router, React 19, Chakra UI, with ISR catalog pages, a pile of API routes, four Vercel cron jobs, and dynamic OG images via next/og. Here's what the migration actually looked like, including the parts that fought back.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The adapter: @opennextjs/cloudflare&lt;/strong&gt;&lt;br&gt;
The OpenNext Cloudflare adapter takes a standard next build output and rebundles it into a worker. Setup is two files. First, open-next.config.ts. The interesting bit is the incremental cache override, which points ISR's cache at an R2 bucket:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { defineCloudflareConfig } from '@opennextjs/cloudflare'

import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'

export default defineCloudflareConfig({

  default: { experimentalBundledNextServer: true },

  incrementalCache: r2IncrementalCache

})
Then wrangler.jsonc:

{

  &quot;main&quot;: &quot;.open-next/entry.js&quot;,

  &quot;name&quot;: &quot;galaxy-movies&quot;,

  &quot;compatibility_flags&quot;: [&quot;nodejs_compat&quot;, &quot;global_fetch_strictly_public&quot;],

  &quot;assets&quot;: { &quot;directory&quot;: &quot;.open-next/assets&quot;, &quot;binding&quot;: &quot;ASSETS&quot; },

  &quot;services&quot;: [{ &quot;binding&quot;: &quot;WORKER_SELF_REFERENCE&quot;, &quot;service&quot;: &quot;galaxy-movies&quot; }],

  &quot;r2_buckets&quot;: [

    { &quot;binding&quot;: &quot;NEXT_INC_CACHE_R2_BUCKET&quot;, &quot;bucket_name&quot;: &quot;galaxy-movies-inc-cache&quot; }

  ],

  &quot;triggers&quot;: { &quot;crons&quot;: [&quot;0 6 * * *&quot;, &quot;30 6 * * *&quot;, &quot;0 7 * * *&quot;, &quot;0 16 * * 5&quot;] },

  &quot;find_additional_modules&quot;: true,

  &quot;rules&quot;: [

    { &quot;type&quot;: &quot;Data&quot;, &quot;globs&quot;: [&quot;public/fonts/**&quot;, &quot;public/backdrop_fallback.jpg&quot;], &quot;fallthrough&quot;: true }

  ]

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deploy is two commands: opennextjs-cloudflare build then wrangler deploy. Everything below is the gap between &quot;two commands&quot; and &quot;actually works in production.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gotcha 1: Next's dev-only require()s break the bundle&lt;/strong&gt;&lt;br&gt;
The adapter bundles the Next server with esbuild, and esbuild wants every require() to resolve, including dev-only code paths inside Next's own dist files (next-dev-server, setup-dev-bundler, an upgrade nudge) and an RSC-only path (react-server-dom-webpack) that a Pages Router app never executes. They're dead code in production, but dead code still has to resolve.&lt;/p&gt;
&lt;p&gt;I fixed it with patch-package to mark them external in the adapter's bundle step, plus a postbuild script that drops tiny stub modules where the runtime resolution lands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// scripts/opennext-postbuild.js: runs after opennextjs-cloudflare build

fs.writeFileSync('.../dev/next-dev-server.js', 'module.exports = {}\n')

fs.writeFileSync('.../router-utils/setup-dev-bundler.js', 'module.exports = {}\n')

// ...etc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The patch also adds preview-props.json to the manifest glob the adapter uses, which the Pages Router build emits, and the adapter wasn't collecting. Not elegant, but it's contained in patches/ and reapplied on every npm install by patch-package. If the upstream adapter fixes these, the patch just stops applying cleanly, and I delete it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gotcha 2: next/og picks the wrong runtime&lt;/strong&gt;&lt;br&gt;
This one took the longest. I generate OG images with next/og, which wraps @vercel/og. The library has two entries: a Node build that does readFileSync on a bundled font and calls WebAssembly.instantiate at runtime (both disallowed on workerd), and an edge build that doesn't.&lt;/p&gt;
&lt;p&gt;The problem: OpenNext forces NEXT_RUNTIME=&quot;nodejs&quot; at bundle time, so next/og always resolves the Node entry, even inside a worker. The workaround is sniffing the runtime myself and importing the edge build directly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const { ImageResponse } = (await (globalThis.navigator?.userAgent === 'Cloudflare-Workers'

  ? import(/* webpackIgnore: true */ 'next/dist/compiled/@vercel/og/index.edge.js')

  : import('next/og'))) as { ImageResponse: typeof ImageResponseType }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Navigator.userAgent === 'Cloudflare-Workers' is genuinely how you detect workerd. The webpackIgnore comment keeps webpack from trying to bundle the path at build time, and I pin the whole @vercel/og compiled directory into the output via outputFileTracingIncludes in next.config.ts so the edge build and its resvg wasm actually ship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gotcha 3: fs reads land in the wrong directory&lt;/strong&gt;&lt;br&gt;
My OG route also reads custom fonts and a fallback backdrop off disk with readFile(path.join(process.cwd(), 'public', ...)). On Vercel, process.cwd() is the project root. On workerd, the vfs cwd is /bundle (the .open-next output root), so public/fonts doesn't exist where the code looks for it.&lt;/p&gt;
&lt;p&gt;The fix is a three-part move: the postbuild script copies public/fonts and backdrop_fallback.jpg into .open-next/public/, and wrangler's find_additional_modules + the Data rules in wrangler.jsonc ship those files in the worker's virtual filesystem. Same code path; files just exist where the worker looks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gotcha 4: packages hiding behind the workerd export condition&lt;/strong&gt;&lt;br&gt;
Some packages, in my case @emotion/* (Chakra's styling engine) and uncrypto, publish their ESM builds behind a workerd export condition. Next's file tracer only follows the CJS variants, so the worker bundle ends up with nothing it can resolve for those imports.&lt;/p&gt;
&lt;p&gt;The fix is listing them in serverExternalPackages in next.config.ts, which tells the adapter to copy the packages whole instead of relying on tracing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serverExternalPackages: [

  '@emotion/react', '@emotion/cache', '@emotion/serialize',

  '@emotion/sheet', '@emotion/weak-memoize', '@emotion/hash',

  '@emotion/memoize', '@emotion/unitless', '@emotion/utils',

  '@emotion/use-insertion-effect-with-fallbacks',

  'hoist-non-react-statics', 'uncrypto'

]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you hit &quot;cannot resolve module&quot; errors on workerd that don't reproduce locally, this is the first thing to check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gotcha 5: Vercel crons don't exist on Workers&lt;/strong&gt;&lt;br&gt;
vercel.json crons are a Vercel platform feature; nothing in a Next.js build output describes them. On Workers, the equivalent is triggers.crons + a scheduled() handler. But the adapter generates worker.js, so I wrap it in a custom entry that adds the handler and maps each cron expression back to the same endpoint Vercel used to hit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// .open-next/entry.js, written by the postbuild script

import worker from './worker.js'

const CRON_ROUTES = {

  '0 6 * * *': '/api/cron/generate-content',

  '30 6 * * *': '/api/cron/streaming-changes',

  '0 7 * * *': '/api/cron/watchlist-alerts',

  '0 16 * * 5': '/api/cron/weekly-digest'

}

export default {

  ...worker,

  async scheduled(event, env, ctx) {

    const route = CRON_ROUTES[event.cron]

    if (!route) return

    ctx.waitUntil(

      env.WORKER_SELF_REFERENCE.fetch(

        new Request('https://worker.internal' + route, {

          headers: { authorization: 'Bearer ' + env.CRON_SECRET }

        })

      )

    )

  }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things make this nice. The WORKER_SELF_REFERENCE service binding lets the worker call itself as an internal fetch, no public round trip. And the endpoints needed zero changes: they already checked Authorization: Bearer $CRON_SECRET because that's how Vercel's cron caller authenticates too. Testing is wrangler dev --test-scheduled, then hit /__scheduled?cron=0 6 &lt;em&gt; &lt;/em&gt; *.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;br&gt;
wrangler secret bulk takes a JSON file, so a 20-line script converts .env.local into .secrets.json, skipping NEXT&lt;em&gt;PUBLIC&lt;/em&gt;* vars (those get inlined at build time anyway) and my local Cloudflare API token. npm run cf:secrets and done: no dashboard clicking through a dozen vars.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cutover and rollback&lt;/strong&gt;&lt;br&gt;
The safest part of the whole migration was the cutover itself, because I deliberately ran both platforms in parallel:&lt;/p&gt;
&lt;p&gt;npm run cf:deploy, then verify everything on the *.workers.dev URL (all routes, OG images, cron triggers registered).&lt;br&gt;
Attach the galaxymovies.app + www custom domains to the worker. Since DNS was already on Cloudflare, this was instant, no propagation wait.&lt;br&gt;
Keep Vercel as cold standby: git.deploymentEnabled.main: false in vercel.json stops main pushes from auto-deploying, and I disabled the Vercel crons in the dashboard so the two platforms don't double-fire jobs.&lt;/p&gt;
&lt;p&gt;Rollback is re-adding two DNS records (A to Vercel's anycast IP, CNAME www to the Vercel DNS name). Traffic flips back immediately because the worker custom domains only take precedence while they're the only records on those names. That escape hatch made the whole thing low-stress: worst case is a DNS edit, not a redeploy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What's actually running&lt;/strong&gt;&lt;br&gt;
Same codebase, same next build, no platform fork in the source. SSR, ISR (backed by R2), all API routes, the OG image route, and all four crons work identically to how they did on Vercel. The only code that knows which platform it's on is that one navigator.userAgent check in the OG route.&lt;/p&gt;
&lt;p&gt;The honest summary: the adapter gets you ~80% there out of the box, and the remaining 20% is the kind of platform-shim work above. It's tractable, but expect an afternoon of debugging, not a lunch break. If your app leans on Node APIs that aren't fetch-shaped, the logic is different. But if you're already edge-friendly and your DNS is on Cloudflare anyway, the economics are hard to argue with, and Vercel sitting dormant as a free rollback target is a pretty nice safety net.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Repo notes worth taking if you're doing this: patch-package for the adapter externals, a postbuild script for stubs + vfs assets + the cron entry, serverExternalPackages for workerd-conditioned packages, and wrangler dev --test-scheduled for cron testing.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Happy to answer questions in the comments!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31124/vercel-was-too-expensive-so-i-migrated-my-next-js-app-to-cloudflare-workers</guid>
<pubDate>Thu, 15 Oct 2026 00:06:44 +0000</pubDate>
</item>
<item>
<title>Your Next.js Page Might Be Caching One User's Data and Serving It to Everyone Else</title>
<link>https://coderlegion.com/31024/your-next-js-page-might-be-caching-one-users-data-and-serving-it-to-everyone-else</link>
<description>&lt;p&gt;This is the kind of bug that goes from &quot;works perfectly in every test&quot; to &quot;actively leaking one customer's data to another customer&quot; without a single code change in between, purely because of how caching and personalization interact in a way that isn't obvious from reading the component code.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-1&quot;&gt;The Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
export default async function DashboardPage() {
  const session = await getServerSession();
  const user = await User.findById(session.userId);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Welcome back, {user.name}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;Account balance: {user.balance}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks obviously personalized, it reads the session, looks up the specific user, renders their specific data. There's nothing here that looks like it needs special caching considerations. That assumption is exactly where this goes wrong.&lt;/p&gt;
&lt;h3 id=&quot;why-next-js-can-get-this-wrong&quot;&gt;Why Next.js Can Get This Wrong&lt;/h3&gt;
&lt;p&gt;Next.js's App Router tries to statically render as much as possible by default, for good performance reasons. A page gets treated as static (cacheable, shared across all visitors) unless something in it signals that it needs to be dynamic (rendered fresh per-request). Specific APIs, reading &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, or &lt;code&gt;searchParams&lt;/code&gt; directly, are what tell Next.js a page actually depends on request-specific data and needs dynamic rendering.&lt;/p&gt;
&lt;p&gt;The problem is that &lt;code&gt;getServerSession()&lt;/code&gt; in the example above may, depending on exactly how it's implemented and which auth library version you're on, read the session cookie in a way that doesn't always propagate that dynamic signal up to the page itself, especially if the cookie read happens inside a library function several layers removed from the page component, rather than a direct &lt;code&gt;cookies()&lt;/code&gt; call visible in the page's own code. When that signal doesn't propagate correctly, Next.js can determine the page is eligible for static optimization, render it once during the build or on first request, and then serve that exact same rendered HTML, including the first user's name and balance, to every subsequent visitor who hits that route, until the cache naturally expires or gets invalidated.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-specifically-dangerous-not-just-buggy&quot;&gt;Why This Is Specifically Dangerous, Not Just Buggy&lt;/h3&gt;
&lt;p&gt;A caching bug that shows stale content is annoying. A caching bug that shows a different user's personal data is a real data exposure incident, and the specific danger here is that it doesn't look like a caching bug from the outside, it looks like the application is broken for that specific user, showing someone else's name and balance, which can easily get reported and triaged as &quot;a display bug&quot; rather than correctly identified as a serious caching and session isolation issue.&lt;/p&gt;
&lt;h3 id=&quot;why-this-survives-normal-testing&quot;&gt;Why This Survives Normal Testing&lt;/h3&gt;
&lt;p&gt;Testing a dashboard page almost always means logging in as a specific test user and confirming their own correct data renders. That test passes regardless of whether the response got cached and shared or genuinely rendered fresh for that specific request, because from a single test account's perspective, the result looks identical either way. The bug only becomes visible with two separate user sessions hitting the same route in a way that reveals the cached response is actually shared, which isn't a scenario most manual or automated testing naturally covers.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-force-dynamic-rendering-explicitly-for-anything-personalized&quot;&gt;The Fix: Force Dynamic Rendering Explicitly for Anything Personalized&lt;/h3&gt;
&lt;p&gt;Don't rely on an auth helper's internal implementation to correctly signal dynamic rendering. State it explicitly for any route serving per-user data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';

export default async function DashboardPage() {
  const session = await getServerSession();
  const user = await User.findById(session.userId);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Welcome back, {user.name}&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;Account balance: {user.balance}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;export const dynamic = 'force-dynamic'&lt;/code&gt; line removes any ambiguity about whether the page qualifies for static optimization. It costs the performance benefit of static rendering for that specific route, which is the correct tradeoff for anything genuinely personalized, that cost is real but small compared to the alternative.&lt;/p&gt;
&lt;p&gt;An equally valid alternative is calling &lt;code&gt;cookies()&lt;/code&gt; or &lt;code&gt;headers()&lt;/code&gt; directly inside the page itself, rather than only inside a wrapped auth helper function, since a direct call from the page component is what reliably signals dynamic rendering without depending on how a library internally implements its own session check.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { cookies } from 'next/headers';

export default async function DashboardPage() {
  cookies(); // explicit, direct call signals dynamic rendering regardless of what getServerSession does internally
  const session = await getServerSession();
  // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule-7&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any page serving per-user or per-request data needs to explicitly and verifiably opt out of static rendering, not just assume an auth library's internals do that correctly on its behalf.&lt;/strong&gt; This matters more, not less, as auth libraries and caching layers evolve between versions, since a signal that correctly propagated in one version can silently stop doing so after an update you didn't closely audit.&lt;/p&gt;
&lt;p&gt;I treat every authenticated dashboard route as explicitly &lt;code&gt;force-dynamic&lt;/code&gt; by default in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://neurodash-dashbord.vercel.app/&quot;&gt;Next.js dashboard templates&lt;/a&gt; I build, specifically because the cost of guessing wrong here is a real user data leak, not just a stale page.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own authenticated pages for an explicit &lt;code&gt;dynamic&lt;/code&gt; export or a direct &lt;code&gt;cookies()&lt;/code&gt;/&lt;code&gt;headers()&lt;/code&gt; call, rather than relying on an auth helper to signal it correctly on your behalf. If you're not sure, this is worth verifying with two separate logged-in sessions today, not assuming. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31024/your-next-js-page-might-be-caching-one-users-data-and-serving-it-to-everyone-else</guid>
<pubDate>Wed, 14 Oct 2026 07:51:52 +0000</pubDate>
</item>
<item>
<title>Your Mongoose pre('save') Hook Is Being Silently Skipped by Half Your Update Calls</title>
<link>https://coderlegion.com/30913/your-mongoose-pre-save-hook-is-being-silently-skipped-by-half-your-update-calls</link>
<description>&lt;p&gt;This is one of the more consequential Mongoose gotchas because of exactly what tends to live inside a &lt;code&gt;pre('save')&lt;/code&gt; hook, password hashing, slug generation, validation logic, and exactly which methods silently bypass it without ever telling you.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-2&quot;&gt;The Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// models/User.ts
const userSchema = new Schema({
  email: String,
  password: String,
});

userSchema.pre('save', async function (next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 12);
  }
  next();
});

export const User = model('User', userSchema);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a new user with &lt;code&gt;User.create()&lt;/code&gt; or &lt;code&gt;new User(data).save()&lt;/code&gt;, and this works exactly as intended. The password arrives hashed, every time, without the rest of your codebase needing to remember to call &lt;code&gt;bcrypt.hash&lt;/code&gt; manually anywhere. It feels like a solved problem.&lt;/p&gt;
&lt;h3 id=&quot;where-it-actually-breaks&quot;&gt;Where It Actually Breaks&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A password reset flow
export async function resetPassword(userId: string, newPassword: string) {
  await User.findByIdAndUpdate(userId, { password: newPassword });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This runs without error. It updates the user's password field in the database. It also never calls &lt;code&gt;pre('save')&lt;/code&gt;, which means the password gets stored in &lt;strong&gt;plain text&lt;/strong&gt;, directly in your database, with no error, no warning, and a function name, &lt;code&gt;findByIdAndUpdate&lt;/code&gt;, that reads as a completely reasonable, idiomatic way to update a user record.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;findByIdAndUpdate&lt;/code&gt;, &lt;code&gt;findOneAndUpdate&lt;/code&gt;, &lt;code&gt;updateOne&lt;/code&gt;, and &lt;code&gt;updateMany&lt;/code&gt; all operate directly against MongoDB's update commands. They don't instantiate a full Mongoose document and call &lt;code&gt;.save()&lt;/code&gt; on it internally, they issue an update operation straight to the database. Mongoose's document middleware, including &lt;code&gt;pre('save')&lt;/code&gt;, only runs on document instances that actually go through &lt;code&gt;.save()&lt;/code&gt;. These query-based update methods structurally never touch that code path at all.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specifically-gets-missed&quot;&gt;Why This Specifically Gets Missed&lt;/h3&gt;
&lt;p&gt;Both approaches, &lt;code&gt;.save()&lt;/code&gt; and &lt;code&gt;findByIdAndUpdate&lt;/code&gt;, look like equally valid, equally idiomatic ways to update a Mongoose document, and most Mongoose tutorials use both interchangeably without flagging that they behave completely differently with respect to middleware. A codebase that uses &lt;code&gt;.save()&lt;/code&gt; for creating users and &lt;code&gt;findByIdAndUpdate&lt;/code&gt; for updating them, which is an extremely common and reasonable-looking pattern, ends up with password hashing that works perfectly at signup and silently doesn't exist at all for a password reset, profile update, or admin-initiated password change, unless someone happens to specifically test that exact path with a tool that shows raw database contents.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-use-query-middleware-not-just-document-middleware&quot;&gt;The Fix: Use Query Middleware, Not Just Document Middleware&lt;/h3&gt;
&lt;p&gt;Mongoose provides a separate middleware hook specifically for query-based update methods:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;userSchema.pre('findOneAndUpdate', async function (next) {
  const update = this.getUpdate() as any;

  if (update.password) {
    update.password = await bcrypt.hash(update.password, 12);
  }

  next();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This specifically targets &lt;code&gt;findOneAndUpdate&lt;/code&gt; and &lt;code&gt;findByIdAndUpdate&lt;/code&gt; (which is implemented as a wrapper around &lt;code&gt;findOneAndUpdate&lt;/code&gt; internally). &lt;code&gt;updateOne&lt;/code&gt; and &lt;code&gt;updateMany&lt;/code&gt; need their own separate middleware registration, since Mongoose treats each query method as its own distinct middleware hook rather than one unified &quot;any kind of update&quot; event:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;userSchema.pre('updateOne', async function (next) {
  const update = this.getUpdate() as any;
  if (update.password) {
    update.password = await bcrypt.hash(update.password, 12);
  }
  next();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;a-more-defensive-alternative-standardize-on-one-update-path&quot;&gt;A More Defensive Alternative: Standardize on One Update Path&lt;/h3&gt;
&lt;p&gt;Registering middleware separately for every query method Mongoose offers is viable but easy to let drift out of sync as new update call sites get added later by someone who doesn't know this distinction exists. A more durable fix for anything genuinely sensitive, like password handling, is routing all updates through a single service function that always uses &lt;code&gt;.save()&lt;/code&gt; internally, rather than letting &lt;code&gt;findByIdAndUpdate&lt;/code&gt; calls for that model spread throughout the codebase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// services/userService.ts
export async function updateUser(userId: string, data: Partial&amp;lt;UserInput&amp;gt;) {
  const user = await User.findById(userId);
  if (!user) throw new Error('User not found');

  Object.assign(user, data);
  await user.save(); // always goes through pre('save') middleware
  return user;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This trades a small amount of performance, an extra read before the write, for a guarantee that every update for this model goes through the exact same validation and transformation logic, with no separate middleware registration required for each query method and no risk of a future &lt;code&gt;findByIdAndUpdate&lt;/code&gt; call quietly skipping it.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-8&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Anything inside a &lt;code&gt;pre('save')&lt;/code&gt; hook only runs for updates that go through &lt;code&gt;.save()&lt;/code&gt;, never for &lt;code&gt;findOneAndUpdate&lt;/code&gt;, &lt;code&gt;findByIdAndUpdate&lt;/code&gt;, &lt;code&gt;updateOne&lt;/code&gt;, or &lt;code&gt;updateMany&lt;/code&gt;.&lt;/strong&gt; For any logic where skipping it has real consequences, password hashing being the clearest example, either register the equivalent query middleware for every update method your codebase actually uses, or standardize on a single service function that always goes through &lt;code&gt;.save()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I route every sensitive field update through a dedicated service layer exactly like this in the auth code behind my &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/mongodb-mongoose-patterns-2026&quot;&gt;Next.js and MongoDB templates&lt;/a&gt;, specifically because this gap is easy to introduce the moment a second developer, or a future version of yourself, reaches for the more convenient-looking &lt;code&gt;findByIdAndUpdate&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own password reset, profile update, and admin edit flows for a &lt;code&gt;findByIdAndUpdate&lt;/code&gt; or &lt;code&gt;updateOne&lt;/code&gt; call on a model with password hashing in &lt;code&gt;pre('save')&lt;/code&gt;. If you find one, check what's actually sitting in that field in your database right now. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30913/your-mongoose-pre-save-hook-is-being-silently-skipped-by-half-your-update-calls</guid>
<pubDate>Tue, 13 Oct 2026 09:40:18 +0000</pubDate>
</item>
<item>
<title>Your NextAuth callbackUrl Might Be an Open Redirect Waiting to Happen</title>
<link>https://coderlegion.com/30794/your-nextauth-callbackurl-might-be-an-open-redirect-waiting-to-happen</link>
<description>&lt;p&gt;Redirecting a user back to the page they were trying to reach after they log in is good, expected UX. Nobody wants to log in and land back on the homepage when they were actually trying to read a specific article or check out a specific product. The common way this gets implemented is also a common way to accidentally build an open redirect vulnerability directly into your auth flow.&lt;/p&gt;
&lt;h3 id=&quot;the-pattern-that-looks-completely-reasonable&quot;&gt;The Pattern That Looks Completely Reasonable&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/login/page.tsx
export default function LoginPage({ searchParams }: { searchParams: { callbackUrl?: string } }) {
  return (
    &amp;lt;form action={async (formData: FormData) =&amp;gt; {
      'use server';
      const result = await signIn('credentials', {
        email: formData.get('email'),
        password: formData.get('password'),
        redirect: false,
      });

      if (result?.ok) {
        redirect(searchParams.callbackUrl || '/dashboard');
      }
    }}&amp;gt;
      {/* form fields */}
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a genuinely common pattern, a protected page redirects an unauthenticated visitor to &lt;code&gt;/login?callbackUrl=/dashboard/reports&lt;/code&gt;, and after a successful login, the app sends them back to wherever that parameter points. It works correctly, and it feels like a small, harmless convenience feature, right up until someone realizes that &lt;code&gt;callbackUrl&lt;/code&gt; value came straight from a query string the visitor controls entirely.&lt;/p&gt;
&lt;h3 id=&quot;what-an-attacker-actually-does-with-this&quot;&gt;What an Attacker Actually Does With This&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;https://yourapp.com/login?callbackUrl=https://evi1-yourapp-login.com/phish
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your redirect logic trusts the &lt;code&gt;callbackUrl&lt;/code&gt; value without validating that it actually points somewhere inside your own app, a victim who clicks a link like this, maybe from a phishing email that looks legitimate because the domain in the visible link is genuinely &lt;code&gt;yourapp.com&lt;/code&gt;, logs in successfully on your real site, with their real credentials, and then gets silently redirected to an attacker-controlled page immediately afterward. That page can look like anything, a fake &quot;session expired, please log in again&quot; prompt designed to harvest the same credentials a second time, now with the victim's guard completely down because they just successfully authenticated on the real site seconds earlier.&lt;/p&gt;
&lt;p&gt;This is specifically dangerous because every part of the actual login happened on your real, legitimate domain. Nothing about the login page itself looked suspicious, because it wasn't, the vulnerability is entirely in what happens after a genuine, successful login.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-easy-to-miss&quot;&gt;Why This Is Easy to Miss&lt;/h3&gt;
&lt;p&gt;Redirect-after-login code gets tested by developers clicking through their own app, which only ever produces &lt;code&gt;callbackUrl&lt;/code&gt; values that point to real internal pages, because that's the only way anyone naturally encounters this flow during development. The idea of someone deliberately crafting a malicious external value for this parameter doesn't come up unless security testing specifically targets it, since from a pure functionality standpoint, the feature works perfectly for every normal case anyone would think to try.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-validate-the-redirect-target-is-actually-internal&quot;&gt;The Fix: Validate the Redirect Target Is Actually Internal&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/safe-redirect.ts
export function getSafeRedirectUrl(callbackUrl: string | undefined, fallback = '/dashboard'): string {
  if (!callbackUrl) return fallback;

  // Reject anything that isn't a relative path starting with exactly one slash.
  // &quot;//evil.com&quot; and &quot;https://evil.com&quot; both get rejected here,
  // protocol-relative URLs are a classic bypass for a naive check.
  if (!callbackUrl.startsWith('/') || callbackUrl.startsWith('//')) {
    return fallback;
  }

  return callbackUrl;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;if (result?.ok) {
  redirect(getSafeRedirectUrl(searchParams.callbackUrl));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The specific check for a leading &lt;code&gt;//&lt;/code&gt; matters more than it looks like it should. A browser interprets &lt;code&gt;//evil.com&lt;/code&gt; as a protocol-relative URL, meaning it resolves to &lt;code&gt;https://evil.com&lt;/code&gt; using whatever protocol the current page is on, and a naive check that only verifies &quot;does this start with a single slash&quot; can be bypassed entirely by that exact pattern. Rejecting anything that doesn't match a genuine single-slash-prefixed relative path closes this specific gap.&lt;/p&gt;
&lt;p&gt;If you're using NextAuth's built-in redirect callback, the same principle applies there directly, NextAuth exposes a &lt;code&gt;redirect&lt;/code&gt; callback specifically so you can validate target URLs centrally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts
export const authOptions: NextAuthOptions = {
  callbacks: {
    async redirect({ url, baseUrl }) {
      // Only allow redirects to the same origin as the app itself
      if (url.startsWith(baseUrl)) return url;
      if (url.startsWith('/')) return `${baseUrl}${url}`;
      return baseUrl;
    },
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule-9&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any redirect target sourced from user-controllable input, a query parameter, a form field, a referrer header, needs to be validated as pointing somewhere inside your own app before you redirect to it.&lt;/strong&gt; &quot;It's just a convenience redirect&quot; is exactly the framing that makes this vulnerability easy to introduce without noticing, since the feature's happy path looks completely benign and only the deliberately crafted input reveals the gap.&lt;/p&gt;
&lt;p&gt;I validate every redirect target this way in the auth flows across my &lt;a rel=&quot;nofollow&quot; href=&quot;https://neurodash-dashbord.vercel.app/&quot;&gt;Next.js dashboard templates&lt;/a&gt;, since login and post-auth redirects show up in basically every project and this is a genuinely easy detail to skip under normal development pressure. More on the broader auth patterns I build around at &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own login flow for a &lt;code&gt;callbackUrl&lt;/code&gt;, &lt;code&gt;returnTo&lt;/code&gt;, or &lt;code&gt;next&lt;/code&gt; parameter that gets redirected to without validation. If you find one, try crafting a &lt;code&gt;//evil.com&lt;/code&gt; style value against it. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30794/your-nextauth-callbackurl-might-be-an-open-redirect-waiting-to-happen</guid>
<pubDate>Mon, 12 Oct 2026 10:39:32 +0000</pubDate>
</item>
<item>
<title>Your Next.js Middleware Matcher Config Is Probably Skipping Routes You Think It Protects</title>
<link>https://coderlegion.com/30712/your-next-js-middleware-matcher-config-is-probably-skipping-routes-you-think-it-protects</link>
<description>&lt;p&gt;Middleware feels like the cleanest way to gate an entire section of a Next.js app behind auth, one file, one config, covering everything under a path. That confidence is exactly the problem, because the &lt;code&gt;matcher&lt;/code&gt; config that's supposed to define &quot;everything under this path&quot; has a few specific gaps that don't show up until someone hits a nested route that quietly skipped the check entirely.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-complete&quot;&gt;The Setup That Looks Complete&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// middleware.ts
import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/dashboard/:path*',
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reads as &quot;protect everything under &lt;code&gt;/dashboard&lt;/code&gt;,&quot; and for the routes most people test during development, &lt;code&gt;/dashboard&lt;/code&gt;, &lt;code&gt;/dashboard/settings&lt;/code&gt;, &lt;code&gt;/dashboard/analytics&lt;/code&gt;, it works exactly as expected. The gap shows up specifically with route groups and parallel/intercepting route conventions, which don't map onto the URL path the way the folder structure visually suggests they do.&lt;/p&gt;
&lt;h3 id=&quot;where-it-actually-breaks-1&quot;&gt;Where It Actually Breaks&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;app/
├── (dashboard)/
│   ├── dashboard/
│   │   └── page.tsx          → /dashboard
│   └── settings/
│       └── page.tsx          → /settings  (NOT /dashboard/settings!)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Route groups, the parenthesized folders, don't appear in the URL at all. A page at &lt;code&gt;app/(dashboard)/settings/page.tsx&lt;/code&gt; is served at &lt;code&gt;/settings&lt;/code&gt;, not &lt;code&gt;/dashboard/settings&lt;/code&gt;, even though it visually sits inside what looks like your dashboard's folder in the codebase. A &lt;code&gt;matcher: '/dashboard/:path*'&lt;/code&gt; pattern never matches &lt;code&gt;/settings&lt;/code&gt;, because as far as the URL is concerned, it was never under &lt;code&gt;/dashboard&lt;/code&gt; to begin with.&lt;/p&gt;
&lt;p&gt;This is exactly the kind of structural confusion that's easy to carry forward from a folder structure that leans on route groups for layout purposes, the same route groups I use throughout the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js templates on pixelanas.com&lt;/a&gt;, without re-checking that every protected page's actual URL still falls inside the matcher pattern protecting it, because the folder nesting and the URL nesting have quietly diverged.&lt;/p&gt;
&lt;h3 id=&quot;a-second-subtler-gap-matcher-patterns-and-encoded-characters&quot;&gt;A Second, Subtler Gap: Matcher Patterns and Encoded Characters&lt;/h3&gt;
&lt;p&gt;Next.js's own documentation for the &lt;code&gt;matcher&lt;/code&gt; config notes that values must be literal strings or simple patterns, matcher patterns don't support arbitrary regex features, and certain edge cases around encoded path segments or unusual characters in dynamic segments can fail to match in ways that aren't obvious from reading the pattern itself. A route like &lt;code&gt;/dashboard/reports/q1%2F2026&lt;/code&gt; (an encoded slash inside a dynamic segment) can behave unpredictably against a pattern that looks like it should obviously cover it.&lt;/p&gt;
&lt;h3 id=&quot;why-this-doesn-t-get-caught&quot;&gt;Why This Doesn't Get Caught&lt;/h3&gt;
&lt;p&gt;Testing auth-gated routes usually means manually clicking through the main pages of a protected section, the ones directly nested under the obvious path, and confirming the redirect works. Pages that technically live in the same conceptual section of your app but sit outside the matched URL pattern because of a route group, or a page that was added later and happens to resolve to a URL slightly outside what the matcher covers, simply never get manually tested against the auth gate, because nobody thinks to check a URL that doesn't look like it needs checking.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-verify-matcher-coverage-against-actual-resolved-urls-not-folder-structure&quot;&gt;The Fix: Verify Matcher Coverage Against Actual Resolved URLs, Not Folder Structure&lt;/h3&gt;
&lt;p&gt;Don't trust the matcher pattern based on how your &lt;code&gt;app/&lt;/code&gt; folder looks. Check it against the actual resolved URL of every page you intend to protect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const config = {
  matcher: [
    '/dashboard/:path*',
    '/settings/:path*',       // explicitly added once the real URL was checked
    '/billing/:path*',
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A more defensive pattern for anything genuinely sensitive is inverting the logic entirely, protect everything by default, and explicitly exclude the few public routes, rather than trying to enumerate every protected path and hoping the list stays complete as the app grows:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const config = {
  matcher: [
    '/((?!login|signup|api/public|_next/static|_next/image|favicon.ico).*)',
  ],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern protects everything except the explicitly named public routes, which means a newly added page under a route group, or anywhere else, is protected by default rather than silently falling outside coverage until someone notices.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-10&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A middleware matcher pattern should be verified against the app's actual resolved URLs, not assumed from how the &lt;code&gt;app/&lt;/code&gt; folder is visually organized.&lt;/strong&gt; Route groups, parallel routes, and intercepting route conventions all affect the URL in ways that don't match the folder nesting, which is exactly where a matcher pattern that looks complete quietly isn't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own middleware matcher against the real URLs of every page it's supposed to protect, not the folder structure. If any of them use route groups, this is worth double-checking today. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30712/your-next-js-middleware-matcher-config-is-probably-skipping-routes-you-think-it-protects</guid>
<pubDate>Sun, 11 Oct 2026 14:25:54 +0000</pubDate>
</item>
<item>
<title>You Changed a User's Role in the Database, But Their JWT Doesn't Know That Yet</title>
<link>https://coderlegion.com/30591/you-changed-a-users-role-in-the-database-but-their-jwt-doesnt-know-that-yet</link>
<description>&lt;p&gt;You find out a user's permissions need to change right now, maybe they're being demoted from admin, maybe their account access is being revoked entirely after an incident. You update the database. The change looks instant in your admin panel. Meanwhile, that user keeps acting with their old permissions for as long as their existing session token stays valid, sometimes for hours, sometimes for days, and nothing about your database update touched that.&lt;/p&gt;
&lt;h3 id=&quot;why-this-happens-jwts-don-t-check-back-in&quot;&gt;Why This Happens: JWTs Don't Check Back In&lt;/h3&gt;
&lt;p&gt;A JWT-based session, the kind most Next.js apps use with something like NextAuth, is a self-contained token. Once it's issued, your server verifies it by checking its signature and expiration, not by looking anything up in your database. That's the entire point of a stateless token, it avoids a database round trip on every single request.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// middleware.ts
export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });

  // This only checks: is the signature valid, has it expired?
  // It does NOT check: does this role still match what's in the database right now?
  if (token?.role !== 'admin') {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  return NextResponse.next();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The role embedded in that token was correct the moment it was issued. It is not re-verified against the database on every request, because doing so would defeat the entire performance benefit of using a stateless token in the first place. If you update the user's role in the database five minutes after their token was issued, the token itself still says the old role, and will keep saying that until it expires and they're forced to log in again.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-causes-real-problems&quot;&gt;Where This Actually Causes Real Problems&lt;/h3&gt;
&lt;p&gt;For a routine role change, like a title update that doesn't affect sensitive access, a short delay before it takes effect is a minor inconvenience, at worst, mildly confusing. For a security-sensitive revocation, suspending an account after detecting suspicious activity, revoking admin access after an employee departure, removing a compromised API key's associated session, that same delay is a real exposure window. The access you believed you'd just shut off is still fully functional from the attacker's or departed employee's side, for however long their existing token remains valid.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-easy-to-miss-building-the-app&quot;&gt;Why This Is Easy to Miss Building the App&lt;/h3&gt;
&lt;p&gt;Session and role logic almost always gets tested by logging in fresh after making a change, which issues a brand new token with the updated role baked in, making the fix look like it worked perfectly. The actual bug only shows up for a user who's already logged in with an existing token at the moment the change happens, a scenario that's genuinely easy to never think to test, since it requires two separate sessions and specific timing to notice.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-force-re-verification-for-anything-sensitive&quot;&gt;The Fix: Force Re-Verification for Anything Sensitive&lt;/h3&gt;
&lt;p&gt;The cleanest general fix is shortening token lifetime for sensitive roles and pairing it with refresh logic that re-checks the database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// auth.ts (NextAuth config)
export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
    maxAge: 15 * 60, // 15 minutes instead of days, forces more frequent re-issuance
  },
  callbacks: {
    async jwt({ token, user, trigger }) {
      if (trigger === 'update' || !token.lastChecked || Date.now() - (token.lastChecked as number) &amp;gt; 60_000) {
        // Re-fetch the current role from the database periodically,
        // not just at initial login
        const dbUser = await User.findById(token.sub).select('role status');
        if (!dbUser || dbUser.status === 'suspended') {
          return { ...token, role: null, revoked: true };
        }
        token.role = dbUser.role;
        token.lastChecked = Date.now();
      }
      return token;
    },
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For genuinely urgent revocations, like an active security incident, token expiration alone isn't fast enough, and this is where some form of server-side revocation check becomes necessary, a denylist of revoked token IDs checked against a fast store like Redis, even if you're otherwise fully stateless everywhere else. It adds back a small lookup cost, but only for the specific case where &quot;wait until the token expires naturally&quot; genuinely isn't an acceptable answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A minimal revocation check added to middleware
const isRevoked = await redis.sismember('revoked_sessions', token.jti);
if (isRevoked) {
  return NextResponse.redirect(new URL('/login', request.url));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-rule-11&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A JWT's claims are a snapshot from the moment it was issued, not a live view of your database.&lt;/strong&gt; Any role or permission check based purely on token contents can be stale for as long as that token remains valid. For routine data, that staleness is usually an acceptable tradeoff for performance. For anything where immediate revocation actually matters, you need either short-lived tokens with periodic re-verification, or an explicit revocation check, because the token itself has no way of knowing anything changed.&lt;/p&gt;
&lt;p&gt;I treat this distinction explicitly in the auth setup behind my &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/mongodb-mongoose-patterns-2026&quot;&gt;Next.js dashboard templates&lt;/a&gt;, where role-based access control needs to actually mean something the moment it changes, not sometime later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Check your own app, specifically how fast a role change or account suspension actually takes effect for a user with an existing session. If the honest answer is &quot;whenever their token expires,&quot; that's worth deciding on purpose rather than by default. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30591/you-changed-a-users-role-in-the-database-but-their-jwt-doesnt-know-that-yet</guid>
<pubDate>Sat, 10 Oct 2026 13:33:53 +0000</pubDate>
</item>
<item>
<title>How we check every AI-generated room render before a user sees it</title>
<link>https://coderlegion.com/30506/how-we-check-every-ai-generated-room-render-before-a-user-sees-it</link>
<description>&lt;p&gt;Image models are very good at restyling a room and quietly bad at leaving the room alone. Ask for a Japandi living room and you get beautiful furniture, and sometimes also:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a window that moved, shrank, or appeared over a kitchen sink where the photo shows a solid wall;&lt;/li&gt;
&lt;li&gt;a closet door that came back as a curtain, a sliding screen, or plain wall;&lt;/li&gt;
&lt;li&gt;a sofa parked across the only archway;&lt;/li&gt;
&lt;li&gt;a new doorway at the frame edge, or a niche carved into a flat wall;&lt;/li&gt;
&lt;li&gt;a camera that slid sideways;&lt;/li&gt;
&lt;li&gt;or the opposite: a picture that is almost exactly the photo you uploaded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Our users put these images in property listings, where a staged photo may change the furniture but not the windows, walls or doors (&lt;a rel=&quot;nofollow&quot; href=&quot;https://aifliproom.com/blog/virtual-staging-disclosure-rules&quot;&gt;why that line exists&lt;/a&gt;). So every render is checked automatically before anyone sees it. Here is how, and what building it taught us.&lt;/p&gt;
&lt;h3 id=&quot;the-pipeline-at-a-glance&quot;&gt;The pipeline at a glance&lt;/h3&gt;
&lt;p&gt;For an indoor room, each render goes through three gates in parallel:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A vision-model judge&lt;/strong&gt; compares the original photo with the render and answers a fixed list of yes/no questions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A mechanical camera check&lt;/strong&gt; measures whether the viewpoint moved, with no language model involved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A crop-level presence check&lt;/strong&gt; looks at each door, window and staircase found in the original, at full resolution.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If all three pass, the render is served. If not, we retry. If nothing passes, the user gets the best attempt, clearly marked as a draft.&lt;/p&gt;
&lt;h3 id=&quot;gate-1-a-judge-that-only-answers-booleans&quot;&gt;Gate 1: a judge that only answers booleans&lt;/h3&gt;
&lt;p&gt;The judge gets the original and the render, both downscaled, and must return a fixed JSON object. We use structured output with a schema, so it cannot reply in prose. An earlier version asked for &quot;only JSON&quot; and scraped it out with a regex; when the output got long or cut off, the parse failed and the gate silently did nothing.&lt;/p&gt;
&lt;p&gt;Indoors there are 16 checks, outdoors 9. Indoors they cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Openings:&lt;/strong&gt; every door is still a door you could open; every passage has clear floor to walk through.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows:&lt;/strong&gt; same count, position, size and shape; none invented; not hidden behind a tall wardrobe; the same view outside.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The room shell:&lt;/strong&gt; ceiling, fireplace, no carved niches, features on the same walls, solid frame edges, no new rooms or corridors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finish consistency:&lt;/strong&gt; no half-repainted rooms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content rules:&lt;/strong&gt; no people, and no photo or realistic portrait of a person in the wall art.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Camera:&lt;/strong&gt; same viewpoint, same room depth.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outdoors: buildings, fence line and gates, terrain (no new pool), mature trees, background, and a usable driveway.&lt;/p&gt;
&lt;p&gt;A simplified, illustrative verdict:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;doors_preserved&quot;: true,
  &quot;openings_clear&quot;: false,
  &quot;windows_ok&quot;: true,
  &quot;window_unblocked&quot;: true,
  &quot;walls_uniform&quot;: true,
  &quot;frame_edges_solid&quot;: true,
  &quot;no_faces_in_art&quot;: true,
  &quot;fail_reasons&quot;: [&quot;sofa end stands on the archway floor&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A render passes only if every counted boolean is true; the reasons feed logs and the retry prompt. Most of the work went into the wording:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Say what must pass.&lt;/strong&gt; Restyling is the product, so the instructions list what is expected (new floors, paint, wallpaper, fixtures) and say &quot;unsure means pass&quot;. The exception is a short list where unsure means fail, led by a window that may not have been in the original. An invented window is the worst defect a listing photo can have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One question per field.&lt;/strong&gt; While the frame-edge rule was a sub-clause of a broader &quot;no phantom architecture&quot; check, new doorways at the frame edge got through. Its own boolean made the model actually look.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Naming things primes the judge.&lt;/strong&gt; An early niche check listed which shelves were allowed, and the judge started flagging every shelf. The current wording is purely geometric: is this flat wall still one plane?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Allow &quot;nothing to compare&quot;.&lt;/strong&gt; The &quot;same view through the window&quot; check kept firing on originals with blinds drawn or glass blown out to white. That case is now an explicit pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Wording is measurable.&lt;/strong&gt; The old wall-art wording wrongly failed 10 of 16 good frames (stylised line drawings counted as faces). The rewrite failed 0 of 16 and still caught all 5 real portraits.&lt;/p&gt;
&lt;h3 id=&quot;gate-2-measuring-the-camera-instead-of-asking-about-it&quot;&gt;Gate 2: measuring the camera instead of asking about it&lt;/h3&gt;
&lt;p&gt;Early on, the vision model was close to blind to viewpoint changes: renders shot from a visibly different spot passed every neural check. Later, a stronger judge had the opposite problem: on 43 renders where the camera had not moved, it said it had 13 times. So indoors the judge's camera answers no longer decide pass or fail. A measurement does, with two channels:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ceiling line.&lt;/strong&gt; We trace the wall/ceiling boundary as a curve (dynamic programming over the vertical brightness gradient) and compare the original's curve with the render's.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Depth profile.&lt;/strong&gt; A monocular depth model estimates both images. Per column we take how far the far wall reads, then compare the profiles with a robust fit, a rank correlation and a search for a sideways shift.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each channel is fooled by something different. Paint fools the ceiling line (dark beams, art on a mantel). Glass fools depth (windows and mirrors are estimated differently each time). Paint is flat in depth, and glass does not move the ceiling line, so we flag a shift only when &lt;strong&gt;both&lt;/strong&gt; agree: the minimum of the two normalised scores.&lt;/p&gt;
&lt;p&gt;On 24 hand-labelled pairs from two rooms, this caught 5 of 5 real shifts with 0 false flags. The margin was thin, so a grey zone around the threshold is logged, not failed. Outdoors there is no ceiling to trace, so the mechanical check is off and the judge's camera question stays in charge.&lt;/p&gt;
&lt;h3 id=&quot;gate-3-crops-because-a-door-is-80-pixels-wide&quot;&gt;Gate 3: crops, because a door is 80 pixels wide&lt;/h3&gt;
&lt;p&gt;At the judge's resolution a front door is a thin strip. So doors, windows and stairs are detected at upload, and after each render a model is asked about each crop at full resolution: is it still there? A mechanical channel also measures whether a window drifted or widened. On 87 labelled pairs, the crop judge caught 7 of 10 broken renders with no false alarms on the 75 clean ones; the mechanical channel caught the other 3.&lt;/p&gt;
&lt;h3 id=&quot;when-a-render-fails&quot;&gt;When a render fails&lt;/h3&gt;
&lt;p&gt;Retries depend on the kind of defect.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Placement defects&lt;/strong&gt; (a blocked passage, a face in the art, half-painted walls) are retried on the same image model with the judge's reasons added to the prompt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geometry defects&lt;/strong&gt; go to a second, pricier image model. We measured a same-model retry fixing broken geometry about one time in five. In an earlier bake-off the two models were clean on 18 and 17 of 20 renders, and 19 of 20 together: their failures barely overlap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The geometry retry gets a fixed, count-based instruction (&quot;same set of openings, not one more, not one less&quot;), never the judge's description of the defect. When a retry prompt named an arched opening at the left edge, the retry model painted exactly that.&lt;/p&gt;
&lt;p&gt;If nothing passes, the user still gets the best attempt, under a &quot;rough draft&quot; banner. On paid plans the first four rejected drafts in a rolling week don't use a render; the free plan gets one. The cap is deliberate: a draft is a real, downloadable image, so unlimited forgiveness would be a free image farm.&lt;/p&gt;
&lt;p&gt;The checks fail open. If the judge itself errors or times out, the user is not blocked: the image is served flagged, and an unverified render is not charged as a draft.&lt;/p&gt;
&lt;h3 id=&quot;the-near-copy-trap&quot;&gt;The near-copy trap&lt;/h3&gt;
&lt;p&gt;At first, &quot;best attempt&quot; meant the one with the fewest failed checks. The attempt that breaks the least is often the one where the model barely did anything. In one real case the first attempt was a convincing industrial restyle that failed on geometry, and the user was served the second: their old room, old wallpaper and all, plus a pair of lamps. The ranking was paying the model for doing nothing.&lt;/p&gt;
&lt;p&gt;The fix is a change meter: shrink both images to a tiny thumbnail and count how much of the frame visibly changed. No model call, and we calibrated it on 110 real renders and 10 synthetic near-copies (recompressed, blurred, brightened, shifted, a couple of lamp-sized patches). The ranking now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// simplified
function draftRank(a: Attempt): number {
  if (a.checkerErrored) return LAST;
  let rank = a.failedChecks.length;
  if (a.hasPeopleOrRealFacesInArt) rank += HARD_BAN;
  if (a.isNearCopy &amp;amp;&amp;amp; a.intensity !== &quot;light&quot;) rank += NEAR_COPY;
  return rank; // lowest is served
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A light restage is exempt, because there the user asked us to keep the room.&lt;/p&gt;
&lt;h3 id=&quot;picking-the-judge-model-with-a-calibration-set&quot;&gt;Picking the judge model with a calibration set&lt;/h3&gt;
&lt;p&gt;The judge runs on every attempt, so a cheaper model is tempting. We keep a calibration set of 31 hand-labelled frames and run every candidate on it twice. A cheaper model at under half the price per call missed 6 defects per run against our current judge's 3. On 46 live renders with 8 defects marked by eye, it caught 0 and 1 across two runs; the current judge caught 6. A newer model wasn't clearly better either: fewer misses (2 vs 3), twice the false alarms (4 vs 2). We kept the current judge.&lt;/p&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Ask the judge narrow yes/no questions, not one &quot;is this good?&quot;.&lt;/li&gt;
&lt;li&gt;Where geometry can be measured, measure it, with channels whose failure modes don't overlap.&lt;/li&gt;
&lt;li&gt;Calibrate every wording change and model swap on a labelled set, twice.&lt;/li&gt;
&lt;li&gt;Design the failure path as carefully as the check: ranking, draft label, who pays.&lt;/li&gt;
&lt;li&gt;&quot;Least broken&quot; can mean &quot;least changed&quot;. Measure change directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We build this at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aifliproom.com&quot;&gt;AI Flip Room&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30506/how-we-check-every-ai-generated-room-render-before-a-user-sees-it</guid>
<pubDate>Fri, 09 Oct 2026 18:55:25 +0000</pubDate>
</item>
<item>
<title>I built email alerts for my app with no login system</title>
<link>https://coderlegion.com/30352/i-built-email-alerts-for-my-app-with-no-login-system</link>
<description>&lt;p&gt;My movie discovery site (&lt;a rel=&quot;nofollow&quot; href=&quot;https://galaxymovies.app&quot;&gt;galaxymovies.app&lt;/a&gt;) needed a feature that sounds simple: &quot;email me when something on my watchlist lands on Netflix.&quot; Users save titles to a local watchlist, pick their streaming services, and want a nudge the day a title actually arrives.&lt;/p&gt;
&lt;p&gt;That feature wants an account system. Passwords, sessions, email verification, password resets, the works. I really did not want to build that. Every line of auth code is a liability I have to maintain forever, and the payoff for users is typing a password into a movie site.&lt;/p&gt;
&lt;p&gt;So I asked a different question: what's the minimum credential that makes this work? The answer turned out to be one email address and two tokens. Here's the whole design.&lt;/p&gt;
&lt;h3 id=&quot;the-insight-the-confirmation-link-is-already-proof&quot;&gt;The insight: the confirmation link is already proof&lt;/h3&gt;
&lt;p&gt;When someone submits the waitlist form, they get a confirmation email with a token link. Clicking it proves they control that inbox. That's it. That's the entire identity system.&lt;/p&gt;
&lt;p&gt;But the confirm token can't be the API credential. It's a link secret, meant to be clicked once and done. Syncing a watchlist needs a durable bearer credential the client sends on every edit. So confirm mints a second, separate token: a UUID sync key stored in a Redis hash keyed by email.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const PENDING_KEY = 'gm:waitlist:pending'       // email =&amp;gt; confirm token
export const CONFIRMED_KEY = 'gm:waitlist:confirmed'   // email =&amp;gt; unsub token
export const SYNCKEY_KEY = 'gm:alerts:synckey'         // email =&amp;gt; sync UUID
export const ALERTS_PREFS_KEY = 'gm:alerts:prefs'      // email =&amp;gt; {watchlist, services, region}
export const ALERTS_NOTIFIED_KEY = 'gm:alerts:notified' // email =&amp;gt; sent alert keys
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two credentials, two jobs. Leaking or rotating the sync key never touches the unsubscribe token, and vice versa.&lt;/p&gt;
&lt;h3 id=&quot;handing-out-the-key-through-a-url-fragment&quot;&gt;Handing out the key through a URL fragment&lt;/h3&gt;
&lt;p&gt;The interesting part is delivery. The sync key has to get from the confirmation click to the user's browser without ever appearing in my server logs. Query strings get logged; fragments don't.&lt;/p&gt;
&lt;p&gt;So the confirm endpoint 302-redirects to a status page with the credentials in the hash:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Fragment never reaches the server on redirect follow, so the sync
// credential stays out of access logs from this point on.
const syncKey = await getOrCreateSyncKey(kv, email)
return redirect('confirmed', `email=${encodeURIComponent(email)}&amp;amp;key=${syncKey}`)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which produces &lt;code&gt;/email-status?status=confirmed#email=you%40x.com&amp;amp;key=abc-123&lt;/code&gt;. The status query param is logged; the fragment is not, because browsers don't send it. The page reads &lt;code&gt;window.location.hash&lt;/code&gt;, writes &lt;code&gt;{email, key}&lt;/code&gt; to localStorage, and the credential now exists in exactly two places: the user's browser and one Redis hash field.&lt;/p&gt;
&lt;h3 id=&quot;syncing-without-sessions&quot;&gt;Syncing without sessions&lt;/h3&gt;
&lt;p&gt;The client posts a snapshot of the watchlist and chosen providers whenever they change. Two details keep this cheap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function syncAlertsNow(data, creds = getAlertsCredentials()) {
  if (!creds) return
  const payload = JSON.stringify(buildSnapshot(data))
  if (localStorage.getItem(LAST_SYNCED_KEY) === payload) return
  const ok = await postPrefs(creds, snapshot)
  if (ok) localStorage.setItem(LAST_SYNCED_KEY, payload)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A 4 second debounce collapses edit bursts, and the &lt;code&gt;LAST_SYNCED_KEY&lt;/code&gt; check skips the request entirely if nothing changed since the last successful sync. Any watchlist edit re-renders the app, so without the diff check every idle re-render would fire a POST.&lt;/p&gt;
&lt;p&gt;On the server side, &lt;code&gt;POST /api/alerts-prefs&lt;/code&gt; validates the email and key formats, rate limits per IP and per email (30/hour each, keyed by a sha256 of the address rather than the raw email), then &lt;code&gt;safeEqual&lt;/code&gt;s the key against the hash:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const stored = await kv.hget&amp;lt;string&amp;gt;(SYNCKEY_KEY, email)
if (!stored || !safeEqual(stored, key)) {
  return res.status(403).json({ error: 'Invalid sync credentials' })
}
await kv.hset(ALERTS_PREFS_KEY, { [email]: { watchlist, services, region, updatedAt: Date.now() } })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what's missing: no session, no cookie, no JWT. The sync key is just a bearer token with a 36 character format check.&lt;/p&gt;
&lt;h3 id=&quot;the-cron-does-the-matching&quot;&gt;The cron does the matching&lt;/h3&gt;
&lt;p&gt;A daily job diffs each tracked provider's catalog (added and coming soon lists are already computed by an earlier cron and cached). Then it loads all three hashes in three &lt;code&gt;HGETALL&lt;/code&gt;s and walks subscribers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;for (const item of change[kind]) {
  if (!item.tmdbId || !watchlistKeys.has(`${item.tmdbType}:${item.tmdbId}`)) continue
  const notifiedKey = `${kind}:${item.tmdbType}:${item.tmdbId}@${providerKey}`
  if (notified.has(notifiedKey)) continue
  out.push({ kind, notifiedKey, tmdbId: item.tmdbId, ... })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each subscriber's watchlist becomes a &lt;code&gt;mediaType:id&lt;/code&gt; set matched against their chosen providers' diffs. The &lt;code&gt;notifiedKey&lt;/code&gt; dedupe (&lt;code&gt;added:movie:550@netflix&lt;/code&gt;) means nobody gets the same alert twice, and each user gets at most one email per day with up to 20 matches. The email carries a &lt;code&gt;List-Unsubscribe&lt;/code&gt; header and a one-click link.&lt;/p&gt;
&lt;p&gt;One subtlety: the cron reads provider diffs with &lt;code&gt;fresh: true&lt;/code&gt; to skip the per-instance memo I added to keep pageviews cheap. A memoized diff is exactly wrong here, the whole point is &quot;what changed today.&quot;&lt;/p&gt;
&lt;h3 id=&quot;a-second-device-is-just-a-form-resubmit&quot;&gt;A second device is just a form resubmit&lt;/h3&gt;
&lt;p&gt;Syncing another device needs no new UI. If an already confirmed email resubmits the waitlist form, the server mails them an &quot;alerts-link&quot; URL that reissues the fragment flow on whatever device opened the email:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;if (await kv.hget(CONFIRMED_KEY, email)) {
  const key = await getOrCreateSyncKey(kv, email)
  await sendLinkDeviceEmail(email, key)
  return res.status(200).json({ ok: true, pending: true })
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same &lt;code&gt;pending: true&lt;/code&gt; response as a fresh signup, deliberately. Returning &quot;you're already subscribed&quot; would make the form an oracle for checking who's on the list.&lt;/p&gt;
&lt;h3 id=&quot;unsubscribing-deletes-everything&quot;&gt;Unsubscribing deletes everything&lt;/h3&gt;
&lt;p&gt;The unsubscribe link uses the original confirm token and wipes all five hash fields: confirmed status, pending, sync key, stored prefs, and the notified set:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await kv.hdel(CONFIRMED_KEY, email)
await kv.hdel(PENDING_KEY, email)
await kv.hdel(SYNCKEY_KEY, email)
await kv.hdel(ALERTS_PREFS_KEY, email)
await kv.hdel(ALERTS_NOTIFIED_KEY, email)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any client still holding the sync key starts getting 403s immediately, and there's no residual data to leak.&lt;/p&gt;
&lt;h3 id=&quot;what-i-gave-up-honestly&quot;&gt;What I gave up, honestly&lt;/h3&gt;
&lt;p&gt;This is not authentication, and I want to be straight about the limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anyone with the sync key can overwrite that email's watchlist prefs. It's a write only vandalism risk bounded to preference data, not an impersonation risk. There's nothing sensitive to read back, the prefs endpoint never returns stored data.&lt;/li&gt;
&lt;li&gt;The confirm and link endpoints skip rate limiting on purpose, because mail scanners prefetch links. The token is the auth. That trade means those endpoints are cheap GETs that do one hash lookup.&lt;/li&gt;
&lt;li&gt;If someone loses their browser profile, the fix is resubmitting the form and getting a fresh link. Annoying, but not a support ticket.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-actual-lesson&quot;&gt;The actual lesson&lt;/h3&gt;
&lt;p&gt;&quot;Needs alerts&quot; does not mean &quot;needs accounts.&quot; If the identity proof you're already doing (email confirmation) is strong enough, a second bearer credential for the chatty sync traffic gets you the whole feature. Fragment delivery keeps it out of logs, format checks plus rate limits keep it boring, and the unsubscribe path deleting every trace keeps it honest.&lt;/p&gt;
&lt;p&gt;Total auth surface: zero passwords, zero sessions, zero cookies. One email field and a UUID.&lt;/p&gt;
&lt;p&gt;The site is galaxymovies.app if you want to see the flow. Sign up, confirm, and watch your browser's localStorage for &lt;code&gt;gm.alerts.v1&lt;/code&gt;, that's the whole credential.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30352/i-built-email-alerts-for-my-app-with-no-login-system</guid>
<pubDate>Thu, 08 Oct 2026 23:42:57 +0000</pubDate>
</item>
<item>
<title>My side project's free tiers were dying, so I counted what each service actually bills</title>
<link>https://coderlegion.com/30349/my-side-projects-free-tiers-were-dying-so-i-counted-what-each-service-actually-bills</link>
<description>&lt;p&gt;Galaxy Movies is a movie discovery side project ~ Next.js Pages Router on Vercel, TMDB for data, Upstash Redis for caching &amp;amp; rate limiting, and OpenAI for a chat assistant &amp;amp; generated synopses. Everything runs on free tiers, which is the whole point of a side project.&lt;/p&gt;
&lt;p&gt;A few weeks ago all three of those free tiers started groaning at once. Vercel function usage was climbing, Upstash was burning through its daily command quota well before midnight, and OpenAI spend had a suspicious correlation with traffic spikes that produced no actual readers.&lt;/p&gt;
&lt;p&gt;The fixes all came down to the same realization: &lt;strong&gt;every &quot;free&quot; tier bills a different unit, and I was counting the wrong things.&lt;/strong&gt; Here's what was actually billing me, and what I changed.&lt;/p&gt;
&lt;h3 id=&quot;act-1-the-bots-were-running-up-my-function-bill&quot;&gt;Act 1: The bots were running up my function bill&lt;/h3&gt;
&lt;p&gt;Vercel bills function &lt;em&gt;duration&lt;/em&gt;, and my movie and TV detail pages were expensive: five parallel TMDB fetches (title, watch providers, credits, ratings, recommendations), plus Redis reads for the AI content slots, all in &lt;code&gt;getServerSideProps&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The expensive traffic wasn't humans. SEO crawlers and AI scrapers sweep the entire TMDB ID space in bulk, and every one of those detail pages ran the full fetch chain. They don't index anything and send zero readers. Pure cost.&lt;/p&gt;
&lt;p&gt;The fix was a thin render path just for them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Bots that don't index get a minimal render. They sweep the TMDB id
// space in bulk and don't need providers, credits, videos, or AI slots.
if (isBot(ua) &amp;amp;&amp;amp; !isSearchEngine(ua)) {
  const titleRes = await fetch(`https://api.themoviedb.org/3/${media}/${id}?api_key=${apiKey}`)
  // on failure return notFound or errorProps; otherwise:
  context.res.setHeader('Cache-Control', 'private, no-store')
  return { props: { ...thinProps, media, title, topCast: [] } }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details matter here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;isSearchEngine&lt;/code&gt; split.&lt;/strong&gt; &lt;code&gt;isBot&lt;/code&gt; matches a broad allowlist, but Googlebot, Bingbot, and DuckDuckGo still get the full render since they index. Social preview bots get the thin version. Whether a bot indexes is the only question that matters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;private, no-store&lt;/code&gt; is critical.&lt;/strong&gt; If the stripped page entered the shared CDN cache, a cache collision could serve the bot version to a human, or worse, to Googlebot. Cheap responses must never contaminate the real cache.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also found &lt;code&gt;robots.txt&lt;/code&gt; was being rendered by a Next.js &lt;em&gt;page&lt;/em&gt;, which meant every crawler's very first request was a function invocation. It moved to a static &lt;code&gt;public/robots.txt&lt;/code&gt;, which doubles as an enforcement layer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;User-agent: AhrefsBot
User-agent: SemrushBot
User-agent: MJ12bot
User-agent: Bytespider
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: CCBot
# ...several more
Disallow: /
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, &lt;code&gt;Disallow&lt;/code&gt; is an honor system, but the big commercial crawlers honor it, and they were most of the volume.&lt;/p&gt;
&lt;p&gt;Finally, I lengthened the SSR CDN cache windows: &lt;code&gt;s-maxage&lt;/code&gt; from 1 hour to 1 day, &lt;code&gt;stale-while-revalidate&lt;/code&gt; from 1 day to 1 week. Repeat crawler hits on popular titles now terminate at the edge instead of running SSR again.&lt;/p&gt;
&lt;h3 id=&quot;act-2-upstash-bills-every-single-redis-command&quot;&gt;Act 2: Upstash bills every single Redis command&lt;/h3&gt;
&lt;p&gt;This one caught me off guard. Upstash's free tier bills &lt;em&gt;per command&lt;/em&gt;, and a pipeline doesn't save you, because &lt;strong&gt;each member of a pipeline is its own billed command&lt;/strong&gt;. My request paths fanned out into Redis calls that felt free because &quot;Redis is fast.&quot;&lt;/p&gt;
&lt;p&gt;The audit turned up some embarrassing finds. Cache reads on detail pages were ~97% misses, so I was paying a billed command for a null almost every time. Rate limiting cost up to three commands per check. A dataset updated once a day was being fetched again on every pageview.&lt;/p&gt;
&lt;p&gt;The fixes, roughly in order of satisfaction:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rate limiting became one Lua script.&lt;/strong&gt; &lt;code&gt;INCRBY&lt;/code&gt; plus a conditional &lt;code&gt;EXPIRE&lt;/code&gt; plus a TTL recovery check run inside a single &lt;code&gt;EVAL&lt;/code&gt;, billed once no matter how many &lt;code&gt;redis.call()&lt;/code&gt;s it makes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;local count = redis.call(&quot;INCRBY&quot;, KEYS[1], ARGV[1])
if count &amp;lt;= tonumber(ARGV[1]) then
  redis.call(&quot;EXPIRE&quot;, KEYS[1], ARGV[2])
elseif count &amp;gt; tonumber(ARGV[3]) and redis.call(&quot;TTL&quot;, KEYS[1]) &amp;lt; 0 then
  redis.call(&quot;EXPIRE&quot;, KEYS[1], ARGV[2])
end
return count
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The TTL check handles a real edge case: if a key somehow loses its expiry, a blocked client fixes it on the next attempt instead of being rate limited forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check caches before paying the rate limit write.&lt;/strong&gt; &lt;code&gt;/api/prices&lt;/code&gt; was rate limiting requests that would have been cache hits, paying a write before doing a read. Reorder, and every cache hit is one command instead of two.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memoize what changes daily.&lt;/strong&gt; The &lt;code&gt;streaming-changes&lt;/code&gt; diffs are rewritten once a day by cron but were being fetched per key on every pageview. A per instance LRU holding results for ten minutes turns repeat page loads into zero Redis commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const changesMemo = new LRUCache&amp;lt;string, Record&amp;lt;string, ProviderChanges&amp;gt;&amp;gt;({ max: 64, ttl: 10 * 60 * 1000 })

export async function getProviderChanges(providerKeys = [], { fresh = false } = {}) {
  const memoKey = [...providerKeys].sort().join(',')
  if (!fresh) {
    const memoized = changesMemo.get(memoKey)
    if (memoized) return memoized
  }
  // ...Redis reads, then changesMemo.set(memoKey, changes)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;fresh&lt;/code&gt; flag exists because the cron itself must never match subscribers against a diff it just replaced. Memoization that's correct &lt;em&gt;most&lt;/em&gt; of the time is a bug with extra steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batch the counters.&lt;/strong&gt; Every &lt;code&gt;HINCRBY&lt;/code&gt; is billed, so AI usage and analytics deltas accumulate in a &lt;code&gt;Map&lt;/code&gt; and flush every 30 seconds (or 50 fields). Stats lag by up to half a minute and a dying instance loses its unflushed tail, a fine trade for telemetry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Restructure data to match access patterns.&lt;/strong&gt; Detail pages fetch both AI content slots in one &lt;code&gt;MGET&lt;/code&gt; instead of two &lt;code&gt;GET&lt;/code&gt;s. Generated lists moved from one key per slug into a single hash, where &lt;code&gt;HGETALL&lt;/code&gt; is one command replacing an unbounded key fanout.&lt;/p&gt;
&lt;p&gt;Net result: roughly half the daily command volume, with quota headroom for the first time in weeks.&lt;/p&gt;
&lt;h3 id=&quot;act-3-openai-spend-or-why-is-a-scraper-generating-my-content-&quot;&gt;Act 3: OpenAI spend, or &quot;why is a scraper generating my content?&quot;&lt;/h3&gt;
&lt;p&gt;The AI features on detail pages (generated synopses, similar title blurbs) trigger an OpenAI call on a cache miss. Guess who was causing cache misses at scale: the same scrapers from Act 1. Bots were literally generating my content.&lt;/p&gt;
&lt;p&gt;So bots got read only access: they can consume AI content that's already cached but can never trigger generation (&lt;code&gt;generate: false&lt;/code&gt; on the cache lookup). Human traffic is unaffected; scraper sweeps now cost zero tokens.&lt;/p&gt;
&lt;p&gt;The other fixes were less dramatic but added up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failed cron seeds stopped retrying forever.&lt;/strong&gt; A broken seed now records &lt;code&gt;failed&lt;/code&gt; with an attempt counter and gives up after 3 tries, instead of burning a &lt;code&gt;discover&lt;/code&gt; call on every daily run until the heat death of the universe.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Longer cache TTLs.&lt;/strong&gt; Reviews and similar titles are static per title, so 365 day TTLs mean one generation per title, ever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prompt dieting.&lt;/strong&gt; Similar title prompts dropped to 6 candidates with 200 character overviews; editorial prompts got explicit word budgets; structured JSON output removed the &quot;please don't ramble&quot; tax.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared chat cache.&lt;/strong&gt; Identical chat requests (quick actions, common first questions; the bulk of traffic) now hit a Redis cache shared across instances, plus tighter limits: 10/hr per IP, 1000/day global.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per task model control.&lt;/strong&gt; All OpenAI calls route through one module with per task &lt;code&gt;reasoning_effort&lt;/code&gt;: &lt;code&gt;none&lt;/code&gt; for latency sensitive tasks, &lt;code&gt;low&lt;/code&gt; only for the discover generator. Real token usage gets logged so I can see where spend actually goes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-kill-switch&quot;&gt;The kill switch&lt;/h4&gt;
&lt;p&gt;The scariest scenario isn't gradual spend. It's a quota or billing problem silently converting every request into an error, or worse, into retry storms. Billing and auth failures (&lt;code&gt;401&lt;/code&gt;/&lt;code&gt;402&lt;/code&gt;, &lt;code&gt;insufficient_quota&lt;/code&gt;) now trip a shared kill switch with a one hour cooldown:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function isQuotaError(err: unknown): boolean {
  if (!(err instanceof OpenAI.APIError)) return false
  if (err.status === 401 || err.status === 402) return true
  const code = err.code || err.error?.code
  return code === 'insufficient_quota'
      || code === 'billing_hard_limit_reached'
      || code === 'billing_not_active'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While it's tripped: &lt;code&gt;/api/chat&lt;/code&gt; returns &lt;code&gt;503 { code: 'ai_unavailable' }&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; spending budget, the chat widget checks &lt;code&gt;/api/aiStatus&lt;/code&gt; on mount and hides itself entirely instead of showing an error bubble, the generators skip their calls, and the content cron skips the run &lt;em&gt;without&lt;/em&gt; burning seed attempts, since an outage isn't a seed's fault. Crucially, ordinary &lt;code&gt;429 rate_limit_exceeded&lt;/code&gt; errors don't trip it; those are transient.&lt;/p&gt;
&lt;p&gt;One deliberate choice throughout: &lt;strong&gt;degrade in the direction that saves money.&lt;/strong&gt; When Redis errors on a check that protects spend, the code falls back to a per instance limiter and never fails open. An in memory limiter might be less accurate, but a Redis outage can't be used to run up my OpenAI bill.&lt;/p&gt;
&lt;h3 id=&quot;the-part-where-i-broke-things&quot;&gt;The part where I broke things&lt;/h3&gt;
&lt;p&gt;Honest bookkeeping: the cost cuts shipped two bugs worth their own paragraph.&lt;/p&gt;
&lt;p&gt;After moving the chat cache to Redis, identical requests started hitting on every quick action prompt, and the cache hit path returned plain JSON that the SSE client never parsed. Cache hits went from &quot;the fast path&quot; to &quot;the broken path&quot; overnight. Fix: return cache hits through the SSE stream like everything else.&lt;/p&gt;
&lt;p&gt;And when bots stopped triggering AI generation, Lighthouse CI got caught in the crossfire. It audits the real detail pages, and its UA matched the bot list, so it was suddenly measuring the thin render. &lt;code&gt;Chrome-Lighthouse&lt;/code&gt; went on the search engine side of the check.&lt;/p&gt;
&lt;p&gt;Both bugs were the same shape: &lt;strong&gt;a filter added at the edge changed behavior for a consumer I forgot about.&lt;/strong&gt; If your bot detection doesn't have a list of &lt;em&gt;legitimate&lt;/em&gt; automated consumers, it will find one for you. In production. On a Sunday.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-tell-past-me&quot;&gt;What I'd tell past me&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Count the billed unit, not the abstraction.&lt;/strong&gt; &quot;Redis call,&quot; &quot;function invocation,&quot; and &quot;API request&quot; are all wrong. It's commands, gigabyte seconds, and tokens, and the gap between what I thought I was spending and what I was billed lived entirely inside that distinction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Segment bots by whether they index&lt;/strong&gt;, not whether they're bots. That one boolean decided which requests deserved the expensive render, the Redis reads, and the cacheable output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache at the cheapest layer that works.&lt;/strong&gt; CDN edge &amp;gt; shared Redis &amp;gt; per instance LRU &amp;gt; regenerate. I had requests traversing the whole hierarchy when a static file would do (looking at you, &lt;code&gt;robots.txt&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free tier quotas are architecture constraints&lt;/strong&gt;, not billing details. &quot;A few Redis commands per request is fine&quot; is a statement that only survives until you do the multiplication.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instrument spend itself.&lt;/strong&gt; The usage counters I added to survive the Upstash quota are now how I watch OpenAI costs. The fix for a quota crisis becomes your observability for the next one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The site is &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.galaxymovies.app&quot;&gt;galaxymovies.app&lt;/a&gt; if you want to see the thin renders in the wild. View source as a human, please; the bots are expensive enough. Happy to compare notes in the comments if you've fought the same free tier math!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30349/my-side-projects-free-tiers-were-dying-so-i-counted-what-each-service-actually-bills</guid>
<pubDate>Thu, 08 Oct 2026 22:42:45 +0000</pubDate>
</item>
<item>
<title>Your useEffect Search Bar Has a Race Condition, and It Only Shows Up When Users Type Fast</title>
<link>https://coderlegion.com/30278/your-useeffect-search-bar-has-a-race-condition-and-it-only-shows-up-when-users-type-fast</link>
<description>&lt;p&gt;This is one of those bugs where the code looks completely reasonable, passes every manual test you'd naturally think to run, and still ships with a real defect that only shows up under a specific, very common usage pattern: someone typing faster than your network requests resolve.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-3&quot;&gt;The Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/SearchBox.tsx
'use client';

export function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState&amp;lt;Result[]&amp;gt;([]);

  useEffect(() =&amp;gt; {
    if (!query) {
      setResults([]);
      return;
    }

    async function fetchResults() {
      const res = await fetch(`/api/search?q=${query}`);
      const data = await res.json();
      setResults(data.results);
    }

    fetchResults();
  }, [query]);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;input value={query} onChange={(e) =&amp;gt; setQuery(e.target.value)} /&amp;gt;
      &amp;lt;ul&amp;gt;
        {results.map((r) =&amp;gt; (
          &amp;lt;li key={r.id}&amp;gt;{r.title}&amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Type slowly, pause between letters, and this works exactly as expected. It's also wrong in a way that's specifically invisible to that kind of careful, deliberate testing.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-breaks&quot;&gt;What Actually Breaks&lt;/h3&gt;
&lt;p&gt;Say a user types &quot;react&quot; quickly, letter by letter: &lt;code&gt;r&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;rea&lt;/code&gt;, &lt;code&gt;reac&lt;/code&gt;, &lt;code&gt;react&lt;/code&gt;. Each keystroke triggers the effect, which fires a new fetch request for that exact partial query. These five requests are now all in flight simultaneously, and nothing about this code guarantees they resolve in the order they were sent.&lt;/p&gt;
&lt;p&gt;Network conditions are not guaranteed to preserve request order. It's entirely possible, and in practice fairly common under real-world network jitter, for the request for &lt;code&gt;&quot;r&quot;&lt;/code&gt; to resolve after the request for &lt;code&gt;&quot;react&quot;&lt;/code&gt; does. When that happens, &lt;code&gt;setResults(data.results)&lt;/code&gt; runs last for the &lt;code&gt;&quot;r&quot;&lt;/code&gt; response, overwriting the correct, complete results for &lt;code&gt;&quot;react&quot;&lt;/code&gt; with a handful of broad, barely-relevant matches for a single letter the user stopped looking at four keystrokes ago.&lt;/p&gt;
&lt;p&gt;The user sees a results list that doesn't match what's sitting in the input field, with no error, no warning, nothing to indicate anything went wrong. It just looks like the search is broken or laggy, which is a uniquely frustrating kind of bug to report because it's intermittent and timing-dependent, not consistently reproducible by just repeating the same steps slowly.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specifically-survives-testing&quot;&gt;Why This Specifically Survives Testing&lt;/h3&gt;
&lt;p&gt;Manual testing during development almost always involves typing at a normal, deliberate pace, often pausing to look at the screen between each change. That pace gives each request time to resolve well before the next one fires, which means the race condition's actual precondition, overlapping in-flight requests for different queries, simply never occurs during the kind of testing most developers naturally do. The bug needs genuinely fast typing, a slow or inconsistent network, or both, to reliably surface, and automated tests using scripted input often type unrealistically fast without that being the specific thing anyone's checking for.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-ignore-stale-responses&quot;&gt;The Fix: Ignore Stale Responses&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  if (!query) {
    setResults([]);
    return;
  }

  let ignore = false;

  async function fetchResults() {
    const res = await fetch(`/api/search?q=${query}`);
    const data = await res.json();

    if (!ignore) {
      setResults(data.results);
    }
  }

  fetchResults();

  return () =&amp;gt; {
    ignore = true;
  };
}, [query]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ignore&lt;/code&gt; flag is set in the effect's cleanup function, which React runs right before the effect re-runs for a new &lt;code&gt;query&lt;/code&gt; value, or when the component unmounts. Every in-flight request from a now-outdated &lt;code&gt;query&lt;/code&gt; gets its &lt;code&gt;ignore&lt;/code&gt; flag flipped to &lt;code&gt;true&lt;/code&gt; the moment a newer request starts, so even if the old request resolves later, its result is silently discarded instead of overwriting the current, correct state.&lt;/p&gt;
&lt;h3 id=&quot;a-cleaner-version-with-abortcontroller&quot;&gt;A Cleaner Version With AbortController&lt;/h3&gt;
&lt;p&gt;The ignore-flag pattern works, but it still lets the outdated network request complete in the background, wasting bandwidth and a server round trip for a result nobody will ever see. &lt;code&gt;AbortController&lt;/code&gt; actually cancels the request itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  if (!query) {
    setResults([]);
    return;
  }

  const controller = new AbortController();

  async function fetchResults() {
    try {
      const res = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      setResults(data.results);
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        console.error('Search failed:', err);
      }
    }
  }

  fetchResults();

  return () =&amp;gt; {
    controller.abort();
  };
}, [query]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now a stale request is actually cancelled, not just ignored, which both fixes the race condition and avoids burning server resources on searches the user has already moved past.&lt;/p&gt;
&lt;h3 id=&quot;where-else-this-exact-pattern-bites&quot;&gt;Where Else This Exact Pattern Bites&lt;/h3&gt;
&lt;p&gt;Any &lt;code&gt;useEffect&lt;/code&gt; that fetches data based on a value that can change again before the fetch resolves has this same underlying issue, not just search boxes. A filter dropdown firing a new query on every change, a tab switcher loading different data per tab, pagination where a user clicks &quot;next&quot; rapidly, all of these share the identical race condition shape. It's the same category of timing bug I build defensively around in the dashboard and data-table components across my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;Next.js templates&lt;/a&gt;, same root cause, different trigger.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-12&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any effect that fetches data based on a value that can change again before the request resolves needs a way to discard or cancel the outdated response.&lt;/strong&gt; Either an ignore flag or an &lt;code&gt;AbortController&lt;/code&gt;, but not neither. The bug is invisible in slow, deliberate testing and reliably present the moment a real user interacts with your app faster than your network responds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own search boxes, filters, and tab switchers for a &lt;code&gt;useEffect&lt;/code&gt; fetch with no cleanup-based cancellation. If you find one, try typing into it as fast as you actually can. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30278/your-useeffect-search-bar-has-a-race-condition-and-it-only-shows-up-when-users-type-fast</guid>
<pubDate>Thu, 08 Oct 2026 12:08:17 +0000</pubDate>
</item>
<item>
<title>Server Actions Don't Need CSRF Protection, API Routes Still Do, and Mixing That Up Is a Real Hole</title>
<link>https://coderlegion.com/30135/server-actions-dont-need-csrf-protection-api-routes-still-do-and-mixing-that-up-is-real-hole</link>
<description>&lt;p&gt;Somewhere along the way, a lot of Next.js developers picked up the idea that &quot;Server Actions are secure by default&quot; and quietly generalized that into &quot;mutations in this app are secure by default.&quot; Those are not the same statement, and the gap between them is exactly where this bug lives.&lt;/p&gt;
&lt;h3 id=&quot;why-server-actions-actually-are-safe-from-csrf&quot;&gt;Why Server Actions Actually Are Safe From CSRF&lt;/h3&gt;
&lt;p&gt;Next.js Server Actions include built-in CSRF protection out of the box. When a Server Action is called, Next.js checks the request's &lt;code&gt;Origin&lt;/code&gt; header against the deployment's own host, and rejects the call if it doesn't match.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// actions/update-email.ts
'use server';

export async function updateEmail(formData: FormData) {
  const newEmail = formData.get('email') as string;
  const userId = await getCurrentUserId();
  await User.findByIdAndUpdate(userId, { email: newEmail });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You didn't write any CSRF-handling code here, and you don't need to. Next.js's framework-level origin check means a malicious site trying to trigger this action from a hidden form or script on a different domain gets rejected before your function body ever runs. This protection is real, and it's one of the genuinely nice defaults Server Actions give you for free.&lt;/p&gt;
&lt;h3 id=&quot;where-the-same-confidence-quietly-stops-applying&quot;&gt;Where the Same Confidence Quietly Stops Applying&lt;/h3&gt;
&lt;p&gt;The problem shows up the moment the same app also has API routes doing equivalent mutations, which is extremely common, Server Actions for your own forms, API routes for anything that needs to be called from outside, a mobile app, a webhook, a public integration.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/update-email/route.ts
export async function POST(request: Request) {
  const { email, userId } = await request.json();

  // No origin check here. Nothing here is Next.js's built-in CSRF protection,
  // because that protection is specific to Server Actions, not API routes.
  await User.findByIdAndUpdate(userId, { email });

  return Response.json({ success: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This route has no framework-provided CSRF protection at all. If it relies on a cookie-based session to identify the user rather than a token the calling code must explicitly attach, a malicious page on a completely different domain can trigger a request to this endpoint using the victim's own browser, which will happily attach their existing session cookie, and the server has no way to tell this request apart from a legitimate one made from your own app.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- On attacker-controlled-site.com --&amp;gt;
&amp;lt;form action=&quot;https://yourapp.com/api/users/update-email&quot; method=&quot;POST&quot;&amp;gt;
  &amp;lt;input type=&quot;hidden&quot; name=&quot;email&quot; value=&quot;*Emails are not allowed*&quot; /&amp;gt;
&amp;lt;/form&amp;gt;
&amp;lt;script&amp;gt;document.forms[0].submit();&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a logged-in victim visits that page, their browser submits this form to your real API route, cookies and all, and your route processes it as if it came from your own frontend, because nothing in that route checks otherwise.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specifically-gets-missed-1&quot;&gt;Why This Specifically Gets Missed&lt;/h3&gt;
&lt;p&gt;The mental model &quot;Next.js handles CSRF&quot; is correct for exactly one surface, Server Actions, and developers reasonably extend it to the whole app because the framework genuinely did remove the need to think about this for the most common mutation path. API routes feel like the same kind of thing, same app, same auth system, same database calls, so the assumption carries over even though the actual protection mechanism does not.&lt;/p&gt;
&lt;p&gt;This also tends to get reinforced by testing, since every normal test of the API route happens from your own frontend, on your own origin, which is exactly the traffic pattern that looks identical whether or not CSRF protection exists.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-treat-api-routes-as-needing-their-own-explicit-protection&quot;&gt;The Fix: Treat API Routes as Needing Their Own Explicit Protection&lt;/h3&gt;
&lt;p&gt;If an API route is meant to only ever be called from your own frontend, verify the origin explicitly, the same check Server Actions do for you automatically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/update-email/route.ts
function isSameOrigin(request: Request): boolean {
  const origin = request.headers.get('origin');
  const host = request.headers.get('host');
  if (!origin) return false;
  return new URL(origin).host === host;
}

export async function POST(request: Request) {
  if (!isSameOrigin(request)) {
    return Response.json({ error: 'Invalid origin' }, { status: 403 });
  }

  const { email, userId } = await request.json();
  await User.findByIdAndUpdate(userId, { email });
  return Response.json({ success: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the route genuinely needs to be called from outside your own frontend, don't rely on cookie-based session auth for it at all. Use an explicit bearer token or API key that an attacker's page has no way to attach automatically, which is the actual reason CSRF doesn't apply to most token-based API authentication in the first place, the browser doesn't auto-include a header a malicious page doesn't know the value of.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-13&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&quot;Next.js handles CSRF&quot; is true for Server Actions specifically, not for every mutation path in your app.&lt;/strong&gt; Any API route that mutates data and relies on cookie-based auth needs its own explicit protection, either an origin check or a move away from cookies toward an explicit token for that specific route.&lt;/p&gt;
&lt;p&gt;I keep this distinction explicit in the auth layer of my &lt;a rel=&quot;nofollow&quot; href=&quot;https://neurodash-dashbord.vercel.app/&quot;&gt;Next.js dashboard templates&lt;/a&gt;, where Server Actions and API routes sit side by side and get treated very differently for exactly this reason. You can see the broader portfolio of templates and projects this pattern comes from at &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Go check your own API routes for anything that mutates data using cookie session auth with no origin check. If you find one, this is worth closing today. Drop what you find in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30135/server-actions-dont-need-csrf-protection-api-routes-still-do-and-mixing-that-up-is-real-hole</guid>
<pubDate>Wed, 07 Oct 2026 12:14:06 +0000</pubDate>
</item>
<item>
<title>AudioFlow- STT and TTS tool</title>
<link>https://coderlegion.com/30084/audioflow-stt-and-tts-tool</link>
<description>&lt;p&gt;Welcome to AudioFlow in this app i created 2 simple ai tools that u can use in daily life&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Voise to Text (STT)&lt;/li&gt;
&lt;li&gt;Text to Voise (TTS)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This project is completely open-source so u can re-use this tools for your any kind of chatbot project&lt;/p&gt;
&lt;p&gt;features of this app&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Real-time audio streaming using web-socket&lt;/li&gt;
&lt;li&gt;Reactive waveform which improve UX&lt;/li&gt;
&lt;li&gt;Keeps Audio and Transaction history in local-storage&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;How to use this app ?&lt;br&gt;
both tools are use Gemini models so u need to provide an api key it free generous tier for you personal use and it safely stored in local storage.&lt;/p&gt;
&lt;p&gt;Live Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://audio-flow-ai.vercel.app/&quot;&gt;website-link&lt;/a&gt;&lt;br&gt;
GitHub Repository: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/OnkarK0273/audio-flow&quot;&gt;repo-link&lt;/a&gt;&lt;br&gt;
Video Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://lnkd.in/p/e4Exwiv5&quot;&gt;video-link&lt;/a&gt;&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/30084/audioflow-stt-and-tts-tool</guid>
<pubDate>Wed, 07 Oct 2026 06:52:22 +0000</pubDate>
</item>
<item>
<title>Why Your Pages Get Indexed But Never Rank</title>
<link>https://coderlegion.com/30011/why-your-pages-get-indexed-but-never-rank</link>
<description>&lt;h2 id=&quot;why-your-pages-get-indexed-but-never-rank&quot;&gt;Why Your Pages Get Indexed But Never Rank&lt;/h2&gt;
&lt;p&gt;You check Google Search Console. Your page is indexed. Google crawled it, read it, and added it to the index without any errors. You search your exact keyword anyway, and your page is nowhere to be found, not on page one, not on page five, sometimes not anywhere in the first several pages of results.&lt;/p&gt;
&lt;p&gt;This confuses a lot of site owners, because it feels like being indexed should mean you are now in the running. It does not. Indexing and ranking are two separate stages, and most of the disappointment around &quot;my SEO isn't working&quot; actually comes from mixing the two up.&lt;/p&gt;
&lt;h3 id=&quot;indexing-just-means-google-knows-your-page-exists&quot;&gt;Indexing Just Means Google Knows Your Page Exists&lt;/h3&gt;
&lt;p&gt;Being indexed means Google has crawled your page, processed its content, and stored it in the enormous database it uses to serve search results. That's it. It is a prerequisite for ranking, not a form of ranking itself.&lt;/p&gt;
&lt;p&gt;Think of indexing as being added to a library's card catalog. Being in the catalog means a librarian could theoretically hand your book to someone. It says nothing about whether your book ends up on the recommended shelf at the front of the store or buried in the back where nobody browses.&lt;/p&gt;
&lt;h3 id=&quot;ranking-is-a-completely-different-competition&quot;&gt;Ranking Is a Completely Different Competition&lt;/h3&gt;
&lt;p&gt;Once a page is indexed, it enters a separate process every time someone searches a related query. Google compares your page against every other indexed page that might answer that same query, and ranks them by its own assessment of relevance, authority, and user experience.&lt;/p&gt;
&lt;p&gt;This is where the real competition happens, and it is a competition against pages that may have been building relevance and authority for years. A new page being indexed just means it is now eligible to enter that competition. It says nothing about where it finishes.&lt;/p&gt;
&lt;h3 id=&quot;the-most-common-reasons-indexed-pages-don-t-rank&quot;&gt;The Most Common Reasons Indexed Pages Don't Rank&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Weak or thin content for the query.&lt;/strong&gt; If your page technically covers a topic in 300 words while top-ranking competitors cover it in 1,500 with examples, data, and clear structure, Google has little reason to consider your page the best answer, even though it is a valid one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No backlinks or topical authority.&lt;/strong&gt; Google weighs how many other credible sites link to a page, and how authoritative your site is on that general subject, as part of ranking relevance. A brand new site with no external signals pointing to it is starting the competition from zero, regardless of content quality.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keyword mismatch with actual search intent.&lt;/strong&gt; A page optimized around a keyword's literal wording, but written for a different intent than what people actually want when they type that phrase, loses to pages that better match what searchers are really looking for. If you're unsure how your pages actually line up with real search terms, that's exactly what &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/google-search-console-explained&quot;&gt;Google Search Console's Performance report&lt;/a&gt; is for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Internal linking gaps.&lt;/strong&gt; A page that nothing else on your site links to sends a weak signal about its importance relative to your other content. If you want to understand how this fits into a broader technical setup, we covered it in our &lt;a rel=&quot;nofollow&quot; href=&quot;/blog/what-is-technical-seo&quot;&gt;technical SEO guide for non-developers&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Direct competition from stronger, older pages.&lt;/strong&gt; Sometimes the honest answer is simply that the top results for a query are well-established, well-linked, and well-written, and a new page needs time and continued improvement to realistically compete, not a quick fix.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-moves-a-page-from-indexed-to-ranking&quot;&gt;What Actually Moves a Page From Indexed to Ranking&lt;/h3&gt;
&lt;p&gt;There is no single switch to flip here, which is exactly why some SEO advice overpromises results it cannot actually guarantee. What consistently helps is making the page a genuinely better answer to the query than what currently ranks: covering the topic more thoroughly, answering the follow-up questions a reader would naturally have, structuring content with clear headings that match how people scan a page, and linking it naturally from other relevant pages on your own site.&lt;/p&gt;
&lt;p&gt;Patience is also a real factor. Even a page that deserves to rank well can take weeks or months to climb, as Google continues to evaluate it against real user behavior and against changes other competing pages are making in the same window of time.&lt;/p&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;How long does it take an indexed page to start ranking?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There's no fixed timeline. Some pages see movement within weeks, others take several months, depending on competition, content quality, and how established your site already is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does more content automatically rank higher?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No. Length alone isn't a ranking factor. What matters is whether the page thoroughly and clearly answers what the searcher actually wants, which sometimes takes more words and sometimes doesn't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can a page be indexed and then dropped from the index later?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes. Pages can be removed from the index if they're deleted, blocked via robots.txt, marked noindex, or if Google reassesses them as low value over time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is it possible to guarantee a page will rank for a specific keyword?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No legitimate SEO practice can guarantee a specific ranking position. Anyone promising that is selling something they can't actually control, since Google's ranking decisions are made algorithmically and are never contractually guaranteed to anyone.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Getting indexed is the easy part. Ranking well is an ongoing process of making your page a genuinely stronger answer than the competition, backed by real structure, real depth, and real signals pointing to it from elsewhere. There's no guaranteed shortcut to the top of the results, but understanding the difference between these two stages at least means you're troubleshooting the right problem instead of wondering why &quot;indexed&quot; didn't already mean &quot;ranked.&quot;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Want a second opinion on why a specific page isn't ranking the way you expected? &lt;a rel=&quot;nofollow&quot; href=&quot;/contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Anas, full-stack Next.js developer building SaaS products and premium templates.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30011/why-your-pages-get-indexed-but-never-rank</guid>
<pubDate>Tue, 06 Oct 2026 13:47:23 +0000</pubDate>
</item>
<item>
<title>placeholder=&quot;blur&quot; on a Next.js Image Silently Does Nothing If You Forget One Thing</title>
<link>https://coderlegion.com/29729/placeholder-blur-on-a-next-js-image-silently-does-nothing-if-you-forget-one-thing</link>
<description>&lt;p&gt;&lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; is one of those props that works perfectly the first time you try it, in a quick example with a locally imported image, and then quietly stops doing anything the moment you swap that example for a real image coming from a CMS, S3, or any other remote source.&lt;/p&gt;
&lt;h3 id=&quot;the-example-that-works-and-why-it-works&quot;&gt;The Example That Works, and Why It Works&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import heroImage from '@/public/hero.jpg'; // a static, local import

&amp;lt;Image src={heroImage} alt=&quot;Hero&quot; placeholder=&quot;blur&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works because a statically imported image is processed at build time, and Next.js automatically generates a tiny, blurred base64 placeholder from the actual image's real content during that build step. You get a genuine, accurate blur preview with zero extra configuration, because the framework has direct, build-time access to the actual image file.&lt;/p&gt;
&lt;h3 id=&quot;where-it-silently-stops-working&quot;&gt;Where It Silently Stops Working&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// A product image coming from your CMS or a database field
&amp;lt;Image src={product.imageUrl} alt={product.name} placeholder=&quot;blur&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;product.imageUrl&lt;/code&gt; is a remote URL, a string, not a statically imported file. Next.js has no way to generate a blur placeholder from a URL at build time, since the actual image content isn't available until runtime, when the page is actually being requested, not when the project is built. Without a manually provided &lt;code&gt;blurDataURL&lt;/code&gt; prop, &lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; on a remote image source simply has nothing to use, and the image renders without a placeholder at all, exactly as if the prop had never been added.&lt;/p&gt;
&lt;h3 id=&quot;why-this-doesn-t-throw-an-error&quot;&gt;Why This Doesn't Throw an Error&lt;/h3&gt;
&lt;p&gt;This is genuinely a silent no-op, not a crash, which is exactly what makes it easy to miss. The prop exists, the code runs, the page renders, the image loads, just without the blur transition anyone added &lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; specifically to get. Nothing in the console points at the actual gap, since from Next.js's perspective, nothing is actually wrong, you simply didn't provide the one piece of data the feature needs for a remote source.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-provide-blurdataurl-explicitly-for-remote-images&quot;&gt;The Fix: Provide blurDataURL Explicitly for Remote Images&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;Image
  src={product.imageUrl}
  alt={product.name}
  placeholder=&quot;blur&quot;
  blurDataURL=&quot;data:image/jpeg;base64,/9j/4AAQSkZJRg...&quot; // a real, small base64 placeholder
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For this to genuinely work, you need an actual base64-encoded placeholder, generated from the real image, not a generic, unrelated blur applied to every image regardless of its actual content. A common approach is generating and storing this placeholder at the time an image is uploaded, saving it alongside the image URL itself in your database, so it's already available whenever the image needs to render, without a separate runtime computation on every single page load.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A simplified example of generating a blur placeholder at upload time
import { getPlaiceholder } from 'plaiceholder';

async function generateBlurPlaceholder(imageUrl: string) {
  const buffer = await fetch(imageUrl).then((res) =&amp;gt; res.arrayBuffer());
  const { base64 } = await getPlaiceholder(Buffer.from(buffer));
  return base64; // store this alongside the image URL in your database
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Libraries like &lt;code&gt;plaiceholder&lt;/code&gt; exist specifically for this, generating a real, accurate blur placeholder from any image buffer, which you can then store once and reuse on every subsequent render, rather than regenerating it on the fly.&lt;/p&gt;
&lt;h3 id=&quot;a-cheaper-fallback-when-a-real-placeholder-isn-t-worth-the-effort&quot;&gt;A Cheaper Fallback When a Real Placeholder Isn't Worth the Effort&lt;/h3&gt;
&lt;p&gt;For cases where generating and storing a genuine per-image placeholder is more infrastructure than the project needs, a generic, static color placeholder is still better than silently getting nothing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;Image
  src={product.imageUrl}
  alt={product.name}
  placeholder=&quot;blur&quot;
  blurDataURL=&quot;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPjxyZWN0IHdpZHRoPSIxIiBoZWlnaHQ9IjEiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4=&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a tiny, solid-color SVG, decoded, just a single gray rectangle, which still gives a smoother loading transition than nothing, even though it isn't a real, content-matched blur of the actual image. Not as polished as a genuine per-image placeholder, genuinely better than the silent no-op most remote images end up with by default.&lt;/p&gt;
&lt;h3 id=&quot;how-to-check-if-this-is-happening-on-your-own-site&quot;&gt;How to Check if This Is Happening on Your Own Site&lt;/h3&gt;
&lt;p&gt;Search for &lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; across your codebase and check, for each match, whether the &lt;code&gt;src&lt;/code&gt; is a static import or a remote URL. Any remote &lt;code&gt;src&lt;/code&gt; without an accompanying &lt;code&gt;blurDataURL&lt;/code&gt; prop is this exact gap, the prop is present, and doing nothing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -B2 -A2 'placeholder=&quot;blur&quot;' --include=&quot;*.tsx&quot; -r .
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-actual-rule&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; only works automatically for statically imported images. Any remote image source needs an explicit &lt;code&gt;blurDataURL&lt;/code&gt;, generated from the real image content, or the prop silently does nothing rather than failing loudly.&lt;/strong&gt; Generating and storing that placeholder once, at upload time, is the more complete fix. A generic, static color placeholder is a reasonable, low-effort fallback when a real one isn't worth the infrastructure for a given project.&lt;/p&gt;
&lt;p&gt;I handle this exact pattern, generating and storing real blur placeholders at upload time, across the dashboards and templates I build at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;, and I've covered related image and performance patterns on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com/blog&quot;&gt;blog&lt;/a&gt; too.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're using &lt;code&gt;placeholder=&quot;blur&quot;&lt;/code&gt; anywhere with a remote image source, check whether &lt;code&gt;blurDataURL&lt;/code&gt; is actually present. If it's not, the prop has been doing nothing this whole time. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29729/placeholder-blur-on-a-next-js-image-silently-does-nothing-if-you-forget-one-thing</guid>
<pubDate>Sun, 04 Oct 2026 13:33:12 +0000</pubDate>
</item>
<item>
<title>Your Zod Schema's .optional() Might Be Silently Rejecting Valid Data From MongoDB</title>
<link>https://coderlegion.com/29666/your-zod-schemas-optional-might-be-silently-rejecting-valid-data-from-mongodb</link>
<description>&lt;p&gt;This one trips people up specifically because the two words feel interchangeable in everyday English, &quot;optional&quot; and &quot;nullable&quot; both sound like &quot;this might not be there,&quot; and Zod treats them as two genuinely distinct, non-overlapping conditions.&lt;/p&gt;
&lt;h3 id=&quot;what-each-one-actually-validates&quot;&gt;What Each One Actually Validates&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { z } from 'zod';

const Schema = z.object({
  bio: z.string().optional(), // allows the key to be MISSING, or a string. Does NOT allow null.
  avatar: z.string().nullable(), // requires the key to be PRESENT, as a string OR null. Rejects missing.
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.optional()&lt;/code&gt; accepts &lt;code&gt;undefined&lt;/code&gt;, meaning the field can be genuinely absent from the object entirely, but if it's present, it must match the base type, never &lt;code&gt;null&lt;/code&gt;. &lt;code&gt;.nullable()&lt;/code&gt; accepts &lt;code&gt;null&lt;/code&gt; specifically, but the key itself still needs to actually be present in the object, just potentially holding the value &lt;code&gt;null&lt;/code&gt; rather than a real value. These aren't two flavors of the same leniency, they're validating two different, non-overlapping conditions, and passing the wrong kind of absence to either one fails validation.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-breaks-with-mongodb-specifically&quot;&gt;Where This Actually Breaks With MongoDB Specifically&lt;/h3&gt;
&lt;p&gt;MongoDB documents very commonly store an explicit &lt;code&gt;null&lt;/code&gt; for a field that has no value, rather than omitting the key entirely, especially for fields that were part of the original schema design but just haven't been set for a given document yet.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A real document from MongoDB
{
  _id: '...',
  name: 'Alex',
  bio: null, // explicitly null, not missing
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// ❌ This schema expects the key to be absent, not present-and-null
const UserSchema = z.object({
  name: z.string(),
  bio: z.string().optional(),
});

UserSchema.safeParse(userFromDb); // fails: bio received null, optional() doesn't accept null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bio: null&lt;/code&gt; is a completely valid, common shape coming straight out of MongoDB, and &lt;code&gt;.optional()&lt;/code&gt; rejects it anyway, since &lt;code&gt;.optional()&lt;/code&gt; only ever expected &lt;code&gt;undefined&lt;/code&gt;, never &lt;code&gt;null&lt;/code&gt;. The validation failure here isn't catching a real data problem, it's a mismatch between what Zod's &lt;code&gt;.optional()&lt;/code&gt; actually means and what the developer assumed it meant when writing the schema against real database output.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-match-the-schema-to-what-the-data-actually-does&quot;&gt;The Fix: Match the Schema to What the Data Actually Does&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const UserSchema = z.object({
  name: z.string(),
  bio: z.string().nullable(), // correctly matches MongoDB's explicit null
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a field that MongoDB stores as explicit &lt;code&gt;null&lt;/code&gt; when empty, &lt;code&gt;.nullable()&lt;/code&gt; is the correct validator, not &lt;code&gt;.optional()&lt;/code&gt;. If a field could genuinely be either missing entirely or explicitly null, both conditions need to be allowed together:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;bio: z.string().nullable().optional(), // accepts a string, null, OR a missing key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Chaining both is completely valid and often the most accurate reflection of real-world data that's passed through several code paths, some of which omit the key, others of which explicitly set it to &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why-this-doesn-t-show-up-until-real-data-hits-it&quot;&gt;Why This Doesn't Show Up Until Real Data Hits It&lt;/h3&gt;
&lt;p&gt;A schema tested only against hand-written test objects during development, where the developer naturally omits fields they don't care about rather than explicitly setting them to &lt;code&gt;null&lt;/code&gt;, never actually exercises this gap. The validation failure only shows up once real documents, containing MongoDB's genuine, common pattern of explicit &lt;code&gt;null&lt;/code&gt; values, flow through the same schema, often well after the schema was written and seemingly working correctly.&lt;/p&gt;
&lt;h3 id=&quot;a-quick-way-to-check-which-one-you-actually-need&quot;&gt;A Quick Way to Check Which One You Actually Need&lt;/h3&gt;
&lt;p&gt;Query a real, representative document from your actual collection and look at the field in question directly. If it shows up as &lt;code&gt;null&lt;/code&gt; in the raw document, you need &lt;code&gt;.nullable()&lt;/code&gt;. If the key is genuinely absent from documents where it has no value, you need &lt;code&gt;.optional()&lt;/code&gt;. Guessing based on the field's name or what &quot;feels right&quot; is exactly how this mismatch gets introduced in the first place, checking the real data removes the ambiguity entirely.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const doc = await User.findOne({}).lean();
console.log(Object.keys(doc).includes('bio'), doc.bio);
// If bio is a key with value null → nullable()
// If bio is simply absent as a key → optional()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-actual-rule-1&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;.optional()&lt;/code&gt; and &lt;code&gt;.nullable()&lt;/code&gt; are not interchangeable leniency settings, they validate two distinct, specific shapes of absence, and MongoDB's own default behavior of storing explicit &lt;code&gt;null&lt;/code&gt; means &lt;code&gt;.nullable()&lt;/code&gt; is very often the one you actually need for fields coming straight out of a database, not &lt;code&gt;.optional()&lt;/code&gt;, even though &quot;optional&quot; is the word that intuitively comes to mind first.&lt;/strong&gt; When a schema seems to be rejecting data that looks completely fine in your database, this mismatch is one of the first things worth checking.&lt;/p&gt;
&lt;p&gt;I ran into exactly this building the MongoDB-backed dashboards covered in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com/blog/mongodb-mongoose-patterns-2026&quot;&gt;MongoDB patterns I use in every Next.js project&lt;/a&gt;, and it's one of those small schema details worth getting right once rather than re-discovering on every new project.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If a Zod schema is rejecting data that looks completely valid sitting in your actual database, check whether it's this exact &lt;code&gt;.optional()&lt;/code&gt; versus &lt;code&gt;.nullable()&lt;/code&gt; mismatch before assuming the data itself is somehow wrong. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29666/your-zod-schemas-optional-might-be-silently-rejecting-valid-data-from-mongodb</guid>
<pubDate>Sat, 03 Oct 2026 21:14:32 +0000</pubDate>
</item>
<item>
<title>Your Server Component Might Be Fetching Data Three Times Slower Than It Needs To</title>
<link>https://coderlegion.com/29565/your-server-component-might-be-fetching-data-three-times-slower-than-it-needs-to</link>
<description>&lt;p&gt;This is one of the easiest performance mistakes to write without noticing, since the code reads completely naturally, top to bottom, each line waiting for the one before it to finish, exactly the way most people learn to write async code in the first place.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-reads-perfectly-naturally&quot;&gt;The Setup That Reads Perfectly Naturally&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
export default async function DashboardPage() {
  const user = await getUser();
  const posts = await getPosts();
  const comments = await getComments();
  const analytics = await getAnalytics();

  return (
    &amp;lt;Dashboard
      user={user}
      posts={posts}
      comments={comments}
      analytics={analytics}
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four &lt;code&gt;await&lt;/code&gt; calls, one after another. Clean, readable, and exactly how sequential code naturally gets written when each line is just &quot;get the next thing I need.&quot; The problem is that none of these four calls actually depend on the results of the others, &lt;code&gt;getPosts&lt;/code&gt; doesn't need anything from &lt;code&gt;getUser&lt;/code&gt;, &lt;code&gt;getComments&lt;/code&gt; doesn't need anything from &lt;code&gt;getPosts&lt;/code&gt;, and so on. They're all independently fetchable at the same time, and writing them sequentially means the total page load time is the sum of all four, not the time of the single slowest one.&lt;/p&gt;
&lt;h3 id=&quot;why-this-costs-more-than-it-looks-like-it-should&quot;&gt;Why This Costs More Than It Looks Like It Should&lt;/h3&gt;
&lt;p&gt;If each of these four calls takes roughly 150 milliseconds, a genuinely reasonable time for a real database query, the sequential version takes roughly 600 milliseconds total, each one waiting for the previous one to fully complete before even starting. Run the same four calls concurrently instead, and the total time is roughly 150 milliseconds, the time of the single slowest call, since they're all genuinely happening at once rather than queued up one behind another for no actual reason.&lt;/p&gt;
&lt;p&gt;This is a real, measurable difference on a page real users are waiting for, and it's entirely avoidable, since nothing about these four pieces of data actually requires this sequential order, the code just happens to be written in a shape that imposes one anyway.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-promise-all-for-independent-fetches&quot;&gt;The Actual Fix: Promise.all for Independent Fetches&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export default async function DashboardPage() {
  const [user, posts, comments, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
    getAnalytics(),
  ]);

  return (
    &amp;lt;Dashboard
      user={user}
      posts={posts}
      comments={comments}
      analytics={analytics}
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Promise.all&lt;/code&gt; starts all four requests essentially simultaneously and waits for all of them to complete together, rather than starting each one only after the last one finished. Same data, same final render, a fraction of the total wait, purely by removing an ordering constraint that was never actually necessary in the first place.&lt;/p&gt;
&lt;h3 id=&quot;where-this-gets-genuinely-trickier-partial-dependencies&quot;&gt;Where This Gets Genuinely Trickier: Partial Dependencies&lt;/h3&gt;
&lt;p&gt;Not every case is this clean. Sometimes one piece of data genuinely depends on another, and mixing dependent and independent fetches needs a bit more care than a single flat &lt;code&gt;Promise.all&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ❌ Everything sequential, even though posts and analytics don't need each other
export default async function DashboardPage() {
  const user = await getUser();
  const posts = await getPostsForUser(user.id); // genuinely depends on user
  const analytics = await getAnalytics(); // does NOT depend on user at all
  return &amp;lt;Dashboard user={user} posts={posts} analytics={analytics} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ✅ Only the genuine dependency stays sequential, everything else runs alongside it
export default async function DashboardPage() {
  const analyticsPromise = getAnalytics(); // starts immediately, not awaited yet

  const user = await getUser();
  const posts = await getPostsForUser(user.id); // genuinely needs user.id first

  const analytics = await analyticsPromise; // was already running this whole time

  return &amp;lt;Dashboard user={user} posts={posts} analytics={analytics} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Starting &lt;code&gt;getAnalytics()&lt;/code&gt; without immediately awaiting it lets that request begin running in the background while &lt;code&gt;getUser&lt;/code&gt; and &lt;code&gt;getPostsForUser&lt;/code&gt; proceed through their genuine, necessary sequence. By the time the code actually needs the analytics result, it's very likely already finished, or at least has been running the whole time instead of only starting after everything else completed.&lt;/p&gt;
&lt;h3 id=&quot;how-to-actually-spot-this-in-your-own-code&quot;&gt;How to Actually Spot This in Your Own Code&lt;/h3&gt;
&lt;p&gt;Look for multiple &lt;code&gt;await&lt;/code&gt; statements in a row where each one calls a genuinely independent function, nothing on the left side of an earlier line appears as an argument to a later one. That absence of a real data dependency is the signal, these calls have no actual reason to be sequential, and wrapping them in &lt;code&gt;Promise.all&lt;/code&gt; is very likely a free, meaningful performance improvement with zero behavior change.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -B2 -A2 &quot;await get&quot; app/**/page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Scanning for clusters of consecutive &lt;code&gt;await&lt;/code&gt; calls to different functions, then checking whether any of them genuinely reference the previous line's result, is a quick, practical way to find real waterfall candidates across an existing codebase.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-more-on-pages-with-several-independent-data-sources&quot;&gt;Why This Matters More on Pages With Several Independent Data Sources&lt;/h3&gt;
&lt;p&gt;A dashboard, a page with several distinct widgets or sections each backed by their own query, is exactly where this pattern accumulates the most cost, since it's common to have four, five, or more genuinely independent pieces of data feeding one page, each one innocently written as its own &lt;code&gt;await&lt;/code&gt; line, each one adding its full latency on top of all the others instead of overlapping.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-2&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Before awaiting multiple async calls sequentially, check whether any of them genuinely need a previous call's result. If none do, &lt;code&gt;Promise.all&lt;/code&gt; turns their combined wait time from a sum into a maximum, for free, with no behavior change.&lt;/strong&gt; For a genuine mix of dependent and independent calls, start the independent ones early without immediately awaiting them, so they run in the background while the real, necessary sequence proceeds.&lt;/p&gt;
&lt;p&gt;I check every Server Component I write for exactly this pattern now, across client work and the dashboards and templates I build at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;, since it's one of the highest-value, lowest-effort performance fixes available, and I've written about related data-fetching patterns on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com/blog&quot;&gt;blog&lt;/a&gt; too.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Go check your own dashboard or any data-heavy page for consecutive &lt;code&gt;await&lt;/code&gt; calls with no genuine dependency between them. If you find some, wrapping them in &lt;code&gt;Promise.all&lt;/code&gt; is very likely a free win sitting right there. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29565/your-server-component-might-be-fetching-data-three-times-slower-than-it-needs-to</guid>
<pubDate>Fri, 02 Oct 2026 18:05:04 +0000</pubDate>
</item>
<item>
<title>Next.js PostgreSQL: Build a Complete Full Stack Application</title>
<link>https://coderlegion.com/29558/next-js-postgresql-build-a-complete-full-stack-application</link>
<description>&lt;p&gt;Building a modern full stack application is about much more than creating attractive user interfaces. A successful application needs a reliable frontend, a powerful backend, secure authentication, efficient database management, and a smooth user experience.&lt;/p&gt;
&lt;p&gt;Among the many technologies available today, Next.js and PostgreSQL provide a powerful combination for building scalable, maintainable, and production-ready web applications.&lt;/p&gt;
&lt;p&gt;Next.js offers a modern React-based framework with server-side rendering, routing, server components, and backend capabilities. PostgreSQL provides a reliable relational database for storing, managing, and retrieving structured information.&lt;/p&gt;
&lt;p&gt;When these technologies work together, developers can create applications such as task management platforms, project management dashboards, ecommerce systems, customer relationship management tools, and SaaS products.&lt;/p&gt;
&lt;p&gt;In this article, we will explore how to build a complete full stack application using Next.js and PostgreSQL, covering architecture, database design, authentication, CRUD operations, API development, security, testing, and deployment.&lt;/p&gt;
&lt;p&gt;The goal is not simply to make an application work. It is to understand how different parts of a real-world software system communicate with one another.&lt;/p&gt;
&lt;h3 id=&quot;1-understanding-the-technology-stack&quot;&gt;1. Understanding the Technology Stack&lt;/h3&gt;
&lt;p&gt;Before starting development, it is important to understand the responsibilities of each technology.&lt;/p&gt;
&lt;h4 id=&quot;what-is-next-js-&quot;&gt;What Is Next.js?&lt;/h4&gt;
&lt;p&gt;Next.js is a React framework designed for building modern web applications. It provides several features that simplify frontend and backend development.&lt;/p&gt;
&lt;p&gt;Important features include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;File-based routing&lt;/li&gt;
&lt;li&gt;Server Components&lt;/li&gt;
&lt;li&gt;Client Components&lt;/li&gt;
&lt;li&gt;Server-side rendering&lt;/li&gt;
&lt;li&gt;Static site generation&lt;/li&gt;
&lt;li&gt;API route handlers&lt;/li&gt;
&lt;li&gt;Server Actions&lt;/li&gt;
&lt;li&gt;Image optimization&lt;/li&gt;
&lt;li&gt;Built-in performance improvements&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of the biggest advantages of Next.js is that developers can build frontend interfaces and server-side functionality within a single project.&lt;/p&gt;
&lt;p&gt;Instead of maintaining completely separate frontend and backend repositories for every application, developers can organize much of the application logic in one codebase.&lt;/p&gt;
&lt;h4 id=&quot;what-is-postgresql&quot;&gt;What Is PostgreSQL&lt;/h4&gt;
&lt;p&gt;PostgreSQL is an open-source relational database management system known for reliability, data integrity, and advanced querying capabilities.&lt;/p&gt;
&lt;p&gt;It supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Relational tables&lt;/li&gt;
&lt;li&gt;Primary and foreign keys&lt;/li&gt;
&lt;li&gt;Transactions&lt;/li&gt;
&lt;li&gt;Indexing&lt;/li&gt;
&lt;li&gt;Complex SQL queries&lt;/li&gt;
&lt;li&gt;JSON and JSONB data&lt;/li&gt;
&lt;li&gt;Constraints&lt;/li&gt;
&lt;li&gt;Database relationships&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For applications involving users, products, orders, tasks, payments, or business records, PostgreSQL offers a structured way to manage information.&lt;/p&gt;
&lt;h4 id=&quot;why-combine-next-js-and-postgresql&quot;&gt;Why Combine Next.js and PostgreSQL&lt;/h4&gt;
&lt;p&gt;The combination provides a clear separation of responsibilities.&lt;/p&gt;
&lt;p&gt;Next.js handles the user interface, application routing, and server-side logic, while PostgreSQL manages persistent application data.&lt;/p&gt;
&lt;p&gt;A typical architecture looks like this:&lt;/p&gt;
&lt;p&gt;User → Next.js Frontend → Server Logic → PostgreSQL Database&lt;/p&gt;
&lt;p&gt;When a user creates a task, for example, the frontend sends the information to the server. The server validates the request, checks authorization, and executes a database operation. PostgreSQL stores the task, and the application returns the updated information to the user.&lt;/p&gt;
&lt;p&gt;This architecture is suitable for applications that need reliable data handling and room for future growth.&lt;/p&gt;
&lt;h3 id=&quot;2-planning-the-application-before-coding&quot;&gt;2. Planning the Application Before Coding&lt;/h3&gt;
&lt;p&gt;Let's build a practical project: a Task Management Application.&lt;/p&gt;
&lt;p&gt;Users will be able to register, log in, create tasks, update task details, mark tasks as completed, and delete records.&lt;/p&gt;
&lt;h4 id=&quot;core-features&quot;&gt;Core Features&lt;/h4&gt;
&lt;p&gt;Our application will include:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User registration and login&lt;/li&gt;
&lt;li&gt;Secure authentication&lt;/li&gt;
&lt;li&gt;Personal task dashboard&lt;/li&gt;
&lt;li&gt;Create, read, update, and delete operations&lt;/li&gt;
&lt;li&gt;Task status management&lt;/li&gt;
&lt;li&gt;PostgreSQL database integration&lt;/li&gt;
&lt;li&gt;Form validation&lt;/li&gt;
&lt;li&gt;Responsive design&lt;/li&gt;
&lt;li&gt;Error handling&lt;/li&gt;
&lt;li&gt;Production deployment&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Planning these features before writing code helps prevent unnecessary complexity.&lt;/p&gt;
&lt;p&gt;It also allows us to identify the database tables, relationships, routes, and security requirements early.&lt;/p&gt;
&lt;h3 id=&quot;3-setting-up-the-next-js-project&quot;&gt;3. Setting Up the Next.js Project&lt;/h3&gt;
&lt;p&gt;First, ensure that Node.js and npm are installed.&lt;/p&gt;
&lt;p&gt;Create a new Next.js application using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest next-postgres-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During setup, choose TypeScript, ESLint, Tailwind CSS, and the App Router.&lt;/p&gt;
&lt;p&gt;Move into the project directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd next-postgres-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start the development server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open the application in your browser:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;http://localhost:3000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should now see the default Next.js application.&lt;/p&gt;
&lt;h4 id=&quot;recommended-project-structure&quot;&gt;Recommended Project Structure&lt;/h4&gt;
&lt;p&gt;A clean folder structure makes a full stack project easier to maintain.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;next-postgres-app/
│
├── app/
│   ├── dashboard/
│   │   └── page.tsx
│   ├── login/
│   │   └── page.tsx
│   ├── register/
│   │   └── page.tsx
│   ├── api/
│   │   └── tasks/
│   │       └── route.ts
│   ├── layout.tsx
│   └── page.tsx
│
├── components/
│   ├── Navbar.tsx
│   ├── TaskForm.tsx
│   └── TaskList.tsx
│
├── lib/
│   ├── db.ts
│   └── auth.ts
│
├── prisma/
│   └── schema.prisma
│
├── public/
├── .env
└── package.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This structure separates pages, reusable components, database utilities, and application logic.&lt;/p&gt;
&lt;h3 id=&quot;4-connecting-postgresql-to-next-js&quot;&gt;4. Connecting PostgreSQL to Next.js&lt;/h3&gt;
&lt;p&gt;There are several ways to connect PostgreSQL to a Next.js application.&lt;/p&gt;
&lt;p&gt;Developers can use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prisma ORM&lt;/li&gt;
&lt;li&gt;Drizzle ORM&lt;/li&gt;
&lt;li&gt;node-postgres (pg)&lt;/li&gt;
&lt;li&gt;Raw SQL queries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For this tutorial, we will use Prisma because it provides schema management, type-safe database queries, and migration support.&lt;/p&gt;
&lt;p&gt;Install Prisma:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install prisma @prisma/client
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Initialize Prisma:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a Prisma configuration and schema file.&lt;/p&gt;
&lt;h4 id=&quot;configure-the-database-connection&quot;&gt;Configure the Database Connection&lt;/h4&gt;
&lt;p&gt;Inside the &lt;code&gt;.env&lt;/code&gt; file, define your PostgreSQL connection string:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;DATABASE_URL=&quot;postgresql://postgres:password@localhost:5432/taskdb&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Replace the username, password, host, and database name with your actual PostgreSQL configuration.&lt;/p&gt;
&lt;p&gt;Never commit real database credentials to a public GitHub repository.&lt;/p&gt;
&lt;p&gt;For production, use environment variables supplied by your hosting provider.&lt;/p&gt;
&lt;h3 id=&quot;5-designing-the-postgresql-database&quot;&gt;5. Designing the PostgreSQL Database&lt;/h3&gt;
&lt;p&gt;Database design is one of the most important parts of full stack development.&lt;/p&gt;
&lt;p&gt;A poorly structured database can create duplicate records, inconsistent information, and difficult queries.&lt;/p&gt;
&lt;p&gt;For our application, we need two primary entities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User&lt;/li&gt;
&lt;li&gt;Task&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each user can have multiple tasks, but each task belongs to one user.&lt;/p&gt;
&lt;p&gt;This is a one-to-many relationship.&lt;/p&gt;
&lt;h4 id=&quot;prisma-database-schema&quot;&gt;Prisma Database Schema&lt;/h4&gt;
&lt;p&gt;Open &lt;code&gt;prisma/schema.prisma&lt;/code&gt; and define the models:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-prisma&quot;&gt;model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  password  String
  tasks     Task[]
  createdAt DateTime @default(now())
}

model Task {
  id          Int      @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean  @default(false)
  userId      Int
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;User&lt;/code&gt; model stores account information, while the &lt;code&gt;Task&lt;/code&gt; model stores task details.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;userId&lt;/code&gt; field connects each task to its owner.&lt;/p&gt;
&lt;p&gt;The unique constraint on email prevents multiple accounts from using the same email address.&lt;/p&gt;
&lt;p&gt;The cascade delete behavior ensures that associated tasks are removed when their user record is deleted. In an actual production system, account deletion policies should be designed carefully.&lt;/p&gt;
&lt;h4 id=&quot;run-the-database-migration&quot;&gt;Run the Database Migration&lt;/h4&gt;
&lt;p&gt;Execute:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate dev --name init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate the Prisma Client:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma generate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your database tables are now ready.&lt;/p&gt;
&lt;h3 id=&quot;6-creating-a-reusable-database-client&quot;&gt;6. Creating a Reusable Database Client&lt;/h3&gt;
&lt;p&gt;Instead of creating a new database connection in every file, we can create a reusable Prisma client.&lt;/p&gt;
&lt;p&gt;Inside &lt;code&gt;lib/db.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { PrismaClient } from &quot;@prisma/client&quot;;

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== &quot;production&quot;) {
  globalForPrisma.prisma = prisma;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern helps prevent unnecessary Prisma client instances during development hot reloads.&lt;/p&gt;
&lt;p&gt;It is especially useful when building applications with multiple server-side modules.&lt;/p&gt;
&lt;h3 id=&quot;7-building-the-first-user-interface&quot;&gt;7. Building the First User Interface&lt;/h3&gt;
&lt;p&gt;Now let's create a simple homepage.&lt;/p&gt;
&lt;p&gt;Inside &lt;code&gt;app/page.tsx&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export default function Home() {
  return (
    &amp;lt;main className=&quot;min-h-screen flex flex-col items-center justify-center&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-4xl font-bold&quot;&amp;gt;
        Task Management App
      &amp;lt;/h1&amp;gt;

      &amp;lt;p className=&quot;mt-4 text-gray-600&quot;&amp;gt;
        Organize your work and manage your daily tasks.
      &amp;lt;/p&amp;gt;

      &amp;lt;a
        href=&quot;/dashboard&quot;
        className=&quot;mt-6 rounded bg-blue-600 px-6 py-3 text-white&quot;
      &amp;gt;
        Open Dashboard
      &amp;lt;/a&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a basic landing page.&lt;/p&gt;
&lt;p&gt;In a production application, we could expand it with navigation, authentication links, feature descriptions, and responsive layouts.&lt;/p&gt;
&lt;p&gt;A useful principle is to keep reusable UI elements inside the &lt;code&gt;components&lt;/code&gt; directory instead of repeating the same code across multiple pages.&lt;/p&gt;
&lt;h3 id=&quot;8-implementing-crud-operations&quot;&gt;8. Implementing CRUD Operations&lt;/h3&gt;
&lt;p&gt;CRUD stands for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create&lt;/li&gt;
&lt;li&gt;Read&lt;/li&gt;
&lt;li&gt;Update&lt;/li&gt;
&lt;li&gt;Delete&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These operations form the foundation of many database-driven applications.&lt;/p&gt;
&lt;p&gt;Let's implement them using Next.js Route Handlers and Prisma.&lt;/p&gt;
&lt;h4 id=&quot;create-a-task-api&quot;&gt;Create a Task API&lt;/h4&gt;
&lt;p&gt;Create &lt;code&gt;app/api/tasks/route.ts&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { NextResponse } from &quot;next/server&quot;;
import { prisma } from &quot;@/lib/db&quot;;

export async function GET() {
  try {
    const tasks = await prisma.task.findMany({
      orderBy: {
        createdAt: &quot;desc&quot;,
      },
    });

    return NextResponse.json(tasks);
  } catch {
    return NextResponse.json(
      { error: &quot;Failed to fetch tasks&quot; },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json();

    if (
      typeof body.title !== &quot;string&quot; ||
      !body.title.trim()
    ) {
      return NextResponse.json(
        { error: &quot;A valid title is required&quot; },
        { status: 400 }
      );
    }

    const task = await prisma.task.create({
      data: {
        title: body.title.trim(),
        description: body.description ?? null,
        userId: 1,
      },
    });

    return NextResponse.json(task, { status: 201 });
  } catch {
    return NextResponse.json(
      { error: &quot;Failed to create task&quot; },
      { status: 500 }
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This demonstrates how the frontend communicates with the database through a server-side endpoint.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;userId: 1&lt;/code&gt; value is only a temporary development placeholder. A real application must obtain the authenticated user's ID from a verified server-side session and authorize every operation.&lt;/p&gt;
&lt;h4 id=&quot;reading-tasks&quot;&gt;Reading Tasks&lt;/h4&gt;
&lt;p&gt;The GET handler retrieves tasks from PostgreSQL.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;orderBy&lt;/code&gt; option sorts tasks by creation date, placing the newest records first.&lt;/p&gt;
&lt;p&gt;For a real multi-user application, the query should filter by the authenticated user's ID.&lt;/p&gt;
&lt;h4 id=&quot;updating-a-task&quot;&gt;Updating a Task&lt;/h4&gt;
&lt;p&gt;A PATCH endpoint can update an existing task.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const updatedTask = await prisma.task.update({
  where: {
    id: taskId,
  },
  data: {
    completed: true,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application should validate the task ID and verify that the authenticated user owns the task before updating it.&lt;/p&gt;
&lt;h4 id=&quot;deleting-a-task&quot;&gt;Deleting a Task&lt;/h4&gt;
&lt;p&gt;Prisma provides a simple deletion method:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;await prisma.task.delete({
  where: {
    id: taskId,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a production system, authorization checks are essential to prevent users from deleting other users' data.&lt;/p&gt;
&lt;h3 id=&quot;9-building-the-frontend-dashboard&quot;&gt;9. Building the Frontend Dashboard&lt;/h3&gt;
&lt;p&gt;A dashboard is where users interact with application data.&lt;/p&gt;
&lt;p&gt;A useful task dashboard should provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A task creation form&lt;/li&gt;
&lt;li&gt;A list of existing tasks&lt;/li&gt;
&lt;li&gt;Completion indicators&lt;/li&gt;
&lt;li&gt;Edit and delete buttons&lt;/li&gt;
&lt;li&gt;Loading states&lt;/li&gt;
&lt;li&gt;Error messages&lt;/li&gt;
&lt;li&gt;Empty-state guidance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The interface should remain understandable even when the database is empty or a request fails.&lt;/p&gt;
&lt;p&gt;For example, instead of showing a blank screen, display:&lt;/p&gt;
&lt;p&gt;&quot;No tasks yet. Create your first task to get started.&quot;&lt;/p&gt;
&lt;p&gt;This small detail improves usability.&lt;/p&gt;
&lt;h4 id=&quot;example-react-component&quot;&gt;Example React Component&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { useState } from &quot;react&quot;;

export default function TaskForm() {
  const [title, setTitle] = useState(&quot;&quot;);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(
    event: React.FormEvent&amp;lt;HTMLFormElement&amp;gt;
  ) {
    event.preventDefault();

    if (!title.trim()) return;

    setLoading(true);

    try {
      const response = await fetch(&quot;/api/tasks&quot;, {
        method: &quot;POST&quot;,
        headers: {
          &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
        body: JSON.stringify({ title }),
      });

      if (!response.ok) {
        throw new Error(&quot;Unable to create task&quot;);
      }

      setTitle(&quot;&quot;);
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  }

  return (
    &amp;lt;form onSubmit={handleSubmit}&amp;gt;
      &amp;lt;input
        value={title}
        onChange={(event) =&amp;gt; setTitle(event.target.value)}
        placeholder=&quot;Enter task title&quot;
      /&amp;gt;

      &amp;lt;button disabled={loading}&amp;gt;
        {loading ? &quot;Saving...&quot; : &quot;Add Task&quot;}
      &amp;lt;/button&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This component demonstrates controlled form input, asynchronous requests, and loading-state management.&lt;/p&gt;
&lt;p&gt;A complete dashboard would also refresh its task list after a successful submission.&lt;/p&gt;
&lt;h3 id=&quot;10-implementing-authentication&quot;&gt;10. Implementing Authentication&lt;/h3&gt;
&lt;p&gt;Authentication is a critical part of applications that store personal information.&lt;/p&gt;
&lt;p&gt;Users should only be able to access their own records.&lt;/p&gt;
&lt;p&gt;A typical authentication workflow includes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User registration&lt;/li&gt;
&lt;li&gt;Password validation&lt;/li&gt;
&lt;li&gt;Secure password hashing&lt;/li&gt;
&lt;li&gt;Login verification&lt;/li&gt;
&lt;li&gt;Session creation&lt;/li&gt;
&lt;li&gt;Protected routes&lt;/li&gt;
&lt;li&gt;Logout functionality&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Passwords must never be stored as plain text.&lt;/p&gt;
&lt;p&gt;Use a password hashing algorithm such as Argon2id or bcrypt.&lt;/p&gt;
&lt;p&gt;For a production application, a maintained authentication solution such as Auth.js can help manage sessions and authentication flows.&lt;/p&gt;
&lt;h4 id=&quot;protecting-user-data&quot;&gt;Protecting User Data&lt;/h4&gt;
&lt;p&gt;Authentication alone is not enough.&lt;/p&gt;
&lt;p&gt;Every database operation should check authorization.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const tasks = await prisma.task.findMany({
  where: {
    userId: authenticatedUser.id,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ensures that the query retrieves records associated with the authenticated account.&lt;/p&gt;
&lt;p&gt;The server must derive &lt;code&gt;authenticatedUser.id&lt;/code&gt; from a verified session, not from a user-controlled request body.&lt;/p&gt;
&lt;p&gt;This distinction is essential for preventing insecure direct object reference vulnerabilities.&lt;/p&gt;
&lt;h3 id=&quot;11-form-validation-and-error-handling&quot;&gt;11. Form Validation and Error Handling&lt;/h3&gt;
&lt;p&gt;Users do not always submit valid information.&lt;/p&gt;
&lt;p&gt;They may leave fields empty, enter incorrect values, or submit unexpected data.&lt;/p&gt;
&lt;p&gt;A robust application should validate data on both the client and server.&lt;/p&gt;
&lt;p&gt;Libraries such as Zod can help define reusable validation schemas.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { z } from &quot;zod&quot;;

export const taskSchema = z.object({
  title: z.string().trim().min(1).max(120),
  description: z.string().max(1000).optional(),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server should validate incoming data before interacting with PostgreSQL.&lt;/p&gt;
&lt;p&gt;Good error handling should also distinguish between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Invalid user input&lt;/li&gt;
&lt;li&gt;Authentication failures&lt;/li&gt;
&lt;li&gt;Authorization failures&lt;/li&gt;
&lt;li&gt;Missing records&lt;/li&gt;
&lt;li&gt;Database errors&lt;/li&gt;
&lt;li&gt;Unexpected server failures&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Avoid returning raw database errors or sensitive implementation details to users.&lt;/p&gt;
&lt;h3 id=&quot;12-improving-database-performance&quot;&gt;12. Improving Database Performance&lt;/h3&gt;
&lt;p&gt;As an application grows, database performance becomes increasingly important.&lt;/p&gt;
&lt;p&gt;A query that works perfectly with 20 records may become slow with 200,000 records.&lt;/p&gt;
&lt;p&gt;Several techniques can improve performance.&lt;/p&gt;
&lt;h4 id=&quot;indexing&quot;&gt;Indexing&lt;/h4&gt;
&lt;p&gt;PostgreSQL indexes help speed up queries that frequently filter or sort by particular fields.&lt;/p&gt;
&lt;p&gt;For example, if the application frequently retrieves tasks by user ID, an index on &lt;code&gt;userId&lt;/code&gt; may help.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_tasks_user_id
ON &quot;Task&quot; (&quot;userId&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Indexes improve some read operations but introduce storage and write overhead, so they should be selected based on actual query patterns.&lt;/p&gt;
&lt;h4 id=&quot;pagination&quot;&gt;Pagination&lt;/h4&gt;
&lt;p&gt;Avoid loading thousands of records at once.&lt;/p&gt;
&lt;p&gt;Instead, implement pagination.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const tasks = await prisma.task.findMany({
  skip: 0,
  take: 20,
  orderBy: {
    createdAt: &quot;desc&quot;,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For large datasets, cursor-based pagination can provide more consistent performance.&lt;/p&gt;
&lt;h4 id=&quot;connection-pooling&quot;&gt;Connection Pooling&lt;/h4&gt;
&lt;p&gt;Production applications may handle many simultaneous requests.&lt;/p&gt;
&lt;p&gt;Database connection pooling helps control the number of active connections and avoid exhausting PostgreSQL resources.&lt;/p&gt;
&lt;p&gt;The appropriate pooling strategy depends on the deployment platform and database provider.&lt;/p&gt;
&lt;h3 id=&quot;13-testing-the-application&quot;&gt;13. Testing the Application&lt;/h3&gt;
&lt;p&gt;Testing is not an optional step in professional development.&lt;/p&gt;
&lt;p&gt;It helps identify problems before users encounter them.&lt;/p&gt;
&lt;p&gt;A complete testing strategy can include:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unit Testing:&lt;/strong&gt; Test individual functions, validation rules, and utility logic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Integration Testing:&lt;/strong&gt; Verify that API routes interact correctly with PostgreSQL.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;End-to-End Testing:&lt;/strong&gt; Simulate user journeys such as registration, login, task creation, and deletion.&lt;/p&gt;
&lt;p&gt;Tools such as Vitest, Jest, and Playwright can support these workflows.&lt;/p&gt;
&lt;p&gt;Important scenarios to test include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating a task with valid information&lt;/li&gt;
&lt;li&gt;Rejecting an empty title&lt;/li&gt;
&lt;li&gt;Preventing unauthorized access&lt;/li&gt;
&lt;li&gt;Updating an existing task&lt;/li&gt;
&lt;li&gt;Handling a missing task&lt;/li&gt;
&lt;li&gt;Displaying errors when the database is unavailable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Testing helps make future code changes safer and easier.&lt;/p&gt;
&lt;h3 id=&quot;14-deploying-the-application&quot;&gt;14. Deploying the Application&lt;/h3&gt;
&lt;p&gt;After completing development and testing, the next step is deployment.&lt;/p&gt;
&lt;p&gt;A common deployment setup includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js application hosted on Vercel&lt;/li&gt;
&lt;li&gt;PostgreSQL hosted on a managed database platform&lt;/li&gt;
&lt;li&gt;Environment variables configured securely&lt;/li&gt;
&lt;li&gt;Database migrations applied during deployment&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Before deploying, verify:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Production database credentials are configured.&lt;/li&gt;
&lt;li&gt;Authentication secrets are secure.&lt;/li&gt;
&lt;li&gt;Database migrations have been applied.&lt;/li&gt;
&lt;li&gt;Sensitive environment files are excluded from Git.&lt;/li&gt;
&lt;li&gt;Error handling is configured.&lt;/li&gt;
&lt;li&gt;The application builds successfully.&lt;/li&gt;
&lt;li&gt;Database backups are available.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For production migrations, use the appropriate deployment migration command, such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx prisma migrate deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not use development migration workflows against a live production database.&lt;/p&gt;
&lt;p&gt;After deployment, monitor application logs, database performance, and error rates.&lt;/p&gt;
&lt;h3 id=&quot;15-common-challenges-developers-face&quot;&gt;15. Common Challenges Developers Face&lt;/h3&gt;
&lt;p&gt;Even with modern tools, full stack development involves challenges.&lt;/p&gt;
&lt;h4 id=&quot;database-connection-errors&quot;&gt;Database Connection Errors&lt;/h4&gt;
&lt;p&gt;Incorrect credentials, unavailable database servers, or connection limits can cause failures.&lt;/p&gt;
&lt;p&gt;Always verify environment configuration and database connectivity.&lt;/p&gt;
&lt;h4 id=&quot;authentication-problems&quot;&gt;Authentication Problems&lt;/h4&gt;
&lt;p&gt;Improper session validation can expose private information.&lt;/p&gt;
&lt;p&gt;Use established authentication libraries and enforce authorization on the server.&lt;/p&gt;
&lt;h4 id=&quot;slow-queries&quot;&gt;Slow Queries&lt;/h4&gt;
&lt;p&gt;Unoptimized database queries can affect response times.&lt;/p&gt;
&lt;p&gt;Use query analysis, appropriate indexes, pagination, and efficient data retrieval.&lt;/p&gt;
&lt;h4 id=&quot;frontend-and-backend-mismatches&quot;&gt;Frontend and Backend Mismatches&lt;/h4&gt;
&lt;p&gt;The frontend may expect a different response structure than the backend provides.&lt;/p&gt;
&lt;p&gt;TypeScript types, validation schemas, and consistent API contracts help reduce these problems.&lt;/p&gt;
&lt;h4 id=&quot;deployment-differences&quot;&gt;Deployment Differences&lt;/h4&gt;
&lt;p&gt;An application that works locally may fail in production because of missing environment variables, migration issues, or incompatible runtime configuration.&lt;/p&gt;
&lt;p&gt;Testing the production build before deployment helps identify these issues earlier.&lt;/p&gt;
&lt;h3 id=&quot;16-how-to-extend-this-project&quot;&gt;16. How to Extend This Project&lt;/h3&gt;
&lt;p&gt;Once the basic task management application is complete, developers can add more advanced features.&lt;/p&gt;
&lt;h4 id=&quot;role-based-access-control&quot;&gt;Role-Based Access Control&lt;/h4&gt;
&lt;p&gt;Introduce roles such as administrator, manager, and standard user.&lt;/p&gt;
&lt;p&gt;Each role can have different permissions.&lt;/p&gt;
&lt;h4 id=&quot;file-uploads&quot;&gt;File Uploads&lt;/h4&gt;
&lt;p&gt;Allow users to attach documents or images to tasks using an object storage service.&lt;/p&gt;
&lt;h4 id=&quot;real-time-updates&quot;&gt;Real-Time Updates&lt;/h4&gt;
&lt;p&gt;Use WebSockets or a suitable real-time service to synchronize task changes between users.&lt;/p&gt;
&lt;h4 id=&quot;email-notifications&quot;&gt;Email Notifications&lt;/h4&gt;
&lt;p&gt;Send reminders when task deadlines approach.&lt;/p&gt;
&lt;h4 id=&quot;analytics-dashboard&quot;&gt;Analytics Dashboard&lt;/h4&gt;
&lt;p&gt;Display completed tasks, pending tasks, productivity trends, and activity summaries.&lt;/p&gt;
&lt;h4 id=&quot;ai-powered-features&quot;&gt;AI-Powered Features&lt;/h4&gt;
&lt;p&gt;Integrate an AI service to summarize tasks, suggest priorities, or convert natural-language instructions into structured task descriptions.&lt;/p&gt;
&lt;p&gt;These extensions transform a basic CRUD project into a more complete software product.&lt;/p&gt;
&lt;h3 id=&quot;17-lessons-learned-from-building-with-next-js-and-postgresql&quot;&gt;17. Lessons Learned from Building with Next.js and PostgreSQL&lt;/h3&gt;
&lt;p&gt;Building a full stack application teaches several important engineering principles.&lt;/p&gt;
&lt;p&gt;First, application architecture matters. Separating database access, validation, UI components, and authentication makes code easier to understand.&lt;/p&gt;
&lt;p&gt;Second, data security must be considered from the beginning rather than added at the end.&lt;/p&gt;
&lt;p&gt;Third, a working application is not necessarily a production-ready application. Performance, testing, monitoring, accessibility, and deployment all contribute to reliability.&lt;/p&gt;
&lt;p&gt;Finally, choosing technologies is only one part of software engineering. Understanding how they interact is what allows developers to build useful products.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Next.js and PostgreSQL provide a practical foundation for building modern full stack applications.&lt;/p&gt;
&lt;p&gt;Next.js simplifies routing, rendering, and server-side application logic, while PostgreSQL provides reliable relational data storage.&lt;/p&gt;
&lt;p&gt;By combining these technologies with Prisma, TypeScript, secure authentication, validation, and thoughtful database design, developers can create applications that are easier to maintain and extend.&lt;/p&gt;
&lt;p&gt;Our task management example demonstrates the essential building blocks: project setup, database modeling, CRUD operations, frontend development, authentication, performance optimization, testing, and deployment.&lt;/p&gt;
&lt;p&gt;Whether you are a beginner learning full stack development or an experienced developer expanding your technical portfolio, building a complete application from start to finish is a valuable way to strengthen your engineering skills.&lt;/p&gt;
&lt;p&gt;The real achievement is not simply writing code that runs. It is creating a secure, reliable, and useful application that solves an actual problem.&lt;/p&gt;
&lt;p&gt;**What would you build first with Next.js and PostgreSQL: a task management system, an ecommerce platform, or a SaaS dashboard? Share your thoughts and project ideas in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29558/next-js-postgresql-build-a-complete-full-stack-application</guid>
<pubDate>Fri, 02 Oct 2026 15:53:08 +0000</pubDate>
</item>
<item>
<title>Galaxy Movies</title>
<link>https://coderlegion.com/29497/galaxy-movies</link>
<description>&lt;p&gt;Galaxy Movies is a movie discovery app I built to make finding something to watch faster and less overwhelming.&lt;/p&gt;
&lt;p&gt;I wanted the experience to feel simple. Open the site, browse what is popular, search for something specific, open a movie, and quickly get the information needed to decide whether it is worth watching.&lt;/p&gt;
&lt;p&gt;The project started as a frontend application and gradually grew into a larger movie discovery platform with streaming availability, movie and cast pages, AI generated content, and a conversational movie recommendation assistant.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What it does&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Users can browse popular, new, upcoming, and genre based movies, search by title, explore movie details, view cast and director information, and check where a movie is available to stream.&lt;/p&gt;
&lt;p&gt;Movie pages include information such as release date, runtime, genres, ratings, cast, director, trailers, streaming providers, similar movies, and an AI generated summary.&lt;/p&gt;
&lt;p&gt;Galaxy Movies also includes Galaxy Bot, a conversational movie assistant that helps users find something to watch based on things like genre, mood, time constraints, streaming preferences, and group preferences.&lt;/p&gt;
&lt;p&gt;The assistant can return actual movies available inside Galaxy Movies rather than only responding with plain text recommendations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tech stack&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The application is built primarily with JavaScript using Next.js and React.&lt;/p&gt;
&lt;p&gt;For the interface, I use Chakra UI, while React Query handles client side fetching and caching.&lt;/p&gt;
&lt;p&gt;Movie data comes from the TMDB API, and OpenAI powers the AI recommendation and summary features.&lt;/p&gt;
&lt;p&gt;The project is deployed on Vercel and also uses Vercel Edge functionality for dynamically generated social preview images.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Performance has been an important part of the project.&lt;/p&gt;
&lt;p&gt;The homepage server renders the initial set of popular movies so users and search engines receive useful content immediately.&lt;/p&gt;
&lt;p&gt;Additional movie results are loaded through infinite scrolling and cached with React Query.&lt;/p&gt;
&lt;p&gt;Search input is debounced to avoid unnecessary requests, and API responses use caching where appropriate.&lt;/p&gt;
&lt;p&gt;I have also spent time improving image loading, movie data caching, and reducing unnecessary network requests.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Galaxy Bot&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;One of the more interesting parts of the project has been building the movie assistant.&lt;/p&gt;
&lt;p&gt;Users can ask questions such as:&lt;/p&gt;
&lt;p&gt;What is a good sci-fi movie for tonight?&lt;/p&gt;
&lt;p&gt;What should my wife and I watch if I like action movies and she likes comedies?&lt;/p&gt;
&lt;p&gt;What are some recent movies worth watching?&lt;/p&gt;
&lt;p&gt;The backend sends the conversation to OpenAI while also providing movie specific context when needed.&lt;/p&gt;
&lt;p&gt;When the assistant mentions a movie, Galaxy Movies can resolve that recommendation back to a real TMDB movie and turn it into a link to the movie page.&lt;/p&gt;
&lt;p&gt;That makes the assistant part of the application rather than a separate chatbot sitting on top of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Streaming availability&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Galaxy Movies also checks streaming provider information by region.&lt;/p&gt;
&lt;p&gt;The goal is to help answer the question that usually comes immediately after finding a movie:&lt;/p&gt;
&lt;p&gt;Where can I actually watch this?&lt;/p&gt;
&lt;p&gt;Streaming availability can vary by country and change over time, so the application uses current provider data rather than hard coding availability.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Movie discovery&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I have tried to keep the main browsing experience intentionally simple.&lt;/p&gt;
&lt;p&gt;The homepage focuses on movie posters, search, categories, and quick discovery rather than filling the screen with a large amount of navigation or promotional content.&lt;/p&gt;
&lt;p&gt;From there, individual movie pages provide the deeper information.&lt;/p&gt;
&lt;p&gt;The project also includes pages for genres, actors, directors, production companies, and streaming services so users can continue exploring naturally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why I built it&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The original motivation was pretty simple.&lt;/p&gt;
&lt;p&gt;There are a lot of movie databases and streaming services, but finding something to actually watch can still take longer than it should.&lt;/p&gt;
&lt;p&gt;I wanted to build something that combines traditional movie browsing with streaming information and conversational recommendations while keeping the interface quick and straightforward.&lt;/p&gt;
&lt;p&gt;It has also become a project where I can experiment with Next.js performance, API design, AI features, caching, SEO, responsive interfaces, and product design in a real application.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I'm working on next&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I'm continuing to improve the discovery experience, recommendation quality, performance, and overall usability.&lt;/p&gt;
&lt;p&gt;I'm especially interested in feedback around how quickly users can understand the application, find a movie they are interested in, and move from discovery to actually deciding what to watch.&lt;/p&gt;
&lt;p&gt;Feedback on the architecture, JavaScript, Next.js implementation, performance, or UX is welcome!&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/29497/galaxy-movies</guid>
<pubDate>Fri, 02 Oct 2026 08:29:08 +0000</pubDate>
</item>
<item>
<title>What If Full-Stack Features Were Installable?</title>
<link>https://coderlegion.com/29360/what-if-full-stack-features-were-installable</link>
<description>&lt;p&gt;&lt;em&gt;NextAPI, and an experiment in reusing whole application capabilities instead of code.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Every developer knows the first week of a new project. Before a single line of product code, you wire up authentication, decide how roles work, connect a database, configure Docker and build the shell your pages will live in. Then, once the foundation is in place, you start on the features, and discover you are rebuilding the same team workspace, profile page and admin area you built on the last project.&lt;/p&gt;
&lt;p&gt;Boilerplate solved the foundation. It never solved the features.&lt;/p&gt;
&lt;p&gt;NextAPI is my attempt to solve both. It began as a Next.js and FastAPI starter and has grown into something more ambitious: a framework where a full-stack feature (its pages, its API, its database model, its permissions and its navigation) can be installed, verified, versioned and rolled back like a package.&lt;/p&gt;
&lt;p&gt;This article walks through what that actually means, and why I think it matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-one-a-foundation-you-can-use-today&quot;&gt;Part One: A Foundation You Can Use Today&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;NextAPI Core&lt;/strong&gt; is a complete full-stack application, ready in minutes. It stands entirely on its own. You never have to touch the module system to get value from it.&lt;/p&gt;
&lt;p&gt;Out of the box, you get:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Capability &lt;/th&gt;&lt;th&gt;What it means in practice&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Encrypted authentication &lt;/td&gt;&lt;td&gt;Credentials-based auth through NextAuth v5. Login and registration payloads are AES-256-CBC encrypted on the client before a signed JWT is ever issued.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Role-based access control &lt;/td&gt;&lt;td&gt;Admin, user and guest roles embedded in the JWT and enforced on both frontend and backend.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Machine-to-machine API access&lt;/td&gt;&lt;td&gt;API client tokens (&lt;code&gt;X-API-Key&lt;/code&gt; / &lt;code&gt;X-API-Secret&lt;/code&gt;) kept separate from user sessions, so integrations never borrow a person's login.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A modern async backend&lt;/td&gt;&lt;td&gt;FastAPI with Pydantic v2 validation and SQLAlchemy 2.0.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two databases, one switch &lt;/td&gt;&lt;td&gt;SQLite by default with zero setup, or MongoDB with a single environment variable.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Web and native mobile from one codebase &lt;/td&gt;&lt;td&gt;The same frontend ships as Android and iOS apps through Capacitor, authenticating against the same JWT endpoints.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A configurable app shell&lt;/td&gt;&lt;td&gt;Sidebar or top navbar, chosen once.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Production-ready deployment &lt;/td&gt;&lt;td&gt;A Docker Compose stack with frontend, backend and an optional nginx reverse proxy with SSL.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even setup has been designed to remove the usual failure points. Running &lt;code&gt;npm run setup&lt;/code&gt; generates real secrets rather than placeholders, and writes the same encryption key to both the frontend and backend configuration. The two most common ways to break authentication by hand, a forgotten placeholder or keys that drift apart, simply cannot happen.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run setup
npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Frontend on port 3000, backend on port 8000, and you are building your product.&lt;/p&gt;
&lt;p&gt;If you would rather start from a genuinely empty canvas, &lt;code&gt;npm run init:blank&lt;/code&gt; permanently removes the demo pages, so there is no dead code to carry into production.&lt;/p&gt;
&lt;p&gt;From here, the application is yours. Write your own routes, models and components. Install any npm or Python package. Change the architecture. NextAPI is not a hosted platform, and nothing about your application depends on anyone but you.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-two-the-problem-package-managers-never-solved&quot;&gt;Part Two: The Problem Package Managers Never Solved&lt;/h3&gt;
&lt;p&gt;The package ecosystem is extraordinary. Need a Python library? Install it. Need a React component? There are thousands.&lt;/p&gt;
&lt;p&gt;But what if you do not need a library? What if you need a &lt;strong&gt;feature&lt;/strong&gt;?&lt;/p&gt;
&lt;p&gt;Consider team management. It is not a component. It is a set of pages for teams, members and invitations; API endpoints behind each of them; database collections to store them; permissions governing who can do what; and navigation entries so users can find them.&lt;/p&gt;
&lt;p&gt;Those pieces describe one capability. Yet the way we reuse software today scatters them across separate packages, separate copy-pastes and, most often, separate rewrites. Every copy starts drifting the moment it lands.&lt;/p&gt;
&lt;p&gt;That led to the question at the heart of NextAPI:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;What if the unit of reuse were the feature rather than the library?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-three-a-feature-with-a-contract&quot;&gt;Part Three: A Feature With a Contract&lt;/h3&gt;
&lt;p&gt;In NextAPI, a feature is a &lt;strong&gt;module&lt;/strong&gt;, and every module is described by a single manifest. Here is a real one, lightly trimmed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;id&quot;: &quot;ai-notes&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;nextapiVersion&quot;: &quot;&amp;gt;=1.0.0 &amp;lt;2.0.0&quot;,
  &quot;requires&quot;: { &quot;modules&quot;: [&quot;profile-page@^1.0.0&quot;] },
  &quot;env&quot;: {
    &quot;backend&quot;: [{ &quot;key&quot;: &quot;ANTHROPIC_API_KEY&quot;, &quot;required&quot;: true, &quot;secret&quot;: true }]
  },
  &quot;dependencies&quot;: {
    &quot;frontend&quot;: { &quot;npm&quot;: { &quot;react-markdown&quot;: &quot;^9.0.0&quot; } },
    &quot;backend&quot;: { &quot;pypi&quot;: { &quot;anthropic&quot;: &quot;^0.40.0&quot; } }
  },
  &quot;frontend&quot;: {
    &quot;routes&quot;: [{ &quot;path&quot;: &quot;/notes&quot;, &quot;protected&quot;: true, &quot;label&quot;: &quot;Notes&quot; }]
  },
  &quot;backend&quot;: {
    &quot;router&quot;: { &quot;module&quot;: &quot;backend/router.py&quot;, &quot;prefix&quot;: &quot;/notes&quot; },
    &quot;collection&quot;: &quot;module_ai_notes&quot;
  },
  &quot;functions&quot;: {
    &quot;uses&quot;: [&quot;auth.get_current_user&quot;, &quot;profile.get_display_name&quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read it closely, because this is where the idea becomes concrete. In one file, this module declares:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;page&lt;/strong&gt; it adds, that the page requires sign-in, and the label it shows in navigation;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;API router&lt;/strong&gt; it contributes and the URL prefix it answers on;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;database collection&lt;/strong&gt; it owns;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;npm and PyPI packages&lt;/strong&gt; it needs;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;environment variables&lt;/strong&gt; it requires, and which are secrets;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;other modules&lt;/strong&gt; it depends on, with version ranges;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;specific functions&lt;/strong&gt; it calls from those modules;&lt;/li&gt;
&lt;li&gt;and the &lt;strong&gt;range of NextAPI versions&lt;/strong&gt; it is compatible with.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A conventional package gives you code. This manifest gives the framework an understanding of a capability: what it is, what it needs and how it fits into the application around it. Everything else NextAPI does is built on that understanding.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-four-two-commands-and-what-really-happens&quot;&gt;Part Four: Two Commands, and What Really Happens&lt;/h3&gt;
&lt;p&gt;Installing a module needs no account:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nextapi add @alice/team-workspace
nextapi sync
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;add&lt;/code&gt; only records your intent in the project's lockfile. Nothing in your application changes.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sync&lt;/code&gt; is where the real work happens, and its defining principle is simple: &lt;strong&gt;nothing is written until the whole install has been proven safe.&lt;/strong&gt; Before touching a single file, it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;resolves dependency order and checks for circular dependencies;&lt;/li&gt;
&lt;li&gt;fetches any modules the new one depends on, automatically;&lt;/li&gt;
&lt;li&gt;verifies that every function a module calls is actually provided by the module it depends on;&lt;/li&gt;
&lt;li&gt;checks for route, backend package and database collection collisions.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any check fails, the install is refused up front. An install is never left half-applied.&lt;/p&gt;
&lt;p&gt;Only then does &lt;code&gt;sync&lt;/code&gt; wire the feature into your real codebase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;your-app
├── app/team/page.tsx                                  new
├── backend/module_alice_team_workspace/router.py      new
├── backend/module_alice_team_workspace/models_mongo.py new
├── config/routes.ts                                   RBAC entry merged
└── .env                                               appended, your values untouched
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The page appears in your sidebar or navbar automatically. The RBAC rules are merged into your configuration. The backend lands as its own isolated package.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-five-the-hard-problems-solved-in-the-open&quot;&gt;Part Five: The Hard Problems, Solved in the Open&lt;/h3&gt;
&lt;p&gt;The idea is easy to state. What makes NextAPI worth taking seriously is how it handles the problems that make installable features genuinely difficult.&lt;/p&gt;
&lt;h4 id=&quot;it-joins-your-data-instead-of-duplicating-it&quot;&gt;It joins your data instead of duplicating it&lt;/h4&gt;
&lt;p&gt;Suppose your application already has users, and you install a user-management module that also declares a &lt;code&gt;users&lt;/code&gt; collection. A naive system creates a second, disconnected collection, and you now have two sources of truth about who can log in.&lt;/p&gt;
&lt;p&gt;NextAPI detects the overlap. If the module's fields are compatible with your existing collection, &lt;code&gt;sync&lt;/code&gt; offers to link the module to your real data instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;'@alice/user-management' declares a &quot;users&quot; collection compatible with your
existing UserCollection. Link it instead of creating a separate collection? (y/N)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It goes further than matching field names:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Method adoption.&lt;/strong&gt; If the module calls methods your collection class does not have yet, &lt;code&gt;sync&lt;/code&gt; adds the module's implementations to your model. It is purely additive and never overwrites a method your application already relies on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-data checks.&lt;/strong&gt; A schema can match on paper and still not match reality. &lt;code&gt;sync&lt;/code&gt; samples your actual MongoDB documents, and if a field your model declares as required is missing from existing records, it asks before relaxing it:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;'users' collection: 'created_at' is declared required, but missing from
214/1,048 existing documents. Demote 'created_at' to optional in your
host's UserMongo model to match reality? (y/N)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Remembered decisions.&lt;/strong&gt; Updates to a linked module re-link automatically. If a future version stops matching, it falls back safely and tells you exactly what happened.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When fields do not match, the module installs against its own collection and you receive a precise, field-by-field report of what did not line up, which is something concrete for the module's author to fix.&lt;/p&gt;
&lt;p&gt;This is the difference between installing code into an empty project and integrating a feature into a living system.&lt;/p&gt;
&lt;h4 id=&quot;modules-depend-on-capabilities-not-just-packages&quot;&gt;Modules depend on capabilities, not just packages&lt;/h4&gt;
&lt;p&gt;Modules can declare the functions they &lt;strong&gt;provide&lt;/strong&gt; and the functions they &lt;strong&gt;use&lt;/strong&gt;. A support-chat module can call &lt;code&gt;profile.get_display_name&lt;/code&gt;, provided by a profile module, and the registry confirms that capability exists before either module installs.&lt;/p&gt;
&lt;p&gt;Package managers resolve graphs of libraries. NextAPI resolves a graph of application capabilities.&lt;/p&gt;
&lt;h4 id=&quot;names-never-collide&quot;&gt;Names never collide&lt;/h4&gt;
&lt;p&gt;Two authors can both build a module with a &lt;code&gt;/profile&lt;/code&gt; page and a &lt;code&gt;profile&lt;/code&gt; collection. At install time, NextAPI automatically namespaces pages, backend code and database storage per module. Authors can write as though their module is the only one that will ever exist.&lt;/p&gt;
&lt;h4 id=&quot;versioned-like-real-software&quot;&gt;Versioned like real software&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Versions follow semver and are &lt;strong&gt;immutable&lt;/strong&gt; once published.&lt;/li&gt;
&lt;li&gt;A bad release can be &lt;strong&gt;yanked&lt;/strong&gt; from new installs without breaking anyone already using it.&lt;/li&gt;
&lt;li&gt;Compatible version ranges are resolved to a single shared version automatically, and genuine conflicts are reported with both requirements named.&lt;/li&gt;
&lt;li&gt;Stateless modules can opt into running &lt;strong&gt;multiple major versions side by side&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Stored data carries its own &lt;code&gt;schemaVersion&lt;/code&gt;. Changing the shape of persisted documents requires publishing a &lt;strong&gt;normalizer&lt;/strong&gt; that upgrades older documents on read, so no previously stored record ever becomes unreadable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;every-risky-change-is-deliberate-and-reversible&quot;&gt;Every risky change is deliberate and reversible&lt;/h4&gt;
&lt;p&gt;Some modules replace an existing page rather than adding one: a custom login screen, a redesigned landing page. That requires an explicit acknowledgement written into the manifest itself, so it cannot be scripted past a prompt. Before anything is replaced, the original is captured as a full snapshot, and &lt;code&gt;nextapi rollback /login&lt;/code&gt; restores it.&lt;/p&gt;
&lt;p&gt;Files you have edited by hand since the last sync are detected and left alone. &lt;code&gt;nextapi list&lt;/code&gt; flags drift, and &lt;code&gt;nextapi remove&lt;/code&gt; will not discard your changes without &lt;code&gt;--force&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-six-it-is-your-code&quot;&gt;Part Six: It Is Your Code&lt;/h3&gt;
&lt;p&gt;None of this turns your application into a black box.&lt;/p&gt;
&lt;p&gt;After &lt;code&gt;sync&lt;/code&gt;, a module's files are committed into your project as ordinary, readable source code, marked in clearly generated regions. You can edit them immediately. There is &lt;strong&gt;no runtime dependency on the registry&lt;/strong&gt;. If the registry disappeared tomorrow, your application would keep running exactly as it does today.&lt;/p&gt;
&lt;p&gt;That was a non-negotiable design goal. The registry distributes capabilities. It never owns them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-seven-building-and-sharing-modules&quot;&gt;Part Seven: Building and Sharing Modules&lt;/h3&gt;
&lt;p&gt;The registry at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nextapi.app&quot;&gt;nextapi.app&lt;/a&gt; is where modules are published, discovered and installed. Building for it is designed to be as approachable as using it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Build in the browser.&lt;/strong&gt; Start from a working template and edit in a full code editor that understands TypeScript, JavaScript, Python and JSON, with errors marked on the exact line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build with AI.&lt;/strong&gt; The AI Builder writes an entire module (manifest, backend, page and preview data) from a description. What sets it apart is discipline: it loads design standards before laying out a page, looks up a library's real documentation instead of guessing, lints its own output for accessibility issues such as contrast and reduced motion, and runs the same validation that gates every publish. Its output is identical in form to a hand-written module. The only difference is who typed it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish with confidence.&lt;/strong&gt; &lt;code&gt;nextapi validate&lt;/code&gt; checks the schema, capabilities and file paths locally, and every publish is re-validated on the server. Each module must ship maintainer-facing implementation notes explaining what it assumes about the host application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Share privately.&lt;/strong&gt; Publish a private module and share it with specific people by username.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fork with credit.&lt;/strong&gt; Fork any public module to adapt it. The original author is recorded permanently and cannot be removed from the fork.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-eight-core-stands-on-its-own&quot;&gt;Part Eight: Core Stands on Its Own&lt;/h3&gt;
&lt;p&gt;This is worth stating plainly, because the registry can make the project appear more complex than it is.&lt;/p&gt;
&lt;p&gt;You can use NextAPI Core purely as a full-stack foundation and ignore the module ecosystem entirely. The registry is a layer on top, not a requirement underneath:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                    Registry
                       |
             +---------+---------+
             |         |         |
          Module A  Module B  Module C
             |         |         |
             +---------+---------+
                       |
                  NextAPI Core
                       |
              +--------+--------+
              |                 |
           Next.js           FastAPI
              |                 |
              +--------+--------+
                       |
                    Database
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NextAPI should be useful before the ecosystem exists. The registry should make it more powerful, never necessary.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-am-actually-building&quot;&gt;What I Am Actually Building&lt;/h3&gt;
&lt;p&gt;I am not building another package manager, and I have no interest in replacing npm or PyPI. They solve a different problem, and they solve it well.&lt;/p&gt;
&lt;p&gt;What interests me is moving one level higher. A library knows what functions it exports. A NextAPI module knows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;I add these pages, and they require sign-in.&quot;&lt;/li&gt;
&lt;li&gt;&quot;I expose these API routes.&quot;&lt;/li&gt;
&lt;li&gt;&quot;I own this data, and this is the version of its shape.&quot;&lt;/li&gt;
&lt;li&gt;&quot;I need these permissions, packages and secrets.&quot;&lt;/li&gt;
&lt;li&gt;&quot;I depend on these capabilities from other modules.&quot;&lt;/li&gt;
&lt;li&gt;&quot;I belong in the navigation, here.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once a framework has that information, it can do far more than copy code into a project. It can reason about how a feature fits into an application: whether it is safe, whether it conflicts, whether it should join existing data, and how to undo it.&lt;/p&gt;
&lt;h3 id=&quot;where-things-stand&quot;&gt;Where Things Stand&lt;/h3&gt;
&lt;p&gt;NextAPI is open source under the MIT license and actively evolving. The foundation is solid, the module system already handles problems most tools never attempt, and plenty of hard work remains: richer migrations, broader host compatibility and a growing library of modules.&lt;/p&gt;
&lt;p&gt;The underlying idea is one I believe is worth pursuing:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Instead of rebuilding the same full-stack features over and over, what if we could install the capability itself?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is what NextAPI has become. Not just a starter kit, and not just a registry, but an attempt to move reusable software one step closer to the application itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Source code: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sup3rus3r/nextapi-core&quot;&gt;github.com/sup3rus3r/nextapi-core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Module registry and docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nextapi.app&quot;&gt;nextapi.app&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Support the project: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sponsors/sup3rus3r&quot;&gt;GitHub Sponsors&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29360/what-if-full-stack-features-were-installable</guid>
<pubDate>Thu, 01 Oct 2026 07:36:17 +0000</pubDate>
</item>
<item>
<title>You Added a Content-Security-Policy Header and Your Next.js App Went Blank. Here's Why.</title>
<link>https://coderlegion.com/29016/you-added-a-content-security-policy-header-and-your-next-js-app-went-blank-heres-why</link>
<description>&lt;p&gt;You read that a Content-Security-Policy header is a good security hardening step. You add a sensible-looking policy to your Next.js config, deploy, and the site loads as a blank or half-working page with a wall of red errors in the console.&lt;/p&gt;
&lt;p&gt;Nothing is wrong with your code. The policy is doing exactly what you told it to do, and what you told it to do happens to block Next.js itself.&lt;/p&gt;
&lt;h3 id=&quot;the-header-that-breaks-everything&quot;&gt;The Header That Breaks Everything&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: &quot;default-src 'self'; script-src 'self'&quot;,
          },
        ],
      },
    ];
  },
};

export default nextConfig;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;script-src 'self'&lt;/code&gt; says scripts may only load from your own origin as external files. That sounds right, until you remember Next.js injects small inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags into every page, for hydration data, for the router, for streaming. Inline scripts are exactly what &lt;code&gt;script-src 'self'&lt;/code&gt; forbids, so the browser refuses to run them, hydration never completes, and the page looks dead.&lt;/p&gt;
&lt;h3 id=&quot;the-tempting-wrong-fix&quot;&gt;The Tempting Wrong Fix&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;value: &quot;default-src 'self'; script-src 'self' 'unsafe-inline'&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes the errors disappear, and it also removes most of the protection you added the header for. &lt;code&gt;'unsafe-inline'&lt;/code&gt; tells the browser to run any inline script, including one an attacker managed to inject through an XSS hole. A CSP with &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt; still exists, but it no longer does the main job a CSP is supposed to do.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-a-per-request-nonce&quot;&gt;The Actual Fix: A Per-Request Nonce&lt;/h3&gt;
&lt;p&gt;A nonce is a random, single-use value generated for each request. You put it in the CSP header, and you attach the same value to the scripts you trust. The browser then only runs inline scripts carrying the matching nonce, and an injected script has no way to guess it.&lt;/p&gt;
&lt;p&gt;Next.js supports this through middleware:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  const csp = [
    &quot;default-src 'self'&quot;,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    &quot;img-src 'self' blob: data:&quot;,
    &quot;object-src 'none'&quot;,
    &quot;base-uri 'self'&quot;,
  ].join('; ');

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  requestHeaders.set('Content-Security-Policy', csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next.js reads the nonce from the request's CSP header and applies it to the framework's own inline scripts automatically. For any script you add yourself, read the nonce and pass it along:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { headers } from 'next/headers';
import Script from 'next/script';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined;

  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        {children}
        &amp;lt;Script src=&quot;https://analytics.example.com/script.js&quot; nonce={nonce} strategy=&quot;afterInteractive&quot; /&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;'strict-dynamic'&lt;/code&gt; in the policy lets a nonced script load further scripts it depends on, which keeps things like analytics loaders working without you allowlisting every domain by hand.&lt;/p&gt;
&lt;h3 id=&quot;the-tradeoff-nobody-mentions-upfront&quot;&gt;The Tradeoff Nobody Mentions Upfront&lt;/h3&gt;
&lt;p&gt;A nonce has to be unique per request, and it has to be baked into the HTML the server sends. That means any page using it must be rendered dynamically on each request. A statically generated page is built once ahead of time, so it cannot contain a fresh nonce for every visitor.&lt;/p&gt;
&lt;p&gt;This is the same static versus dynamic rendering boundary covered in my earlier posts on &lt;code&gt;cookies()&lt;/code&gt; and &lt;code&gt;searchParams&lt;/code&gt;. Reading the nonce from &lt;code&gt;headers()&lt;/code&gt; opts the route into dynamic rendering, so turning on nonce-based CSP across your whole site gives up static generation for every route it covers.&lt;/p&gt;
&lt;p&gt;For a mostly static marketing site, that is a real cost. Two reasonable ways to handle it:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Apply the strict nonce policy only where it matters most&lt;/strong&gt;, like authenticated dashboards and anything handling user input, and use a hash-based or more permissive policy for purely static pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use hash-based CSP for static pages&lt;/strong&gt;, where the script contents are known at build time, so each inline script's hash can be allowlisted instead of requiring a per-request nonce.&lt;/p&gt;
&lt;h3 id=&quot;how-to-test-it-without-breaking-production&quot;&gt;How to Test It Without Breaking Production&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Ship this first, it reports violations without blocking anything
{
  key: 'Content-Security-Policy-Report-Only',
  value: &quot;default-src 'self'; script-src 'self' 'nonce-...'&quot;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; logs every violation to the console and to a reporting endpoint without actually blocking a thing. Run it for a while, fix what it flags, then switch to the enforcing header. This is how you avoid the blank-page deploy from the top of this post.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-3&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A strict CSP and Next.js's inline scripts are incompatible unless you use nonces or hashes, and nonces mean dynamic rendering.&lt;/strong&gt; Do not reach for &lt;code&gt;'unsafe-inline'&lt;/code&gt; to make errors go away, since that removes the protection you were adding. Start in report-only mode, decide which routes genuinely need the strictest policy, and accept that those routes will render per request.&lt;/p&gt;
&lt;p&gt;I go through this same tradeoff when hardening client projects and the templates at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;, and I write up more of these security and rendering details on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com/blog&quot;&gt;blog&lt;/a&gt; as I run into them.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you have a CSP on a Next.js app right now, check whether it contains &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt;. If it does, it is worth knowing how much protection it is really giving you. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29016/you-added-a-content-security-policy-header-and-your-next-js-app-went-blank-heres-why</guid>
<pubDate>Tue, 29 Sep 2026 09:08:44 +0000</pubDate>
</item>
<item>
<title>Forgetting default.tsx in a Next.js Parallel Route Can 404 Your Entire Page, Not Just One Slot</title>
<link>https://coderlegion.com/28790/forgetting-default-tsx-in-next-js-parallel-route-can-404-your-entire-page-not-just-one-slot</link>
<description>&lt;p&gt;This is a genuinely surprising failure mode the first time you hit it, since the natural assumption is that an unmatched parallel route slot would just render nothing, not take down a page that has nothing to do with the slot in question.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-from-an-earlier-post-extended&quot;&gt;The Setup From an Earlier Post, Extended&lt;/h3&gt;
&lt;p&gt;Building on the intercepting route modal pattern from an earlier post, the &lt;code&gt;@modal&lt;/code&gt; slot renders the modal when a specific route is intercepted. But a parallel route slot needs to render &lt;em&gt;something&lt;/em&gt; for every URL within its scope, not just the one specific route it was built for.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/
├── @modal/
│   ├── (.)photo/
│   │   └── [id]/
│   │       └── page.tsx    // renders when a photo route is intercepted
│   └── (missing: default.tsx)
├── photo/
│   └── [id]/
│       └── page.tsx
├── settings/
│   └── page.tsx             // a completely unrelated route
├── layout.tsx
└── page.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-visiting-an-unrelated-page-can-break-entirely&quot;&gt;Why Visiting an Unrelated Page Can Break Entirely&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;@modal&lt;/code&gt; slot is defined at the root layout level here, which means it's part of every route rendered under that layout, not just the photo routes it was actually built for. Next.js needs to know what &lt;code&gt;@modal&lt;/code&gt; should render for every single route within its scope, including &lt;code&gt;/settings&lt;/code&gt;, which has nothing to do with photos or modals at all. If there's no &lt;code&gt;page.tsx&lt;/code&gt; inside &lt;code&gt;@modal&lt;/code&gt; matching &lt;code&gt;/settings&lt;/code&gt;, and critically, no &lt;code&gt;default.tsx&lt;/code&gt; file to serve as a fallback for exactly this situation, Next.js has no content to render for that slot on that route, and without a fallback, it treats this as a genuine 404 for the entire page, not just an empty modal slot.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specific-failure-mode-is-so-confusing&quot;&gt;Why This Specific Failure Mode Is So Confusing&lt;/h3&gt;
&lt;p&gt;The intercepting modal pattern itself works perfectly, clicking a photo from the grid correctly shows the modal, refreshing correctly shows the full page, exactly the behavior covered in the earlier post. The &lt;code&gt;/settings&lt;/code&gt; page, something that looks completely unrelated to any of that photo and modal logic, is what actually breaks, with a full 404, and nothing about that page's own code gives any indication why. The actual cause lives entirely in the parallel route configuration at the layout level, several files away from the page that's actually failing, which makes this a genuinely disorienting bug to trace the first time.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-a-default-tsx-for-every-parallel-slot&quot;&gt;The Fix: A default.tsx for Every Parallel Slot&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/@modal/default.tsx
export default function Default() {
  return null; // this slot simply renders nothing when there's no active modal
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;default.tsx&lt;/code&gt; tells Next.js exactly what to render for this slot whenever the current route doesn't match anything more specific within it. Returning &lt;code&gt;null&lt;/code&gt; here is completely correct and intentional, for any route that isn't specifically showing a modal, &lt;code&gt;@modal&lt;/code&gt; should render nothing, and &lt;code&gt;default.tsx&lt;/code&gt; is what makes that an explicit, defined behavior instead of an unhandled gap that produces a full 404.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-easy-to-miss-when-first-setting-up-parallel-routes&quot;&gt;Why This Is Easy to Miss When First Setting Up Parallel Routes&lt;/h3&gt;
&lt;p&gt;Most tutorials and examples for intercepting routes focus entirely on the specific route being intercepted, since that's the actual feature being demonstrated, and &lt;code&gt;default.tsx&lt;/code&gt; is easy to treat as an afterthought or skip in a quick example, since the demo itself often only ever navigates between the two or three routes that were actually set up correctly. The gap only becomes visible once someone navigates to a genuinely unrelated route that was never part of the original example, exactly the kind of route a real, full application has plenty of.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule&quot;&gt;The Broader Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Every parallel route slot needs a &lt;code&gt;default.tsx&lt;/code&gt;, without exception, the moment that slot exists anywhere in a layout that also renders routes the slot has no specific content for.&lt;/strong&gt; This isn't an edge case fallback for rare situations, it's the default, expected behavior for the overwhelming majority of routes in any real application, since a slot built for one specific interaction, a modal, a sidebar variant, is inherently the exception, not the rule, across a site's full set of routes.&lt;/p&gt;
&lt;h3 id=&quot;checking-your-own-parallel-routes&quot;&gt;Checking Your Own Parallel Routes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find app -type d -name &quot;@*&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For every parallel route slot this turns up, confirm a &lt;code&gt;default.tsx&lt;/code&gt; exists directly inside it. If it doesn't, any route within that layout's scope that isn't specifically handled by that slot is at genuine risk of a full 404, not a minor rendering gap, the moment someone actually navigates there.&lt;/p&gt;
&lt;p&gt;I set up a &lt;code&gt;default.tsx&lt;/code&gt; as the very first file whenever I add a parallel route slot to any project now, client work included, for exactly this reason, it costs one trivial file and prevents a genuinely confusing, hard-to-trace production bug. If you're exploring more Next.js patterns like this, I write about them regularly at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com/blog&quot;&gt;pixelanas.com/blog&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're using parallel routes anywhere, run the check above right now and confirm every slot has its fallback file. If one doesn't, you've got a real, ready-to-happen 404 waiting on some route you haven't tested yet. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28790/forgetting-default-tsx-in-next-js-parallel-route-can-404-your-entire-page-not-just-one-slot</guid>
<pubDate>Sun, 27 Sep 2026 09:32:05 +0000</pubDate>
</item>
<item>
<title>Why Your useEffect Runs Twice in Development (And Why You Shouldn't &quot;Fix&quot; It)</title>
<link>https://coderlegion.com/28704/why-your-useeffect-runs-twice-in-development-and-why-you-shouldnt-fix-it</link>
<description>&lt;p&gt;This causes a very specific, very common reaction, a developer sees a &lt;code&gt;console.log&lt;/code&gt; inside &lt;code&gt;useEffect&lt;/code&gt; fire twice in the browser console during local development, assumes something's genuinely broken, and starts debugging a bug that doesn't actually exist.&lt;/p&gt;
&lt;h3 id=&quot;what-s-actually-happening&quot;&gt;What's Actually Happening&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';
import { useEffect } from 'react';

export function Tracker() {
  useEffect(() =&amp;gt; {
    console.log('Effect ran'); // logs twice in development, once in production
    trackPageView();
  }, []);

  return null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In development, with React's Strict Mode enabled, which the Next.js App Router enables by default, React deliberately mounts a component, runs its effects, unmounts it, and immediately remounts it again, running the effects a second time. This is not a bug, and it's not accidental, it's a specific, intentional development-only behavior designed to help surface exactly the kind of bug this pattern causes in the first place, effects with real, unintended side effects or missing cleanup.&lt;/p&gt;
&lt;h3 id=&quot;why-react-does-this-on-purpose&quot;&gt;Why React Does This on Purpose&lt;/h3&gt;
&lt;p&gt;React is preparing for a future where components can be safely paused, discarded, and remounted, for features like offline support and certain rendering optimizations, and that capability genuinely requires effects to be safely re-runnable, mount, unmount, mount again, without producing broken or duplicated behavior. The double-invoke in development is a deliberate stress test, if your effect breaks when run twice in a row, mount, unmount, mount, it's revealing a genuine correctness gap, code that assumes it only ever runs once, which was never actually a safe assumption to begin with, even before Strict Mode made it visible.&lt;/p&gt;
&lt;h3 id=&quot;why-the-instinct-to-fix-this-is-usually-wrong&quot;&gt;Why the Instinct to &quot;Fix&quot; This Is Usually Wrong&lt;/h3&gt;
&lt;p&gt;Seeing double logs or double network calls in development and reaching for a way to suppress it, disabling Strict Mode entirely, or adding a fragile &lt;code&gt;useRef&lt;/code&gt; guard specifically to prevent the second invocation, treats the symptom as the problem. The actual problem, if there is one, is that the effect wasn't written to safely handle being cleaned up and re-run, which is a real gap worth fixing properly, not hiding by preventing Strict Mode from ever revealing it again.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ❌ A common &quot;fix&quot; that just hides the signal instead of addressing what it revealed
const hasRun = useRef(false);

useEffect(() =&amp;gt; {
  if (hasRun.current) return;
  hasRun.current = true;
  trackPageView();
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern specifically defeats the entire point of Strict Mode's double-invoke check, and worse, it can behave inconsistently across React versions and concurrent rendering scenarios in ways that are genuinely harder to reason about than just writing the effect correctly in the first place.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-write-effects-that-are-safe-to-run-twice&quot;&gt;The Actual Fix: Write Effects That Are Safe to Run Twice&lt;/h3&gt;
&lt;p&gt;For something like an analytics call, the real question worth asking is whether firing it twice in development actually matters, and for most analytics providers, it doesn't, in production, without Strict Mode's double-invoke, it only fires once anyway. The development-only double log is not the same as double-counting real production analytics data, a distinction worth confirming directly rather than assuming.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  // In production, this fires once. In development, Strict Mode fires it
  // twice, which is expected and does not reflect production behavior.
  trackPageView();
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For an effect that sets up something genuinely stateful, a subscription, an interval, an event listener, the actual fix is a real, correct cleanup function, which is precisely what Strict Mode's double-invoke is designed to verify you've written:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  const interval = setInterval(() =&amp;gt; {
    checkForUpdates();
  }, 5000);

  return () =&amp;gt; clearInterval(interval); // correctly cleans up, safe to run twice
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With a correct cleanup function, the double-invoke in development runs cleanly, set up, tear down, set up again, exactly the pattern Strict Mode exists to confirm your code actually handles safely. If removing the cleanup function causes something to visibly break or duplicate in development, that's Strict Mode doing its job, not React misbehaving.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-matters-for-real-bugs-not-just-console-noise&quot;&gt;Where This Actually Matters for Real Bugs, Not Just Console Noise&lt;/h3&gt;
&lt;p&gt;This connects directly to the stale fetch race condition covered in an earlier post, an effect fetching data without a cleanup function that ignores stale responses is exactly the kind of effect that also breaks under Strict Mode's double-invoke in development, showing a flash of duplicated or incorrect state before settling, which is a genuine, useful early warning for a bug that would otherwise only surface in production under real network timing variance.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-4&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A &lt;code&gt;useEffect&lt;/code&gt; that produces visibly different or broken behavior when Strict Mode runs it twice in development almost always has a real gap, missing or incorrect cleanup, not properly guarding against being re-invoked, that would eventually cause a genuine bug in production too, just under different, harder-to-reproduce conditions.&lt;/strong&gt; The fix is writing the effect to genuinely handle mount, unmount, remount correctly, not suppressing the specific mechanism designed to reveal that it doesn't.&lt;/p&gt;
&lt;p&gt;I keep Strict Mode enabled across every project I build, client work and the templates at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt; alike, specifically because catching this category of bug in development, as annoying as the double logs are at first, is far cheaper than debugging the same underlying issue once it surfaces as an intermittent, hard-to-reproduce production bug instead.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've disabled Strict Mode, or added a ref-based guard specifically to suppress double-invoked effects, worth reconsidering whether that's hiding a real, fixable gap rather than actually solving anything. Drop your own experience with this in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28704/why-your-useeffect-runs-twice-in-development-and-why-you-shouldnt-fix-it</guid>
<pubDate>Sat, 26 Sep 2026 09:20:46 +0000</pubDate>
</item>
<item>
<title>MUI v9 Theme Setup in Next.js 16: Full 2026 Guide</title>
<link>https://coderlegion.com/28671/mui-v9-theme-setup-in-next-js-16-full-2026-guide</link>
<description>&lt;p&gt;You installed &lt;code&gt;@mui/material&lt;/code&gt;, dropped a &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; on the page, and it looked... fine. Then you added a custom color, refreshed the page, and for a split second saw the default MUI blue before your color kicked in. Or worse — your styles randomly break depending on which route loads first.&lt;/p&gt;
&lt;p&gt;None of that is a coincidence. It's what happens when MUI's styling engine (Emotion) meets Next.js's server-streaming App Router without the right glue in between. MUI ships an official package specifically for this, and most tutorials online still show the old Pages Router setup or skip the dark mode flash entirely.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll have a complete, production-ready MUI theme wired into a Next.js 16 App Router project — proper SSR style injection, a TypeScript-friendly custom theme, Google Fonts through &lt;code&gt;next/font&lt;/code&gt;, a flash-free light/dark mode toggle, and the correct way to write mode-aware styles so they don't flicker.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Next.js's App Router renders on the server and &lt;strong&gt;streams&lt;/strong&gt; HTML to the browser in chunks. MUI's default styling engine, Emotion, generates CSS at render time and needs to know how to collect all of that generated CSS and inject it into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the streamed HTML reaches the browser — otherwise you get unstyled flashes or duplicated styles.&lt;/p&gt;
&lt;p&gt;On top of that, MUI components that use hooks (like &lt;code&gt;useState&lt;/code&gt; for a theme toggle) need to be Client Components, but your root layout is a Server Component by default. If the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/server-vs-client-components-nextjs-16&quot;&gt;Server vs. Client Component split&lt;/a&gt; isn't second nature to you yet, it's worth a quick read before this guide — almost every mistake in a MUI + Next.js setup comes down to getting that boundary wrong. Get it wrong here and you'll see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A flash of unstyled or default-themed content (FOUC)&lt;/li&gt;
&lt;li&gt;Hydration mismatch warnings in the console&lt;/li&gt;
&lt;li&gt;Dark mode flashing light for a split second on every page load&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;MUI's official &lt;code&gt;@mui/material-nextjs&lt;/code&gt; package exists specifically to solve the streaming/cache problem, and a few small App Router patterns solve the rest. Let's build it properly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;This guide assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React 19.2&lt;/strong&gt; (Next.js 16's default)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;npm&lt;/strong&gt; (swap for &lt;code&gt;yarn&lt;/code&gt;/&lt;code&gt;pnpm&lt;/code&gt; commands as needed)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-install-mui-and-its-dependencies&quot;&gt;Step 1: Install MUI and Its Dependencies&lt;/h3&gt;
&lt;p&gt;MUI's core package is &lt;code&gt;@mui/material&lt;/code&gt;, and it needs Emotion (its default style engine) plus the official Next.js integration package.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @mui/material @emotion/react @emotion/styled @mui/material-nextjs @mui/icons-material
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what each package actually does:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Package &lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@mui/material&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The core component library (buttons, inputs, layout, etc.)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@emotion/react&lt;/code&gt; / &lt;code&gt;@emotion/styled&lt;/code&gt; &lt;/td&gt;&lt;td&gt;MUI's default CSS-in-JS engine — generates the actual styles&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@mui/material-nextjs&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Official adapter that makes Emotion's SSR streaming work correctly with the App Router&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@mui/icons-material&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Material Design icon set (optional, but almost every project ends up needing it)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;Version note: This guide targets MUI v9 with Next.js 16. Keep the MUI packages on compatible v9 releases, and use the &lt;code&gt;v16-appRouter&lt;/code&gt; integration entry point.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; MUI's current package layout uses Node.js package exports to provide appropriate ESM and CommonJS entry points, so this setup doesn't require special MUI bundler configuration.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;a-note-on-the-versioned-import-paths&quot;&gt;A note on the versioned import paths&lt;/h4&gt;
&lt;p&gt;You'll notice the import in the next step is &lt;code&gt;@mui/material-nextjs/v16-appRouter&lt;/code&gt;, not just &lt;code&gt;@mui/material-nextjs&lt;/code&gt;. This isn't cosmetic — the package ships a &lt;strong&gt;separate entry point per Next.js major version&lt;/strong&gt;, because each new Next.js major has occasionally changed how App Router streaming/rendering internals work under the hood. Using the wrong one for your Next.js version is a real (if often silently-working) mismatch, so pick the entry point that matches your installed Next.js major:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Next.js version &lt;/th&gt;&lt;th&gt;&lt;code&gt;@mui/material-nextjs&lt;/code&gt; entry point&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Next.js 13 &lt;/td&gt;&lt;td&gt;&lt;code&gt;v13-appRouter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Next.js 14 &lt;/td&gt;&lt;td&gt;&lt;code&gt;v14-appRouter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Next.js 15 &lt;/td&gt;&lt;td&gt;&lt;code&gt;v15-appRouter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Next.js 16 &lt;/td&gt;&lt;td&gt;&lt;code&gt;v16-appRouter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Copy-pasting an older tutorial's &lt;code&gt;v15-appRouter&lt;/code&gt; (or even older) import into a Next.js 16 project. It may still build without errors, but you're no longer using the entry point MUI actually maintains against your Next.js version — always match the number to your installed &lt;code&gt;next&lt;/code&gt; major.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-add-the-app-router-cache-provider&quot;&gt;Step 2: Add the App Router Cache Provider&lt;/h3&gt;
&lt;p&gt;This is the piece almost every outdated tutorial skips or gets wrong. &lt;code&gt;AppRouterCacheProvider&lt;/code&gt; is what collects Emotion's generated CSS during server rendering and streams it into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; correctly, instead of leaving it to render inline in the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; (which causes the unstyled flash).&lt;/p&gt;
&lt;p&gt;Create your root layout:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import type { Metadata } from &quot;next&quot;;
import { AppRouterCacheProvider } from &quot;@mui/material-nextjs/v16-appRouter&quot;;

export const metadata: Metadata = {
  title: &quot;My MUI App&quot;,
  description: &quot;A Next.js 16 app styled with MUI&quot;,
};

export default function RootLayout({
  children,
}: Readonly&amp;lt;{ children: React.ReactNode }&amp;gt;) {
  return (
    &amp;lt;html lang=&quot;en&quot;&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;AppRouterCacheProvider&amp;gt;
          {children}
        &amp;lt;/AppRouterCacheProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;AppRouterCacheProvider&lt;/code&gt; wraps everything under &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. As Next.js streams your page in chunks, this provider intercepts every bit of CSS Emotion generates along the way and inserts it into &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; ahead of the content that needs it — so by the time a chunk of HTML reaches the browser, its styles are already there.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 If you also need to set page titles, Open Graph tags, or other &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; data alongside this setup, that's handled separately through Next.js's own &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-metadata&quot;&gt;Metadata API&lt;/a&gt; — the &lt;code&gt;metadata&lt;/code&gt; export above is a small example of it, not something &lt;code&gt;AppRouterCacheProvider&lt;/code&gt; manages.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At this point, run your dev server and add any MUI component — the styles should already apply correctly with no flash. Now let's make the theme actually yours.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;create-a-custom-theme-file&quot;&gt;Step 3: Create a Custom Theme File&lt;/h2&gt;
&lt;p&gt;A &quot;theme&quot; in MUI is just a JavaScript object describing your colors, typography, spacing, and component overrides. Centralizing it in one file means every component in your app pulls from the same design tokens instead of scattered inline styles.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// theme/theme.ts
&quot;use client&quot;;

import { createTheme } from &quot;@mui/material/styles&quot;;

const theme = createTheme({
  cssVariables: true,
  palette: {
    primary: {
      main: &quot;#0F62FE&quot;,
    },
    secondary: {
      main: &quot;#6929C4&quot;,
    },
  },
  shape: {
    borderRadius: 10,
  },
  typography: {
    fontFamily: &quot;var(--font-roboto)&quot;,
  },
});

export default theme;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&quot;use client&quot;&lt;/code&gt; is required at the top of this file. MUI recommends creating the theme in a Client Component module when using it with the App Router because the theme object is consumed by the client-side &lt;code&gt;ThemeProvider&lt;/code&gt;, and the theme contains more than plain serializable configuration data.&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/server-vs-client-components-nextjs-16&quot;&gt;Related: Server vs. Client Components in Next.js 16&lt;/a&gt; covers exactly what does and doesn't need this directive.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cssVariables: true&lt;/code&gt; tells MUI to expose theme tokens as CSS custom properties such as &lt;code&gt;--mui-palette-primary-main&lt;/code&gt;. MUI components can then reference these variables, which is particularly useful for switching color schemes without requiring a full CSS regeneration. This is the modern MUI approach — it plays nicer with server rendering and is what makes the flash-free dark mode in &lt;a rel=&quot;nofollow&quot; href=&quot;#add-flash-free-dark-mode&quot;&gt;Step 5&lt;/a&gt; possible.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;palette.primary&lt;/code&gt; / &lt;code&gt;palette.secondary&lt;/code&gt; override MUI's default blue/purple with your own brand colors. Every component (&lt;code&gt;&amp;lt;Button color=&quot;primary&quot;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;Chip color=&quot;secondary&quot;&amp;gt;&lt;/code&gt;, etc.) automatically picks these up.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;typography.fontFamily&lt;/code&gt; references a CSS variable we'll define in the next step using &lt;code&gt;next/font&lt;/code&gt; — this is the correct way to combine MUI with Next.js's font optimization.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Keep this file focused on your app's design tokens (colors, spacing, typography), and use component-level &lt;code&gt;sx&lt;/code&gt; props for one-off styling. Don't try to theme every possible edge case up front — add overrides as you actually need them.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-load-fonts-with-next-font-and-wire-up-the-theme-provider&quot;&gt;Step 4: Load Fonts with &lt;code&gt;next/font&lt;/code&gt; and Wire Up the Theme Provider&lt;/h3&gt;
&lt;p&gt;Next.js's built-in font optimization (&lt;code&gt;next/font&lt;/code&gt;) self-hosts Google Fonts at build time, avoiding a render-blocking request to Google's font CDN. MUI's docs specifically recommend combining it with the theme via a CSS variable rather than passing the font name as a plain string.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import type { Metadata } from &quot;next&quot;;
import { Roboto } from &quot;next/font/google&quot;;
import { AppRouterCacheProvider } from &quot;@mui/material-nextjs/v16-appRouter&quot;;
import { ThemeProvider } from &quot;@mui/material/styles&quot;;
import CssBaseline from &quot;@mui/material/CssBaseline&quot;;
import theme from &quot;@/theme/theme&quot;;

const roboto = Roboto({
  weight: [&quot;300&quot;, &quot;400&quot;, &quot;500&quot;, &quot;700&quot;],
  subsets: [&quot;latin&quot;],
  display: &quot;swap&quot;,
  variable: &quot;--font-roboto&quot;,
});

export const metadata: Metadata = {
  title: &quot;My MUI App&quot;,
  description: &quot;A Next.js 16 app styled with MUI&quot;,
};

export default function RootLayout({
  children,
}: Readonly&amp;lt;{ children: React.ReactNode }&amp;gt;) {
  return (
    &amp;lt;html lang=&quot;en&quot; className={roboto.variable}&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;AppRouterCacheProvider&amp;gt;
          &amp;lt;ThemeProvider theme={theme}&amp;gt;
            &amp;lt;CssBaseline /&amp;gt;
            {children}
          &amp;lt;/ThemeProvider&amp;gt;
        &amp;lt;/AppRouterCacheProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Roboto({ ... variable: &quot;--font-roboto&quot; })&lt;/code&gt; generates a CSS variable holding the correct font-family string, and Next.js injects the actual &lt;code&gt;@font-face&lt;/code&gt; rules automatically — no manual &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags.&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;className={roboto.variable}&lt;/code&gt; to &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; makes that CSS variable available everywhere in your app, including inside the MUI theme file from &lt;a rel=&quot;nofollow&quot; href=&quot;#create-a-custom-theme-file&quot;&gt;Step 3&lt;/a&gt; (&lt;code&gt;fontFamily: &quot;var(--font-roboto)&quot;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;ThemeProvider theme={theme}&amp;gt;&lt;/code&gt; makes your custom theme available to every MUI component via context — this is what actually applies your colors and typography.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;CssBaseline /&amp;gt;&lt;/code&gt; is MUI's version of a CSS reset — it normalizes margins, sets a consistent &lt;code&gt;box-sizing&lt;/code&gt;, and applies your theme's background/text colors to &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. Almost every MUI project should include it once, at the root.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Passing &lt;code&gt;theme={theme}&lt;/code&gt; where &lt;code&gt;theme&lt;/code&gt; was created &lt;em&gt;without&lt;/em&gt; &lt;code&gt;&quot;use client&quot;&lt;/code&gt; in its own file, then importing it into a Server Component layout directly. Keep the theme creation itself client-side (as shown in &lt;a rel=&quot;nofollow&quot; href=&quot;#create-a-custom-theme-file&quot;&gt;Step 3&lt;/a&gt;) to avoid subtle serialization issues — &lt;code&gt;ThemeProvider&lt;/code&gt; here is already a Client Component internally, so this composition is safe.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At this point, your fonts, colors, and typography are fully wired up with zero flash of unstyled content. Now let's add dark mode.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;add-flash-free-dark-mode&quot;&gt;Step 5: Add Flash-Free Dark Mode&lt;/h2&gt;
&lt;p&gt;The trickiest part of theming in Next.js isn't picking colors — it's avoiding the flash where a user's saved &quot;dark mode&quot; preference briefly shows light mode before JavaScript hydrates and corrects it. MUI's &lt;code&gt;cssVariables&lt;/code&gt; mode ships a dedicated component for exactly this: &lt;code&gt;InitColorSchemeScript&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;First, update the theme to support both color schemes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// theme/theme.ts
&quot;use client&quot;;

import { createTheme } from &quot;@mui/material/styles&quot;;

const theme = createTheme({
  cssVariables: {
    colorSchemeSelector: &quot;class&quot;,
  },
  colorSchemes: {
    light: {
      palette: {
        primary: { main: &quot;#0F62FE&quot; },
        secondary: { main: &quot;#6929C4&quot; },
      },
    },
    dark: {
      palette: {
        primary: { main: &quot;#7CA9FF&quot; },
        secondary: { main: &quot;#B490E8&quot; },
      },
    },
  },
  shape: {
    borderRadius: 10,
  },
  typography: {
    fontFamily: &quot;var(--font-roboto)&quot;,
  },
});

export default theme;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; instead of one flat &lt;code&gt;palette&lt;/code&gt;, we now define &lt;code&gt;colorSchemes.light&lt;/code&gt; and &lt;code&gt;colorSchemes.dark&lt;/code&gt;, each with its own palette. &lt;code&gt;colorSchemeSelector: &quot;class&quot;&lt;/code&gt; tells MUI to switch schemes by toggling a CSS class (rather than &lt;code&gt;data-&lt;/code&gt; attributes or &lt;code&gt;prefers-color-scheme&lt;/code&gt; alone), which gives you full manual control over the toggle.&lt;/p&gt;
&lt;p&gt;Next, add &lt;code&gt;InitColorSchemeScript&lt;/code&gt; to the root layout, &lt;strong&gt;before&lt;/strong&gt; anything else renders:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/layout.tsx
import { InitColorSchemeScript } from &quot;@mui/material&quot;;
// ...other imports from Step 4

export default function RootLayout({
  children,
}: Readonly&amp;lt;{ children: React.ReactNode }&amp;gt;) {
  return (
    &amp;lt;html lang=&quot;en&quot; className={roboto.variable} suppressHydrationWarning&amp;gt;
      &amp;lt;body&amp;gt;
        &amp;lt;InitColorSchemeScript attribute=&quot;class&quot; /&amp;gt;
        &amp;lt;AppRouterCacheProvider&amp;gt;
          &amp;lt;ThemeProvider theme={theme}&amp;gt;
            &amp;lt;CssBaseline /&amp;gt;
            {children}
          &amp;lt;/ThemeProvider&amp;gt;
        &amp;lt;/AppRouterCacheProvider&amp;gt;
      &amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;InitColorSchemeScript&lt;/code&gt; injects a tiny, synchronous inline script that runs &lt;strong&gt;before&lt;/strong&gt; React hydrates. It reads the user's saved preference (from &lt;code&gt;localStorage&lt;/code&gt;) and applies the right class to &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; immediately — so the correct theme is already active by the time the browser paints anything. This is exactly what eliminates the flash. If you want the deeper &quot;why&quot; behind pre-hydration scripts like this one, &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained&lt;/a&gt; covers how streaming and hydration fit together.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ If you don't add &lt;code&gt;suppressHydrationWarning&lt;/code&gt; to your &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; tag, you will see warnings about &quot;Extra attributes from the server&quot; because &lt;code&gt;InitColorSchemeScript&lt;/code&gt; updates that element.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now build a simple toggle button as a Client Component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/theme-toggle.tsx
&quot;use client&quot;;

import IconButton from &quot;@mui/material/IconButton&quot;;
import Brightness4Icon from &quot;@mui/icons-material/Brightness4&quot;;
import Brightness7Icon from &quot;@mui/icons-material/Brightness7&quot;;
import { useColorScheme } from &quot;@mui/material/styles&quot;;

export default function ThemeToggle() {
  const { mode, setMode } = useColorScheme();

  if (!mode) {
    // Renders on the server before the client has resolved a mode —
    // returning null here avoids a hydration mismatch.
    return null;
  }

  return (
    &amp;lt;IconButton
      onClick={() =&amp;gt; setMode(mode === &quot;light&quot; ? &quot;dark&quot; : &quot;light&quot;)}
      color=&quot;inherit&quot;
      aria-label=&quot;Toggle light and dark theme&quot;
    &amp;gt;
      {mode === &quot;dark&quot; ? &amp;lt;Brightness7Icon /&amp;gt; : &amp;lt;Brightness4Icon /&amp;gt;}
    &amp;lt;/IconButton&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;useColorScheme()&lt;/code&gt; is a hook MUI provides specifically for &lt;code&gt;cssVariables&lt;/code&gt;-based themes — it gives you the current &lt;code&gt;mode&lt;/code&gt; and a &lt;code&gt;setMode&lt;/code&gt; function that updates both the DOM class and &lt;code&gt;localStorage&lt;/code&gt; for you. The &lt;code&gt;if (!mode) return null&lt;/code&gt; guard matters: on the very first client render, &lt;code&gt;mode&lt;/code&gt; can briefly be &lt;code&gt;undefined&lt;/code&gt; before MUI resolves it, and rendering nothing during that split second avoids a server/client mismatch warning.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &lt;code&gt;mode&lt;/code&gt; can be &lt;code&gt;&quot;light&quot;&lt;/code&gt;, &lt;code&gt;&quot;dark&quot;&lt;/code&gt;, or &lt;code&gt;&quot;system&quot;&lt;/code&gt;. If you want a three-way toggle (light/dark/follow OS), cycle through all three values instead of just two in the &lt;code&gt;onClick&lt;/code&gt; handler.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;write-mode-aware-styles-the-right-way&quot;&gt;Step 6: Write Mode-Aware Styles the Right Way — Avoid `theme.palette.mode`&lt;/h2&gt;
&lt;p&gt;Here's a mistake that's easy to make even after everything above is set up correctly: once a component needs a color that depends on light vs. dark mode, it's tempting to reach for a plain conditional:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ❌ Don't do this with CSS variables enabled
&amp;lt;Card
  sx={(theme) =&amp;gt; ({
    backgroundColor: theme.palette.mode === &quot;dark&quot; ? &quot;#000&quot; : &quot;#fff&quot;,
  })}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks harmless, but MUI's own docs explicitly warn against it: &lt;code&gt;theme.palette.mode&lt;/code&gt; reflects whatever mode was resolved &lt;strong&gt;at render time&lt;/strong&gt;, and on the server that's always a guess (or the default). The result is the exact SSR flicker you just spent &lt;a rel=&quot;nofollow&quot; href=&quot;#add-flash-free-dark-mode&quot;&gt;Step 5&lt;/a&gt; eliminating — the server renders one color, the client corrects it a moment later, and you see a flash.&lt;/p&gt;
&lt;p&gt;The fix is &lt;code&gt;theme.applyStyles()&lt;/code&gt;, which generates real CSS rules scoped to each mode instead of picking one value at render time:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ✅ Correct — no flicker, works with CSS variables
import Card from &quot;@mui/material/Card&quot;;

&amp;lt;Card
  sx={[
    {
      backgroundColor: &quot;#fff&quot;,
    },
    (theme) =&amp;gt;
      theme.applyStyles(&quot;dark&quot;, {
        backgroundColor: &quot;#000&quot;,
      }),
  ]}
/&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; instead of computing a single color based on the &lt;em&gt;current&lt;/em&gt; mode, &lt;code&gt;applyStyles(&quot;dark&quot;, {...})&lt;/code&gt; generates mode-specific CSS that is activated by the color-scheme selector configured in the theme. Both the light and dark rules can exist in the stylesheet, while the active selector on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; determines which styles apply.&lt;/p&gt;
&lt;p&gt;The same pattern works with the &lt;code&gt;styled()&lt;/code&gt; API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/themed-box.tsx
&quot;use client&quot;;

import { styled } from &quot;@mui/material/styles&quot;;

export const ThemedBox = styled(&quot;div&quot;)(({ theme }) =&amp;gt; [
  {
    color: &quot;#111&quot;,
    backgroundColor: theme.palette.background.paper,
  },
  theme.applyStyles(&quot;dark&quot;, {
    color: &quot;#fff&quot;,
    backgroundColor: theme.palette.grey[900],
  }),
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Sprinkling &lt;code&gt;theme.palette.mode === &quot;dark&quot;&lt;/code&gt; checks throughout &lt;code&gt;sx&lt;/code&gt; props &quot;just to get something working,&quot; then wondering why dark mode flickers even after adding &lt;code&gt;InitColorSchemeScript&lt;/code&gt;. &lt;code&gt;InitColorSchemeScript&lt;/code&gt; only prevents the &lt;em&gt;class&lt;/em&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; from being wrong on first paint — it does nothing to fix a component that's still choosing its color at render time instead of via CSS. Audit your &lt;code&gt;sx&lt;/code&gt; props for &lt;code&gt;palette.mode&lt;/code&gt; checks and swap them for &lt;code&gt;applyStyles()&lt;/code&gt; as you find them.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; You can pass an array of style objects (as shown above) or plain objects — &lt;code&gt;theme.applyStyles()&lt;/code&gt; composes fine with any other static styles in the same &lt;code&gt;sx&lt;/code&gt; array.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-verify-everything-works&quot;&gt;Step 7: Verify Everything Works&lt;/h3&gt;
&lt;p&gt;Drop the toggle into a page and confirm the full setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/page.tsx
import Button from &quot;@mui/material/Button&quot;;
import Typography from &quot;@mui/material/Typography&quot;;
import Stack from &quot;@mui/material/Stack&quot;;
import ThemeToggle from &quot;@/components/theme-toggle&quot;;

export default function HomePage() {
  return (
    &amp;lt;Stack spacing={3} sx={{ p: 4 }}&amp;gt;
      &amp;lt;Stack direction=&quot;row&quot; sx={{ justifyContent: &quot;center&quot;, alignItems: &quot;center&quot; }}&amp;gt;
        &amp;lt;Typography variant=&quot;h4&quot;&amp;gt;MUI + Next.js 16&amp;lt;/Typography&amp;gt;
        &amp;lt;ThemeToggle /&amp;gt;
      &amp;lt;/Stack&amp;gt;
      &amp;lt;Typography variant=&quot;body1&quot;&amp;gt;
        This button, color, and font are all coming from your custom theme.
      &amp;lt;/Typography&amp;gt;
      &amp;lt;Button variant=&quot;contained&quot; color=&quot;primary&quot;&amp;gt;
        Themed Button
      &amp;lt;/Button&amp;gt;
    &amp;lt;/Stack&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Refresh the page a few times with dark mode on — there should be &lt;strong&gt;zero&lt;/strong&gt; flash of the wrong theme, and the button color should match your custom palette instead of MUI's default blue.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;app/page.tsx&lt;/code&gt; does not need &quot;use client&quot; just because it renders &lt;code&gt;ThemeToggle&lt;/code&gt;. Next.js allows Server Components to render Client Components; only the interactive subtree needs the client boundary.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-mui-material-nextjs-if-i-m-only-using-the-pages-router-&quot;&gt;Do I need &lt;code&gt;@mui/material-nextjs&lt;/code&gt; if I'm only using the Pages Router?&lt;/h4&gt;
&lt;p&gt;The Next.js integration package supports both routers. For the Pages Router, use the versioned &lt;code&gt;pagesRouter&lt;/code&gt; entry point that matches your Next.js version, such as &lt;code&gt;v13-pagesRouter&lt;/code&gt; for Next.js 13. The setup differs from the App Router and uses &lt;code&gt;_document.tsx&lt;/code&gt;/&lt;code&gt;_app.tsx&lt;/code&gt; rather than the App Router cache-provider setup.&lt;/p&gt;
&lt;h4 id=&quot;why-does-my-theme-file-need-use-client-if-themeprovider-is-used-in-a-server-component-layout-&quot;&gt;Why does my theme file need &lt;code&gt;&quot;use client&quot;&lt;/code&gt; if &lt;code&gt;ThemeProvider&lt;/code&gt; is used in a Server Component layout?&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ThemeProvider&lt;/code&gt; itself is already a Client Component internally, so it's safe to render from a Server Component layout. The &lt;code&gt;&quot;use client&quot;&lt;/code&gt; on the theme file is a safety measure — &lt;code&gt;createTheme()&lt;/code&gt; returns a rich object with methods, and keeping its creation explicitly client-side avoids subtle serialization issues as your theme grows more complex.&lt;/p&gt;
&lt;h4 id=&quot;why-can-t-i-just-check-theme-palette-mode-dark-in-my-styles-&quot;&gt;Why can't I just check &lt;code&gt;theme.palette.mode === &quot;dark&quot;&lt;/code&gt; in my styles?&lt;/h4&gt;
&lt;p&gt;You can, but only if you don't care about the SSR flicker — MUI's own docs say directly that any &lt;code&gt;theme.palette.mode&lt;/code&gt; check will flash the wrong mode on first load when CSS variables are enabled, because the server has no way to know the client's saved preference at render time. &lt;code&gt;theme.applyStyles(&quot;dark&quot;, {...})&lt;/code&gt; avoids this entirely by generating mode-scoped CSS instead of picking a value during render. See &lt;a rel=&quot;nofollow&quot; href=&quot;#write-mode-aware-styles-the-right-way&quot;&gt;Step 6&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;can-i-use-mui-with-tailwind-css-in-the-same-next-js-project-&quot;&gt;Can I use MUI with Tailwind CSS in the same Next.js project?&lt;/h4&gt;
&lt;p&gt;Yes. The important issue is CSS cascade and style precedence rather than class-name collisions. MUI's &lt;code&gt;enableCssLayer&lt;/code&gt; option places generated MUI styles inside an &lt;code&gt;@layer mui&lt;/code&gt; layer, making it easier to control how MUI styles interact with Tailwind CSS, CSS Modules, and other global styles.&lt;/p&gt;
&lt;h4 id=&quot;my-styles-show-up-correctly-but-flash-unstyled-for-a-split-second-what-s-wrong-&quot;&gt;My styles show up correctly but flash unstyled for a split second — what's wrong?&lt;/h4&gt;
&lt;p&gt;Almost always means &lt;code&gt;AppRouterCacheProvider&lt;/code&gt; isn't wrapping your whole app, or it's nested in the wrong place (it needs to be a direct wrapper inside &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, around everything else). Double-check Step 2's structure.&lt;/p&gt;
&lt;h4 id=&quot;do-i-still-need-cssbaseline-&quot;&gt;Do I still need &lt;code&gt;CssBaseline&lt;/code&gt;?&lt;/h4&gt;
&lt;p&gt;No, &lt;code&gt;CssBaseline&lt;/code&gt; isn't technically required. It is commonly recommended because it provides MUI's baseline/reset styles and applies theme-aware background and text defaults.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;You now have a complete, flash-free MUI setup in Next.js 16 — the official &lt;code&gt;AppRouterCacheProvider&lt;/code&gt; handling SSR streaming correctly (using the Next.js 16-matched &lt;code&gt;v16-appRouter&lt;/code&gt; entry point), a typed custom theme with your own colors and &lt;code&gt;next/font&lt;/code&gt;-optimized typography, a working light/dark toggle powered by MUI's native CSS variables, and mode-aware component styles written with &lt;code&gt;theme.applyStyles()&lt;/code&gt; instead of a flicker-prone &lt;code&gt;palette.mode&lt;/code&gt; check. This is the same foundation you'd want under a real dashboard, admin panel, or SaaS product.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is exploring MUI's &lt;code&gt;styled()&lt;/code&gt; API and &lt;code&gt;sx&lt;/code&gt; prop for component-level customization, or building out a full design system with typed theme augmentation (&lt;code&gt;declare module &quot;@mui/material/styles&quot;&lt;/code&gt;) for custom palette keys.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/server-vs-client-components-nextjs-16&quot;&gt;Server vs Client Components in Next.js 16 (2026 Guide)&lt;/a&gt; — go deeper on the Server/Client boundary this guide's &lt;code&gt;&quot;use client&quot;&lt;/code&gt; theme file relies on.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-metadata&quot;&gt;How to Add Metadata in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt; — pair this theme setup with proper per-page titles, Open Graph tags, and SEO metadata.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt; — understand the streaming/hydration model that makes &lt;code&gt;InitColorSchemeScript&lt;/code&gt; necessary in the first place.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt; — see where a themed component library like this one fits into a larger app structure.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/mui-theme-setup-nextjs-16&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28671/mui-v9-theme-setup-in-next-js-16-full-2026-guide</guid>
<pubDate>Sat, 26 Sep 2026 03:07:57 +0000</pubDate>
</item>
<item>
<title>Your useEffect Data Fetch Might Show Stale Data If a User Navigates Quickly</title>
<link>https://coderlegion.com/28607/your-useeffect-data-fetch-might-show-stale-data-if-a-user-navigates-quickly</link>
<description>&lt;p&gt;This is a genuinely common race condition, and it's specifically the kind that only shows up under real usage conditions, someone clicking between items quickly, a slow network, exactly the situations casual development testing on a fast local connection rarely reproduces.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-completely-standard&quot;&gt;The Setup That Looks Completely Standard&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';
import { useState, useEffect } from 'react';

export function UserDetail({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() =&amp;gt; {
    fetch(`/api/users/${userId}`)
      .then((res) =&amp;gt; res.json())
      .then((data) =&amp;gt; setUser(data));
  }, [userId]);

  return &amp;lt;div&amp;gt;{user ? user.name : 'Loading...'}&amp;lt;/div&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is close to the most common way to fetch data based on a changing prop in a Client Component, and it works completely correctly the vast majority of the time. It also has a real race condition that only shows up under a specific, genuinely common sequence of events.&lt;/p&gt;
&lt;h3 id=&quot;the-exact-sequence-that-triggers-the-bug&quot;&gt;The Exact Sequence That Triggers the Bug&lt;/h3&gt;
&lt;p&gt;A user clicks on user A in a list, &lt;code&gt;userId&lt;/code&gt; becomes A's ID, the effect fires, a request for A's data starts. Before that request resolves, network latency, a slow connection, the user clicks user B instead, &lt;code&gt;userId&lt;/code&gt; becomes B's ID, the effect fires again, a second, separate request for B's data starts. Now two requests are in flight simultaneously.&lt;/p&gt;
&lt;p&gt;If B's request happens to resolve faster than A's, perfectly plausible depending on server load, caching, or just network variance, B's data renders correctly first. Then A's slower, now-stale request finally resolves, and its &lt;code&gt;.then()&lt;/code&gt; callback runs &lt;code&gt;setUser(data)&lt;/code&gt; with A's data, overwriting B's correct, current data with A's outdated, no-longer-relevant response. The component now displays user A's name and information while &lt;code&gt;userId&lt;/code&gt; itself is actually set to B, a genuine, visible mismatch between what's shown and what's actually selected.&lt;/p&gt;
&lt;h3 id=&quot;why-this-feels-random-rather-than-reliably-broken&quot;&gt;Why This Feels Random Rather Than Reliably Broken&lt;/h3&gt;
&lt;p&gt;This only manifests when the specific timing lines up, a slower first request resolving after a faster second one. On a fast, consistent local connection during development, requests often resolve close enough to their sending order that this exact interleaving rarely happens, which is exactly why it's easy to ship without ever seeing it locally, and exactly why it tends to surface first as a confusing, hard-to-reproduce bug report from a real user on a real, more variable connection.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-ignore-stale-responses-explicitly&quot;&gt;The Actual Fix: Ignore Stale Responses Explicitly&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';
import { useState, useEffect } from 'react';

export function UserDetail({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() =&amp;gt; {
    let ignore = false;

    fetch(`/api/users/${userId}`)
      .then((res) =&amp;gt; res.json())
      .then((data) =&amp;gt; {
        if (!ignore) {
          setUser(data);
        }
      });

    return () =&amp;gt; {
      ignore = true; // marks this specific effect's request as stale on cleanup
    };
  }, [userId]);

  return &amp;lt;div&amp;gt;{user ? user.name : 'Loading...'}&amp;lt;/div&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ignore&lt;/code&gt; flag, scoped locally to each individual effect invocation, gets set to &lt;code&gt;true&lt;/code&gt; in the cleanup function, which React runs automatically the moment &lt;code&gt;userId&lt;/code&gt; changes again, before the next effect invocation starts. When A's slow request finally resolves, its specific closure's &lt;code&gt;ignore&lt;/code&gt; flag is already &lt;code&gt;true&lt;/code&gt;, since the effect was cleaned up the moment the user clicked B, and the stale &lt;code&gt;setUser(data)&lt;/code&gt; call for A's outdated data simply never happens. Only B's request, whose &lt;code&gt;ignore&lt;/code&gt; flag remains &lt;code&gt;false&lt;/code&gt; because its effect was never cleaned up, actually updates state.&lt;/p&gt;
&lt;h3 id=&quot;an-alternative-using-abortcontroller&quot;&gt;An Alternative Using AbortController&lt;/h3&gt;
&lt;p&gt;For fetch specifically, &lt;code&gt;AbortController&lt;/code&gt; provides a more complete version of the same fix, actually canceling the in-flight request rather than just ignoring its eventual result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) =&amp;gt; res.json())
    .then((data) =&amp;gt; setUser(data))
    .catch((err) =&amp;gt; {
      if (err.name !== 'AbortError') {
        console.error(err); // genuinely handle real errors, ignore expected aborts
      }
    });

  return () =&amp;gt; {
    controller.abort();
  };
}, [userId]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This has a real advantage over the simple ignore flag, the actual network request gets canceled, not just its result discarded, which saves real bandwidth and server load for a request whose result was never going to be used anyway, particularly valuable for anything more expensive than a small JSON response.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-more-for-search-and-filtering-specifically&quot;&gt;Why This Matters More for Search and Filtering Specifically&lt;/h3&gt;
&lt;p&gt;This exact pattern shows up constantly in search-as-you-type and rapid filtering interactions, exactly the kind of interface covered in an earlier post on search and filtering patterns. A user typing quickly triggers a new request on every keystroke, and without this protection, a slower response to an earlier, now-outdated keystroke can overwrite the correct results for what the user is actually searching for right now, displaying results for a query they've already moved past.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-5&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any effect that fetches data based on a value that can change again before the fetch resolves needs a way to distinguish a stale, no-longer-relevant response from a current one.&lt;/strong&gt; A simple &lt;code&gt;ignore&lt;/code&gt; flag set in the cleanup function handles this cheaply for most cases. &lt;code&gt;AbortController&lt;/code&gt; handles it more completely, actually canceling the wasted request rather than just discarding its result, and is worth the small amount of extra code for anything with real cost behind each request, a genuinely expensive query, meaningful bandwidth, a rate-limited endpoint.&lt;/p&gt;
&lt;p&gt;I handle this exact pattern, mostly with &lt;code&gt;AbortController&lt;/code&gt; for anything backed by a real database query, across the dashboards and templates I build at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pixelanas.com&quot;&gt;pixelanas.com&lt;/a&gt;, since it's exactly the kind of subtle bug that looks fine in every casual test and only shows up once real users start clicking around at real speed.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've got a &lt;code&gt;useEffect&lt;/code&gt; fetching data based on a changing prop with no stale-response handling, go test it specifically by clicking or navigating quickly between a few items in a row. If you see a flash of the wrong data settling in, that's this exact race condition. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28607/your-useeffect-data-fetch-might-show-stale-data-if-a-user-navigates-quickly</guid>
<pubDate>Fri, 25 Sep 2026 13:18:13 +0000</pubDate>
</item>
<item>
<title>Why I stopped using online image compressors and built a CLI instead</title>
<link>https://coderlegion.com/28438/why-i-stopped-using-online-image-compressors-and-built-a-cli-instead</link>
<description>&lt;p&gt;Four years of optimizing React and Next.js projects taught me one thing: unoptimized images are everywhere, and nobody wants to fix them.&lt;/p&gt;
&lt;p&gt;Every project has the same pattern. Heavy PNG and JPG files are sitting inside &lt;code&gt;/public&lt;/code&gt;, there is no consistent image pipeline, and some of those files have no business being that large in a production codebase.&lt;/p&gt;
&lt;p&gt;This is especially common in small and mid-sized projects. There is no CDN transformation layer or dedicated asset pipeline. Images get added while the product is moving quickly, and the cleanup becomes a task for “later.”&lt;/p&gt;
&lt;p&gt;Later, of course, never comes.&lt;/p&gt;
&lt;p&gt;Then, at 1am, while refactoring an extremely vibe-coded Next.js project, I found myself doing the cleanup manually again.&lt;/p&gt;
&lt;p&gt;Find an image. Upload it to an online compressor. Hit the free limit. Open another tool. Convert a few more. Download everything. Replace the original files. Hunt through the codebase for every import and &lt;code&gt;src&lt;/code&gt; path. Hope I did not miss one.&lt;/p&gt;
&lt;p&gt;And I finally thought: I am a developer. Why am I doing this by hand?&lt;/p&gt;
&lt;p&gt;So I built &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/pixcrush&quot;&gt;pixcrush&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One command to convert the images, compress them, and update their matching code references automatically.&lt;/p&gt;
&lt;h3 id=&quot;-but-doesn-t-next-js-already-optimize-images-&quot;&gt;“But doesn’t Next.js already optimize images?”&lt;/h3&gt;
&lt;p&gt;Yes, and if your application uses &lt;code&gt;next/image&lt;/code&gt; consistently, you should absolutely take advantage of it.&lt;/p&gt;
&lt;p&gt;The Next.js &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; component can resize images for different devices, lazy-load them, and serve modern formats such as WebP. Files inside &lt;code&gt;/public&lt;/code&gt; can be referenced from the root URL, while statically imported images also give Next.js access to their intrinsic dimensions. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/getting-started/images&quot;&gt;official Next.js image documentation&lt;/a&gt; explains these runtime optimizations in detail.&lt;/p&gt;
&lt;p&gt;But that solves a different layer of the problem.&lt;/p&gt;
&lt;p&gt;I wanted to clean up the source assets themselves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Replace heavy PNG and JPG files with smaller WebP files when conversion is worthwhile.&lt;/li&gt;
&lt;li&gt;Update existing imports and string-based image paths across the repository.&lt;/li&gt;
&lt;li&gt;Identify images that are no longer referenced.&lt;/li&gt;
&lt;li&gt;Make the migration reviewable before touching any files.&lt;/li&gt;
&lt;li&gt;Keep the process useful for projects that do not consistently use &lt;code&gt;next/image&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This was not about replacing the Next.js image pipeline. It was about making a tedious codebase migration safe and repeatable.&lt;/p&gt;
&lt;h3 id=&quot;why-a-simple-format-conversion-becomes-a-code-migration&quot;&gt;Why a simple format conversion becomes a code migration&lt;/h3&gt;
&lt;p&gt;Consider a small project with images referenced in a few different ways:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import hero from './assets/hero.png';

export function HomePage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;Image src={hero} alt=&quot;&quot; /&amp;gt;
      &amp;lt;img src=&quot;/images/team.jpg&quot; alt=&quot;Our team&quot; /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Converting &lt;code&gt;hero.png&lt;/code&gt; and &lt;code&gt;team.jpg&lt;/code&gt; is only half of the job. The code now points to files that no longer represent the desired output.&lt;/p&gt;
&lt;p&gt;After the migration, the references need to become:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import hero from './assets/hero.webp';

export function HomePage() {
  return (
    &amp;lt;main&amp;gt;
      &amp;lt;Image src={hero} alt=&quot;&quot; /&amp;gt;
      &amp;lt;img src=&quot;/images/team.webp&quot; alt=&quot;Our team&quot; /&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now multiply that across &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.jsx&lt;/code&gt;, &lt;code&gt;.ts&lt;/code&gt;, &lt;code&gt;.tsx&lt;/code&gt;, &lt;code&gt;.html&lt;/code&gt;, and &lt;code&gt;.json&lt;/code&gt; files, plus nested apps inside a Turborepo.&lt;/p&gt;
&lt;p&gt;That is no longer an image-conversion task. It is a codemod with image processing attached.&lt;/p&gt;
&lt;h3 id=&quot;the-workflow-i-wanted&quot;&gt;The workflow I wanted&lt;/h3&gt;
&lt;p&gt;I wanted the tool to perform five clear phases:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Scan the repository for supported images and source files.&lt;/li&gt;
&lt;li&gt;Determine which images are actually referenced.&lt;/li&gt;
&lt;li&gt;Convert used PNG and JPG files to WebP.&lt;/li&gt;
&lt;li&gt;Update only the references connected to successful conversions.&lt;/li&gt;
&lt;li&gt;Report what changed, what was skipped, and what needs human review.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The default command is intentionally small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a first pass on an unfamiliar project, I prefer starting with a dry run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush . --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This previews the migration without writing files.&lt;/p&gt;
&lt;h3 id=&quot;step-1-find-the-images-and-their-references&quot;&gt;Step 1: Find the images and their references&lt;/h3&gt;
&lt;p&gt;pixcrush scans for PNG, JPG, and JPEG files while ignoring directories such as &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.next&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, and &lt;code&gt;.git&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It separately collects the source files that may contain references:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;.js  .jsx  .ts  .tsx  .html  .htm  .json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finding image files is easy. Determining whether they are used is the interesting part.&lt;/p&gt;
&lt;p&gt;For JavaScript and TypeScript, pixcrush parses the source with Babel using the JSX and TypeScript plugins. Babel’s parser produces an abstract syntax tree, which makes it possible to inspect actual string literals instead of blindly replacing every &lt;code&gt;.png&lt;/code&gt; substring. Babel documents its JSX and TypeScript parsing support in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://babeljs.io/docs/babel-parser/&quot;&gt;&lt;code&gt;@babel/parser&lt;/code&gt; reference&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The tracker resolves several common patterns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import logo from './logo.png';

&amp;lt;Image src=&quot;/images/hero.jpg&quot; alt=&quot;&quot; /&amp;gt;

const imagePath = '@/assets/product.png';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also handles image-bearing values in HTML and JSON, including &lt;code&gt;srcset&lt;/code&gt;-style strings and query parameters.&lt;/p&gt;
&lt;p&gt;For absolute paths such as &lt;code&gt;/images/hero.jpg&lt;/code&gt;, pixcrush reconciles the reference with matching &lt;code&gt;public&lt;/code&gt; directories. This matters in monorepos, where the real file may be located at something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;apps/web/public/images/hero.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Images without a resolved reference are reported as unused instead of being converted automatically.&lt;/p&gt;
&lt;h3 id=&quot;step-2-convert-in-memory-and-keep-the-original-when-webp-is-larger&quot;&gt;Step 2: Convert in memory, and keep the original when WebP is larger&lt;/h3&gt;
&lt;p&gt;The actual image conversion uses &lt;a rel=&quot;nofollow&quot; href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;Sharp&lt;/a&gt;, which supports WebP output and exposes a configurable quality setting.&lt;/p&gt;
&lt;p&gt;The important part is not just calling &lt;code&gt;.webp()&lt;/code&gt;. pixcrush creates the WebP data in memory first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const webpBuffer = await sharp(imagePath)
  .webp({ quality })
  .toBuffer();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sharp’s &lt;a rel=&quot;nofollow&quot; href=&quot;https://sharp.pixelplumbing.com/api-output/&quot;&gt;&lt;code&gt;toBuffer()&lt;/code&gt; documentation&lt;/a&gt; confirms that WebP output can be generated directly as a buffer. That lets pixcrush compare the proposed output with the original before writing anything.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;if (webpBuffer.length &amp;gt;= originalSize) {
  // Skip this conversion.
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WebP is often smaller, but “modern format” does not automatically mean “smaller file” for every source image. If the generated WebP would be the same size or larger, pixcrush skips it.&lt;/p&gt;
&lt;p&gt;More importantly, skipped images are not added to the rewrite map. Their code references remain unchanged.&lt;/p&gt;
&lt;p&gt;The default WebP quality is &lt;code&gt;80&lt;/code&gt;, and it can be adjusted:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush . --quality 90
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-3-rewrite-code-without-reformatting-the-entire-file&quot;&gt;Step 3: Rewrite code without reformatting the entire file&lt;/h3&gt;
&lt;p&gt;The riskiest part of the migration is not creating a &lt;code&gt;.webp&lt;/code&gt; file. It is changing the code safely.&lt;/p&gt;
&lt;p&gt;A global find-and-replace is tempting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;.png → .webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is also far too broad. It can modify unrelated text, references to images that were skipped, or paths that do not resolve to a file pixcrush converted.&lt;/p&gt;
&lt;p&gt;Instead, pixcrush builds a map containing only successful conversions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/absolute/path/hero.png → /absolute/path/hero.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It parses each JavaScript or TypeScript source file, visits its string literals, resolves candidate image paths, and creates targeted text replacements only for matches in that conversion map.&lt;/p&gt;
&lt;p&gt;The replacements are applied from the end of the file toward the beginning. This prevents an earlier edit from shifting the offsets required by a later edit.&lt;/p&gt;
&lt;p&gt;There is another subtle benefit: pixcrush does not regenerate the entire source file from the AST. It replaces only the extension inside the matched string. Existing formatting, comments, quote styles, and surrounding code remain untouched.&lt;/p&gt;
&lt;p&gt;Query strings are preserved too:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/images/hero.png?v=2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/images/hero.webp?v=2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-safety-rules-matter-more-than-the-happy-path&quot;&gt;The safety rules matter more than the happy path&lt;/h3&gt;
&lt;p&gt;Code-modifying tools should be conservative. I would rather have pixcrush warn about one path than confidently break a project.&lt;/p&gt;
&lt;p&gt;These are the guardrails I added:&lt;/p&gt;
&lt;h4 id=&quot;dry-runs-do-not-write-files&quot;&gt;Dry runs do not write files&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush . --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This runs the analysis, conversion calculation, and reference-matching flow without writing the WebP files or modified source.&lt;/p&gt;
&lt;h4 id=&quot;dynamic-paths-are-reported-not-guessed&quot;&gt;Dynamic paths are reported, not guessed&lt;/h4&gt;
&lt;p&gt;pixcrush can understand a static value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;img src=&quot;/products/shoe.png&quot; alt=&quot;&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It cannot safely resolve every runtime value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;lt;img src={`/products/${product.slug}.png`} alt=&quot;&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Dynamic template paths are warned about so they can be reviewed manually.&lt;/p&gt;
&lt;h4 id=&quot;parse-failures-block-destructive-cleanup&quot;&gt;Parse failures block destructive cleanup&lt;/h4&gt;
&lt;p&gt;If a source file cannot be parsed, pixcrush records the failure. When &lt;code&gt;--delete-originals&lt;/code&gt; is enabled, original and unused images are deleted only if both the analysis and rewrite phases completed without parse failures.&lt;/p&gt;
&lt;h4 id=&quot;framework-metadata-images-are-excluded&quot;&gt;Framework metadata images are excluded&lt;/h4&gt;
&lt;p&gt;Files such as favicons, Apple touch icons, Open Graph images, Twitter images, and common PWA manifest icons may be discovered through framework conventions rather than ordinary source references.&lt;/p&gt;
&lt;p&gt;pixcrush excludes these patterns from the migration instead of incorrectly treating them as orphans.&lt;/p&gt;
&lt;h4 id=&quot;external-and-inline-images-are-left-alone&quot;&gt;External and inline images are left alone&lt;/h4&gt;
&lt;p&gt;HTTP URLs and &lt;code&gt;data:&lt;/code&gt; values are not local assets, so the tracker ignores them.&lt;/p&gt;
&lt;h3 id=&quot;running-the-full-migration&quot;&gt;Running the full migration&lt;/h3&gt;
&lt;p&gt;My preferred workflow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Preview the migration
npx pixcrush . --dry-run

# 2. Review the reported conversions, warnings, and unused files

# 3. Run the migration
npx pixcrush .

# 4. Run the application and its tests

# 5. Remove converted originals only when ready
npx pixcrush . --delete-originals
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final summary includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Images converted&lt;/li&gt;
&lt;li&gt;Space saved&lt;/li&gt;
&lt;li&gt;Source files updated&lt;/li&gt;
&lt;li&gt;Unused images detected&lt;/li&gt;
&lt;li&gt;Dynamic references or parsing issues requiring review&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I still recommend running this on a clean Git branch. A dry run reduces surprises; Git gives you the final, exact review surface.&lt;/p&gt;
&lt;h3 id=&quot;what-pixcrush-does-not-handle-yet&quot;&gt;What pixcrush does not handle yet&lt;/h3&gt;
&lt;p&gt;The current limitations are intentional and worth stating clearly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CSS files are not parsed, so references inside &lt;code&gt;url(...)&lt;/code&gt; are not rewritten. An image referenced only from CSS may also appear in the unused-image report; review that list carefully before using &lt;code&gt;--delete-originals&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dynamic paths are detected but not automatically converted.&lt;/li&gt;
&lt;li&gt;The image inputs are currently PNG, JPG, and JPEG.&lt;/li&gt;
&lt;li&gt;Framework-convention metadata images are excluded for safety.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If a project relies heavily on CSS backgrounds or dynamically generated filenames, expect some manual work after the automated pass.&lt;/p&gt;
&lt;p&gt;“Automated” should not mean “pretend every edge case is safe.”&lt;/p&gt;
&lt;h3 id=&quot;what-changed-for-me&quot;&gt;What changed for me&lt;/h3&gt;
&lt;p&gt;The biggest improvement is not that Sharp can produce a WebP file. The biggest improvement is that the full cleanup is now one reviewable operation.&lt;/p&gt;
&lt;p&gt;The old workflow looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;find → upload → hit a limit → try another tool → download
→ replace files → search the codebase → update paths → repeat
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new workflow is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush . --dry-run
npx pixcrush .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the kind of automation I like: not a new platform, dashboard, subscription, or upload queue. Just a local tool that removes a chore and then gets out of the way.&lt;/p&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;pixcrush is open source and runs locally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx pixcrush .
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/pixcrush&quot;&gt;npm package&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/karishma-dev/pixcrush&quot;&gt;GitHub repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you try it on a real React, Next.js, or Turborepo project, I would especially love feedback on path-resolution edge cases and file types worth supporting next.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Connect with me ✌🏻:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LinkedIn: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/karishma-garg-/&quot;&gt;Karishma garg&lt;/a&gt; &lt;/li&gt;
&lt;li&gt;Twitter: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/_karishma10&quot;&gt;_karishma10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/karishma-dev&quot;&gt;karishma-dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Portfolio: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.karishma.dev/&quot;&gt;www.karishma.dev&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28438/why-i-stopped-using-online-image-compressors-and-built-a-cli-instead</guid>
<pubDate>Thu, 24 Sep 2026 07:44:12 +0000</pubDate>
</item>
<item>
<title>That bcrypt.hash(password, 10) You Copy-Pasted Might Not Be as Secure as You Think</title>
<link>https://coderlegion.com/28330/that-bcrypt-hash-password-10-you-copy-pasted-might-not-be-as-secure-as-you-think</link>
<description>&lt;p&gt;Almost every auth tutorial, including some of my own examples in earlier posts, uses &lt;code&gt;bcrypt.hash(password, 10)&lt;/code&gt; without much explanation of what that number actually controls or why it's 10 specifically rather than some other value. It's worth understanding, because the right number isn't fixed, it's a moving target that most codebases never revisit after the initial copy-paste.&lt;/p&gt;
&lt;h3 id=&quot;what-the-salt-rounds-number-actually-controls&quot;&gt;What the Salt Rounds Number Actually Controls&lt;/h3&gt;
&lt;p&gt;bcrypt's cost factor, the second argument, controls how computationally expensive hashing a single password is, specifically by determining how many times an internal key-setup routine repeats. It's not a linear scale, each increment roughly doubles the computation required. A cost factor of 10 means roughly 1,024 iterations of that internal routine. 11 means roughly 2,048. 12 means roughly 4,096.&lt;/p&gt;
&lt;p&gt;This deliberate slowness is the entire point. A fast hash function is great for most use cases and terrible for passwords specifically, since a fast hash lets an attacker who's obtained a database of hashed passwords try billions of guesses per second against them. A deliberately slow hash function directly limits how many guesses an attacker can attempt in any given amount of time, even with significant computing power.&lt;/p&gt;
&lt;h3 id=&quot;why-10-specifically-and-why-that-s-worth-questioning&quot;&gt;Why 10 Specifically, and Why That's Worth Questioning&lt;/h3&gt;
&lt;p&gt;The number 10 shows up constantly in tutorials and documentation because it was, at the time much of that content was written, a reasonable balance, slow enough to meaningfully limit brute-force attempts, fast enough not to noticeably slow down a real login flow for a legitimate user. That balance point depends entirely on how fast the hardware attempting to brute-force it actually is, and hardware, particularly specialized hardware built for exactly this kind of computation, gets faster every year. A cost factor considered a reasonable balance several years ago represents meaningfully less real protection against modern hardware than it did when that number first became the common default.&lt;/p&gt;
&lt;h3 id=&quot;why-this-doesn-t-show-up-as-an-obvious-problem&quot;&gt;Why This Doesn't Show Up as an Obvious Problem&lt;/h3&gt;
&lt;p&gt;There's no error, no warning, no visible symptom of a salt rounds value that's become outdated relative to current hardware capability. Login continues working exactly as expected for legitimate users. The gap is entirely about resistance to an offline brute-force attempt against a stolen password database, something that only becomes relevant if that database is ever actually compromised, which means the weakness sits completely invisible until the exact moment it matters most.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-guidance-and-why-it-s-a-moving-target-rather-than-a-fixed-number&quot;&gt;The Actual Guidance, and Why It's a Moving Target Rather Than a Fixed Number&lt;/h3&gt;
&lt;p&gt;Security guidance on this specific number gets revised periodically as hardware capability changes, and current recommendations from security-focused organizations trend higher than the value that shows up in most existing tutorials and codebases. Rather than citing a specific number here that will itself become outdated, the actual, durable guidance is this, check current recommendations specifically when setting this up, rather than trusting whatever number happens to be sitting in the tutorial or codebase you're referencing, since that number is a snapshot of a past moment, not a permanently correct value.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-way-to-choose-the-right-number-for-your-own-server&quot;&gt;A Practical Way to Choose the Right Number for Your Own Server&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A quick, practical way to find a cost factor appropriate for your actual hardware,
// rather than trusting a number copied from somewhere else
import bcrypt from 'bcryptjs';

async function benchmarkCostFactor(rounds: number) {
  const start = Date.now();
  await bcrypt.hash('benchmark-password', rounds);
  return Date.now() - start;
}

// Run this on your actual production server or equivalent hardware,
// and pick the highest cost factor where a single hash still completes
// in a reasonable time for your login flow, commonly cited targets
// are in the range of a few hundred milliseconds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you a real, current answer specific to your actual infrastructure, rather than a number that was correct for someone else's hardware at some point in the past and has simply been carried forward through tutorials and copy-paste ever since.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule-this-points-to&quot;&gt;The Broader Rule This Points To&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A security parameter borrowed from a tutorial is a snapshot of what was reasonable when that tutorial was written, not a permanently correct value.&lt;/strong&gt; This applies beyond just bcrypt cost factors, TLS cipher suite choices, JWT expiration windows, rate limit thresholds, all of these are calibrated against assumptions, about hardware, about attacker capability, about acceptable risk, that shift over time. A value copied once and never revisited quietly drifts from &quot;reasonable&quot; toward &quot;outdated&quot; without any code change ever signaling that drift.&lt;/p&gt;
&lt;h3 id=&quot;what-to-actually-do-about-your-own-codebase&quot;&gt;What to Actually Do About Your Own Codebase&lt;/h3&gt;
&lt;p&gt;Check what cost factor your app is actually using right now, and compare it against current guidance rather than assuming a number from an older tutorial or your own original setup is still appropriate. If it's genuinely outdated, increasing it is straightforward for new passwords going forward, though existing stored hashes were created with the old cost factor and remain at that lower value unless you specifically implement a rehash-on-next-login pattern, checking and upgrading a user's stored hash cost factor the next time they successfully log in with their correct password.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Check what cost factor your own app is actually using, and when you last revisited that number versus just inheriting it from the original setup or a tutorial. Drop what you find in the comments, genuinely curious how many people have actually reconsidered this since first setting it up versus never touching it again.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28330/that-bcrypt-hash-password-10-you-copy-pasted-might-not-be-as-secure-as-you-think</guid>
<pubDate>Wed, 23 Sep 2026 11:43:06 +0000</pubDate>
</item>
<item>
<title>Why Your Next.js Modal Route Works Perfectly Until Someone Refreshes the Page</title>
<link>https://coderlegion.com/28121/why-your-next-js-modal-route-works-perfectly-until-someone-refreshes-the-page</link>
<description>&lt;p&gt;This one isn't really a bug, it's a genuinely common point of confusion about a feature working exactly as designed, and understanding why it behaves this way changes how you'd actually build around it, rather than fighting it.&lt;/p&gt;
&lt;h3 id=&quot;the-pattern-a-photo-grid-that-opens-items-as-a-modal&quot;&gt;The Pattern: A Photo Grid That Opens Items as a Modal&lt;/h3&gt;
&lt;p&gt;Intercepting routes are what makes an Instagram-style interaction possible in the App Router, click a photo in a grid, it opens as a modal overlay on top of the current page, without a full navigation, while the URL still updates to something shareable and bookmarkable.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/
├── @modal/
│   ├── default.tsx
│   └── (.)photo/
│       └── [id]/
│           └── page.tsx    // renders as a modal when intercepted
├── photo/
│   └── [id]/
│       └── page.tsx        // the actual full page
├── layout.tsx
└── page.tsx                 // the grid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Click a photo from the grid, and the &lt;code&gt;(.)photo/[id]&lt;/code&gt; route intercepts the navigation, rendering the modal version in the &lt;code&gt;@modal&lt;/code&gt; slot on top of the grid, without a full page transition. The URL correctly updates to &lt;code&gt;/photo/123&lt;/code&gt;. It feels seamless, and it's a genuinely well-built pattern for exactly this kind of interaction.&lt;/p&gt;
&lt;h3 id=&quot;where-the-confusion-starts&quot;&gt;Where the Confusion Starts&lt;/h3&gt;
&lt;p&gt;Someone building this, testing by clicking through the grid, sees exactly the intended modal behavior. Then they refresh the page while the modal is open, or share that &lt;code&gt;/photo/123&lt;/code&gt; URL with someone else, or that someone else pastes it directly into a new browser tab, and instead of the modal, they get the full, standalone &lt;code&gt;photo/[id]/page.tsx&lt;/code&gt; page, no grid behind it, no modal chrome, just the plain page rendering on its own.&lt;/p&gt;
&lt;p&gt;The natural first reaction is that something's broken, the modal &quot;isn't working&quot; on refresh. It's actually working exactly as designed, just not in the way that first reaction assumes.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-correct-not-broken&quot;&gt;Why This Is Correct, Not Broken&lt;/h3&gt;
&lt;p&gt;Interception is specifically a client-side navigation behavior. It only intercepts a navigation that happens through Next.js's client-side router, clicking a &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;, calling &lt;code&gt;router.push()&lt;/code&gt;, moving from one already-loaded page to another within the same app session. A hard refresh, a direct URL visit, or someone opening that link in a completely fresh browser tab isn't a client-side navigation at all, it's a fresh, full server request for that specific URL, with no prior page state to intercept from, no grid already rendered behind it to interrupt. Next.js correctly falls back to rendering the actual underlying route, the full page, because that's genuinely the only thing that makes sense in a context where there's no previous client-side navigation to interrupt in the first place.&lt;/p&gt;
&lt;p&gt;This is why the folder structure includes both the intercepted route (&lt;code&gt;(.)photo/[id]&lt;/code&gt;, the modal version) and the actual route (&lt;code&gt;photo/[id]&lt;/code&gt;, the full page) as separate, real routes. The full page isn't a fallback or an error state, it's the intended, correct experience for anyone arriving at that URL directly, someone who shared or bookmarked the link, a search engine crawling it, anyone without an existing client-side session to interrupt.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-design-implication&quot;&gt;The Actual Design Implication&lt;/h3&gt;
&lt;p&gt;This isn't something to work around, it's something to design for deliberately. The full, non-modal version of the route needs to be a genuinely complete, standalone page on its own merits, not a stripped-down fallback that only half-works, since a real share of your actual traffic to that URL, direct visits, refreshes, shared links, search engines, will land there specifically, not on the modal version.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/photo/[id]/page.tsx
// This needs to be a genuinely complete page, not an afterthought,
// since direct visits, refreshes, and shared links all land here specifically
export default async function PhotoPage({ params }) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;BackToGridLink /&amp;gt; {/* since there's no grid rendered behind this version */}
      &amp;lt;PhotoDisplay photo={photo} /&amp;gt;
      &amp;lt;PhotoMetadata photo={photo} /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Including something like a clear way back to the grid, since this version genuinely doesn't have the grid rendered behind it the way the modal does, treats this as the real, complete experience it needs to be for a meaningful share of actual visitors, rather than assuming everyone always arrives via the modal path.&lt;/p&gt;
&lt;h3 id=&quot;why-this-actually-matters-for-seo-too&quot;&gt;Why This Actually Matters for SEO Too&lt;/h3&gt;
&lt;p&gt;This split has a genuine upside worth being deliberate about. Since the full page is a real, standalone route, it's exactly what search engines actually crawl and index, a real, complete, server-rendered page for that specific photo, with its own metadata, its own content, rather than something only reachable through a client-side modal interaction a crawler would never trigger. Building the full page version to be genuinely complete isn't just handling an edge case gracefully, it's the version doing real SEO work for that specific piece of content.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-6&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Intercepting routes are a client-side navigation enhancement layered on top of real, independently functional routes, not a replacement for them.&lt;/strong&gt; The full, non-intercepted version of the route needs to be built as a genuinely complete experience on its own, since refreshes, direct visits, shared links, and search engine crawlers all land there specifically, not on the modal version, and treating it as an afterthought means a real share of your actual traffic gets an incomplete experience.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're building or have built an intercepting route pattern, worth checking whether the full, non-modal version genuinely stands on its own, or was built assuming everyone always arrives through the modal. Drop your experience with this pattern in the comments, curious how many people hit this exact confusion the first time before understanding why it's actually correct.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28121/why-your-next-js-modal-route-works-perfectly-until-someone-refreshes-the-page</guid>
<pubDate>Tue, 22 Sep 2026 08:14:06 +0000</pubDate>
</item>
<item>
<title>Multi-Tenant Architecture in Next.js 16 (2026)</title>
<link>https://coderlegion.com/27782/multi-tenant-architecture-in-next-js-16-2026</link>
<description>&lt;p&gt;Every SaaS product hits the same wall eventually: you built it for one company, and now you need it to work for a hundred — each with their own users, their own data, and their own little corner of the app, like &lt;code&gt;acme.yourapp.com&lt;/code&gt;. Bolt that on badly and you get the nightmare scenario every backend developer fears: one bug in a &lt;code&gt;WHERE&lt;/code&gt; clause, and Company A's customer list shows up in Company B's dashboard.&lt;/p&gt;
&lt;p&gt;That's the entire problem &lt;strong&gt;multi-tenant architecture&lt;/strong&gt; solves. It's not one trick — it's a set of decisions about routing, data isolation, and access control that, done right, make &quot;which company does this user belong to?&quot; a question your app can never get wrong, even if a developer forgets to ask it.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll have a working multi-tenant Next.js 16 app: subdomain-based routing through &lt;code&gt;proxy.ts&lt;/code&gt;, a tenant data model backed by Postgres Row Level Security so isolation is enforced by the database — not just your application code — and a path to deploying it on Vercel with wildcard domains.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-answer-what-is-multi-tenant-architecture-&quot;&gt;Quick Answer: What Is Multi-Tenant Architecture?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Multi-tenant architecture&lt;/strong&gt; is a software design where a single application instance and (usually) a single database serve multiple independent customers — called &lt;strong&gt;tenants&lt;/strong&gt; — while keeping each tenant's data, users, and configuration completely isolated from every other tenant. Instead of deploying a separate copy of your app per customer, one codebase and one deployment serve everyone, and the app itself decides which tenant a request belongs to and what data that tenant is allowed to see.&lt;/p&gt;
&lt;p&gt;Think of it like an apartment building: one building (your app), many separate units (tenants), each with its own locked door (data isolation) — but the plumbing, the elevator, and the foundation (your codebase and infrastructure) are shared.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;single-tenant-vs-multi-tenant-the-difference-that-matters&quot;&gt;Single-Tenant vs. Multi-Tenant: The Difference That Matters&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Single-Tenant &lt;/th&gt;&lt;th&gt;Multi-Tenant&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Deployment &lt;/td&gt;&lt;td&gt;One app + one database per customer &lt;/td&gt;&lt;td&gt;One app (usually one database) for all customers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Infrastructure cost &lt;/td&gt;&lt;td&gt;Scales linearly with customers &lt;/td&gt;&lt;td&gt;Shared, scales with usage not customer count&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Data isolation &lt;/td&gt;&lt;td&gt;Physical (separate databases) &lt;/td&gt;&lt;td&gt;Logical (enforced in code and/or the database)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Updates/bug fixes &lt;/td&gt;&lt;td&gt;Deploy to every customer separately &lt;/td&gt;&lt;td&gt;Deploy once, everyone gets it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Customization per customer &lt;/td&gt;&lt;td&gt;Easy — each customer has an isolated deployment/configuration &lt;/td&gt;&lt;td&gt;Harder — needs a plan/feature-flag system&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Best for &lt;/td&gt;&lt;td&gt;Enterprise clients needing full data isolation or compliance &lt;/td&gt;&lt;td&gt;SaaS products with many small-to-mid customers&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Many collaboration and SaaS platforms expose workspace/organization concepts that follow the multi-tenant model. You don't get your own copy of Slack's servers when you sign up; you get a &lt;strong&gt;workspace&lt;/strong&gt;, which is just a tenant inside their shared infrastructure.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &quot;Tenant&quot; is deliberately generic — in your app it might be called a workspace, organization, team, company, or account. The underlying pattern is identical regardless of what you call it in your UI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky-1&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Multi-tenancy isn't hard because the concept is complicated — it's hard because &lt;strong&gt;the cost of getting it wrong is severe&lt;/strong&gt; (a data leak between customers), and there are genuinely several valid ways to build it, each with real tradeoffs. Before writing any code, you need to answer two separate questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How do I identify which tenant a request belongs to?&lt;/strong&gt; (subdomain, custom domain, or URL path)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do I keep each tenant's data isolated?&lt;/strong&gt; (separate databases, separate schemas, or one shared table set with a &lt;code&gt;tenant_id&lt;/code&gt; column)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Get the first one wrong and tenants can't reach the right version of your app. Get the second one wrong and — worst case — tenants can see each other's data. We'll cover both, and this guide focuses on the combination most SaaS products actually ship with: &lt;strong&gt;subdomain-based routing&lt;/strong&gt; plus a &lt;strong&gt;shared database with row-level isolation&lt;/strong&gt;, because it's the cheapest to run and the fastest to build on, while still giving you database-enforced security.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-three-data-isolation-models&quot;&gt;The Three Data Isolation Models&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;How it works &lt;/th&gt;&lt;th&gt;Isolation strength &lt;/th&gt;&lt;th&gt;Cost &amp;amp; complexity&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Database-per-tenant&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Every tenant gets a fully separate database &lt;/td&gt;&lt;td&gt;Strongest — physical separation &lt;/td&gt;&lt;td&gt;Highest — migrations, backups, and connections multiply per tenant&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Schema-per-tenant&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;One database, one Postgres schema per tenant &lt;/td&gt;&lt;td&gt;Strong — separate tables per tenant &lt;/td&gt;&lt;td&gt;Medium — still need to manage N schemas, but one database to back up&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Shared tables + &lt;code&gt;tenant_id&lt;/code&gt;&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;One database, one set of tables, every row tagged with a &lt;code&gt;tenant_id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Depends entirely on enforcement — weak if only in app code, strong with Postgres Row Level Security &lt;/td&gt;&lt;td&gt;Lowest — one schema, one migration, scales to thousands of tenants easily&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For most SaaS products — and everything in this guide — &lt;strong&gt;shared tables with a &lt;code&gt;tenant_id&lt;/code&gt; column, enforced by Postgres Row Level Security (RLS)&lt;/strong&gt;, is the right default. It's the cheapest to operate, the easiest to migrate, and — critically — RLS means isolation is enforced by the database itself, not just by remembering to add &lt;code&gt;.eq(&quot;tenant_id&quot;, ...)&lt;/code&gt; to every query. Reach for schema-per-tenant or database-per-tenant only when a specific customer has a compliance requirement (like data residency) that a shared database genuinely can't satisfy.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Related: Supabase RLS Policy Generator&lt;/a&gt; — generates the exact kind of tenant-scoped policies this guide writes by hand, in seconds.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;three-ways-to-identify-a-tenant&quot;&gt;Three Ways to Identify a Tenant&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Strategy &lt;/th&gt;&lt;th&gt;Example URL &lt;/th&gt;&lt;th&gt;Pros &lt;/th&gt;&lt;th&gt;Cons&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Subdomain&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;acme.yourapp.com&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Clean URLs, easy to brand, works great with wildcard SSL &lt;/td&gt;&lt;td&gt;Needs wildcard DNS + nameserver setup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Custom domain&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;app.acme.com&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Best for enterprise/white-label, tenant owns their domain &lt;/td&gt;&lt;td&gt;Most setup work — DNS verification, SSL provisioning per domain&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Path-based&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;yourapp.com/acme/dashboard&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Zero DNS config, works anywhere immediately &lt;/td&gt;&lt;td&gt;Tenant is visible and editable in every URL, no per-tenant branding&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This guide builds &lt;strong&gt;subdomain-based&lt;/strong&gt; routing, since it's the sweet spot most SaaS products land on — better UX and branding than path-based, far less setup work than per-tenant custom domains. We'll touch on custom domains near the end for when you're ready to offer that too.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-we-re-building&quot;&gt;What We're Building&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supabase (Postgres)&lt;/strong&gt; for the database, using Row Level Security for tenant isolation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the (minimal) UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By the end, visiting &lt;code&gt;acme.localhost:3000&lt;/code&gt; will resolve to Acme's tenant context, &lt;code&gt;acme.yourapp.com&lt;/code&gt; will do the same in production, and every database query for tenant data will be protected by RLS — so even a bug in your own code can't leak one tenant's rows to another.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;tenant1.yourapp.com/dashboard
        ↓
   proxy.ts resolves &quot;tenant1&quot; → looks up tenant in DB
        ↓
   Tenant ID attached to the request as a header
        ↓
   Server Component reads the header, fetches tenant-scoped data
        ↓
   Postgres RLS double-checks: is this row actually this tenant's?
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-project-setup&quot;&gt;Step 1: Project Setup&lt;/h3&gt;
&lt;p&gt;If you're starting fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx create-next-app@latest multi-tenant-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Choose TypeScript, Tailwind CSS, App Router, and the &lt;code&gt;src/&lt;/code&gt; directory if you prefer it (this guide omits &lt;code&gt;src/&lt;/code&gt; for shorter paths). You'll also need Supabase:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @supabase/supabase-js @supabase/ssr
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add your environment variables:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# The root domain your app is served from. In development this keeps
# its port (localhost:3000); in production it's your bare domain.
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; must never be prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. It bypasses every Row Level Security policy in your database — treat it exactly like a database root password, server-only, never shipped to the browser.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;resolve-the-tenant-in-proxy&quot;&gt;Step 2: Resolve the Tenant in `proxy.ts`&lt;/h2&gt;
&lt;p&gt;In Next.js 16, the file previously called &lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;strong&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/strong&gt; — same job, same file conventions, just a new name and a change in default runtime (it now runs on the Node.js runtime by default, which matters here since we're making a database call on every request).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Think of &lt;code&gt;proxy.ts&lt;/code&gt; like a receptionist at the front desk of your building.&lt;/strong&gt; Every visitor (request) talks to the receptionist first. The receptionist checks which company (tenant) they're visiting based on which door they walked through (the subdomain), confirms that company actually exists in the building directory, and then tells the elevator (your app) exactly where to send them — before the visitor ever reaches an office.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;First, a small server-only lookup function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/service-client.ts
import &quot;server-only&quot;;
import { createClient } from &quot;@supabase/supabase-js&quot;;

// Service-role client — bypasses RLS, has no user session attached.
// Only ever call this for trusted, server-only reads like tenant lookups.
// Never import this into a Client Component.
export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/get-tenant-by-subdomain.ts
import &quot;server-only&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;

export type TenantSummary = {
  id: string;
  slug: string;
};

export async function getTenantBySubdomain(
  subdomain: string
): Promise&amp;lt;TenantSummary | null&amp;gt; {
  const { data, error } = await supabaseAdmin
    .from(&quot;tenants&quot;)
    .select(&quot;id, slug&quot;)
    .eq(&quot;slug&quot;, subdomain)
    .maybeSingle();

  if (error || !data) return null;
  return data;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you already have Supabase auth wired up (see our &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth&lt;/a&gt; guide if not), you likely already have these two files for keeping the auth session refreshed on every request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/proxy.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { NextResponse, type NextRequest } from &quot;next/server&quot;;

export async function updateSession(request: NextRequest) {
    let supabaseResponse = NextResponse.next({ request });

    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
        {
            cookies: {
                getAll() {
                    return request.cookies.getAll();
                },

                setAll(cookiesToSet, headers) {
                    cookiesToSet.forEach(({ name, value }) =&amp;gt;
                        request.cookies.set(name, value)
                    );

                    supabaseResponse = NextResponse.next({
                        request,
                    });

                    cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
                        supabaseResponse.cookies.set(name, value, options)
                    );
                    Object.entries(headers).forEach(([key, value]) =&amp;gt;
                        supabaseResponse.headers.set(key, value)
                    );
                },
            },
        }
    );

    await supabase.auth.getClaims();

    return supabaseResponse;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/supabase/server.ts
import { createServerClient } from &quot;@supabase/ssr&quot;;
import { cookies } from &quot;next/headers&quot;;

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },

        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =&amp;gt;
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Don't skip this if you have authentication.&lt;/strong&gt; Tenant resolution and session refresh are two separate concerns, but they both have to happen in the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;proxy.ts&lt;/code&gt; run. If you only resolve the tenant and never call &lt;code&gt;updateSession&lt;/code&gt;, Supabase's access token is never refreshed on the server, and signed-in users get logged out once it expires — even though nothing about tenant routing looks broken.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now the proxy itself, composing both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { updateSession } from &quot;@/lib/supabase/proxy&quot;;
import { getTenantBySubdomain } from &quot;@/lib/tenant/get-tenant-by-subdomain&quot;;

// Keep the port for building an absolute redirect URL later; strip it
// only when comparing hostnames.
const ROOT_DOMAIN_ENV = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? &quot;localhost:3000&quot;;
const ROOT_DOMAIN = ROOT_DOMAIN_ENV.split(&quot;:&quot;)[0];

// Subdomains that belong to your main app, not a tenant.
const RESERVED_SUBDOMAINS = new Set([&quot;www&quot;, &quot;app&quot;, &quot;admin&quot;, &quot;api&quot;]);

export async function proxy(request: NextRequest) {
  // Refresh the Supabase auth session first. This may rewrite Set-Cookie
  // headers if the access token needed rotating — we copy those onto
  // whatever response we ultimately return below.
  const supabaseResponse = await updateSession(request);

  const host = (request.headers.get(&quot;host&quot;) ?? &quot;&quot;).split(&quot;:&quot;)[0];
  const subdomain = getSubdomain(host);

  // No subdomain, or a reserved one — this is the marketing site or the
  // main app shell, not a tenant. Nothing tenant-related to attach.
  if (!subdomain || RESERVED_SUBDOMAINS.has(subdomain)) {
    return supabaseResponse;
  }

  const tenant = await getTenantBySubdomain(subdomain);

  if (!tenant) {
    return redirectToTenantNotFound(request, subdomain);
  }

  // Strip any inbound tenant headers so a client can never forge them —
  // only this proxy is allowed to set x-tenant-*.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete(&quot;x-tenant-id&quot;);
  requestHeaders.delete(&quot;x-tenant-slug&quot;);
  requestHeaders.set(&quot;x-tenant-id&quot;, tenant.id);
  requestHeaders.set(&quot;x-tenant-slug&quot;, tenant.slug);

  // Rebuild the response so the tenant headers ride along on the
  // *request* (this is what makes them readable via headers() in a
  // Server Component), then carry over any refreshed auth cookies.
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  supabaseResponse.cookies.getAll().forEach((cookie) =&amp;gt; {
    response.cookies.set(cookie);
  });

  return response;
}

// Redirects to the ROOT domain, not the unknown tenant's subdomain —
// redirecting back to the same bad host would just re-trigger this
// same lookup and loop forever.
//
// Built as a brand-new URL rather than mutating a clone of request.url.
// Cloning request.url first (new URL(request.url)) means the object
// starts out with the tenant subdomain already baked into it, and
// reassigning .host on that object is not reliable for fully replacing
// an existing hostname — in practice it can end up only changing the
// port and leaving the old subdomain in place (e.g. producing
// &quot;unknowndomain.localhost:3000&quot; instead of &quot;localhost:3000&quot;), which
// silently recreates the exact loop this function exists to prevent.
// Constructing the URL from a plain template string sidesteps that
// entirely, since there's no stale hostname left for anything to retain.
function redirectToTenantNotFound(request: NextRequest, subdomain: string) {
  const protocol = request.nextUrl.protocol; // &quot;http:&quot; or &quot;https:&quot;
  const notFoundUrl = new URL(`${protocol}//${ROOT_DOMAIN_ENV}/tenant-not-found`);
  notFoundUrl.searchParams.set(&quot;tenant&quot;, subdomain);
  return NextResponse.redirect(notFoundUrl);
}

function getSubdomain(host: string): string | null {
  if (host === ROOT_DOMAIN || host === `www.${ROOT_DOMAIN}`) return null;
  if (!host.endsWith(`.${ROOT_DOMAIN}`)) return null;

  return host.slice(0, -(ROOT_DOMAIN.length + 1));
}

export const config = {
  matcher: [&quot;/((?!_next/static|_next/image|favicon.ico).*)&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;updateSession(request)&lt;/code&gt; runs first and unconditionally — auth refresh shouldn't depend on whether the host happens to be a tenant subdomain.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redirectToTenantNotFound()&lt;/code&gt; builds a brand-new URL pointed at &lt;code&gt;ROOT_DOMAIN_ENV&lt;/code&gt; (your real app domain) instead of the unresolved tenant subdomain. The next time proxy runs against that new URL, &lt;code&gt;getSubdomain()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for the root domain and the request just passes through — no loop. This has to be a &lt;em&gt;fresh&lt;/em&gt; URL, not a mutated clone of the incoming request's URL — cloning &lt;code&gt;request.url&lt;/code&gt; first means the tenant subdomain is already baked into the object, and reassigning &lt;code&gt;.host&lt;/code&gt; on it is not reliable for fully replacing that hostname (in practice it can end up changing only the port while the old subdomain silently survives, e.g. &lt;code&gt;unknowndomain.localhost:3000&lt;/code&gt; instead of &lt;code&gt;localhost:3000&lt;/code&gt;), which just recreates the same loop this function exists to prevent.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;?tenant=&lt;/code&gt; query param survives the redirect, so your &lt;code&gt;/tenant-not-found&lt;/code&gt; page can show &lt;em&gt;which&lt;/em&gt; subdomain didn't resolve.&lt;/li&gt;
&lt;li&gt;We only build &lt;code&gt;requestHeaders&lt;/code&gt; (and delete any inbound &lt;code&gt;x-tenant-*&lt;/code&gt; values) once we know a real tenant was found — there's nothing to strip or attach on the reserved/no-subdomain path, since &lt;code&gt;supabaseResponse&lt;/code&gt; is returned as-is.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;supabaseResponse.cookies.getAll()&lt;/code&gt; returns whatever refreshed auth cookies Supabase's client queued up; copying them onto the new tenant-aware response means neither concern silently overwrites the other.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A minimal page for the redirect target:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/tenant-not-found/page.tsx
interface PageProps {
  searchParams: Promise&amp;lt;{ tenant?: string }&amp;gt;;
}

export default async function TenantNotFoundPage({ searchParams }: PageProps) {
  const { tenant } = await searchParams;

  return (
    &amp;lt;div className=&quot;mx-auto max-w-md space-y-3 p-8 text-center&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;Workspace not found&amp;lt;/h1&amp;gt;
      &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;
        {tenant
          ? `We couldn't find a workspace at &quot;${tenant}&quot;.`
          : &quot;That workspace doesn't exist.&quot;}
      &amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Running your tenant lookup on the Edge runtime by explicitly setting &lt;code&gt;runtime: 'edge'&lt;/code&gt;. Don't — &lt;code&gt;proxy.ts&lt;/code&gt; in Next.js 16 runs on the Node.js runtime by default (and throws if you try to override it), which is exactly what you want here, since a database client typically needs Node.js APIs that aren't available on the Edge runtime.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; For a small application, this direct indexed lookup is straightforward. At higher traffic, cache the subdomain → tenant mapping in Redis/KV/Edge Config or another low-latency store so proxy doesn't hit Postgres on every request.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-testing-subdomains-locally&quot;&gt;Step 3: Testing Subdomains Locally&lt;/h3&gt;
&lt;p&gt;You don't need to edit your &lt;code&gt;/etc/hosts&lt;/code&gt; file for this. Modern browsers (Chrome, Firefox, Safari) automatically resolve any &lt;code&gt;*.localhost&lt;/code&gt; address to &lt;code&gt;127.0.0.1&lt;/code&gt; — it's a reserved TLD, not a Next.js feature. So once your dev server is running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Visiting &lt;code&gt;http://acme.localhost:3000&lt;/code&gt; in your browser just works, as long as &lt;code&gt;NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000&lt;/code&gt; is set in &lt;code&gt;.env.local&lt;/code&gt; and a tenant with &lt;code&gt;slug = 'acme'&lt;/code&gt; exists in your database.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If a subdomain isn't resolving locally, it's almost always a browser/network quirk (some corporate VPNs or older browsers don't support &lt;code&gt;.localhost&lt;/code&gt; resolution) rather than a bug in your code — try a different browser before debugging &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-model-tenants-and-isolation-in-postgres&quot;&gt;Step 4: Model Tenants and Isolation in Postgres&lt;/h3&gt;
&lt;p&gt;This is the part that actually keeps tenants' data apart. Run this in the Supabase SQL editor.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Core tenant table. The slug doubles as a DNS label (acme.yourapp.com),
-- so its constraint is stricter than a generic &quot;url-safe string&quot; check:
-- no leading/trailing hyphens, and capped at 63 characters (the DNS
-- label limit), on top of lowercase alphanumerics and hyphens.
create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  slug text unique not null check (
    char_length(slug) between 1 and 63
    and slug ~ '^[a-z0-9-]+$'
    and slug !~ '(^-|-$)'
  ),
  name text not null,
  plan text not null default 'free',
  created_at timestamptz not null default now()
);

-- Who belongs to which tenant, and with what role
create table public.memberships (
  user_id uuid not null references auth.users(id) on delete cascade,
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member')),
  created_at timestamptz not null default now(),
  primary key (user_id, tenant_id)
);

-- An example tenant-scoped resource — every real table in your app
-- (invoices, documents, tasks, whatever) follows this same shape.
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- The composite primary key (user_id, tenant_id) already gives Postgres
-- a btree index usable for &quot;where user_id = ?&quot; lookups, since a
-- multi-column index supports equality search on a leading prefix of
-- its columns — a separate single-column index on user_id would just
-- be a redundant duplicate. What that composite index can't serve
-- efficiently is &quot;where tenant_id = ?&quot; on its own, since tenant_id
-- isn't the leading column — that's the index actually worth adding.
create index idx_memberships_tenant_id on public.memberships (tenant_id);
create index idx_projects_tenant_id on public.projects (tenant_id);

alter table public.tenants enable row level security;
alter table public.memberships enable row level security;
alter table public.projects enable row level security;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;tenants&lt;/code&gt; is your list of customers. &lt;code&gt;memberships&lt;/code&gt; is the join table linking Supabase's built-in &lt;code&gt;auth.users&lt;/code&gt; to tenants — one user can belong to multiple tenants (think: a consultant working with several client workspaces). &lt;code&gt;projects&lt;/code&gt; is a stand-in for any real, tenant-owned data table — invoices, documents, tasks, whatever your app actually stores.&lt;/p&gt;
&lt;h4 id=&quot;write-the-rls-policies&quot;&gt;Write the RLS Policies&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;tenant_id&lt;/code&gt; column on a table means nothing on its own — it's just data. &lt;strong&gt;Row Level Security is what turns that column into an actual security boundary&lt;/strong&gt;, enforced by Postgres itself rather than trusted to every query you (or a future teammate) ever write.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Security-definer helper: returns every tenant_id the current user
-- belongs to. Wrapping it this way avoids the classic recursive-RLS
-- trap (a policy on &quot;projects&quot; querying &quot;memberships&quot;, which has its
-- own RLS that would otherwise re-trigger the same check).
--
-- It lives in a dedicated &quot;private&quot; schema rather than &quot;public&quot; —
-- Supabase's Data API (PostgREST) only exposes schemas you've explicitly
-- opted into (public by default), so putting security-definer functions
-- in &quot;private&quot; keeps them uncallable directly over the REST/RPC API.
-- A security-definer function sitting in &quot;public&quot; is callable by anyone
-- who can reach your API, running with the *creator's* privileges —
-- that's a real privilege-escalation surface if it isn't locked down.
create schema if not exists private;

create or replace function private.user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = ''
as \(
  select tenant_id
  from public.memberships
  where user_id = (select auth.uid())
\);

-- Postgres grants EXECUTE on every new function to PUBLIC by default —
-- revoke it explicitly, then grant only to the role that actually needs it.
revoke execute on function private.user_tenant_ids() from public;
revoke execute on function private.user_tenant_ids() from anon;
grant usage on schema private to authenticated;
grant execute on function private.user_tenant_ids() to authenticated;

-- Members can see their own membership rows
create policy &quot;memberships_select_own&quot;
on public.memberships
for select
to authenticated
using ( user_id = (select auth.uid()) );

-- Members can see the tenants they belong to
create policy &quot;tenants_select_member&quot;
on public.tenants
for select
to authenticated
using ( id in (select private.user_tenant_ids()) );

-- Members can only see, create, edit, or delete projects that belong
-- to a tenant they're actually part of
create policy &quot;projects_select_tenant_member&quot;
on public.projects
for select
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_insert_tenant_member&quot;
on public.projects
for insert
to authenticated
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_update_tenant_member&quot;
on public.projects
for update
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) )
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy &quot;projects_delete_tenant_member&quot;
on public.projects
for delete
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;user_tenant_ids()&lt;/code&gt; is a &lt;code&gt;security definer&lt;/code&gt; function — it runs with elevated privileges internally, which lets it read &lt;code&gt;memberships&lt;/code&gt; (bypassing that table's own RLS just for this lookup) without creating a circular dependency. &lt;code&gt;set search_path = ''&lt;/code&gt; forces every identifier inside the function body to be fully schema-qualified (notice &lt;code&gt;public.memberships&lt;/code&gt;, not just &lt;code&gt;memberships&lt;/code&gt;) — without this, a malicious or accidental object created earlier in the search path could get resolved instead of the table you actually meant, which is exactly the class of attack Supabase's own linter flags as &quot;Function Search Path Mutable.&quot; The &lt;code&gt;revoke&lt;/code&gt;/&lt;code&gt;grant&lt;/code&gt; pair closes the other half of the gap: by default Postgres lets &lt;strong&gt;any&lt;/strong&gt; role call a newly created function, so without the explicit revoke, &lt;code&gt;anon&lt;/code&gt; (unauthenticated requests) could call it too.&lt;/p&gt;
&lt;p&gt;Every policy on &lt;code&gt;projects&lt;/code&gt; then just asks &quot;is this row's &lt;code&gt;tenant_id&lt;/code&gt; one this user belongs to?&quot; Wrapping &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select ...)&lt;/code&gt; lets Postgres cache the result once per statement instead of re-evaluating it for every row — a meaningful performance win once your tables have real data in them.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; Notice there's no policy granting &lt;code&gt;using (true)&lt;/code&gt; anywhere. Every single operation is explicitly scoped to tenant membership — this is deliberate. A missing policy means Postgres denies access by default, which is exactly the fail-safe direction you want security to fail in.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Putting a &lt;code&gt;security definer&lt;/code&gt; helper function in the &lt;code&gt;public&lt;/code&gt; schema &quot;because that's where everything else is.&quot; If a schema is exposed through Supabase's Data API settings (public is, by default), every function in it is a potential RPC endpoint — &lt;code&gt;private&lt;/code&gt; (or any schema you deliberately keep out of the exposed-schemas list) is the safer default for anything that runs with elevated privileges.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Related: Supabase Auth vs Firebase Auth in 2026&lt;/a&gt; if you haven't wired up authentication yet.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-read-the-tenant-in-server-components&quot;&gt;Step 5: Read the Tenant in Server Components&lt;/h3&gt;
&lt;p&gt;Now that &lt;code&gt;proxy.ts&lt;/code&gt; attaches &lt;code&gt;x-tenant-id&lt;/code&gt; to every request, build a small &lt;strong&gt;Data Access Layer&lt;/strong&gt; function that reads it — this is the single place the rest of your app goes to ask &quot;which tenant is this?&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/get-current-tenant.ts
import &quot;server-only&quot;;
import { cache } from &quot;react&quot;;
import { headers } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { supabaseAdmin } from &quot;@/lib/supabase/service-client&quot;;

export type Tenant = {
  id: string;
  slug: string;
  name: string;
  plan: string;
};

export const getCurrentTenant = cache(async (): Promise&amp;lt;Tenant&amp;gt; =&amp;gt; {
  const headersList = await headers();
  const tenantId = headersList.get(&quot;x-tenant-id&quot;);

  if (!tenantId) {
    // No tenant header means this request never went through a tenant
    // subdomain — proxy.ts only sets it for tenant*.yourapp.com traffic.
    redirect(&quot;/&quot;);
  }

  const { data: tenant, error } = await supabaseAdmin
    .from(&quot;tenants&quot;)
    .select(&quot;id, slug, name, plan&quot;)
    .eq(&quot;id&quot;, tenantId)
    .maybeSingle();

  if (error || !tenant) {
    redirect(&quot;/tenant-not-found&quot;);
  }

  return tenant;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;headers()&lt;/code&gt; is async in Next.js 16, so it has to be awaited. Wrapping the whole function in React's &lt;code&gt;cache()&lt;/code&gt; means no matter how many components on the same page call &lt;code&gt;getCurrentTenant()&lt;/code&gt;, the actual header read and database lookup only run once per request.&lt;/p&gt;
&lt;p&gt;Next, a helper that also confirms the signed-in user actually belongs to this tenant — not just that the tenant exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/tenant/require-tenant-member.ts
import &quot;server-only&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;
import { getCurrentTenant } from &quot;./get-current-tenant&quot;;

export async function requireTenantMember() {
  const tenant = await getCurrentTenant();
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect(`/login?tenant=${tenant.slug}`);
  }

  // This query runs through the cookie-based client, so RLS applies —
  // it can only ever return a row for the signed-in user themselves.
  const { data: membership } = await supabase
    .from(&quot;memberships&quot;)
    .select(&quot;role&quot;)
    .eq(&quot;tenant_id&quot;, tenant.id)
    .eq(&quot;user_id&quot;, user.id)
    .maybeSingle();

  if (!membership) {
    redirect(&quot;/not-a-member&quot;);
  }

  return { tenant, user, role: membership.role };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use it in a page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { requireTenantMember } from &quot;@/lib/tenant/require-tenant-member&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;

export default async function DashboardPage() {
  const { tenant, user } = await requireTenantMember();
  const supabase = await createClient();

  // The explicit tenant_id filter selects the active tenant represented
  // by this request. RLS provides the database-level safety net that
  // prevents the signed-in user from accessing tenants they don't belong to.
  const { data: projects } = await supabase
    .from(&quot;projects&quot;)
    .select(&quot;id, name, created_at&quot;)
    .eq(&quot;tenant_id&quot;, tenant.id)
    .order(&quot;created_at&quot;, { ascending: false });

  return (
    &amp;lt;div className=&quot;mx-auto max-w-2xl space-y-6 p-8&quot;&amp;gt;
      &amp;lt;div&amp;gt;
        &amp;lt;h1 className=&quot;text-2xl font-semibold&quot;&amp;gt;{tenant.name}&amp;lt;/h1&amp;gt;
        &amp;lt;p className=&quot;text-sm text-muted-foreground&quot;&amp;gt;Signed in as {user.email}&amp;lt;/p&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;ul className=&quot;space-y-2&quot;&amp;gt;
        {projects?.map((project) =&amp;gt; (
          &amp;lt;li key={project.id} className=&quot;rounded-lg border p-3&quot;&amp;gt;
            {project.name}
          &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; notice the &lt;code&gt;.eq(&quot;tenant_id&quot;, tenant.id)&lt;/code&gt; filter is still there in the app code, even though RLS would block cross-tenant rows either way. This is &lt;strong&gt;defense in depth&lt;/strong&gt; — the query filter makes the intent obvious to any developer reading the code, and RLS is the safety net that catches the case where someone forgets it.&lt;/p&gt;
&lt;h4 id=&quot;writing-tenant-scoped-data-with-a-server-action&quot;&gt;Writing Tenant-Scoped Data with a Server Action&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/create-project.ts
&quot;use server&quot;;

import { revalidatePath } from &quot;next/cache&quot;;
import { createClient } from &quot;@/lib/supabase/server&quot;;
import { requireTenantMember } from &quot;@/lib/tenant/require-tenant-member&quot;;

export async function createProject(formData: FormData) {
  const { tenant } = await requireTenantMember();
  const supabase = await createClient();

  const name = (formData.get(&quot;name&quot;) as string | null)?.trim();

  if (!name) {
    return { error: &quot;Project name is required.&quot; };
  }

  const { error } = await supabase
    .from(&quot;projects&quot;)
    .insert({ name, tenant_id: tenant.id });

  if (error) {
    return { error: error.message };
  }

  revalidatePath(&quot;/dashboard&quot;);
  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;requireTenantMember()&lt;/code&gt; runs first, so an unauthenticated or non-member request never reaches the database at all. Even so, the &lt;code&gt;insert&lt;/code&gt; explicitly sets &lt;code&gt;tenant_id: tenant.id&lt;/code&gt; rather than trusting a value from the form — never let tenant identity come from client-submitted data, always derive it server-side from the resolved tenant context.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Accepting a &lt;code&gt;tenantId&lt;/code&gt; field from &lt;code&gt;formData&lt;/code&gt; or a request body and trusting it. The tenant a user is allowed to write to should always come from &lt;code&gt;getCurrentTenant()&lt;/code&gt; / the resolved request context — never from anything the client sent.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Related: Role-Based Access Control (RBAC) in Next.js 16&lt;/a&gt; pairs naturally with this — once you have tenants, you'll usually want owner/admin/member permissions &lt;em&gt;within&lt;/em&gt; each tenant too, not just membership.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-deploy-wildcard-domains-on-vercel&quot;&gt;Step 6: Deploy Wildcard Domains on Vercel&lt;/h3&gt;
&lt;p&gt;To get &lt;code&gt;acme.yourapp.com&lt;/code&gt;, &lt;code&gt;beta-co.yourapp.com&lt;/code&gt;, and every future tenant's subdomain working automatically in production, you need a &lt;strong&gt;wildcard domain&lt;/strong&gt; pointed at your Vercel project.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Point your domain to Vercel's nameservers&lt;/strong&gt; (&lt;code&gt;ns1.vercel-dns.com&lt;/code&gt; and &lt;code&gt;ns2.vercel-dns.com&lt;/code&gt;). Vercel needs to control your DNS to automatically issue SSL certificates for each new subdomain.&lt;/li&gt;
&lt;li&gt;In your Vercel project's &lt;strong&gt;Domains&lt;/strong&gt; settings, add the apex domain: &lt;code&gt;yourapp.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a wildcard domain: &lt;code&gt;*.yourapp.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once that's done, any single-label subdomain — &lt;code&gt;acme.yourapp.com&lt;/code&gt;, &lt;code&gt;beta-co.yourapp.com&lt;/code&gt; — automatically resolves to your deployment, with Vercel issuing a certificate for each one on the fly. No per-tenant DNS work required on your end.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ A wildcard certificate for &lt;code&gt;*.yourapp.com&lt;/code&gt; covers exactly &lt;strong&gt;one&lt;/strong&gt; DNS label — it matches &lt;code&gt;acme.yourapp.com&lt;/code&gt; but not a deeper subdomain like &lt;code&gt;docs.acme.yourapp.com&lt;/code&gt;. That kind of nested subdomain would need its own certificate and DNS entry, and this guide's &lt;code&gt;getSubdomain()&lt;/code&gt; parser doesn't handle it either (it would slice &lt;code&gt;docs.acme.yourapp.com&lt;/code&gt; down to the single string &lt;code&gt;&quot;docs.acme&quot;&lt;/code&gt;, which isn't even a valid slug under the constraint from Step 4). If you need per-tenant sub-paths like that, treat it as a separate, deliberate feature — don't assume it falls out of wildcard support for free.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Update your production environment variable to match:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;NEXT_PUBLIC_ROOT_DOMAIN=yourapp.com
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If tenants will ever be able to publish their own content or run their own code on your subdomains (not just view data you control), submit your domain to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://publicsuffix.org/&quot;&gt;Public Suffix List&lt;/a&gt; so browsers isolate cookies between &lt;code&gt;tenant1.yourapp.com&lt;/code&gt; and &lt;code&gt;tenant2.yourapp.com&lt;/code&gt;. This matters more for user-generated-content platforms than a typical internal-dashboard SaaS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; If you become a platform that gives mutually untrusted customers control over content or code on separate subdomains, investigate whether your domain qualifies for the PSL PRIVATE section. This changes browser cookie boundaries, so understand the consequences before applying.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;what-about-custom-domains-&quot;&gt;What About Custom Domains?&lt;/h4&gt;
&lt;p&gt;Letting a tenant use their own domain (&lt;code&gt;app.acme.com&lt;/code&gt; instead of &lt;code&gt;acme.yourapp.com&lt;/code&gt;) is a common upsell for higher-tier plans, but it's meaningfully more setup: you provision the domain onto your Vercel project programmatically (via the Vercel SDK), verify the tenant actually owns it with a TXT record, and let Vercel auto-issue SSL once verified. The tenant-resolution logic in &lt;code&gt;proxy.ts&lt;/code&gt; from &lt;a rel=&quot;nofollow&quot; href=&quot;#resolve-the-tenant-in-proxy&quot;&gt;Step 2&lt;/a&gt; extends naturally — you'd just add a lookup by full hostname (&lt;code&gt;domain_${hostname}&lt;/code&gt;) before falling back to subdomain parsing. Treat this as a Phase 2 feature; ship subdomains first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;production-checklist&quot;&gt;Production Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;proxy.ts&lt;/code&gt; deletes inbound &lt;code&gt;x-tenant-*&lt;/code&gt; headers before setting its own — never trust client-supplied tenant headers&lt;/li&gt;
&lt;li&gt;[ ] An unresolved tenant subdomain redirects to the &lt;strong&gt;root domain&lt;/strong&gt;, not the tenant's own (bad) host — otherwise it loops&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;proxy.ts&lt;/code&gt; refreshes the Supabase auth session on every request, not just tenant headers&lt;/li&gt;
&lt;li&gt;[ ] Every tenant-owned table has a &lt;code&gt;tenant_id&lt;/code&gt; column, a matching index, and RLS enabled&lt;/li&gt;
&lt;li&gt;[ ] RLS policies exist for &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;delete&lt;/code&gt; — not just &lt;code&gt;select&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;[ ] No policy anywhere uses a bare &lt;code&gt;using (true)&lt;/code&gt; unless a resource is genuinely meant to be public&lt;/li&gt;
&lt;li&gt;[ ] Server Actions derive &lt;code&gt;tenant_id&lt;/code&gt; from the resolved server-side tenant context, never from client-submitted form data&lt;/li&gt;
&lt;li&gt;[ ] Reserved subdomains (&lt;code&gt;www&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;) are excluded from tenant resolution&lt;/li&gt;
&lt;li&gt;[ ] Tenant slugs are constrained as valid single-label DNS names (no leading/trailing hyphens, ≤63 characters)&lt;/li&gt;
&lt;li&gt;[ ] Wildcard domain (&lt;code&gt;*.yourapp.com&lt;/code&gt;) and apex domain are both added in Vercel, with nameservers delegated&lt;/li&gt;
&lt;li&gt;[ ] anon/authenticated table grants are explicitly restricted to only the operations the app requires&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Multi-tenant architecture in Next.js 16 comes down to two layers working together: &lt;code&gt;proxy.ts&lt;/code&gt; figures out &lt;em&gt;which&lt;/em&gt; tenant a request belongs to and hands that context downstream as a header, while Postgres Row Level Security makes sure that context is actually enforced — not just trusted — every time data gets read or written. Get those two pieces right, and &quot;which company does this row belong to?&quot; stops being a question your application code has to get right on every single query; it becomes a guarantee your database makes for you.&lt;/p&gt;
&lt;p&gt;From here, natural next steps are layering &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;role-based permissions&lt;/a&gt; on top of tenant membership (owner vs. admin vs. member), building a tenant-scoped billing flow with &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/stripe-subscription-billing-nextjs-16&quot;&gt;Stripe subscriptions&lt;/a&gt;, and revisiting &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;session vs. JWT&lt;/a&gt; if you want tenant context embedded directly in the session instead of resolved fresh on every request.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/saas-starter-architecture-nextjs&quot;&gt;SaaS Starter Architecture with Next.js 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-supabase-todo-app&quot;&gt;Build a Todo App with Next.js 16 and Supabase (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT: Which Should You Use in Next.js 16?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-route-handlers&quot;&gt;Route Handlers Explained in Next.js 16 (Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — generate strict, tenant/team-scoped Row Level Security policies like the ones in this guide, including the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper and a security-definer helper function, without hand-writing SQL.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27782/multi-tenant-architecture-in-next-js-16-2026</guid>
<pubDate>Sun, 20 Sep 2026 03:02:16 +0000</pubDate>
</item>
<item>
<title>generateMetadata and Your Page Component Are Probably Querying the Same Data Twice</title>
<link>https://coderlegion.com/27732/generatemetadata-and-your-page-component-are-probably-querying-the-same-data-twice</link>
<description>&lt;p&gt;I wrote earlier about &lt;code&gt;cache()&lt;/code&gt; fixing redundant database queries when multiple components in a tree independently call the same query function. There's one specific, extremely common place this exact pattern shows up that's worth calling out directly, because it's not an edge case, it's the default shape of nearly every dynamic page with SEO metadata.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-almost-every-dynamic-page-has&quot;&gt;The Setup Almost Every Dynamic Page Has&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/queries/posts';

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = await getPost(slug); // query #1

  return {
    title: post.title,
    description: post.excerpt,
  };
}

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const post = await getPost(slug); // query #2, same post, same request

  return (
    &amp;lt;article&amp;gt;
      &amp;lt;h1&amp;gt;{post.title}&amp;lt;/h1&amp;gt;
      &amp;lt;div&amp;gt;{post.content}&amp;lt;/div&amp;gt;
    &amp;lt;/article&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the completely standard pattern from the Next.js metadata setup covered in an earlier post, and it's genuinely necessary, &lt;code&gt;generateMetadata&lt;/code&gt; needs the post's title and excerpt for the meta tags, the page component needs the post's full content to actually render it. Both run for every single request to this page. Both call &lt;code&gt;getPost(slug)&lt;/code&gt; independently. Without a specific fix, that's two full database round trips for what is, semantically, the exact same piece of data, fetched twice, for every single page view.&lt;/p&gt;
&lt;h3 id=&quot;why-this-specific-case-matters-more-than-it-might-seem&quot;&gt;Why This Specific Case Matters More Than It Might Seem&lt;/h3&gt;
&lt;p&gt;This isn't a rare pattern someone might occasionally write, it's the default shape of &lt;code&gt;generateMetadata&lt;/code&gt; combined with any dynamic page, which means it's quietly doubling database load on nearly every content-driven page across an entire site, blog posts, product pages, any dynamically rendered detail page with SEO metadata. A site with meaningful traffic on these pages is paying for twice the database queries it actually needs, on some of its most frequently visited pages, for no functional reason.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-wrap-the-query-in-react-s-cache-&quot;&gt;The Fix: Wrap the Query in React's cache()&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/queries/posts.ts
import { cache } from 'react';
import { connectDB } from '@/lib/db';
import Post from '@/models/Post';

export const getPost = cache(async (slug: string) =&amp;gt; {
  await connectDB();
  return Post.findOne({ slug }).lean();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the entire fix, no changes needed anywhere else. &lt;code&gt;generateMetadata&lt;/code&gt; and the page component both still call &lt;code&gt;getPost(slug)&lt;/code&gt; exactly as before, the code looks identical. What changes is that React's request memoization now recognizes both calls, same function, same argument, within the same render pass, and only actually executes the underlying query once, returning the cached result for the second call.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-worth-checking-specifically-not-just-generally&quot;&gt;Why This Is Worth Checking Specifically, Not Just Generally&lt;/h3&gt;
&lt;p&gt;The earlier post on this topic covered the general pattern, multiple components in a tree calling the same query. This specific case is worth checking on its own because it's so easy to miss precisely because &lt;code&gt;generateMetadata&lt;/code&gt; and the page component don't look like they're part of the same &quot;component tree&quot; in the way nested components obviously are. They're two separate exported functions in the same file, and it's genuinely easy to write both, each looking completely correct in isolation, without ever noticing they're duplicating a database call for the same request.&lt;/p&gt;
&lt;h3 id=&quot;a-quick-way-to-verify-this-is-actually-happening&quot;&gt;A Quick Way to Verify This Is Actually Happening&lt;/h3&gt;
&lt;p&gt;Add a temporary log inside the un-cached query function and load the page once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export async function getPost(slug: string) {
  console.log('getPost called for:', slug); // temporary
  await connectDB();
  return Post.findOne({ slug }).lean();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see that log line twice for a single page load, that's this exact issue, confirmed directly rather than assumed. After wrapping the function in &lt;code&gt;cache()&lt;/code&gt;, the same test should show it logging only once per request.&lt;/p&gt;
&lt;h3 id=&quot;where-this-doesn-t-apply&quot;&gt;Where This Doesn't Apply&lt;/h3&gt;
&lt;p&gt;If &lt;code&gt;generateMetadata&lt;/code&gt; and the page component genuinely need different data, metadata pulling from a lighter, separate summary endpoint while the page fetches full content from somewhere else entirely, there's no duplication to fix, since they're not actually calling the same underlying function with the same arguments in the first place. This fix specifically applies when both are calling the identical query function, which, for most blog and content-driven page setups, is exactly the common case.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-7&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any query function called both inside &lt;code&gt;generateMetadata&lt;/code&gt; and inside the page component it belongs to should be wrapped in &lt;code&gt;cache()&lt;/code&gt;, as close to a universal default as a rule gets in this specific area.&lt;/strong&gt; It costs nothing to add, changes no calling code, and directly eliminates a real, silent, doubled database query that exists by default on essentially every dynamically rendered page with SEO metadata.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Go check any page in your own project using &lt;code&gt;generateMetadata&lt;/code&gt; alongside a matching page component, specifically whether the underlying query function is wrapped in &lt;code&gt;cache()&lt;/code&gt;. If it's not, you're very likely double-querying on every single page load for that route. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27732/generatemetadata-and-your-page-component-are-probably-querying-the-same-data-twice</guid>
<pubDate>Sat, 19 Sep 2026 10:48:58 +0000</pubDate>
</item>
<item>
<title>Setting dynamicParams to false Can Make Brand New Content 404 Until Your Next Deploy</title>
<link>https://coderlegion.com/27517/setting-dynamicparams-to-false-can-make-brand-new-content-404-until-your-next-deploy</link>
<description>&lt;p&gt;This one's a genuinely reasonable setting that causes a genuinely confusing symptom, a brand new piece of content, added correctly, visible in your CMS, just returns a 404 on the live site, with nothing in your code actually wrong.&lt;/p&gt;
&lt;h3 id=&quot;what-dynamicparams-actually-controls&quot;&gt;What dynamicParams Actually Controls&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) =&amp;gt; ({ slug: post.slug }));
}

export const dynamicParams = false; // reject anything not in the list above

export default async function BlogPost({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return &amp;lt;article&amp;gt;{post.content}&amp;lt;/article&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;generateStaticParams&lt;/code&gt; runs at build time, pre-rendering a page for every slug it returns. &lt;code&gt;dynamicParams&lt;/code&gt; controls what happens for a slug that shows up in a request but wasn't in that build-time list. The default is &lt;code&gt;true&lt;/code&gt;, meaning Next.js will attempt to render that path on demand anyway, genuinely useful behavior for content added after the last build. Setting it explicitly to &lt;code&gt;false&lt;/code&gt; tells Next.js to immediately 404 anything not in the pre-generated list, full stop, no attempt to render it fresh.&lt;/p&gt;
&lt;h3 id=&quot;why-someone-sets-this-deliberately&quot;&gt;Why Someone Sets This Deliberately&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;dynamicParams = false&lt;/code&gt; is a reasonable, intentional choice in specific situations, guaranteeing that only explicitly known, build-time-verified paths are ever servable, useful for something like a fixed set of product pages where an unexpected slug showing up would genuinely indicate a bug or a malicious request, not legitimate new content. It's not a mistake in that context, it's a deliberate constraint.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-causes-real-problems-1&quot;&gt;Where This Actually Causes Real Problems&lt;/h3&gt;
&lt;p&gt;For content that genuinely grows over time, a blog, a CMS-driven catalog, anything where new items get added independently of a code deployment, this exact setting means every new item is completely inaccessible, a real 404, until the next build actually happens and includes it in &lt;code&gt;generateStaticParams&lt;/code&gt;'s output.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Blog post published in the CMS at 2:00 PM
2. Someone shares the link
3. Visitor clicks it, gets a 404
4. The post is completely correct in the CMS, the slug is right, everything's fine there
5. The site just has no idea this post exists yet, since the last build predates it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is exactly the kind of gap that's genuinely confusing to diagnose, since checking the CMS shows everything looks correct, checking the code shows the query logic is fine, and the actual cause, a build-time snapshot that's now stale, doesn't announce itself anywhere obvious.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-depending-on-what-you-actually-need&quot;&gt;The Actual Fix, Depending on What You Actually Need&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;If new content should be servable immediately without waiting for a rebuild&lt;/strong&gt;, this is almost always the right default, just don't set &lt;code&gt;dynamicParams&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; at all, or set it explicitly to &lt;code&gt;true&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const dynamicParams = true; // or simply omit this line, true is the default
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;New slugs not in the build-time list get rendered on demand, then, depending on your caching setup, can be cached going forward, giving you both immediate availability and good performance after the first request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you genuinely need the strict guarantee &lt;code&gt;dynamicParams = false&lt;/code&gt; provides&lt;/strong&gt;, a fixed, known set of pages where anything else really should 404, pair it with a deliberate process for triggering a new build whenever new content is actually added, a webhook from your CMS triggering a redeploy, rather than relying on a periodic or manual build schedule that leaves a real gap between content going live and the site actually knowing about it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// A CMS webhook triggering an on-demand revalidation or rebuild
// whenever new content is actually published, closing the gap
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-this-interacts-badly-with-good-seo-practices&quot;&gt;Why This Interacts Badly With Good SEO Practices&lt;/h3&gt;
&lt;p&gt;This is a genuinely important compounding issue. If you've set up automatic sitemap generation, covered in an earlier post, and someone shares a freshly published post before the next build happens, both the visitor clicking the link and, worse, a search engine crawler attempting to index that URL early, encounter a real 404. A crawler hitting a 404 on a URL is a real, negative signal, not a neutral non-event, and repeated early 404s on genuinely valid content can meaningfully hurt how quickly and how well that content ends up indexed once it is actually available.&lt;/p&gt;
&lt;h3 id=&quot;how-to-check-if-this-is-affecting-you&quot;&gt;How to Check If This Is Affecting You&lt;/h3&gt;
&lt;p&gt;If you're using &lt;code&gt;dynamicParams = false&lt;/code&gt; anywhere, specifically check how long the gap actually is between content being published in your CMS and the next site build or deploy happening. If that gap is measured in hours or days rather than minutes, and the content in question is meant to be immediately live, that gap is a real, live problem, not a theoretical edge case.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;dynamicParams&quot; --include=&quot;*.tsx&quot; app/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-actual-rule-8&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;dynamicParams = false&lt;/code&gt; is a deliberate access-control decision, not a performance optimization, and it should only be used where truly unexpected paths genuinely should be rejected.&lt;/strong&gt; For any content source that grows independently of your deployment schedule, a blog, a CMS-driven catalog, user-generated content, the default &lt;code&gt;true&lt;/code&gt; behavior, or an explicit &lt;code&gt;true&lt;/code&gt;, is almost always what you actually want, letting new content render on demand rather than waiting on the next build to even acknowledge it exists.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're using &lt;code&gt;dynamicParams = false&lt;/code&gt; anywhere in a project with content that grows outside your deploy cycle, worth checking today whether new content is actually reachable the moment it's published, or silently 404ing until the next deploy catches up. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27517/setting-dynamicparams-to-false-can-make-brand-new-content-404-until-your-next-deploy</guid>
<pubDate>Thu, 17 Sep 2026 11:41:42 +0000</pubDate>
</item>
<item>
<title>Using searchParams in a Next.js Page Silently Opts It Out of Static Rendering Too</title>
<link>https://coderlegion.com/27252/using-searchparams-in-a-next-js-page-silently-opts-it-out-of-static-rendering-too</link>
<description>&lt;p&gt;I wrote earlier about a buried &lt;code&gt;cookies()&lt;/code&gt; call silently forcing an entire page into dynamic rendering. &lt;code&gt;searchParams&lt;/code&gt; causes the exact same category of behavior, through a completely different, and in some ways more surprising, mechanism, since using it doesn't look like reading anything server-specific at all.&lt;/p&gt;
&lt;h3 id=&quot;why-this-one-is-extra-surprising&quot;&gt;Why This One Is Extra Surprising&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cookies()&lt;/code&gt; and &lt;code&gt;headers()&lt;/code&gt; at least sound like they're reaching into something request-specific, so forcing dynamic rendering feels intuitive once you think about it. &lt;code&gt;searchParams&lt;/code&gt; just looks like reading a prop, a completely normal-looking destructure at the top of a page component. There's nothing about the syntax that signals &quot;this is a server-specific value tied to this exact request.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/products/page.tsx
export default function ProductsPage({
  searchParams,
}: {
  searchParams: Promise&amp;lt;{ category?: string; page?: string }&amp;gt;;
}) {
  // reading searchParams here means this page can no longer be
  // statically generated, full stop, regardless of what else it does
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The moment a page component accepts and uses &lt;code&gt;searchParams&lt;/code&gt;, Next.js treats the entire page as dynamic. This makes complete sense once you think through why, the query string is part of the actual request URL, and a page reading it necessarily produces different output for &lt;code&gt;/products?category=shoes&lt;/code&gt; versus &lt;code&gt;/products?category=hats&lt;/code&gt;, which is inherently incompatible with generating one fixed, static HTML output ahead of time. It's correct, intentional behavior. It's just not obviously signaled by the code itself the way reading &lt;code&gt;cookies()&lt;/code&gt; is.&lt;/p&gt;
&lt;h3 id=&quot;where-this-actually-costs-you&quot;&gt;Where This Actually Costs You&lt;/h3&gt;
&lt;p&gt;For a genuinely filterable page, a product listing with category and sort filters, this dynamic behavior is exactly correct and necessary, the whole feature depends on rendering different content per query string. This isn't a bug to fix in that case.&lt;/p&gt;
&lt;p&gt;The actual cost shows up when &lt;code&gt;searchParams&lt;/code&gt; gets accepted by a page component out of habit, or copied from a similar page, without the page actually using the specific query params in any way that changes its output.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/about/page.tsx
export default function AboutPage({
  searchParams, // accepted but never actually used for anything meaningful
}: {
  searchParams: Promise&amp;lt;{ [key: string]: string | undefined }&amp;gt;;
}) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;About Us&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;We build things.&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If this component's type signature includes &lt;code&gt;searchParams&lt;/code&gt; but the actual rendered output never varies based on it, simply accepting the prop can still be enough to affect how Next.js treats the route, depending on whether the value is actually read and used within the render. A genuinely static about page, forced into unnecessary dynamic rendering because of a leftover parameter or copy-pasted type signature from a different page, loses real caching benefit for no actual functional reason.&lt;/p&gt;
&lt;h3 id=&quot;how-to-tell-if-this-is-actually-happening&quot;&gt;How to Tell If This Is Actually Happening&lt;/h3&gt;
&lt;p&gt;Same check as the earlier &lt;code&gt;cookies()&lt;/code&gt; post, run a real build and look at the route summary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A route marked dynamic (ƒ) that you'd expect to be static (○) is worth investigating. If the page genuinely needs to vary based on query parameters, that's correct and expected. If it doesn't, actually use those params to produce different output, the fix is simply not accepting or reading &lt;code&gt;searchParams&lt;/code&gt; in that component at all.&lt;/p&gt;
&lt;h3 id=&quot;the-distinction-that-actually-matters&quot;&gt;The Distinction That Actually Matters&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Does this specific page's rendered output genuinely need to differ based on the query string?&lt;/strong&gt; If yes, dynamic rendering triggered by &lt;code&gt;searchParams&lt;/code&gt; is correct, intentional, and not something to fight against, the feature depends on it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;searchParams&lt;/code&gt; present in the component's signature or destructured, but never actually used to change what gets rendered?&lt;/strong&gt; That's the case worth fixing, either by removing the unused parameter entirely, or, if the params are genuinely needed for something that doesn't affect the main rendered content, handling that specific piece in a way that doesn't force the whole page dynamic, a client-side read via &lt;code&gt;useSearchParams()&lt;/code&gt; inside a small Client Component, for something that only needs to react to params after the initial static render.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// A pattern that keeps the main page static while still reacting to query params
// for a small, specific piece of UI that genuinely needs them
export default function ProductsPage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Our Products&amp;lt;/h1&amp;gt;
      &amp;lt;ProductGrid /&amp;gt; {/* genuinely static content */}
      &amp;lt;QueryAwareBanner /&amp;gt; {/* small client component reading useSearchParams() */}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-broader-pattern&quot;&gt;The Broader Pattern&lt;/h3&gt;
&lt;p&gt;This is the same underlying lesson as the &lt;code&gt;cookies()&lt;/code&gt; post, applied to a different, less obviously &quot;server-specific&quot; mechanism. Next.js's static-versus-dynamic decision is driven by what a page actually reads and depends on, not by how obviously server-related that code looks at a glance. &lt;code&gt;searchParams&lt;/code&gt;, &lt;code&gt;cookies()&lt;/code&gt;, and &lt;code&gt;headers()&lt;/code&gt; all trigger the same category of behavior, and all three are easy to include without actually needing what they provide, especially when copying a page's structure from a similar one that genuinely did need it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Go check your own build output for any page accepting &lt;code&gt;searchParams&lt;/code&gt; that doesn't actually use it to change what gets rendered. If you find one, that's a free, easy static-rendering win sitting right there. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27252/using-searchparams-in-a-next-js-page-silently-opts-it-out-of-static-rendering-too</guid>
<pubDate>Tue, 15 Sep 2026 09:33:11 +0000</pubDate>
</item>
<item>
<title>Wrapping redirect() in a try/catch in Next.js Can Silently Swallow the Redirect</title>
<link>https://coderlegion.com/27134/wrapping-redirect-in-a-try-catch-in-next-js-can-silently-swallow-the-redirect</link>
<description>&lt;p&gt;This one catches people specifically because the code looks like careful error handling, and it's actually the thing breaking the exact feature it's wrapped around.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-like-responsible-error-handling&quot;&gt;The Setup That Looks Like Responsible Error Handling&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/auth.ts
'use server';

export async function login(formData: FormData) {
  try {
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    const user = await verifyCredentials(email, password);
    if (!user) {
      return { error: 'Invalid credentials' };
    }

    await setSessionCookie(user);
    redirect('/dashboard');
  } catch (error) {
    console.error('Login failed:', error);
    return { error: 'Something went wrong' };
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks like exactly the kind of defensive coding you'd want, wrap the risky operations, catch anything unexpected, return a clean error instead of crashing. It also means a successful login never actually redirects anywhere, and the console gets a confusing, misleading error log every single time someone logs in correctly.&lt;/p&gt;
&lt;h3 id=&quot;why-this-happens&quot;&gt;Why This Happens&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;redirect()&lt;/code&gt; in Next.js doesn't work like a normal function returning a value that tells the framework &quot;please navigate now.&quot; It works by throwing a special internal error, effectively an escape hatch, that Next.js's own machinery catches at a higher level in the rendering or request lifecycle, and uses to actually trigger the navigation. This is genuinely how the feature is implemented under the hood, not a bug, it's a deliberate mechanism for interrupting normal execution flow to redirect.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;try/catch&lt;/code&gt; block doesn't know or care that this particular thrown error is special. It catches everything thrown within its boundary, indiscriminately, which includes this internal redirect signal exactly the same way it would catch a genuine database error or a network failure. Your &lt;code&gt;catch&lt;/code&gt; block intercepts it, logs a confusing &quot;Login failed&quot; message for what was actually a completely successful login, and returns your generic error response instead of ever letting the redirect signal propagate up to where Next.js needs to see it.&lt;/p&gt;
&lt;h3 id=&quot;why-the-console-log-makes-this-extra-confusing&quot;&gt;Why the Console Log Makes This Extra Confusing&lt;/h3&gt;
&lt;p&gt;The logged error typically has a distinctive-looking message or code related to Next.js's internal redirect mechanism, not a normal JavaScript error you'd immediately recognize. Someone unfamiliar with this specific gotcha sees an unfamiliar-looking error logged on every successful login and reasonably assumes something is actually broken, when the &quot;error&quot; being caught is, ironically, the successful outcome trying to happen.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-call-redirect-outside-the-try-catch&quot;&gt;The Actual Fix: Call redirect() Outside the try/catch&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/auth.ts
'use server';

export async function login(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  let user;
  try {
    user = await verifyCredentials(email, password);
  } catch (error) {
    console.error('Login failed:', error);
    return { error: 'Something went wrong' };
  }

  if (!user) {
    return { error: 'Invalid credentials' };
  }

  await setSessionCookie(user);
  redirect('/dashboard'); // outside any try/catch, free to throw its internal signal cleanly
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Scoping the &lt;code&gt;try/catch&lt;/code&gt; specifically around the operation that can genuinely fail in an unexpected way, the credential verification, database call, whatever's actually risky, and calling &lt;code&gt;redirect()&lt;/code&gt; afterward, outside that block entirely, means its internal throw mechanism reaches Next.js cleanly, without your own error handling accidentally standing in the way.&lt;/p&gt;
&lt;h3 id=&quot;if-you-need-try-catch-around-code-that-includes-a-redirect-call&quot;&gt;If You Need Try/Catch Around Code That Includes a redirect() Call&lt;/h3&gt;
&lt;p&gt;Sometimes the structure genuinely requires a broader try block. In that case, explicitly re-throw anything that looks like a Next.js redirect or notFound signal, rather than treating everything caught as a genuine error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { isRedirectError } from 'next/dist/client/components/redirect';

try {
  // some code that includes a redirect() call somewhere within it
} catch (error) {
  if (isRedirectError(error)) {
    throw error; // let it propagate, this isn't a real error
  }
  console.error('Actual error:', error);
  return { error: 'Something went wrong' };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern is more defensive but also more fragile, since it depends on an internal Next.js utility rather than a fully public, stable API. Restructuring the code to keep &lt;code&gt;redirect()&lt;/code&gt; outside any try/catch entirely, the first fix above, is the cleaner, more robust solution whenever the code can reasonably be organized that way.&lt;/p&gt;
&lt;h3 id=&quot;where-else-this-bites-1&quot;&gt;Where Else This Bites&lt;/h3&gt;
&lt;p&gt;The exact same issue applies to &lt;code&gt;notFound()&lt;/code&gt;, which uses the same underlying throw-based mechanism. Any Server Action, Server Component, or route handler with a broad try/catch wrapping code that also calls &lt;code&gt;redirect()&lt;/code&gt; or &lt;code&gt;notFound()&lt;/code&gt; anywhere within that boundary is at risk of this exact silent failure, not just login flows specifically.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-9&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Never let a try/catch block unintentionally wrap a call to &lt;code&gt;redirect()&lt;/code&gt; or &lt;code&gt;notFound()&lt;/code&gt;.&lt;/strong&gt; Scope error handling tightly around the specific operations that can genuinely throw a real error, and keep redirect and not-found calls outside that boundary, or explicitly detect and re-throw them if the structure genuinely requires a broader catch.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've got a Server Action with a broad try/catch that also calls redirect() somewhere inside it, go check whether that redirect is actually firing, or silently getting logged as a mysterious error instead. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27134/wrapping-redirect-in-a-try-catch-in-next-js-can-silently-swallow-the-redirect</guid>
<pubDate>Mon, 14 Sep 2026 08:46:50 +0000</pubDate>
</item>
<item>
<title>That .lean() Query Optimization Might Be Leaking Password Hashes in Your API Response</title>
<link>https://coderlegion.com/26819/that-lean-query-optimization-might-be-leaking-password-hashes-in-your-api-response</link>
<description>&lt;p&gt;I've recommended &lt;code&gt;.lean()&lt;/code&gt; in earlier posts, it's genuinely the right call for read-heavy queries, faster, lighter, no Mongoose document overhead. There's a real gap it opens up that's worth calling out specifically, because it silently undoes a common, otherwise-good security pattern, and the failure is invisible unless you specifically go check the actual API response.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-like-a-solid-security-pattern&quot;&gt;The Setup That Looks Like a Solid Security Pattern&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// models/User.ts
const UserSchema = new Schema({
  name: String,
  email: String,
  password: { type: String, select: false }, // excluded from queries by default
}, {
  toJSON: {
    transform: (doc, ret) =&amp;gt; {
      delete ret.password; // extra safety net, strips it even if somehow selected
      delete ret.__v;
      return ret;
    },
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a reasonable, defense-in-depth setup, &lt;code&gt;select: false&lt;/code&gt; keeps the password out of normal queries by default, and the &lt;code&gt;toJSON&lt;/code&gt; transform is a second safety net stripping it from anything that does get serialized, in case it was ever explicitly selected somewhere. Two layers, looks solid.&lt;/p&gt;
&lt;h3 id=&quot;where-this-quietly-breaks&quot;&gt;Where This Quietly Breaks&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/route.ts
export async function GET() {
  const users = await User.find().select('+password').lean();
  // some legitimate internal reason to select password here, comparing hashes, an admin tool, etc.
  return Response.json({ users });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.lean()&lt;/code&gt; returns plain JavaScript objects, not real Mongoose documents. That &lt;code&gt;toJSON&lt;/code&gt; transform you configured on the schema is a method that exists on Mongoose document instances specifically, it never runs on a plain object. &lt;code&gt;Response.json({ users })&lt;/code&gt; calls &lt;code&gt;JSON.stringify&lt;/code&gt; under the hood, which on a lean plain object just serializes every property directly, no transform, no stripping, nothing. If &lt;code&gt;password&lt;/code&gt; was selected anywhere in that query chain, it goes straight into the API response, completely bypassing the safety net you specifically built to prevent exactly that.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-genuinely-easy-to-introduce&quot;&gt;Why This Is Genuinely Easy to Introduce&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;select('+password')&lt;/code&gt; and &lt;code&gt;.lean()&lt;/code&gt; combination often gets added at different times, for different reasons, by different people, or by the same developer months apart with the original toJSON safety net long forgotten. Someone adds &lt;code&gt;.lean()&lt;/code&gt; to a query for a legitimate performance reason, weeks or months after the &lt;code&gt;select('+password')&lt;/code&gt; was added for some specific internal need, comparing a password during a migration script, an admin debugging tool, and neither change alone looks dangerous in isolation. The combination is what's dangerous, and nothing about reviewing either change independently reveals that.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-never-rely-on-tojson-as-your-only-safety-net&quot;&gt;The Actual Fix: Never Rely on toJSON as Your Only Safety Net&lt;/h3&gt;
&lt;p&gt;The real lesson here isn't &quot;don't use &lt;code&gt;.lean()&lt;/code&gt;&quot;, it's genuinely great for performance and you should keep using it. The lesson is that &lt;code&gt;toJSON&lt;/code&gt; transforms are not a reliable safety net for anything you might combine with &lt;code&gt;.lean()&lt;/code&gt;, so the actual stripping needs to happen explicitly, regardless of whether the query happens to be lean or not.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/serializers/user.ts
export function toSafeUser(user: any) {
  const { password, __v, ...safe } = user;
  return safe;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/users/route.ts
import { toSafeUser } from '@/lib/serializers/user';

export async function GET() {
  const users = await User.find().lean(); // no need to select password here at all
  return Response.json({ users: users.map(toSafeUser) });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An explicit serializer function works identically whether the input came from a lean query or a real document, since it's just plain object destructuring, not relying on a Mongoose-specific lifecycle hook that only fires under certain conditions.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule-this-points-to-1&quot;&gt;The Broader Rule This Points To&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Never assume a security-relevant transform tied to a document lifecycle method still applies once you've introduced &lt;code&gt;.lean()&lt;/code&gt;, &lt;code&gt;.toObject()&lt;/code&gt;, or any pattern that returns a plain object instead of a real document instance.&lt;/strong&gt; &lt;code&gt;toJSON&lt;/code&gt;, &lt;code&gt;toObject&lt;/code&gt; transforms, and virtuals are all Mongoose document features, tied specifically to the document class, not properties of the raw data itself. The moment you're working with a plain object, none of that schema-level behavior comes along automatically, and any security assumption riding on it needs to be re-verified explicitly, not assumed to still hold.&lt;/p&gt;
&lt;h3 id=&quot;check-your-own-api-routes&quot;&gt;Check Your Own API Routes&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;lean()&quot; --include=&quot;*.ts&quot; app/api/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For each match, check two things, does the underlying query ever select a genuinely sensitive field, and does the response actually get filtered explicitly, not just relying on a schema-level transform that a lean query never triggers. If a route selects a sensitive field for some legitimate internal reason and also calls &lt;code&gt;.lean()&lt;/code&gt;, that combination is worth verifying directly against the actual JSON response, not just the code.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've got &lt;code&gt;.lean()&lt;/code&gt; anywhere near a query that ever selects a genuinely sensitive field, go check the actual raw response, not just the code, this specific combination is exactly the kind of thing that looks completely fine on read-through and only reveals itself in the real serialized output. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26819/that-lean-query-optimization-might-be-leaking-password-hashes-in-your-api-response</guid>
<pubDate>Fri, 11 Sep 2026 08:29:25 +0000</pubDate>
</item>
<item>
<title>A Wildcard in Your next.config Image remotePatterns Might Turn Your Site Into an SSRF Proxy</title>
<link>https://coderlegion.com/26705/wildcard-in-your-next-config-image-remotepatterns-might-turn-your-site-into-an-ssrf-proxy</link>
<description>&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; optimizing external images is genuinely convenient, resizing, format conversion, all handled automatically through Next.js's built-in image optimization endpoint. That endpoint fetches the remote image server-side before serving the optimized version to the browser, and that server-side fetch is exactly the mechanism that becomes a real problem if &lt;code&gt;remotePatterns&lt;/code&gt; is configured too loosely.&lt;/p&gt;
&lt;h3 id=&quot;the-configuration-that-looks-convenient&quot;&gt;The Configuration That Looks Convenient&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**', // &quot;just let any https image load, easier than maintaining a list&quot;
      },
    ],
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows up constantly, especially on projects where images come from a genuinely wide variety of sources, user-uploaded content pointing at various CDNs, a CMS that could reference images from anywhere. The wildcard hostname feels like a reasonable convenience, avoid maintaining an explicit allowlist, just accept any https image URL.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-actually-a-real-vulnerability&quot;&gt;Why This Is Actually a Real Vulnerability&lt;/h3&gt;
&lt;p&gt;Next.js's image optimization endpoint, &lt;code&gt;/_next/image&lt;/code&gt;, accepts a &lt;code&gt;url&lt;/code&gt; parameter and fetches it server-side. With &lt;code&gt;hostname: '**'&lt;/code&gt;, that endpoint will fetch essentially any URL an attacker provides, as long as it's https. This turns your own server into a general-purpose URL-fetching proxy, controllable by anyone who can construct a request to that endpoint, which is a textbook Server-Side Request Forgery setup.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://yoursite.com/_next/image?url=https://internal-admin-panel.yourcompany.com/&amp;amp;w=640&amp;amp;q=75
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your infrastructure has any internal services reachable from where your Next.js app runs, an internal admin panel, a cloud metadata endpoint, an internal API with no separate auth layer because it was assumed to only be reachable from inside your own network, a wildcard &lt;code&gt;remotePatterns&lt;/code&gt; configuration means your own server will happily make that request on an attacker's behalf and, depending on exactly how the endpoint's response gets exposed, potentially leak some information about what came back.&lt;/p&gt;
&lt;h3 id=&quot;the-specific-well-known-version-of-this-cloud-metadata-endpoints&quot;&gt;The Specific, Well-Known Version of This: Cloud Metadata Endpoints&lt;/h3&gt;
&lt;p&gt;This is the classic SSRF target, and it's directly relevant here. Most cloud providers expose an internal metadata endpoint, &lt;code&gt;http://169.254.169.254/&lt;/code&gt;, reachable only from within their own infrastructure, that can return real credentials or configuration data for the running instance. A wildcard image proxy is exactly the kind of mechanism that's been used in real-world SSRF attacks to reach exactly this endpoint from inside cloud environments, since the request originates from your own server, which does have network access to it, not from the attacker's machine, which doesn't.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-an-actual-allowlist-not-a-wildcard&quot;&gt;The Fix: An Actual Allowlist, Not a Wildcard&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
      },
      {
        protocol: 'https',
        hostname: 'yourbucket.s3.amazonaws.com',
      },
      {
        protocol: 'https',
        hostname: 'lh3.googleusercontent.com', // if accepting Google account avatars, for example
      },
    ],
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An explicit list of the actual, known image sources your app legitimately uses closes this off entirely. The image optimization endpoint will only fetch from hosts on this list, regardless of what URL parameter someone tries to pass in, which is the entire point, the server should only ever be willing to fetch from sources you've actually decided to trust, not from anywhere an attacker can construct a URL for.&lt;/p&gt;
&lt;h3 id=&quot;what-if-you-genuinely-need-broad-user-controlled-image-sources&quot;&gt;What If You Genuinely Need Broad, User-Controlled Image Sources&lt;/h3&gt;
&lt;p&gt;For a case like user-generated content where images could legitimately come from many different, unpredictable domains, a wildcard isn't the only option, and it's worth resisting it even here. A more careful approach validates and stores the actual final image URL at upload time, ideally re-hosting the image on your own trusted storage rather than proxying an arbitrary external URL live on every request, which removes the SSRF surface entirely rather than trying to sanitize URLs on the fly, a genuinely hard problem to get completely right.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule-1&quot;&gt;The Broader Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any server-side mechanism that fetches a URL based on client-supplied input is a potential SSRF vector, and &lt;code&gt;next/image&lt;/code&gt;'s &lt;code&gt;remotePatterns&lt;/code&gt; wildcard is a specific, easy-to-overlook instance of a much more general pattern.&lt;/strong&gt; The same caution applies to any custom endpoint you build that fetches a URL, downloads a file, or proxies a request based on something a client sent, not just this one specific Next.js feature.&lt;/p&gt;
&lt;h3 id=&quot;check-your-own-config&quot;&gt;Check Your Own Config&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -A 5 &quot;remotePatterns&quot; next.config.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see &lt;code&gt;hostname: '**'&lt;/code&gt; or a similarly broad wildcard, and your app has any internal services or cloud infrastructure reachable from where it runs, this is worth tightening today, not eventually. Replace it with an explicit list of the actual domains your images genuinely come from.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're running a wildcard &lt;code&gt;remotePatterns&lt;/code&gt; config right now, genuinely curious what led to it, deliberate convenience tradeoff, or just copied from a tutorial without thinking through what it actually opens up. Drop your situation in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26705/wildcard-in-your-next-config-image-remotepatterns-might-turn-your-site-into-an-ssrf-proxy</guid>
<pubDate>Thu, 10 Sep 2026 08:17:19 +0000</pubDate>
</item>
<item>
<title>How I Built a Digital Invitation Platform with Next.js, Supabase, and Vercel</title>
<link>https://coderlegion.com/26602/how-i-built-a-digital-invitation-platform-with-next-js-supabase-and-vercel</link>
<description>&lt;h2 id=&quot;how-i-built-a-digital-invitation-platform-with-next-js-supabase-and-vercel&quot;&gt;How I Built a Digital Invitation Platform with Next.js, Supabase, and Vercel&lt;/h2&gt;
&lt;p&gt;I wanted to build a small product that solved a problem people actually have, rather than another project that would only live in a GitHub repository.&lt;/p&gt;
&lt;p&gt;That idea eventually became InviteVia, a digital invitation platform for weddings, birthdays, engagements, parties, and other events.&lt;/p&gt;
&lt;p&gt;The initial idea sounded simple: let someone create an invitation, share it with their guests, and collect RSVPs.&lt;/p&gt;
&lt;p&gt;The interesting part was everything that came after that.&lt;/p&gt;
&lt;h3 id=&quot;starting-with-a-simple-mvp&quot;&gt;Starting with a simple MVP&lt;/h3&gt;
&lt;p&gt;I decided to keep the first version as simple as possible.&lt;/p&gt;
&lt;p&gt;The core flow was straightforward. A user creates an invitation, adds the event information, publishes it, and gets a shareable URL. Guests open the URL and can view the invitation and respond to the event.&lt;/p&gt;
&lt;p&gt;One decision I made early was not requiring guests to create an account just to RSVP.&lt;/p&gt;
&lt;p&gt;From a developer perspective, authentication would have been relatively easy to add. From a user's perspective, however, it would add another unnecessary step.&lt;/p&gt;
&lt;p&gt;For an event invitation, I would rather remove friction than add another login screen.&lt;/p&gt;
&lt;h3 id=&quot;why-i-chose-next-js&quot;&gt;Why I chose Next.js&lt;/h3&gt;
&lt;p&gt;I built the application with Next.js and TypeScript.&lt;/p&gt;
&lt;p&gt;Next.js made sense for this project because the application has both interactive parts and pages that benefit from being discoverable by search engines.&lt;/p&gt;
&lt;p&gt;The invitation itself is also something that needs to load quickly when somebody receives a link through WhatsApp, Instagram, SMS, or another messaging platform.&lt;/p&gt;
&lt;p&gt;I wanted the application architecture to support both the product experience and the SEO side of the project without maintaining a separate frontend application.&lt;/p&gt;
&lt;h3 id=&quot;supabase-for-the-backend&quot;&gt;Supabase for the backend&lt;/h3&gt;
&lt;p&gt;For the backend, I used Supabase.&lt;/p&gt;
&lt;p&gt;It gave me authentication, PostgreSQL, database access, and other backend functionality without having to build all of those services from scratch.&lt;/p&gt;
&lt;p&gt;For an MVP, this was especially useful because I wanted to spend more time working on the actual invitation experience rather than building infrastructure that already exists.&lt;/p&gt;
&lt;p&gt;The database structure revolves around the things the product actually needs: users, invitations, events, guests, and RSVP information.&lt;/p&gt;
&lt;p&gt;Keeping those relationships simple was important because I knew the application might eventually need to support more complex event features.&lt;/p&gt;
&lt;h3 id=&quot;one-of-the-more-interesting-problems-social-previews&quot;&gt;One of the more interesting problems: social previews&lt;/h3&gt;
&lt;p&gt;One of the things I wanted InviteVia to do well was sharing.&lt;/p&gt;
&lt;p&gt;When somebody sends an invitation URL through a messaging application or social network, the link should not just appear as a random URL.&lt;/p&gt;
&lt;p&gt;It should have a useful preview containing the event information and an appropriate image.&lt;/p&gt;
&lt;p&gt;This led me to implement dynamic Open Graph images using Vercel's OG image generation.&lt;/p&gt;
&lt;p&gt;Instead of creating a static image for every invitation, the application can generate a preview based on the invitation data.&lt;/p&gt;
&lt;p&gt;That means different invitations can have different previews while still using the same underlying application.&lt;/p&gt;
&lt;p&gt;For me, this was one of the more interesting parts of the project because it sits somewhere between frontend development, backend data, and SEO/social sharing.&lt;/p&gt;
&lt;h3 id=&quot;making-rsvp-as-frictionless-as-possible&quot;&gt;Making RSVP as frictionless as possible&lt;/h3&gt;
&lt;p&gt;Another problem I wanted to solve was the RSVP experience.&lt;/p&gt;
&lt;p&gt;Imagine receiving a wedding invitation and being asked to create an account, verify your email, and remember another password before you can simply answer &quot;Yes.&quot;&lt;/p&gt;
&lt;p&gt;That felt unnecessary.&lt;/p&gt;
&lt;p&gt;So I designed the RSVP flow around the guest rather than the account system.&lt;/p&gt;
&lt;p&gt;A guest can open the invitation, see the event information, and respond without creating an account.&lt;/p&gt;
&lt;p&gt;The goal is simple: if someone has the invitation link, they should be able to interact with the invitation.&lt;/p&gt;
&lt;p&gt;This sounds like a small product decision, but removing a few steps from a user flow can make a significant difference.&lt;/p&gt;
&lt;h3 id=&quot;building-for-multiple-languages&quot;&gt;Building for multiple languages&lt;/h3&gt;
&lt;p&gt;InviteVia is also designed for an international audience.&lt;/p&gt;
&lt;p&gt;Instead of building the application around a single language and adding localization later, I decided to support multiple languages from the beginning.&lt;/p&gt;
&lt;p&gt;The platform currently supports 19 languages.&lt;/p&gt;
&lt;p&gt;That introduced another layer of complexity because localization is not just translating strings. Routes, metadata, page titles, descriptions, structured content, and user-generated information all need to work correctly across languages.&lt;/p&gt;
&lt;p&gt;It also made me think much more carefully about how I structure the application.&lt;/p&gt;
&lt;h3 id=&quot;the-seo-side-of-the-project&quot;&gt;The SEO side of the project&lt;/h3&gt;
&lt;p&gt;SEO became another interesting engineering problem.&lt;/p&gt;
&lt;p&gt;A digital invitation platform has many pages that can potentially be discovered through search engines, but simply generating thousands of pages is not enough.&lt;/p&gt;
&lt;p&gt;The pages need useful content, correct metadata, sensible heading structures, canonical URLs, and proper language relationships.&lt;/p&gt;
&lt;p&gt;I also had to think about how dynamically generated invitation pages should behave when they are crawled.&lt;/p&gt;
&lt;p&gt;This is where building a product and working on SEO start to overlap. Technical decisions made in the application can directly affect how search engines understand the site.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-from-building-it&quot;&gt;What I learned from building it&lt;/h3&gt;
&lt;p&gt;The biggest lesson was that building the product is only one part of the work.&lt;/p&gt;
&lt;p&gt;It is relatively easy to create a page that looks good and works locally.&lt;/p&gt;
&lt;p&gt;It is much harder to build something that is fast, accessible, discoverable, easy to share, and simple enough for someone who has never seen the application before.&lt;/p&gt;
&lt;p&gt;I also learned that small product decisions can have a surprisingly large impact.&lt;/p&gt;
&lt;p&gt;Not requiring an account for guests, generating dynamic social previews, supporting multiple languages, and keeping the invitation URL simple were all decisions that seemed small when I made them.&lt;/p&gt;
&lt;p&gt;Together, they became an important part of the product experience.&lt;/p&gt;
&lt;h3 id=&quot;where-invitevia-is-going&quot;&gt;Where InviteVia is going&lt;/h3&gt;
&lt;p&gt;InviteVia started as an MVP, but I am treating it as a real product rather than just a portfolio project.&lt;/p&gt;
&lt;p&gt;The next challenges are not only technical. They involve understanding how people actually use the invitations, improving the creation experience, making RSVP management better, and finding sustainable ways to bring new users to the platform.&lt;/p&gt;
&lt;p&gt;For me, that is what makes building a product more interesting than building another demo.&lt;/p&gt;
&lt;p&gt;You have to keep solving problems that you did not anticipate when you wrote the first line of code.&lt;/p&gt;
&lt;p&gt;If you want to see the project, you can find it at &lt;a rel=&quot;nofollow&quot; href=&quot;https://inviteviaapp.com/&quot;&gt;InviteVia&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I am still building and improving it, so this is very much a work in progress.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26602/how-i-built-a-digital-invitation-platform-with-next-js-supabase-and-vercel</guid>
<pubDate>Wed, 09 Sep 2026 11:03:45 +0000</pubDate>
</item>
<item>
<title>Using an Array Index as a React key Can Leak Form State Between Completely Different Items</title>
<link>https://coderlegion.com/26573/using-an-array-index-as-a-react-key-can-leak-form-state-between-completely-different-items</link>
<description>&lt;p&gt;This is one of those React gotchas that's been written about for years and still shows up constantly in real code, specifically because it looks completely correct and works fine in the exact scenario most people test.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-fine&quot;&gt;The Setup That Looks Fine&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// components/QueueList.tsx
'use client';

export function QueueList({ entries }: { entries: QueueEntry[] }) {
  return (
    &amp;lt;div&amp;gt;
      {entries.map((entry, index) =&amp;gt; (
        &amp;lt;QueueRow key={index} entry={entry} /&amp;gt;
      ))}
    &amp;lt;/div&amp;gt;
  );
}

function QueueRow({ entry }: { entry: QueueEntry }) {
  const [isEditing, setIsEditing] = useState(false);
  const [note, setNote] = useState('');

  return (
    &amp;lt;div&amp;gt;
      {isEditing ? (
        &amp;lt;input value={note} onChange={(e) =&amp;gt; setNote(e.target.value)} /&amp;gt;
      ) : (
        &amp;lt;span&amp;gt;{entry.name}&amp;lt;/span&amp;gt;
      )}
      &amp;lt;button onClick={() =&amp;gt; setIsEditing(!isEditing)}&amp;gt;Edit&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;key={index}&lt;/code&gt; compiles fine, renders fine, and works completely correctly for a list that never reorders or has items removed from the middle. It's also a ticking bug for the exact moment that stops being true, which, for a live queue, a to-do list, anything backed by real-time or user-driven changes, is basically guaranteed to happen eventually.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-breaks-1&quot;&gt;What Actually Breaks&lt;/h3&gt;
&lt;p&gt;Say a user clicks &quot;Edit&quot; on the third row in a five-person queue, typing a note into that row's input. Then, before they finish, the first person in the queue gets called and removed from the list. React re-renders with four entries instead of five, and here's the actual bug, React uses the &lt;code&gt;key&lt;/code&gt; to decide whether a given DOM node and its associated state represent the same conceptual item across renders or a genuinely new one.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;key={index}&lt;/code&gt;, the item that used to be at index 3 (where the user was editing) is now a completely different person's data, since everyone shifted up by one position after the removal. But React sees &lt;code&gt;key={2}&lt;/code&gt; existed before and &lt;code&gt;key={2}&lt;/code&gt; exists now, same key, so as far as React's reconciliation is concerned, this is the same component instance, not a new one. The &lt;code&gt;isEditing&lt;/code&gt; and &lt;code&gt;note&lt;/code&gt; state that belonged to the original row stays attached to whatever now occupies that same index. The user's open text input, and whatever they'd started typing, is now sitting on a completely different person's row, potentially about to submit a note meant for someone else onto someone else's queue entry.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-so-easy-to-miss-in-testing&quot;&gt;Why This Is So Easy to Miss in Testing&lt;/h3&gt;
&lt;p&gt;Most manual testing during development involves a list that doesn't change while you're interacting with it, you open an edit field, you look at it, maybe you close it, and move on. The bug specifically requires the list to reorder or shrink &lt;em&gt;while&lt;/em&gt; some item has open local state, exactly the kind of timing-dependent interaction that's easy to never happen to trigger during normal development testing, and completely likely to happen in production the moment two things occur close together in real usage, someone editing a note right as a queue updates in real time, for instance.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-key-by-something-stable-and-unique-to-the-item&quot;&gt;The Fix: Key by Something Stable and Unique to the Item&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export function QueueList({ entries }: { entries: QueueEntry[] }) {
  return (
    &amp;lt;div&amp;gt;
      {entries.map((entry) =&amp;gt; (
        &amp;lt;QueueRow key={entry.id} entry={entry} /&amp;gt; {/* stable, unique, tied to the actual item */}
      ))}
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using the entry's actual database ID, something that stays attached to that specific piece of data regardless of its position in the array, means React correctly tracks each row as the same conceptual item across reorders and removals. If the person originally at index 3 moves to index 2 after someone ahead of them is removed, React now correctly recognizes it's the same person, keyed by &lt;code&gt;entry.id&lt;/code&gt;, not a coincidentally-matching array position, and their open edit state, their typed note, moves correctly along with them instead of staying pinned to a now-different row.&lt;/p&gt;
&lt;h3 id=&quot;where-this-specifically-bites-harder-animatepresence&quot;&gt;Where This Specifically Bites Harder: AnimatePresence&lt;/h3&gt;
&lt;p&gt;This gets worse, not just wrong but visually confusing, combined with exit animations. If you're using &lt;code&gt;AnimatePresence&lt;/code&gt; for a list, covered in an earlier post on animation patterns, keying by index actively breaks the exit animation's ability to correctly identify which item is actually leaving.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// ❌ AnimatePresence can't correctly animate the actual removed item
&amp;lt;AnimatePresence&amp;gt;
  {entries.map((entry, index) =&amp;gt; (
    &amp;lt;motion.div key={index} exit={{ opacity: 0 }}&amp;gt;
      {entry.name}
    &amp;lt;/motion.div&amp;gt;
  ))}
&amp;lt;/AnimatePresence&amp;gt;

// ✅ Correctly animates the specific item that was actually removed
&amp;lt;AnimatePresence&amp;gt;
  {entries.map((entry) =&amp;gt; (
    &amp;lt;motion.div key={entry.id} exit={{ opacity: 0 }}&amp;gt;
      {entry.name}
    &amp;lt;/motion.div&amp;gt;
  ))}
&amp;lt;/AnimatePresence&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With an index-based key, removing the first item doesn't register as &quot;index 0 left,&quot; it registers as &quot;every remaining item shifted to a new key,&quot; which can cause the exit animation to fire on the wrong visual element, or fail to trigger the intended fade-out on the item that was actually removed at all.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-rule-10&quot;&gt;The Actual Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Any list where items can be reordered, filtered, or removed from anywhere other than strictly the end needs a key tied to the item's own stable identity, not its position.&lt;/strong&gt; A database ID, a UUID, anything genuinely unique and attached to that specific piece of data works. Array index is only ever safe for a list that's guaranteed static, never reordered, never filtered, items only ever appended at the end and never removed, a genuinely narrow set of real-world cases.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Go check any list in your own codebase using &lt;code&gt;key={index}&lt;/code&gt;, specifically ones with any local state inside each item, an expanded row, an open edit mode, a checked checkbox, and check whether that list can ever reorder or have items removed from the middle. If it can, this bug is very likely already there, whether or not anyone's noticed yet. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26573/using-an-array-index-as-a-react-key-can-leak-form-state-between-completely-different-items</guid>
<pubDate>Wed, 09 Sep 2026 08:21:44 +0000</pubDate>
</item>
<item>
<title>Next.js Full-Stack Auth Template</title>
<link>https://coderlegion.com/26536/next-js-full-stack-auth-template</link>
<description>&lt;p&gt;Setting up a complete, production-grade auth flow shouldn't take days.&lt;/p&gt;
&lt;p&gt;I built an open-source starter template combining Next.js, Better-Auth, Drizzle ORM, and PostgreSQL to streamline the entire setup.&lt;/p&gt;
&lt;p&gt;What it includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Email/Password &amp;amp; Social OAuth (Google &amp;amp; GitHub)&lt;/li&gt;
&lt;li&gt;PostgreSQL schema management &amp;amp; migrations with Drizzle ORM&lt;/li&gt;
&lt;li&gt;Server-side route protection via Next.js layouts&lt;/li&gt;
&lt;li&gt;Full client and server type safety&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I've shared the entire codebase and published a comprehensive architectural breakdown explaining the full setup step-by-step.&lt;/p&gt;
&lt;p&gt;Live Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://next-js-better-auth-template.vercel.app/&quot;&gt;website-link&lt;/a&gt;&lt;br&gt;
GitHub Repository: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/OnkarK0273/next-better-auth-template&quot;&gt;repo-link&lt;/a&gt;&lt;br&gt;
Full Step-by-Step Guide: &lt;a rel=&quot;nofollow&quot; href=&quot;https://onkark.hashnode.dev/next-js-full-stack-auth-better-auth-drizzle-orm-guide&quot;&gt;article-link&lt;/a&gt;&lt;br&gt;
Video Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://lnkd.in/p/gAXKe4uS&quot;&gt;video-link&lt;/a&gt;&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/26536/next-js-full-stack-auth-template</guid>
<pubDate>Wed, 09 Sep 2026 05:55:26 +0000</pubDate>
</item>
<item>
<title>Rendering new Date() in a Server Component Is a Silent Timezone Bug Waiting to Happen</title>
<link>https://coderlegion.com/26468/rendering-new-date-in-a-server-component-is-a-silent-timezone-bug-waiting-to-happen</link>
<description>&lt;p&gt;If you've ever seen a hydration mismatch warning in the console for a timestamp, and just suppressed it with &lt;code&gt;suppressHydrationWarning&lt;/code&gt; because the page still worked, it's worth actually understanding what that warning was telling you, because the underlying issue doesn't go away just because the warning did.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-completely-normal&quot;&gt;The Setup That Looks Completely Normal&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Dashboard&amp;lt;/h1&amp;gt;
      &amp;lt;p&amp;gt;Last updated: {new Date().toLocaleString()}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This renders a timestamp. It looks correct in the browser. It also has two separate problems stacked on top of each other, one causing a visible React warning, the other causing a genuinely wrong displayed value that never throws any warning at all.&lt;/p&gt;
&lt;h3 id=&quot;problem-one-the-hydration-mismatch&quot;&gt;Problem One: The Hydration Mismatch&lt;/h3&gt;
&lt;p&gt;The server renders this component once, at whatever exact moment the request is processed, producing a specific timestamp string. Then React hydrates that same component on the client, and &lt;code&gt;new Date().toLocaleString()&lt;/code&gt; runs again, this time at whatever moment hydration actually happens, milliseconds or sometimes longer after the server render. Two different calls to &lt;code&gt;new Date()&lt;/code&gt;, two different actual timestamps, and React's hydration process expects the server-rendered HTML and the client's first render to match exactly. When they don't, React logs a hydration mismatch warning and, depending on the specifics, may discard and re-render the mismatched content client-side.&lt;/p&gt;
&lt;h3 id=&quot;problem-two-the-one-that-doesn-t-even-warn-you-timezone&quot;&gt;Problem Two, the One That Doesn't Even Warn You: Timezone&lt;/h3&gt;
&lt;p&gt;This is the one that actually causes wrong data, not just a console warning. &lt;code&gt;toLocaleString()&lt;/code&gt; without an explicit locale and timezone uses the &lt;em&gt;runtime environment's&lt;/em&gt; local timezone setting, and the server's runtime environment and the visitor's browser are very often not in the same timezone at all. A server running in UTC, common for most cloud infrastructure, formats &quot;3:00 PM&quot; in UTC. If a visitor's browser is in a different timezone, the hydrated client-side render of the exact same underlying timestamp shows a completely different clock time, &quot;3:00 PM&quot; during server rendering, silently replaced by something like &quot;10:00 AM&quot; once client-side hydration takes over and reformats it in the visitor's actual local timezone.&lt;/p&gt;
&lt;p&gt;Neither of these values is actually wrong in isolation, they're both technically correct representations of the same underlying moment. The problem is a visitor briefly seeing one, then having it silently change to a different clock time right in front of them, or worse, seeing whichever version happens to persist without ever realizing the other one was theoretically shown first.&lt;/p&gt;
&lt;h3 id=&quot;why-suppressing-the-warning-doesn-t-fix-anything&quot;&gt;Why Suppressing the Warning Doesn't Fix Anything&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// This makes the console warning go away. It does not fix the actual mismatch.
&amp;lt;p suppressHydrationWarning&amp;gt;Last updated: {new Date().toLocaleString()}&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;suppressHydrationWarning&lt;/code&gt; tells React to stop complaining about a mismatch on this specific element, it does nothing to prevent the mismatch from happening, and it does nothing about the underlying timezone confusion. It's treating the symptom, a noisy console warning, while leaving the actual bug, a timestamp that renders differently depending on server versus client timezone, completely intact.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-format-explicitly-or-defer-to-the-client-deliberately&quot;&gt;The Actual Fix: Format Explicitly, or Defer to the Client Deliberately&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Option one, format with an explicit, fixed timezone&lt;/strong&gt;, removing ambiguity entirely by never relying on either environment's local timezone setting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export default function DashboardPage({ lastUpdated }: { lastUpdated: Date }) {
  const formatted = lastUpdated.toLocaleString('en-US', {
    timeZone: 'UTC',
    dateStyle: 'medium',
    timeStyle: 'short',
  });

  return &amp;lt;p&amp;gt;Last updated: {formatted} UTC&amp;lt;/p&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produces the same, deterministic string regardless of where the server runs or where the visitor's browser happens to be, since the timezone is explicitly fixed rather than implicitly inferred from either environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option two, if you genuinely want the visitor's own local time&lt;/strong&gt;, defer that specific formatting to the client deliberately, rather than accidentally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;'use client';
import { useState, useEffect } from 'react';

export function LocalTimestamp({ date }: { date: string }) {
  const [formatted, setFormatted] = useState&amp;lt;string | null&amp;gt;(null);

  useEffect(() =&amp;gt; {
    setFormatted(new Date(date).toLocaleString());
  }, [date]);

  return &amp;lt;span&amp;gt;{formatted ?? 'Loading...'}&amp;lt;/span&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rendering nothing (or a neutral placeholder) on the server, then formatting in the visitor's actual local timezone only after mounting client-side, means there's no mismatch to begin with, since the server never rendered a locale-specific value for the client to potentially disagree with in the first place.&lt;/p&gt;
&lt;h3 id=&quot;the-rule-worth-remembering&quot;&gt;The Rule Worth Remembering&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Anything genuinely time-sensitive or locale-sensitive rendered in a Server Component needs an explicit, fixed format, not an implicit one that depends on which environment happens to be doing the formatting.&lt;/strong&gt; If you actually want the visitor's own local time specifically, that formatting decision belongs on the client, deliberately, not as an accidental side effect of a Server Component's output silently disagreeing with its own hydration.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've got a &lt;code&gt;suppressHydrationWarning&lt;/code&gt; sitting on a timestamp somewhere in your codebase, worth going back and checking whether it's actually just hiding this exact timezone issue rather than a harmless cosmetic mismatch. Drop what you find in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26468/rendering-new-date-in-a-server-component-is-a-silent-timezone-bug-waiting-to-happen</guid>
<pubDate>Tue, 08 Sep 2026 08:04:36 +0000</pubDate>
</item>
<item>
<title>revalidatePath Might Not Be Invalidating What You Think It Is</title>
<link>https://coderlegion.com/26247/revalidatepath-might-not-be-invalidating-what-you-think-it-is</link>
<description>&lt;p&gt;If you've ever called &lt;code&gt;revalidatePath&lt;/code&gt; after a mutation, watched the data still show up stale on the page, and fixed it by just also calling &lt;code&gt;revalidatePath&lt;/code&gt; on a few more related paths until it finally worked, you were probably fighting the actual behavior of the function rather than a bug in your own code. This one has a genuinely confusing default that catches a lot of people, myself included the first time I ran into it.&lt;/p&gt;
&lt;h3 id=&quot;the-setup-that-looks-like-it-should-just-work&quot;&gt;The Setup That Looks Like It Should Just Work&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  await Post.create({ title: formData.get('title') });
  revalidatePath('/blog');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A new post gets created, &lt;code&gt;revalidatePath('/blog')&lt;/code&gt; runs, and yet the blog index sometimes still shows the old, stale list on the very next visit. Nothing throws an error. It just quietly doesn't update the way you'd expect, and the natural instinct is to assume the mutation itself failed, when the actual data was written correctly the whole time.&lt;/p&gt;
&lt;h3 id=&quot;what-s-actually-happening-1&quot;&gt;What's Actually Happening&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;revalidatePath&lt;/code&gt; takes an optional second argument, &lt;code&gt;'page'&lt;/code&gt; or &lt;code&gt;'layout'&lt;/code&gt;, and it changes what actually gets invalidated:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;revalidatePath('/blog');              // defaults to invalidating just this specific path
revalidatePath('/blog', 'page');      // same as above, explicit
revalidatePath('/blog', 'layout');    // invalidates this path AND all paths sharing its layout
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without the second argument, &lt;code&gt;revalidatePath('/blog')&lt;/code&gt; only invalidates that exact path. If your blog index is actually rendered through a layout that also affects nested routes, &lt;code&gt;/blog/[slug]&lt;/code&gt;, and you're expecting a change on the index to also refresh those nested pages, the default page-scoped call doesn't do that. You need the &lt;code&gt;'layout'&lt;/code&gt; variant explicitly if the invalidation is meant to cascade.&lt;/p&gt;
&lt;h3 id=&quot;an-even-more-common-gotcha-dynamic-segments&quot;&gt;An Even More Common Gotcha: Dynamic Segments&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// This does NOT invalidate /blog/my-first-post or any other specific slug
revalidatePath('/blog/[slug]');

// This DOES invalidate the specific rendered path
revalidatePath('/blog/my-first-post');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Passing the literal route pattern, &lt;code&gt;[slug]&lt;/code&gt; included as text, does not magically invalidate every dynamically rendered version of that route. &lt;code&gt;revalidatePath&lt;/code&gt; needs the actual, real path that was rendered, not the file-system pattern that generated it. If you're revalidating a specific post after an edit, you need the specific slug, not the dynamic segment's placeholder syntax.&lt;/p&gt;
&lt;h3 id=&quot;why-this-is-genuinely-confusing-to-debug&quot;&gt;Why This Is Genuinely Confusing to Debug&lt;/h3&gt;
&lt;p&gt;The failure mode here is not an error, it's stale data that sometimes seems to resolve itself anyway, since a full page reload, a different route triggering revalidation elsewhere, or the cache eventually expiring on its own can mask the actual bug intermittently. This makes it feel inconsistent and hard to reproduce reliably, when the actual rule underneath it is completely deterministic, it's just not the rule most people assume by default.&lt;/p&gt;
&lt;h3 id=&quot;when-to-reach-for-revalidatetag-instead&quot;&gt;When to Reach for revalidateTag Instead&lt;/h3&gt;
&lt;p&gt;For anything genuinely complex, several routes that share underlying data but don't share a clean path or layout relationship, &lt;code&gt;revalidateTag&lt;/code&gt; is usually the more precise, more maintainable tool, tagging the actual data dependency rather than trying to map out every path relationship by hand.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/queries/posts.ts
export const getPosts = unstable_cache(
  async () =&amp;gt; Post.find().sort({ createdAt: -1 }).lean(),
  ['posts-list'],
  { tags: ['posts'] }
);

export const getPost = unstable_cache(
  async (slug: string) =&amp;gt; Post.findOne({ slug }).lean(),
  ['post-detail'],
  { tags: ['posts'] }
);
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/posts.ts
'use server';
export async function createPost(formData: FormData) {
  await Post.create({ title: formData.get('title') });
  revalidateTag('posts'); // invalidates every cached query tagged 'posts', regardless of path
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sidesteps the entire path-matching question, since it invalidates based on what data was actually affected, not which specific URL path happens to render that data.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-checklist&quot;&gt;The Actual Checklist&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;If you need a change to cascade to nested routes under a shared layout, pass &lt;code&gt;'layout'&lt;/code&gt; explicitly, the default won't do this for you.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Never pass a route pattern with the literal &lt;code&gt;[param]&lt;/code&gt; syntax expecting it to match every rendered instance. Pass the actual, specific path that was rendered.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For data shared across multiple, not-cleanly-related paths, reach for &lt;code&gt;revalidateTag&lt;/code&gt; instead of trying to enumerate every affected path by hand with &lt;code&gt;revalidatePath&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If a page still seems stale after revalidation, check whether the actual issue is a missing &lt;code&gt;'layout'&lt;/code&gt; argument or a route pattern being passed literally, before assuming the underlying mutation itself failed.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you've hit stale data after a &lt;code&gt;revalidatePath&lt;/code&gt; call and eventually &quot;fixed&quot; it by adding a few more calls until it worked, genuinely curious whether it turned out to be one of these two issues, or something else entirely. Drop what actually fixed it in the comments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26247/revalidatepath-might-not-be-invalidating-what-you-think-it-is</guid>
<pubDate>Sat, 05 Sep 2026 07:06:12 +0000</pubDate>
</item>
<item>
<title>Next.js Automatically Dedupes fetch() Calls. Your MongoDB Queries Don't Get That for Free.</title>
<link>https://coderlegion.com/26198/next-js-automatically-dedupes-fetch-calls-your-mongodb-queries-dont-get-that-for-free</link>
<description>&lt;p&gt;Here's something that trips up almost everyone moving from a &lt;code&gt;fetch&lt;/code&gt;-based API to a direct MongoDB connection in Next.js, and it's costing real database load on projects that don't even realize it's happening.&lt;/p&gt;
&lt;p&gt;Next.js automatically deduplicates &lt;code&gt;fetch()&lt;/code&gt; calls with identical parameters within a single render pass. If your layout, your page, and three nested components all call &lt;code&gt;fetch('/api/user')&lt;/code&gt;, Next.js is smart enough to only actually make that request once and reuse the result everywhere else it's called. This is called request memoization, and it's one of the quieter, genuinely useful things the App Router does for you automatically.&lt;/p&gt;
&lt;p&gt;Direct database queries get none of this. Nothing.&lt;/p&gt;
&lt;h3 id=&quot;what-this-actually-looks-like&quot;&gt;What This Actually Looks Like&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/queries/user.ts
import { connectDB } from '@/lib/db';
import User from '@/models/User';

export async function getCurrentUser(userId: string) {
  await connectDB();
  return User.findById(userId).lean();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }) {
  const user = await getCurrentUser(userId); // query #1
  return &amp;lt;div&amp;gt;&amp;lt;Sidebar user={user} /&amp;gt;{children}&amp;lt;/div&amp;gt;;
}

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const user = await getCurrentUser(userId); // query #2, same user, same request
  return &amp;lt;Header user={user} /&amp;gt;;
}

// components/UserMenu.tsx (Server Component, rendered inside the page)
export default async function UserMenu() {
  const user = await getCurrentUser(userId); // query #3
  return &amp;lt;Menu user={user} /&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three separate calls to &lt;code&gt;getCurrentUser&lt;/code&gt;, same user ID, same single incoming request from the browser. If this were &lt;code&gt;fetch('/api/user')&lt;/code&gt; instead of a direct Mongoose query, Next.js would automatically collapse these into one actual network call. Since it's a direct database query, none of that automatic deduplication applies. This is three real round trips to MongoDB, for data that was identical all three times, on every single page load.&lt;/p&gt;
&lt;h3 id=&quot;why-this-doesn-t-show-up-in-development&quot;&gt;Why This Doesn't Show Up in Development&lt;/h3&gt;
&lt;p&gt;On a local database with almost no latency and no real load, three redundant queries feel instant, indistinguishable from one. This is exactly why it's so easy to ship without noticing. The cost only becomes visible under real production conditions, real network latency to your database, real concurrent traffic, where those extra round trips add up into measurably slower page loads and meaningfully higher database load than the page actually needed.&lt;/p&gt;
&lt;h3 id=&quot;the-actual-fix-react-s-cache-function&quot;&gt;The Actual Fix: React's cache() Function&lt;/h3&gt;
&lt;p&gt;React ships a &lt;code&gt;cache()&lt;/code&gt; function specifically for this. It memoizes a function's result within a single render pass, exactly the same behavior &lt;code&gt;fetch&lt;/code&gt; gets automatically, just applied manually to whatever function you wrap with it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/queries/user.ts
import { cache } from 'react';
import { connectDB } from '@/lib/db';
import User from '@/models/User';

export const getCurrentUser = cache(async (userId: string) =&amp;gt; {
  await connectDB();
  return User.findById(userId).lean();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the entire fix. Every call to &lt;code&gt;getCurrentUser&lt;/code&gt; with the same &lt;code&gt;userId&lt;/code&gt;, anywhere in that same render pass, layout, page, nested component, now returns the same cached promise instead of triggering a new query. Three calls become one actual database hit.&lt;/p&gt;
&lt;h3 id=&quot;the-part-people-get-wrong-about-cache-&quot;&gt;The Part People Get Wrong About cache()&lt;/h3&gt;
&lt;p&gt;This is request-scoped, not persistent across requests. It deduplicates calls within one single render of one single incoming request, then resets completely for the next request. It is not the same thing as &lt;code&gt;unstable_cache&lt;/code&gt;, which persists data across multiple requests and needs explicit revalidation.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// cache() - deduplicates within ONE request, resets every time
export const getCurrentUser = cache(async (userId: string) =&amp;gt; { ... });

// unstable_cache - persists ACROSS requests, needs revalidateTag to update
export const getPosts = unstable_cache(async () =&amp;gt; { ... }, ['posts'], {
  revalidate: 3600,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mixing these up causes two different problems. Using &lt;code&gt;cache()&lt;/code&gt; where you needed &lt;code&gt;unstable_cache&lt;/code&gt; means you're still hitting the database on every single request, just once instead of three times. Using &lt;code&gt;unstable_cache&lt;/code&gt; where you needed &lt;code&gt;cache()&lt;/code&gt; means you're persisting data across requests when you actually just wanted to avoid redundant calls within one page load, which can serve stale data longer than intended.&lt;/p&gt;
&lt;h3 id=&quot;where-i-d-actually-add-this&quot;&gt;Where I'd Actually Add This&lt;/h3&gt;
&lt;p&gt;Not everywhere, that would be over-engineering a solution for calls that only ever happen once per request anyway. The pattern worth watching for specifically: any query function, &lt;code&gt;getCurrentUser&lt;/code&gt;, &lt;code&gt;getTenant&lt;/code&gt;, &lt;code&gt;getSession&lt;/code&gt;-adjacent lookups, that reasonably gets called from multiple places in the same component tree, a layout, a page, and a few nested components all independently needing &quot;who is the current user.&quot;&lt;/p&gt;
&lt;h3 id=&quot;check-your-own-project&quot;&gt;Check Your Own Project&lt;/h3&gt;
&lt;p&gt;Grep your query functions for ones called from more than one component in the same tree, and check whether they're wrapped in &lt;code&gt;cache()&lt;/code&gt;. If they're not, and they're not already going through &lt;code&gt;unstable_cache&lt;/code&gt; or an equivalent persistent layer, you likely have the exact redundant-query pattern above running silently in production right now.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Curious how many people actually knew about this before reading this post versus assumed Next.js handled it automatically the same way it does for fetch. Drop your honest answer in the comments, genuinely curious how well-known this actually is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26198/next-js-automatically-dedupes-fetch-calls-your-mongodb-queries-dont-get-that-for-free</guid>
<pubDate>Fri, 04 Sep 2026 10:14:16 +0000</pubDate>
</item>
</channel>
</rss>