<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[KnowledgeMarkG Dev Notes]]></title><description><![CDATA[Real errors and real fixes from a freelance web developer: Next JS, React, ASP NET Core, Core Web Vitals, caching and technical SEO.]]></description><link>https://knowledgemarkg.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae8385f19be44d2c35fd547/6af77348-3a0f-4383-b7df-24ae4ee8424c.png</url><title>KnowledgeMarkG Dev Notes</title><link>https://knowledgemarkg.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 09:41:35 GMT</lastBuildDate><atom:link href="https://knowledgemarkg.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents]]></title><description><![CDATA[Run a Next.js standalone build on IIS behind Plesk on Windows: the web.config that works, the locked section that 500s every URL, and why IIS eats your…]]></description><link>https://knowledgemarkg.hashnode.dev/deploy-nextjs-on-iis-plesk-windows</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/deploy-nextjs-on-iis-plesk-windows</guid><category><![CDATA[Next.js]]></category><category><![CDATA[IIS]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:17:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/9b60cd53-bedf-48e2-a565-23cd41c5b839.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">knowledgemarkg.com</a>.</em></p>
<p>Search for how to deploy Next.js on IIS and you will find the same answer repeated: install Application Request Routing, run the app under PM2, and point an IIS reverse proxy rule at <code>localhost:3000</code>. That is good advice if you own the server. On Plesk shared Windows hosting you cannot install ARR, you cannot run PM2 as a service, and you cannot edit <code>applicationHost.config</code>. </p>
<p>This site runs on that stack. What follows is the configuration that actually works, plus the four failures that cost me real hours - each one silent, and each one looking like a different problem than it was. </p>
<h2>What you are actually working with</h2>
<p>Plesk's Windows Node.js support runs your app through <strong>iisnode</strong>, an IIS module that hosts a Node process inside IIS rather than beside it. There is no second port, no PM2, no reverse proxy. IIS owns the request, hands it to Node, and takes the response back. </p>
<p>That last part matters more than it sounds. It is where the 404 problem further down comes from. </p>
<h2>Build for standalone output</h2>
<p>Set the output mode in <code>next.config.ts</code>: </p>
<pre><code>const nextConfig = {
  output: "standalone",
};
export default nextConfig;
</code></pre>
<p>A standalone build produces a self-contained <code>server.js</code> plus a pruned <code>node_modules</code> holding only what the app actually imports. On shared hosting, where you are not running <code>npm install</code> on the server, that is the difference between a 25 MB upload and a 400 MB one. </p>
<p>The trap is that <code>next build</code> does <strong>not</strong> put everything you need into <code>.next/standalone</code>. Two directories are left out by design, and the app boots perfectly without them: </p>
<ul>
<li><code>.next/static</code> → copy to <code>.next/static</code> in the deploy folder </li>
<li><code>public</code> → copy to <code>public</code> in the deploy folder</li>
</ul>
<p>Miss either one and you get a 200 response, correct HTML, and a completely unstyled page with no JavaScript. Nothing errors, nothing logs. </p>
<h2>What the deploy folder has to look like</h2>
<p>Because you are assembling the folder by hand rather than letting a platform do it, it helps to know exactly what the finished thing contains. Mine is produced by a script, and the layout is: </p>
<pre><code>deploy-folder/
  server.js            &lt;- from .next/standalone
  app.js               &lt;- the iisnode shim, written by hand
  web.config           &lt;- written by hand
  package.json         &lt;- written by hand, not the project one
  node_modules/        &lt;- from .next/standalone, already pruned
  .next/
    BUILD_ID
    server/            &lt;- from .next/standalone
    static/            &lt;- copied separately, see above
  public/              &lt;- copied separately, see above
</code></pre>
<p>Two of those are worth a comment. The <code>package.json</code> at the root is not your project's - it is a minimal file telling the host what to start. And if you script this copy on Windows, force long-path support: <code>node_modules</code> nests well past the 260-character limit, and without the extended-length prefix the copy fails silently part-way through, leaving you with a folder that looks complete and boots to an error. </p>
<p>Finally, anything in <code>public/</code> that proves ownership of the domain - a Search Console verification file, an IndexNow key file - has to survive every deploy. Both fail silently and slowly if they go missing: Google quietly unverifies the property, and IndexNow rejects every submission. Add an assertion to your staging script so a missing verification file fails the build rather than the search engine. </p>
<h2>The web.config that works</h2>
<p>Keep it minimal. Every section you add is a section that might be locked at server level: </p>
<pre><code>&lt;?xml version="1.0" encoding="utf-8"?&gt;
&lt;configuration&gt;
  &lt;system.webServer&gt;
    &lt;handlers&gt;
      &lt;add name="iisnode" path="app.js" verb="*" modules="iisnode" /&gt;
    &lt;/handlers&gt;

    &lt;rewrite&gt;
      &lt;rules&gt;
        &lt;rule name="DynamicContent"&gt;
          &lt;match url="/*" /&gt;
          &lt;action type="Rewrite" url="app.js" /&gt;
        &lt;/rule&gt;
      &lt;/rules&gt;
    &lt;/rewrite&gt;

    &lt;httpErrors existingResponse="PassThrough" /&gt;

    &lt;iisnode loggingEnabled="false" devErrorsEnabled="false" /&gt;
  &lt;/system.webServer&gt;
&lt;/configuration&gt;
</code></pre>
<p>The handler path has to match a real file, so <code>app.js</code> is a two-line shim that requires the standalone server: </p>
<pre><code>process.env.PORT = process.env.PORT || 3000;
require("./server.js");
</code></pre>
<h2>Trap 1: one locked section takes the whole site down</h2>
<p>Shared hosting locks several <code>system.webServer</code> sections at server level - <code>&lt;security&gt;&lt;requestFiltering&gt;</code> is the usual culprit. Writing a locked section into your site's <code>web.config</code> does not produce a message about that section. IIS fails to parse <strong>the entire file</strong>, and every URL returns an empty HTTP 500, including static files that never reach Node. </p>
<p>It reads exactly like an application crash. It is a configuration parse error. The distinguishing signal is that <code>/favicon.ico</code> fails too - if Node were the problem, static files would still serve. </p>
<p>Keep a known-good copy of <code>web.config</code> beside the live one so the rollback is a file copy rather than a debugging session. </p>
<p>One correction to the folklore: <code>&lt;httpErrors&gt;</code> is widely <em>assumed</em> to be locked and frequently is not. Plesk's own "Custom error documents" feature writes that exact section into site <code>web.config</code> files, which it could not do if the section were locked. Test it rather than avoiding it - and test by loading your <strong>homepage</strong>, not a 404, because a locked section breaks everything, not just error pages. </p>
<h2>Trap 2: IIS throws away your custom 404 page</h2>
<p>This one is invisible unless you go looking. Your <code>app/not-found.tsx</code> renders correctly, the status code is a correct 404, and visitors still get the grey IIS page reading <em>404 - File or directory not found.</em> </p>
<p>Here is how to prove where it happens. Request a missing URL and read the headers: </p>
<pre><code>curl -s -D - -o /dev/null https://example.com/no-such-page
</code></pre>
<p>If the response carries <code>x-nextjs-cache</code> and <code>x-nextjs-prerender</code> headers but the body is about 1 KB of Verdana-styled HTML, then Next rendered your page and IIS replaced the body afterwards. The headers survive the swap; the body does not. That single detail is what tells you to stop debugging your Next.js route and go and look at IIS. </p>
<p>The cause is IIS's <code>existingResponse</code> behaviour, which defaults to replacing the body of any response with an error status. One line fixes it: </p>
<pre><code>&lt;httpErrors existingResponse="PassThrough" /&gt;
</code></pre>
<p>Two things worth knowing. First, this is presentation, not SEO: the status code was a correct 404 the whole time, so search engines were never misled and nothing was indexed that should not have been. Second, once IIS stops replacing error bodies it also stops masking <em>500</em> bodies - which is exactly why <code>devErrorsEnabled</code> is <code>false</code> in the config above. </p>
<h2>Trap 3: environment variables are not what you think</h2>
<p>Two rules catch people on every Next.js host, and they bite harder on shared hosting where the control panel offers you an environment-variable editor that looks authoritative. </p>
<p><strong><code>NEXT_PUBLIC_*</code> is inlined at build time.</strong> Anything with that prefix is compiled into the JavaScript bundle during <code>next build</code>. Setting it in Plesk's Node.js panel afterwards does nothing whatsoever, because the string is already baked in. If it has to change, you rebuild. </p>
<p><strong><code>.env.local</code> loads in production builds too, and it wins.</strong> It is not a development-only file, and it overrides <code>.env.production</code>. I wrote about <a href="https://knowledgemarkg.com/blog/nextjs-env-local-overrides-env-production">how .env.local silently overrides .env.production</a> separately, because it once baked a localhost API URL into a live build. The safest habit is to name the file <code>.env.development.local</code>, which cannot load in a production build by accident. </p>
<h2>Trap 4: deploy order, because Windows locks files</h2>
<p>Windows holds a lock on files the running process has open. Extract a new build over a running app and the extract silently skips the locked files - while still reporting success. You end up with a half-updated application and no error anywhere. </p>
<p>The order that works: </p>
<ol>
<li>Put an <code>app_offline.htm</code> in the application root. IIS stops the app and serves that file for every request. </li>
<li>Delete the old files. </li>
<li>Extract the new build. </li>
<li>Remove <code>app_offline.htm</code>.</li>
</ol>
<p>Before uploading, confirm the archive actually contains the change you made. A stale build zips just as happily as a fresh one: </p>
<pre><code>grep -rl "a string unique to this change" staging/.next/static/chunks | head
</code></pre>
<h2>What you do and do not have to redeploy for</h2>
<p>Because every deploy here is a manual zip-and-extract, it is worth being clear about which changes actually need one. On this setup, with incremental regeneration doing the work: </p>
<ul>
<li><strong>No redeploy needed</strong> - new or edited content coming from your API or database, and anything a route handler builds from it. If your sitemap route has <code>revalidate = 3600</code>, a new page appears in it within the hour on its own. </li>
<li><strong>Redeploy needed</strong> - anything compiled: components, metadata exported from a page, <code>next.config</code> redirects, and every <code>NEXT_PUBLIC_*</code> value.</li>
</ul>
<p>One consequence worth internalising: a stale cached page can make you think a code fix did not work. After a deploy I checked a page, found the new metadata missing, and nearly started debugging - the response header said <code>x-nextjs-cache: STALE</code>, meaning the old body was being served while the page regenerated behind it. The next request was correct. Check that header before you conclude anything about a deploy. </p>
<p>The other half of that rule is the one people get wrong in the opposite direction: a route handler can only output a field the API already returns. Shipping the frontend first and the backend later gives you a sitemap that silently omits everything. </p>
<h2>Debugging a 500 you cannot see</h2>
<p>If the app will not boot you get a 500 with an empty body and nothing to read. Turn iisnode's diagnostics on temporarily: </p>
<pre><code>&lt;iisnode loggingEnabled="true" devErrorsEnabled="true" /&gt;
</code></pre>
<p>That writes stdout and stderr into an <code>iisnode</code> folder beside the app and puts the error detail in the response body. Read it, fix the boot failure, then <strong>turn both back off</strong> - especially with <code>existingResponse="PassThrough"</code> enabled, because together they will show stack traces to the public. </p>
<p>One more: if Plesk's Node.js panel offers to "Auto-configure" the application, decline. It generates its own <code>web.config</code> and overwrites yours, including the handler path and the rewrite rule the standalone build depends on. </p>
<h2>Is this the right place to host Next.js at all?</h2>
<p>Usually not, if you have a free choice. A small Linux VPS running the standalone server behind nginx is simpler in every way and the tooling assumes it. The reason to do this is that something else already lives on the Windows box - an ASP.NET Core API, a SQL Server database, or a client who buys Windows hosting and is not going to move. </p>
<p>That is the situation this site is in, and it works fine. It is only that nothing warns you about the four failures above, and every one of them presents as something other than what it is. If you are still weighing platforms, I compared <a href="https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core">Windows VPS against Linux VPS for ASP.NET Core</a> and most of that reasoning carries over. If you are newer to the framework itself, the <a href="https://knowledgemarkg.com/blog/nextjs-15-tutorial-complete-guide-from-beginner-to-advanced-2025">complete Next.js tutorial</a> is a better starting point than the host.</p>
<h2>FAQ</h2>
<p><strong>Can you run Next.js on IIS without a reverse proxy?</strong></p>
<p>Yes. iisnode hosts the Node process inside IIS, so there is no second port and no reverse proxy rule. This is what Plesk's Windows Node.js support uses, and it is the only option on shared hosting where you cannot install Application Request Routing.</p>
<p><strong>Why is my Next.js site loading with no CSS after deploying to IIS?</strong></p>
<p>A standalone build does not include .next/static or public. Both have to be copied into the deploy folder yourself. The app boots and returns correct HTML without them, so nothing errors - you just get an unstyled page.</p>
<p><strong>Why does IIS show its own 404 page instead of my Next.js not-found page?</strong></p>
<p>IIS replaces the body of any error-status response by default. Adding httpErrors existingResponse="PassThrough" to web.config fixes it. You can confirm that is the cause by checking whether the 404 response still carries x-nextjs-cache headers: if it does, Next rendered the page and IIS swapped the body out afterwards.</p>
<p><strong>Why does every URL return an empty 500 after I edited web.config?</strong></p>
<p>A system.webServer section locked at server level makes IIS fail to parse the whole file, not just that section. Static files fail too, which is how you tell it apart from an application crash. Remove the section you added and the site comes straight back.</p>
<p><strong>Why is my NEXT_PUBLIC environment variable empty in production?</strong></p>
<p>NEXT_PUBLIC_ values are inlined into the JavaScript bundle during next build. Setting them on the server or in Plesk's Node.js panel afterwards has no effect, because the value is already compiled in. Change it and rebuild.</p>
<p><strong>Why did my deploy only partly update the site?</strong></p>
<p>Windows locks files the running process has open, and extracting over a running app silently skips them while still reporting success. Drop an app_offline.htm in first, delete the old files, extract, then remove it.</p>
<p><strong>Is Plesk on Windows a reasonable place to host Next.js?</strong></p>
<p>It works, but it is rarely the easiest choice. It makes sense when something else already requires the Windows box, such as an ASP.NET Core API or SQL Server. If the stack is yours to pick, a small Linux VPS running the standalone server is simpler and far better documented.</p>
<h2>Related on knowledgemarkg.com</h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors">Deploying ASP.NET Core to Plesk on Windows: Every Error I Hit</a></li>
<li><a href="https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html">ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML</a></li>
<li><a href="https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache">Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header First</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Deploying ASP.NET Core to Plesk on Windows: Every Error I Hit and What It Actually Meant]]></title><description><![CDATA[The ASP.NET Core errors shared Windows hosting actually throws: 500.31, 500.30, 500.19, app_offline.htm that does nothing, and EF migration history drift.]]></description><link>https://knowledgemarkg.hashnode.dev/deploy-aspnet-core-plesk-500-errors</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/deploy-aspnet-core-plesk-500-errors</guid><category><![CDATA[Aspnetcore]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[IIS]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:15:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/badb166a-e0ea-42f1-aaaf-5dc93997f5e3.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors">knowledgemarkg.com</a>.</em></p>
<p>Every guide to deploying ASP.NET Core on Plesk says the same four things: install the hosting component, run <code>dotnet publish</code>, upload the folder, set the application pool. That is all correct, and I have never had a deploy where those four steps were the hard part. </p>
<p>The hard part is everything that happens after, where the server answers with a three-digit sub-code and no explanation. This is that list, in the order you actually meet it. This site's API runs on Plesk shared Windows hosting, so all of it is first-hand. </p>
<h2>Publish the right thing first</h2>
<pre><code>dotnet publish src/YourApi/YourApi.csproj -c Release -o ./publish
</code></pre>
<p>Upload the <em>contents</em> of <code>publish</code>, not the folder itself. Framework-dependent is fine and much smaller, as long as the server has the matching runtime — which is the first thing that goes wrong. </p>
<h2>500.31 — the runtime is not there</h2>
<p>Full text: <em>Failed to load ASP.NET Core runtime</em>. </p>
<p>The server does not have the <strong>ASP.NET Core Hosting Bundle</strong> for your target framework, or has an older one. Note it is the <em>Hosting Bundle</em> specifically, not the SDK and not the plain runtime: the bundle is what installs the ASP.NET Core Module into IIS. </p>
<p>On shared hosting you cannot install it yourself. Two options: ask support which versions are installed and target one of those, or publish self-contained so the runtime travels with the app. Self-contained turns a 6 MB upload into about 70 MB, which is a fair trade for not filing a support ticket every time .NET ships a major version. </p>
<h2>500.30 — the app started and then died</h2>
<p>Full text: <em>ASP.NET Core app failed to start</em>. The module launched your process and the process exited. Three causes cover almost all of it. </p>
<p><strong>The application pool is wrong.</strong> It must be set to <strong>No Managed Code</strong>. ASP.NET Core does not run on the .NET CLR that the pool loads, and a pool set to "v4.0" will fail in a way that looks like your code crashed. </p>
<p><strong>Configuration is missing at runtime.</strong> Anything the app reads during startup — a connection string, an API key — that is present on your machine and absent on the server will throw before the first request is served. </p>
<p><strong>Something in <code>Program.cs</code> throws.</strong> Which you cannot see, until you turn the log on: </p>
<pre><code>&lt;aspNetCore processPath="dotnet" arguments=".\YourApi.dll"
            stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout"
            hostingModel="inprocess" /&gt;
</code></pre>
<p>Create the <code>logs</code> folder yourself — the module will not create it, and if it cannot write there you get the same blank 500.30 with no log. Read the stack trace, fix it, then set <code>stdoutLogEnabled</code> back to <code>false</code>. It is not a logger; it grows forever. </p>
<h2>500.19 — IIS could not read your web.config</h2>
<p>This is the one that fools people, because it has nothing to do with your application. IIS failed to <em>parse the configuration file</em>, so the app never started at all. </p>
<p>On shared hosting the usual cause is a <strong>locked section</strong>. Hosts lock parts of <code>system.webServer</code> at server level, and writing a locked section into your site's <code>web.config</code> makes IIS reject <strong>the entire file</strong> — not just that section. </p>
<p>The giveaway: static files fail too. If a plain text file in the same folder also returns 500, the problem is configuration, not code. </p>
<p>Keep the file to the minimum ASP.NET Core actually needs: </p>
<pre><code>&lt;?xml version="1.0" encoding="utf-8"?&gt;
&lt;configuration&gt;
  &lt;location path="." inheritInChildApplications="false"&gt;
    &lt;system.webServer&gt;
      &lt;handlers&gt;
        &lt;add name="aspNetCore" path="*" verb="*"
             modules="AspNetCoreModuleV2" resourceType="Unspecified" /&gt;
      &lt;/handlers&gt;
      &lt;aspNetCore processPath="dotnet" arguments=".\YourApi.dll"
                  stdoutLogEnabled="false" hostingModel="inprocess" /&gt;
    &lt;/system.webServer&gt;
  &lt;/location&gt;
&lt;/configuration&gt;
</code></pre>
<p>One piece of folklore worth correcting: not every section people assume is locked actually is. On this host <code>&lt;httpErrors&gt;</code> turned out to be allowed, and I had avoided it for months on the strength of a warning I had written myself. Test a section before you rule it out — but test by loading a page you know works, because a locked section breaks everything at once. </p>
<h2>app_offline.htm "not working", and the deploy order that fixes it</h2>
<p>Drop a file called <code>app_offline.htm</code> in the application root and the ASP.NET Core Module shuts the app down and serves that file to every request. Remove it and the app restarts. It is the cleanest maintenance switch IIS has. </p>
<p>When people say it does not work, it is almost always one of these: </p>
<ul>
<li><strong>Wrong name or place.</strong> It must be exactly <code>app_offline.htm</code> — not <code>.html</code>, not <code>App_Offline.htm</code> in a subfolder — and it must sit next to <code>web.config</code> in the application root, not the site root. </li>
<li><strong>Windows hid the extension from you.</strong> You created <code>app_offline.htm.txt</code> and Explorer is showing you <code>app_offline.htm</code>. </li>
<li><strong>In-flight requests.</strong> Only new requests get the offline page; existing ones finish normally.</li>
</ul>
<p>The reason it matters on Windows is the thing it protects you from. <strong>Windows locks files that a running process has open.</strong> Extract a new build over a running app and the extract skips the locked DLLs — and Plesk's extract still reports success. You get a half-updated application, a version mismatch, and no error anywhere. </p>
<p>So the order is not optional: </p>
<ol>
<li>Upload <code>app_offline.htm</code> to the application root. </li>
<li>Delete the old files. </li>
<li>Extract the new build. </li>
<li>Delete <code>app_offline.htm</code>.</li>
</ol>
<h2>appsettings.Production.json does not replace appsettings.json</h2>
<p>It layers on top of it. Keys present in both are overridden by the environment file; keys that exist <strong>only</strong> in the base file still apply. </p>
<p>That cuts both ways and both have bitten me. A key you thought you had overridden is still being read from the base file because you spelled the section differently. And a key you assumed was dead because it is not in the production file is very much alive. </p>
<p>If a setting is not behaving, log the resolved value at startup once rather than reasoning about which file won: </p>
<pre><code>app.Logger.LogInformation("Using provider {Provider}",
    builder.Configuration["Email:Provider"] ?? "(null)");
</code></pre>
<p>And keep <code>appsettings.Production.json</code> out of source control — it holds the real connection string. Copy it forward from the previous deploy rather than regenerating it, or you will overwrite live credentials with <code>Server=localhost;Database=dev;User Id=sa;Password=changeme;</code> and spend an hour on a 500.30 that is really a login failure. </p>
<h2>__EFMigrationsHistory drift</h2>
<p>EF Core records applied migrations in a table called <code>__EFMigrationsHistory</code>, keyed on the migration's ID — the timestamp in its filename. That ID is the only thing EF compares. </p>
<p>Here is how it goes wrong. A migration is applied to production. Later, someone regenerates that migration — a rename, a rebase, a merge that recreated the file — and it comes back with a <strong>new timestamp</strong>. EF now sees an ID it has no record of, concludes the migration has never run, and tries to apply it again. On a table that already exists, that fails. </p>
<p>The fix is to tell the history table what it already knows, idempotently, so re-running it is harmless: </p>
<pre><code>IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory
               WHERE MigrationId = N'20260927134609_AddSomeColumn')
BEGIN
    INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion)
    VALUES (N'20260927134609_AddSomeColumn', N'9.0.0');
END
</code></pre>
<p>Do not delete rows from this table to "force a rerun" unless you have checked that the migration is genuinely reversible against live data. It is a ledger, not a cache. </p>
<h2>Give yourself a way to tell which build is live</h2>
<p>Because a Windows deploy can half-succeed, "did my change actually ship?" is a question you will ask often. Guessing from behaviour is slow. A tiny unauthenticated endpoint answers it in a second: </p>
<pre><code>app.MapGet("/api/v1/version", () =&gt; new
{
    version = typeof(Program).Assembly.GetName().Version?.ToString(),
    built = System.IO.File.GetLastWriteTimeUtc(
        typeof(Program).Assembly.Location).ToString("u"),
});
</code></pre>
<p>One warning from my own code, which I am in the middle of fixing. When I first added this I also returned the assembly's full path, because it was useful while debugging a deploy that had half-extracted. That ships the absolute directory layout of the server to anyone who asks, and it stayed in production far longer than the debugging session did. </p>
<p>Return a version and a timestamp. Do not return paths, environment variables, connection state, or the machine name. If you need those while chasing a problem, add them for the hour and take them out again — a diagnostic endpoint is the easiest thing in a codebase to forget about, because it never breaks and nothing tests it. </p>
<h2>One last thing: the database is probably not local</h2>
<p>On shared hosting your SQL Server usually lives on a different box, sometimes a different datacentre. Every query crosses a network, and the first one after an idle period pays a cold start. Local development against that database feels broken when it is merely remote. </p>
<p>This is worth knowing before you spend an afternoon profiling EF Core over a 40 ms round trip. Measure against the server, not your laptop, and judge a query by its round-trip count before its execution plan. </p>
<h2>Is Plesk the right place for this?</h2>
<p>If the API is the only thing you are hosting, a small Linux VPS running Kestrel behind nginx is simpler and better documented. Plesk on Windows earns its place when something else already requires that box — a SQL Server licence, a legacy ASP.NET app, or a client who buys Windows hosting and will not move. </p>
<p>That is the situation here, and it is stable once the five problems above are behind you. If you are running the front end on the same stack, I wrote up <a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">the matching set of traps for Next.js on IIS and Plesk</a> — different runtime, same hosting model, and a couple of the failures are identical. And if you are still choosing, this is the <a href="https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core">Windows VPS versus Linux VPS comparison</a> I would read first.</p>
<h2>FAQ</h2>
<p><strong>What does ASP.NET Core error 500.31 mean?</strong></p>
<p>The ASP.NET Core Hosting Bundle for your target framework is not installed on the server, or is an older version. It must be the Hosting Bundle, not the SDK or the plain runtime, because the bundle is what installs the ASP.NET Core Module into IIS. On shared hosting, either target a version the host already has or publish self-contained.</p>
<p><strong>How do I fix ASP.NET Core 500.30, app failed to start?</strong></p>
<p>The process launched and exited. Check that the application pool is set to No Managed Code, that every setting the app reads at startup exists on the server, and then turn on stdoutLogEnabled in web.config to read the actual exception. Create the logs folder yourself - the module will not, and without it you get the same blank error with no log.</p>
<p><strong>Why do I get HTTP Error 500.19 on IIS?</strong></p>
<p>IIS could not parse web.config, so your app never started. On shared hosting the usual cause is a system.webServer section the host locks at server level; writing one makes IIS reject the whole file rather than that section. The giveaway is that static files in the same folder fail too.</p>
<p><strong>Why is app_offline.htm not working?</strong></p>
<p>Usually the name or the location. It must be exactly app_offline.htm, in the application root next to web.config, not the site root - and check Windows is not hiding a .txt extension from you. Requests already in flight also finish normally; only new ones get the offline page.</p>
<p><strong>Does appsettings.Production.json replace appsettings.json?</strong></p>
<p>No, it layers on top. Keys in both are overridden by the environment file, but keys that exist only in the base file still apply. If a setting is not behaving, log the resolved value at startup rather than reasoning about which file won.</p>
<p><strong>Why is EF Core trying to apply a migration that already ran?</strong></p>
<p>EF compares the migration ID, which is the timestamp in the filename. If the migration file was regenerated after it was applied, it comes back with a new timestamp, so EF sees an ID it has no record of and runs it again. Insert the new ID into __EFMigrationsHistory idempotently rather than deleting rows.</p>
<p><strong>Can you deploy ASP.NET Core to Plesk shared hosting at all?</strong></p>
<p>Yes, and it is stable once configured. You cannot install the Hosting Bundle or edit applicationHost.config yourself, so you work within what the host allows. It makes most sense when something else already requires the Windows box, such as a SQL Server licence or a legacy ASP.NET application.</p>
<h2>Related on knowledgemarkg.com</h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/error-unprotecting-session-cookie-aspnet-core">ASP.NET Core 'Error Unprotecting the Session Cookie': Why Your Keys Disappear</a></li>
<li><a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database">There Is Already an Object Named 'X' in the Database: What EF Core Is Really Telling You</a></li>
<li><a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header Before You Debug Anything]]></title><description><![CDATA[One response header tells you whether your Next.js deploy failed or the page is simply stale. Here is what HIT, MISS and STALE mean, and the trap they set.]]></description><link>https://knowledgemarkg.hashnode.dev/nextjs-page-not-updating-x-nextjs-cache</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/nextjs-page-not-updating-x-nextjs-cache</guid><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[caching]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:13:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/9a35f34f-808c-4ba2-a00e-5be8ff2f4037.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache">knowledgemarkg.com</a>.</em></p>
<p>You shipped a change. The database has it, the build succeeded, the deploy finished. You load the page and it shows the old content. </p>
<p>At this point most people start debugging the thing they just changed. Nine times out of ten on a Next.js site, nothing is broken. Before you touch anything, send one request and read one header: </p>
<pre><code>curl -s -D - -o /dev/null https://example.com/the-page | grep -i x-nextjs-cache
</code></pre>
<p>That header separates "my deploy failed" from "the page is simply stale", and those are completely different problems. I have confused them three times in two days on this site, and twice I reached a wrong conclusion and had to walk it back. </p>
<h2>What the values mean</h2>
<p><code>x-nextjs-cache</code> reports how the response you just received was produced: </p>
<ul>
<li><strong><code>HIT</code></strong> — served from the cache, and the cached entry is still considered fresh. What you see is what the cache holds. </li>
<li><strong><code>MISS</code></strong> — nothing in the cache for this path, so it was rendered now. The content is current. </li>
<li><strong><code>STALE</code></strong> — <strong>this is the one that fools you.</strong> The cached entry has passed its revalidation window, so the server handed you the <em>old</em> body and kicked off a regeneration in the background. Your next request will almost certainly be fresh.</li>
</ul>
<p>Read that third one again, because it is the whole article. A <code>STALE</code> response is not a failure. It is the cache doing exactly what it was configured to do: never make a visitor wait for a rebuild. The cost is that the first person to ask for an expired page gets yesterday's copy. </p>
<h2>The trap, with the actual numbers</h2>
<p>I had just fixed a metadata bug and deployed it. To verify, I crawled every URL in the sitemap and checked whether the new tag was present. Result: 175 of 177 pages correct, two pages missing the tag. </p>
<p>Two pages. That looks exactly like a real bug — some code path the fix missed. I was already forming a theory about which one. </p>
<p>Then I requested one of those two pages again and read the headers: </p>
<pre><code>x-nextjs-cache: STALE
</code></pre>
<p>The page had not missed the fix. It was serving a copy generated <em>before</em> the deploy, while regenerating behind the scenes. The very next request returned the correct tag. There was no bug. There had never been a bug. </p>
<p>It gets worse, in an interesting way. Earlier the same day I ran two tools over the site back to back — a crawler, then a content scanner. The crawler reported dead links that the scanner, running two minutes later, could not find. Both were correct. <strong>The crawler's own requests were what expired the cache and triggered regeneration</strong>, so by the time the second tool ran it was reading freshly generated pages. The first tool measured the old world; the second measured the world the first tool had created. </p>
<p>If you take one habit from this: <strong>when a verification result surprises you, request the page a second time before you believe it.</strong> </p>
<h2>What actually controls this</h2>
<p>For a route segment, the revalidation window is an export: </p>
<pre><code>// app/blog/[slug]/page.tsx
export const revalidate = 3600;   // seconds
</code></pre>
<p>That means: serve from cache for an hour; after the hour, the next request gets the stale copy and triggers a rebuild. It does <strong>not</strong> mean "rebuild every hour" — nothing regenerates if nobody asks. A page nobody visits stays stale indefinitely, which is why a low-traffic site can show week-old content and a busy one feels instant. </p>
<p>Two values are worth knowing: </p>
<ul>
<li><code>export const revalidate = 0</code> — never serve from the cache; render per request. </li>
<li><code>export const dynamic = "force-dynamic"</code> — the same effect, stated as intent.</li>
</ul>
<p>A sitemap route handler with <code>revalidate = 3600</code> is a good illustration. Publish a post and it will not appear in <code>sitemap.xml</code> immediately. It appears within the hour, with no deploy and no manual step. I watched this happen yesterday: the post went live, the sitemap still listed 61 URLs, and an hour later it listed 62. Nothing was broken in between. </p>
<h2>When a timer is the wrong tool</h2>
<p>Time-based revalidation assumes the content changes on its own schedule. It is the wrong tool when <em>you</em> change something and need it live now — particularly when the change was made outside the application. </p>
<p>That is the case people hit most often and understand least. If you update rows with a SQL script, or edit content in a CMS that your app does not know about, the app has no idea anything happened. No amount of redeploying helps, because the deploy is not what is stale — the cache entry is. </p>
<p>For that, invalidate explicitly: </p>
<pre><code>import { revalidatePath, revalidateTag } from "next/cache";

// after a write that the app performed
revalidatePath("/blog/the-slug");
revalidateTag("posts");
</code></pre>
<p>Two things catch people here. <code>revalidatePath</code> only runs on the server — in a route handler or a server action — so calling it from a client component does nothing. And it marks the entry stale rather than rebuilding immediately, so the very next request may still return the old body with <code>x-nextjs-cache: STALE</code>. That is not a failure either. </p>
<h2>What a deploy does and does not clear</h2>
<p>This depends on your host more than on Next.js, which is why the answers you find online contradict each other. </p>
<p>On a platform that builds fresh and discards the old filesystem, a deploy effectively clears everything. On a self-hosted setup where you extract a build over the previous one, whether the on-disk cache survives depends on whether you deleted it. If your deploy copies files over the top without removing <code>.next/cache</code>, entries can outlive the deploy that was supposed to replace them. </p>
<p>Worth separating from a related failure that looks identical: on Windows, a running process holds locks on files, so an extract over a live app silently skips them and reports success anyway. That produces a genuinely half-updated app, not a stale cache — and the header will say <code>HIT</code> or <code>MISS</code> rather than <code>STALE</code>. I covered that one, and the deploy order that avoids it, in <a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">the Next.js on IIS and Plesk write-up</a>. </p>
<h2>Three caches, and only one of them is this header</h2>
<p>A lot of the contradictory advice online comes from people solving a different layer and assuming it was the same one. There are usually three between your render and the visitor's screen: </p>
<ul>
<li><strong>The Next.js data and route cache</strong> — on your server, reported by <code>x-nextjs-cache</code>. This is the one <code>revalidate</code> and <code>revalidatePath</code> control. </li>
<li><strong>A CDN or reverse proxy</strong> in front of it, obeying <code>cache-control</code>. It can keep serving an old body long after Next.js has regenerated, and <code>x-nextjs-cache</code> will never tell you, because the response may not have reached Next.js at all. </li>
<li><strong>The browser.</strong> Cheap to rule out, so rule it out first.</li>
</ul>
<p>Two more headers help you tell them apart. <code>age</code> is how many seconds an intermediate cache has been holding the response — a large and growing <code>age</code> on a page you keep requesting means something in front of your server is answering. And <code>x-nextjs-prerender</code> tells you the page was prerendered rather than rendered per request, which is useful when you expected a route to be dynamic and it is not. </p>
<p>The practical ordering: hard-refresh once to eliminate the browser, then compare <code>curl</code> against what the browser shows. If curl is correct and the browser is not, it is local. If both are wrong but <code>x-nextjs-cache</code> says <code>MISS</code>, you are probably looking at something in front of Next.js, not Next.js itself. </p>
<h2>Reading it without curl</h2>
<p>In Chrome or Firefox DevTools, open the Network tab, tick <em>Disable cache</em>, reload, click the document request — the very first one, not an asset — and read Response Headers. The value is there on every request Next.js serves, so you can watch it flip from <code>STALE</code> to <code>HIT</code> on a second reload without leaving the browser. </p>
<p>One caveat that wastes time: if you are looking at a client-side navigation rather than a full page load, there is no document request to inspect. Reload the URL directly. </p>
<h2>A checklist for "is my change actually live?"</h2>
<ol>
<li><strong>Request the page twice.</strong> If the second response differs from the first, you were looking at a stale entry, not a bug. </li>
<li><strong>Read <code>x-nextjs-cache</code>.</strong> <code>STALE</code> means wait and retry; <code>HIT</code> or <code>MISS</code> with wrong content means something real is wrong. </li>
<li><strong>Check the content at the source.</strong> Hit your API or query the database directly. If the source is wrong, the cache is innocent. </li>
<li><strong>Decide which layer is stale.</strong> Content changed outside the app needs <code>revalidatePath</code>, not a redeploy. Code changes need a deploy, and a timer will never fix them. </li>
<li><strong>Only then start debugging.</strong></li>
</ol>
<p>Compiled things — components, exported metadata, redirects in <code>next.config</code>, anything with a <code>NEXT_PUBLIC_</code> prefix — always need a rebuild, because they are baked in at build time. That last one catches people constantly; I wrote about a related environment-variable trap in <a href="https://knowledgemarkg.com/blog/nextjs-env-local-overrides-env-production">how .env.local silently overrides .env.production</a>. If you are newer to the framework, the <a href="https://knowledgemarkg.com/blog/nextjs-15-tutorial-complete-guide-from-beginner-to-advanced-2025">complete Next.js tutorial</a> covers the rendering model this builds on. </p>
<h2>The short version</h2>
<p>A page showing old content is usually not a broken deploy. It is a cache entry doing its job, and one header tells you which. Checking it costs one request. Not checking it cost me two wrong conclusions in two days, and both times I was already three steps into debugging code that was working perfectly.</p>
<h2>FAQ</h2>
<p><strong>What does x-nextjs-cache: STALE mean?</strong></p>
<p>The cached copy has passed its revalidation window, so Next.js served you the old body and started regenerating the page in the background. It is not an error. Request the page again and you will almost certainly get the fresh version.</p>
<p><strong>What is the difference between HIT, MISS and STALE?</strong></p>
<p>HIT means it came from the cache and is still considered fresh. MISS means there was nothing cached so it was rendered now, which makes it current. STALE means the entry expired, you received the old body, and a rebuild was triggered behind your request.</p>
<p><strong>Why is my Next.js page not updating after deploy?</strong></p>
<p>Request it twice and read x-nextjs-cache. If the first response is STALE and the second is correct, nothing was wrong. If it says HIT or MISS and the content is still wrong, then check the source data and whether the deploy actually replaced the files.</p>
<p><strong>Does export const revalidate = 3600 rebuild the page every hour?</strong></p>
<p>No. It means the cached copy is considered fresh for an hour. Nothing regenerates unless someone requests the page after that window, so a page nobody visits can stay stale indefinitely.</p>
<p><strong>Why is revalidatePath not working?</strong></p>
<p>Two usual reasons. It only runs server-side, so calling it from a client component does nothing. And it marks the entry stale rather than rebuilding immediately, so the next request can still return the old body with a STALE header before the fresh one appears.</p>
<p><strong>I changed data directly in the database and the page still shows the old content.</strong></p>
<p>The application never saw the change, so nothing invalidated the cache, and redeploying will not help because the deploy is not what is stale. Either call revalidatePath for the affected routes, or wait out the revalidate window on that route.</p>
<p><strong>Does deploying clear the Next.js cache?</strong></p>
<p>It depends on the host. A platform that builds fresh and discards the old filesystem effectively clears it. On a self-hosted setup where you extract a build over the previous one, cache entries can survive unless you delete .next/cache as part of the deploy.</p>
<h2>Related on knowledgemarkg.com</h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html">ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML</a></li>
<li><a href="https://knowledgemarkg.com/blog/nextjs-env-local-overrides-env-production">Next.js Loads .env.local During Production Builds</a></li>
<li><a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[ASP.NET Core 'Error Unprotecting the Session Cookie': Why Your Keys Disappear on Deploy]]></title><description><![CDATA[Fix 'Error unprotecting the session cookie' and 'key not found in the key ring' in ASP.NET Core on IIS. Persist Data Protection keys so logins survive…]]></description><link>https://knowledgemarkg.hashnode.dev/error-unprotecting-session-cookie-aspnet-core</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/error-unprotecting-session-cookie-aspnet-core</guid><category><![CDATA[Aspnetcore]]></category><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[IIS]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:11:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/2be788f6-fa57-484d-b179-103657ae95c5.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on <a href="https://knowledgemarkg.com/blog/error-unprotecting-session-cookie-aspnet-core">knowledgemarkg.com</a>.</em></p>
<p>You deploy an ASP.NET Core app, or IIS recycles its app pool overnight, and the log fills up with this: </p>
<pre><code class="language-plaintext">warn: Microsoft.AspNetCore.Session.SessionMiddleware[7]
      Error unprotecting the session cookie.
      System.Security.Cryptography.CryptographicException: The key {83f2e736-3392-4263-9f0f-ad943c51fe3e} was not found in the key ring.
</code></pre>
<p>At the same time, users report being logged out, and forms fail with "The antiforgery token could not be decrypted". The session warning is just the visible part. The cause is that the app has <strong>lost the keys it used to encrypt its cookies</strong>. Every cookie issued before that moment is now unreadable. </p>
<p>This happened on the API behind this site. It runs on IIS under Plesk, where the app pool has no loaded user profile, so ASP.NET Core kept its keys in memory and threw them away on every restart. Below is what I reproduced, the exact messages each cause produces, and the fix. The deploy side of IIS and Plesk 500 errors is in <a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors">the Plesk 500 errors post</a>. </p>
<p><strong>The short fix:</strong> store the Data Protection keys in a folder that survives restarts and deploys, and give the app a fixed name: <code>services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(@"App_Data\keys")).SetApplicationName("MyApp")</code>. Then make sure your deploy never deletes that folder. Users who already have old cookies will be logged out once more, and after that it stops. </p>
<h2>What the error actually means</h2>
<p>ASP.NET Core doesn't store session data, login tickets or antiforgery tokens in plain text. It encrypts them with the <strong>Data Protection</strong> system, which keeps a set of keys called the <em>key ring</em>. Each encrypted payload carries the ID of the key that made it. When a request comes in, the app looks up that ID in its ring. If it isn't there, you get "The key {...} was not found in the key ring". </p>
<p>Three components report it, in three different ways: </p>
<ul>
<li><strong>Session middleware</strong> logs "Error unprotecting the session cookie" as a warning, then quietly starts a new, empty session. </li>
<li><strong>Cookie authentication</strong> logs "Cookies was not authenticated. Failure message: Unprotect ticket failed", but only at Information level. With the default <code>"Microsoft.AspNetCore": "Warning"</code> setting in <code>appsettings.json</code>, you never see it. The user is just signed out. </li>
<li><strong>Antiforgery</strong> throws <code>AntiforgeryValidationException</code>: "The antiforgery token could not be decrypted", which usually surfaces as a 400 on form posts.</li>
</ul>
<p>That's why the session warning is the one people search for. It's the only one that shows up in a default log, even though the logouts hurt more. </p>
<h2>What I tested</h2>
<p>I built a minimal ASP.NET Core 9 app (runtime 9.0.8, Windows 11, Kestrel) with cookie sign-in, session and antiforgery. I signed in with curl, kept the cookie jar, restarted the app with a different key setup each time, and sent the old cookies back. These are the results. </p>
<h3>1. Keys persisted, plain restart: nothing breaks</h3>
<p>With keys stored in a folder and the app restarted on the same folder, the user was still signed in and the old antiforgery token still validated. This is the control. One thing did reset: the session value was empty after the restart. That isn't a key problem. <code>AddDistributedMemoryCache()</code> keeps session data in process memory, so a restart loses it even when the cookie decrypts fine. If you need sessions to survive restarts, use SQL Server or Redis for the session store as well. </p>
<h3>2. Key folder wiped: "was not found in the key ring"</h3>
<p>Pointing the restarted app at an <em>empty</em> folder is exactly what a deploy does when it deletes the old site folder, including <code>App_Data</code>. The user came back anonymous, the antiforgery post failed with "The antiforgery token could not be decrypted", and the log showed the error from the top of this article, naming the old key's ID. The app also silently created a new key, logging: </p>
<pre><code class="language-plaintext">No XML encryptor configured. Key {53f0f160-...} may be persisted to storage in unencrypted form.
</code></pre>
<h3>3. Same keys, different application name: "The payload was invalid"</h3>
<p>Data Protection isolates apps from one another using an application name. If you don't set one, it is derived from the app's content root path. So moving the site to a different folder, or swapping deployment slots, can change it. I kept the same key folder and changed only <code>SetApplicationName</code> from <code>SiteA</code> to <code>SiteB</code>. The keys were found, but nothing decrypted: </p>
<pre><code class="language-plaintext">warn: Microsoft.AspNetCore.Session.SessionMiddleware[7]
      Error unprotecting the session cookie.
      System.Security.Cryptography.CryptographicException: The payload was invalid.
info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Unprotect ticket failed
</code></pre>
<p>The antiforgery error changed too, to "The required antiforgery cookie ".AspNetCore.Antiforgery.6asUPwDiMTw" is not present". The suffix of that cookie name is derived from the application name, so the app was looking for a cookie with a different name. If you see "payload was invalid" rather than "key not found", look at the application name before the keys. </p>
<h3>4. Where the keys go by default</h3>
<p>With no configuration, a normal Windows user account gets this at startup: </p>
<pre><code class="language-plaintext">User profile is available. Using 'C:\Users\&lt;you&gt;\AppData\Local\ASP.NET\DataProtection-Keys' as key repository and Windows DPAPI to encrypt keys at rest.
</code></pre>
<p>That's why it works on your machine. An IIS app pool doesn't have that folder unless "Load User Profile" is on, and on shared hosting such as Plesk you often can't control that setting. When there is no profile and no registry hive set up for the pool, the framework uses an in-memory ring. I couldn't reproduce that fallback outside IIS, but its warning is in the framework itself (<code>Microsoft.AspNetCore.DataProtection.dll</code>, 9.0.8): </p>
<pre><code class="language-plaintext">Neither user profile nor HKLM registry available. Using an ephemeral key repository. Protected data will be unavailable when application exits.
</code></pre>
<p>If that line is in your startup log, every app pool recycle, idle timeout and deploy logs your users out. </p>
<h2>The fix</h2>
<p>Three settings, all in <code>Program.cs</code> before <code>builder.Build()</code>. This is the configuration this site's API uses now: </p>
<pre><code class="language-csharp">var keysDir = new DirectoryInfo(Path.GetFullPath("App_Data/keys", builder.Environment.ContentRootPath));
keysDir.Create();

var dataProtection = builder.Services
    .AddDataProtection()
    .PersistKeysToFileSystem(keysDir)          // keys survive restarts and recycles
    .SetApplicationName("MyApp");              // fixed, so moving the folder or slot doesn't change it

if (OperatingSystem.IsWindows())
{
    dataProtection.ProtectKeysWithDpapi(protectToLocalMachine: true);  // encrypt key files at rest
}
</code></pre>
<p>Notes on each line: </p>
<ul>
<li><strong>The folder must be outside the release files.</strong> If your deploy copies the site over <code>App_Data</code>, or deletes and re-extracts it, the keys go with it. Keep <code>App_Data</code> out of the delete step, the same way you keep uploads. </li>
<li><strong>Use <code>ProtectKeysWithDpapi(protectToLocalMachine: true)</code>, not the per-user default.</strong> An app pool has no user profile to hold a per-user DPAPI key. Machine scope works without one. The trade-off is that the key files can only be decrypted on the same machine. </li>
<li><strong>Set the application name once and never change it.</strong> Changing it logs every user out, the same as losing the keys.</li>
</ul>
<p>After the change, the first deploy logs everyone out once. That's expected: the old cookies were encrypted with keys that no longer exist, and there's nothing to recover them with. From then on, restarts and recycles keep the same keys. </p>
<h2>How to check it worked</h2>
<ol>
<li>Sign in, then recycle the app pool or restart the app. </li>
<li>Check that you are still signed in. If you are, the keys survived. </li>
<li>Check the log. The "User profile is available" line should be gone, and no "Neither user profile nor HKLM registry available" line should appear. Either one would mean the keys went to memory. </li>
<li>Check that <code>App_Data\keys</code> contains <code>key-*.xml</code> files, and that the timestamps don't change on every restart.</li>
</ol>
<h2>FAQ</h2>
<p><strong>What does 'The key was not found in the key ring' mean in ASP.NET Core?</strong></p>
<p>ASP.NET Core encrypted a cookie or token with a key that the app no longer has. The key ring is empty or missing that key, so the payload can't be decrypted. The usual cause is keys stored in memory, or a key folder that was wiped during a deploy.</p>
<p><strong>Why am I logged out every time I deploy or recycle the app pool?</strong></p>
<p>The Data Protection keys are kept in memory, or in a folder that the deploy deletes. The new process has different keys, so cookies from before can't be decrypted. Persist the keys to a folder outside the release files, with PersistKeysToFileSystem.</p>
<p><strong>What does 'The payload was invalid' mean in ASP.NET Core Data Protection?</strong></p>
<p>The app found the key but the cookie still couldn't be decrypted. A common cause is a different application name. Check SetApplicationName, and check that the app isn't running from a different folder or deployment slot with a different default name.</p>
<p><strong>What is the difference between 'key not found' and 'payload was invalid'?</strong></p>
<p>'Key not found' means the key ring doesn't contain the key that encrypted the cookie, so the keys were lost or moved. 'Payload was invalid' means the key was found, but the app name or key data doesn't match. Keys first, then application name.</p>
<p><strong>Where should ASP.NET Core store Data Protection keys on IIS?</strong></p>
<p>In a folder that the app pool identity can write to, outside the release folder, for example App_Data/keys. Protect the key files with ProtectKeysWithDpapi(protectToLocalMachine: true), since an app pool usually has no user profile for per-user DPAPI.</p>
<p><strong>What does 'No XML encryptor configured' mean?</strong></p>
<p>The Data Protection keys are being saved to disk without encryption. It's a warning, not an error. Fix it with ProtectKeysWithDpapi on Windows, or with a certificate or Key Vault on other hosts.</p>
<p><strong>Does the session warning 'Error unprotecting the session cookie' mean my users are logged out?</strong></p>
<p>Not directly. It's the session middleware, which starts an empty session. Login is handled by cookie authentication, which logs a separate message at Information level. Check that too, because the warning alone doesn't show it.</p>
<h2>Related on knowledgemarkg.com</h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors">Deploying ASP.NET Core to Plesk on Windows: Every Error I Hit and What It Actually Meant</a></li>
<li><a href="https://knowledgemarkg.com/blog/aspnet-core-web-api-performance">ASP.NET Core Web API Performance: From 900ms to 3ms on Cheap Hosting</a></li>
<li><a href="https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core">Windows VPS vs Linux VPS for ASP.NET Core: Which Do You Actually Need?</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[sqlcmd 'Scripting Variable Not Defined': Why $( Breaks Your Script and How to Fix It]]></title><description><![CDATA[Why sqlcmd says 'scripting variable not defined', why the script still runs with exit code 0, how environment variables silently rewrite $( text, and the…]]></description><link>https://knowledgemarkg.hashnode.dev/sqlcmd-scripting-variable-not-defined</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/sqlcmd-scripting-variable-not-defined</guid><category><![CDATA[SQL Server]]></category><category><![CDATA[SQL]]></category><category><![CDATA[Databases]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 08:05:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/4268fda6-fa80-4dbb-962d-9b543b7781e0.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on</em> <a href="https://knowledgemarkg.com/blog/sqlcmd-scripting-variable-not-defined"><em>knowledgemarkg.com</em></a><em>.</em></p>
<p>If you run SQL scripts through <code>sqlcmd</code> - a deploy step, a data fix, a seed script, a pipeline that loads text into a table - sooner or later one of them prints this:</p>
<pre><code class="language-plaintext">'Amount' scripting variable not defined.
</code></pre>
<p>The name in quotes changes; the cause doesn't. Somewhere in your script there is a <code>$(</code> followed by a name and a <code>)</code>, and sqlcmd has read it as one of its own variables. Usually it's inside a string you meant literally: a price template, a shell command stored in a table, a blog post with a code sample in it. I hit it on this site, whose posts are inserted by a script that pipes generated SQL through sqlcmd - any post with a shell example like <code>$(cygpath -w file)</code> in it tripped over this.</p>
<p><strong>The short fix:</strong> if the script doesn't use sqlcmd variables at all, run it with <code>sqlcmd -x</code> (or <code>Invoke-Sqlcmd -DisableVariables</code> in PowerShell). If it does use them, break the pattern inside the SQL text instead: write <code>'$' + '(Amount)'</code> or <code>CHAR(36) + '(Amount)'</code>. And if you build the command in Bash or PowerShell, put the query in single quotes - in double quotes the shell eats <code>$(...)</code> before sqlcmd ever sees it.</p>
<p>That's the fix. What makes this worth an article is that the error message is the <em>least</em> dangerous thing sqlcmd does with <code>$(...)</code> - the dangerous cases print nothing at all.</p>
<h2>What sqlcmd is doing</h2>
<p><code>sqlcmd</code> is not just a pipe to SQL Server. Before it sends each batch, it runs its own pre-processor over the text, and one of that pre-processor's jobs is replacing <code>$(Name)</code> with the value of a <em>scripting variable</em> called <code>Name</code>. This is a feature: it lets one script run against different databases or servers.</p>
<pre><code class="language-sql">:setvar TargetDb "Shop_Staging"
USE $(TargetDb);
SELECT COUNT(*) FROM dbo.Orders;
</code></pre>
<p>The catch is that the pre-processor doesn't care about T-SQL quoting. It replaces <code>$(Name)</code> inside string literals - <code>'...'</code> and <code>N'...'</code> - exactly as it does in code. To sqlcmd, <code>'Total: $(Amount)'</code> is a variable reference wrapped in some characters that happen to be quotes.</p>
<p>A value for a variable can come from five places. From lowest to highest precedence, according to Microsoft's documentation:</p>
<ol>
<li>system-level environment variables</li>
<li>user-level environment variables</li>
<li>variables set in the shell before starting sqlcmd (<code>SET X=Y</code>, <code>export X=Y</code>)</li>
<li><code>sqlcmd -v X=Y</code></li>
<li><code>:setvar X Y</code> inside the script</li>
</ol>
<p>Only if none of the five has a value do you get "scripting variable not defined". Keep the first three in mind - they are where the silent failures come from.</p>
<h2>What I tested</h2>
<p>Everything below was run on 26 October 2026 with the classic ODBC-based <code>sqlcmd</code> (version 15.0.1300.359, Windows) against SQL Server 2022, using only <code>SELECT</code> and <code>PRINT</code>. The test script is one line:</p>
<pre><code class="language-sql">SELECT 'Total: $(Amount)' AS r;
</code></pre>
<h3>1. Without -b, the error doesn't stop anything</h3>
<p>Run it plainly and you get the message on stderr - and the query <em>still runs</em>, with the literal text left in:</p>
<pre><code class="language-plaintext">r
-
Total: $(Amount)

(1 rows affected)
'Amount' scripting variable not defined.
</code></pre>
<p>The exit code is <strong>0</strong>. If this script were an <code>INSERT</code>, the row would be saved exactly as written, and any CI job checking the exit code would report success. Microsoft's own page contradicts itself here: one sentence says "the execution of the script stops", while a worked example further down shows the row being returned. The example - and my test - are right. It only stops when you pass <code>-b</code>.</p>
<h3>2. With -b, it stops - halfway through</h3>
<p>With <code>-b</code>, the batch containing the reference is never sent and sqlcmd exits with code 1. But batches before it have already run. A three-batch script printed <code>batch 1 ran</code>, then the error, then nothing. If the script isn't wrapped in a transaction, you are left with the first half applied. That's still the behaviour you want in automation - just know that "failed" doesn't mean "nothing happened".</p>
<h3>3. Environment variables fill it in - silently</h3>
<p>This is the one that actually corrupts data. I changed the test string to:</p>
<pre><code class="language-sql">SELECT 'path=$(USERNAME)' AS envleak, '$(SQLCMDSERVER)' AS srv;
</code></pre>
<p>No message, exit code 0, and the result came back with my Windows user name in place of <code>$(USERNAME)</code> and the server's address in place of <code>$(SQLCMDSERVER)</code>. sqlcmd treats every environment variable as a scripting variable, and it defines a set of its own (<code>SQLCMDSERVER</code>, <code>SQLCMDUSER</code>, <code>SQLCMDDBNAME</code> and more). So a stored string that happens to contain <code>$(PATH)</code>, <code>$(TEMP)</code>, <code>$(COMPUTERNAME)</code> or <code>$(HOME)</code> is quietly rewritten with values from whatever machine ran the script - including, on a build agent, paths and account names you didn't mean to put in your database. Names are case-insensitive too: <code>-v AMOUNT=5</code> filled in <code>$(amount)</code>.</p>
<p>The visible error only appears when the name matches nothing. You only get a warning when you're lucky.</p>
<h3>4. A broken pattern is a syntax error</h3>
<p>An unclosed <code>$(</code> or a name with a space in it gives a different message:</p>
<pre><code class="language-plaintext">Sqlcmd: Error: Syntax error at line 1 in file 't7.sql'.
</code></pre>
<p>Here the batch is <em>not</em> sent - but without <code>-b</code> the exit code is still 0, so the script "succeeds" having skipped that statement.</p>
<h3>5. Things that turned out to be fine</h3>
<p>Some things I expected to break and didn't, in this version:</p>
<ul>
<li><code>$(Anything)</code> inside a <code>--</code> or <code>/* */</code> comment produced no message.</li>
<li>A multi-line string literal containing a line that starts with <code>GO</code>, <code>:setvar</code> or <code>!!echo</code> was kept as text. sqlcmd did not split the batch, set a variable or run a shell command.</li>
</ul>
<p>So it's specifically <code>$(...)</code> in code and string literals you need to watch.</p>
<h2>Fix 1: turn variables off with -x</h2>
<p>If the script never uses sqlcmd variables, this is the whole fix:</p>
<pre><code class="language-bash">sqlcmd -S myserver -d Shop -x -b -i load_content.sql
</code></pre>
<p><code>-x</code> makes sqlcmd ignore scripting variables completely - no substitution, no message, the text goes to SQL Server as written. It's an all-or-nothing switch: in my test, <code>-x</code> together with <code>-v Amount=19.99</code> still produced the literal <code>$(Amount)</code>, and <code>:setvar</code> inside the file was ignored as well. If the script relies on <code>$(TargetDb)</code> somewhere, <code>-x</code> breaks that.</p>
<p>Keep <code>-b</code> alongside it, so real SQL errors still fail the run.</p>
<h3>PowerShell: Invoke-Sqlcmd -DisableVariables</h3>
<p>The <code>SqlServer</code> module's <code>Invoke-Sqlcmd</code> uses the same variable syntax, and its equivalent of <code>-x</code> is <code>-DisableVariables</code>. Microsoft's documentation describes it in nearly the same words as <code>-x</code> - "useful when a script contains many INSERT statements that may contain strings that have the same format as variables". (I didn't have the module installed for this test, so this one is from the docs, not from a run.)</p>
<pre><code class="language-powershell">Invoke-Sqlcmd -ServerInstance myserver -Database Shop -InputFile .\load_content.sql -DisableVariables
</code></pre>
<h2>Fix 2: break the pattern inside the SQL</h2>
<p>When the script needs variables <em>and</em> contains literal <code>$(...)</code> text, change how the text is written so the pre-processor never sees <code>$(</code>. Both of these came back as the literal <code>Total: $(Amount)</code> with no message:</p>
<pre><code class="language-sql">SELECT 'Total: $' + '(Amount)'          AS concat_fix,
       'Total: ' + CHAR(36) + '(Amount)' AS char_fix;
</code></pre>
<p><code>CHAR(36)</code> is the dollar sign. Use <code>NCHAR(36)</code> and <code>N'...'</code> for <code>nvarchar</code> data. There is no escape sequence for <code>$(</code> in sqlcmd - splitting it is the workaround. If you generate SQL from code, do the split in the generator so nobody has to remember it by hand:</p>
<pre><code class="language-python">def sql_literal(text):
    # N'...' with quotes doubled, and "$(" split so sqlcmd can't see a variable
    body = text.replace("'", "''").replace("$(", "$' + N'(")
    return "N'" + body + "'"
</code></pre>
<p>The same goes for SQL database projects and SSMS in SQLCMD mode, which use the same <code>$(Name)</code> syntax for their variables. I haven't tested those here, but the split string has no <code>$(</code> in it for any of them to find.</p>
<h2>Fix 3: check what your shell did first</h2>
<p>A related trap sits one layer earlier, in the shell. Both Bash and PowerShell use <code>$(...)</code> for <em>their own</em> command substitution, and both expand it inside double quotes before sqlcmd starts:</p>
<pre><code class="language-bash">$ q="SELECT 'Total: $(Amount)' AS r"; echo "$q"
bash: Amount: command not found
SELECT 'Total: ' AS r
</code></pre>
<p>PowerShell does the same and says <code>The term 'Amount' is not recognized as the name of a cmdlet, function, script file, or operable program</code>, then hands over <code>SELECT 'Total: ' AS r</code>. The query runs, the value is just gone - and sqlcmd never complains because it never saw a <code>$(</code>. If what comes out has an empty gap where your text was, this is why. Use single quotes in the shell (doubling the inner SQL quotes in PowerShell), escape it as ``$(<code>in a PowerShell double-quoted string, or - better - put the SQL in a file and pass it with</code>-i`.</p>
<h2>Which fix to use</h2>
<ul>
<li><strong>Loading content</strong> (articles, templates, HTML, anything users wrote): <code>-x</code> or <code>-DisableVariables</code>. You can't predict what text contains, and environment variable substitution makes it unsafe to rely on the error appearing.</li>
<li><strong>Deployment scripts that use variables</strong>: keep variables on, split any literal <code>$(</code> with <code>CHAR(36)</code>, and always pass <code>-b</code>.</li>
<li><strong>Large or repeated data loads</strong>: consider not generating SQL text at all. A parameterised command from your application, <code>bcp</code> or <code>BULK INSERT</code> doesn't put the data through sqlcmd's pre-processor, so there's nothing to escape.</li>
</ul>
<h2>The other sqlcmd defaults that cause trouble</h2>
<p>This is the second article I've written about sqlcmd quietly behaving differently from the application that normally talks to the same database. The first was <a href="https://knowledgemarkg.com/blog/sql-server-msg-1934-set-options-quoted-identifier">Msg 1934, "SET options have incorrect settings: QUOTED_IDENTIFIER"</a> - the classic sqlcmd starts sessions with <code>QUOTED_IDENTIFIER OFF</code>, which breaks writes to any table with a filtered index. The script that inserts posts on this site now passes both <code>-x</code> and a <code>SET QUOTED_IDENTIFIER ON</code> where it needs one. If you run SQL by hand against a database an ORM normally manages, it's also worth reading about <a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database">EF Core's "There is already an object named X in the database"</a>, which is what happens when hand-run SQL and migration history drift apart.</p>
<h2>FAQ</h2>
<p><strong>What does 'scripting variable not defined' mean in sqlcmd?</strong></p>
<p>Your script contains $(Name) and sqlcmd treats that as a reference to a scripting variable called Name. It found no value for it in :setvar, -v or the environment, so it prints the message. The text is usually a literal string you didn't mean as a variable.</p>
<p><strong>Does the script stop when sqlcmd says a scripting variable is not defined?</strong></p>
<p>Not by default. In a test with sqlcmd 15, the statement still ran with the literal $(Name) text and the exit code was 0. Only with -b does sqlcmd skip that batch and exit with code 1, and batches before it have already run.</p>
<p><strong>How do I stop sqlcmd from replacing $( in my data?</strong></p>
<p>Run sqlcmd with -x, which turns off scripting variables entirely, or use Invoke-Sqlcmd -DisableVariables in PowerShell. If the script also needs variables, write the dollar sign as CHAR(36) or split the string as '$' + '(Name)' so sqlcmd never sees the $( pattern.</p>
<p><strong>Is there an escape character for $( in sqlcmd?</strong></p>
<p>No. sqlcmd has no escape sequence for the $( pattern. The workarounds are -x to disable variables, or building the text so the two characters are never next to each other in the script, for example CHAR(36) + '(Name)'.</p>
<p><strong>Can environment variables change my data when I run sqlcmd?</strong></p>
<p>Yes. sqlcmd treats environment variables as scripting variables, so a string containing $(USERNAME), $(PATH) or $(TEMP) is silently replaced with that machine's value, with no warning and exit code 0. That's why -x is the safer choice for loading user-written content.</p>
<p><strong>What is the order of precedence for sqlcmd variables?</strong></p>
<p>From lowest to highest: system environment variables, user environment variables, variables set in the shell before starting sqlcmd, the -v option, and :setvar inside the script. Variable names are case-insensitive.</p>
<p><strong>Why is my query empty where $(...) was, with no sqlcmd error?</strong></p>
<p>Your shell probably expanded it first. Bash and PowerShell both treat $(...) inside double quotes as command substitution, so the text is removed before sqlcmd starts. Use single quotes, escape it as `$( in PowerShell, or put the SQL in a file and pass it with -i.</p>
<h2>Related on <a href="http://knowledgemarkg.com">knowledgemarkg.com</a></h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/sql-server-msg-1934-set-options-quoted-identifier">SQL Server Msg 1934: 'SET Options Have Incorrect Settings: QUOTED_IDENTIFIER'</a></li>
<li><a href="https://knowledgemarkg.com/blog/ef-core-there-is-already-an-object-named-in-the-database">There Is Already an Object Named 'X' in the Database: What EF Core Is Really Telling You</a></li>
<li><a href="https://knowledgemarkg.com/blog/database-indexes-and-idempotency">Database Indexes and Idempotency: What They Fix, and How to Get Them Wrong</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[IndexNow: What a 202 Actually Means, and Whether Google Cares]]></title><description><![CDATA[A 202 from IndexNow does not mean your URLs were accepted. Real status codes from wiring it into a live site, plus what Google actually does with it.]]></description><link>https://knowledgemarkg.hashnode.dev/indexnow-202-status-codes-and-whether-it-works</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/indexnow-202-status-codes-and-whether-it-works</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Bing]]></category><category><![CDATA[indexnow]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 07:53:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/a2ec3fe6-c9b5-4c40-adda-e2750181e9b4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on</em> <a href="https://knowledgemarkg.com/blog/indexnow-202-status-codes-and-whether-it-works"><em>knowledgemarkg.com</em></a><em>.</em></p>
<p>I wired IndexNow into this site this week and learned something in the first ten minutes that is not in most write-ups: <strong>the success response you almost certainly got back means nothing.</strong></p>
<p>If you submitted URLs and saw <code>HTTP 202</code>, you have not confirmed anything. I proved that to myself with a deliberately invalid key, and it returned the same 202 as my real one.</p>
<p>Here is what the status codes mean, what Google does with IndexNow, and whether the whole thing is worth half an hour of your time.</p>
<h2>The 202 problem</h2>
<p>IndexNow's bulk endpoint takes a JSON body with your host, your key and a list of URLs. I submitted 175 URLs pulled from this site's sitemap and got back:</p>
<pre><code class="language-plaintext">HTTP 202
</code></pre>
<p>Which looks like success. It is not. Per the protocol's own specification:</p>
<ul>
<li><strong>200 OK</strong> - URL submitted successfully. The key was validated.</li>
<li><strong>202 Accepted</strong> - URL received. <em>IndexNow key validation pending.</em></li>
<li><strong>400 Bad Request</strong> - the request itself was malformed.</li>
<li><strong>403 Forbidden</strong> - the key is not valid.</li>
<li><strong>422 Unprocessable Entity</strong> - the URLs do not belong to the host, or the key does not match the schema.</li>
<li><strong>429 Too Many Requests</strong> - you are submitting too often.</li>
</ul>
<p>A 202 is a receipt, not a verdict. The engine has taken your payload and will go and check your key later. If that check fails, nothing happens, nobody tells you, and your URLs are silently dropped.</p>
<h3>The control experiment</h3>
<p>To be sure I was reading that correctly rather than guessing, I sent two single-URL submissions through the GET endpoint - one with my real key, one with thirty-two zeros:</p>
<pre><code class="language-plaintext">real key    -&gt; HTTP 200
all zeros   -&gt; HTTP 202
</code></pre>
<p>That is the whole lesson in two lines. The invalid key produced the <em>more</em> reassuring-looking response of the two, because 202 simply defers the question. The 200 is the only one that tells you the engine went and fetched your key file and was satisfied.</p>
<p><strong>So: verify with a single GET, not with your bulk POST.</strong> Submit one URL on its own and look for a 200. Bulk submissions legitimately return 202 and you cannot conclude anything from that either way.</p>
<h2>Does Google use IndexNow?</h2>
<p>No. This is the most-searched question about the protocol and it has a short answer that a lot of posts talk around.</p>
<p>IndexNow is a Microsoft-led protocol. Bing, Yandex, Seznam and Naver participate. Google announced back in 2021 that it would evaluate it and has never adopted it for indexing. Your IndexNow submission does not reach Google, and Google still has to be told about new pages the old way - a sitemap it crawls on its own schedule, or a manual <em>Request indexing</em> in Search Console.</p>
<p>That makes IndexNow a worthwhile addition to your setup rather than a replacement for any of it. If someone sells it to you as a way to "get indexed by Google instantly", they are either confused or hoping you are.</p>
<h2>Setting it up: the part that silently fails</h2>
<p>The mechanics are genuinely simple. You need three things:</p>
<ol>
<li>A key - any hex string between 8 and 128 characters. Generate it, do not invent it by hand.</li>
<li>That key, as plain text, in a file at the root of your site: <code>https://example.com/&lt;key&gt;.txt</code>, containing nothing but the key.</li>
<li>A POST to <code>https://api.indexnow.org/indexnow</code> with your host, key, key location and URL list.</li>
</ol>
<p>The failure mode that catches people is ordering. <strong>The key file has to be live before you submit anything.</strong> IndexNow fetches it to validate the request. If you deploy your application code first and the key file second - which is the natural order if the submission lives in your backend and the key file lives in your frontend - then every submission in between is rejected, and the 202 you got back will have told you nothing was wrong.</p>
<p>On this site the key file is a static file in the frontend's <code>public/</code> directory and the submission happens in the backend API, which are two separate deployments. I deployed the frontend first on purpose, then checked the file was actually reachable before letting the API send anything:</p>
<pre><code class="language-plaintext">curl -s -o /dev/null -w "%{http_code}" https://example.com/&lt;key&gt;.txt
200
</code></pre>
<p>Thirty seconds of checking that saves you from a silent failure you would not otherwise notice for weeks.</p>
<h3>What the request actually looks like</h3>
<p>There is no SDK and you do not need one. The entire protocol is one POST with a JSON body:</p>
<pre><code class="language-plaintext">POST https://api.indexnow.org/indexnow
Content-Type: application/json

{
  "host": "example.com",
  "key": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "keyLocation": "https://example.com/a1b2c3d4e5f60718293a4b5c6d7e8f90.txt",
  "urlList": [
    "https://example.com/blog/my-new-post",
    "https://example.com/blog"
  ]
}
</code></pre>
<p>Four fields. <code>keyLocation</code> is technically optional when the file sits at the root under its own name, but send it anyway - it removes any ambiguity about where the engine should look, and costs you nothing.</p>
<p>Two constraints worth knowing before you write the loop: every URL in a single request must belong to the same host, and you can send up to 10,000 of them at a time. If you are backfilling an existing site, read the URLs straight out of your own sitemap rather than querying your database - the sitemap already encodes which pages you consider public, so you inherit that filtering for free.</p>
<p>On a Next.js front end the key file is simply a static file in <code>public/</code>, which means it ships with your normal build and needs no route handler. If your URLs and your publishing logic live in different applications, as they do here, the only thing to remember is that the key file belongs to whichever host serves the pages - not to whichever service makes the call.</p>
<h3>Where to call it from</h3>
<p>Submit when content actually changes, not on a schedule. On this site the ping is attached to the three admin actions that can make a post publicly visible - create, update and publish - so nothing has to be remembered by hand.</p>
<p>Three conditions are worth enforcing before you send anything, because pointing a crawler at a page it will then be told to ignore wastes the submission and teaches the engine to trust your feed less:</p>
<ul>
<li>The post is published, not a draft.</li>
<li>It is not scheduled for a future date.</li>
<li>It is not marked <code>noindex</code>.</li>
</ul>
<p>And make the call best-effort. A search engine being slow or down must never fail an editor's save. Catch everything, log a warning, move on - the sitemap is still there as the slow fallback, which is the entire reason this is an optimisation rather than a dependency.</p>
<h2>Don't trust the dashboard either</h2>
<p>After my 175 URLs came back 202, I opened Bing Webmaster Tools to watch them arrive. The IndexNow page said:</p>
<pre><code class="language-plaintext">URLs submitted in last 4 hours: 0
</code></pre>
<p>and listed nothing but submissions from April 2025. Hours later it still did.</p>
<p>That is reporting lag, not failure - the protocol-level acceptance and the key validation had both already been confirmed by then. But if the dashboard is the only thing you look at, you will conclude your integration is broken when it is fine, or convince yourself to "fix" something that was never wrong.</p>
<p>Check in this order: the key file returns 200, a single-URL GET returns 200, and only then the dashboard, days later, as a lagging confirmation.</p>
<h2>So is it worth it?</h2>
<p>Honest answer, and it depends entirely on which problem you have.</p>
<p><strong>Worth it if your content changes and your site has little authority.</strong> A site nobody links to gets crawled rarely. Mine had been publishing into a void: the previous version of this site pinged IndexNow through a WordPress plugin until April 2025, then moved to a Next.js stack and nothing replaced it. Eighteen months of posts were never announced to anyone - they just sat in a sitemap waiting for a crawler that had no particular reason to hurry.</p>
<p><strong>Not worth much if you are big.</strong> If Bingbot already crawls you hourly, you are telling it something it was about to find out anyway.</p>
<p><strong>Never worth it as a Google strategy</strong>, for the reason above.</p>
<p>The real argument for it is the cost side rather than the benefit side. It is one static file, one HTTP call, and perhaps thirty lines of code. There is no ongoing cost, no quota to manage and no account to maintain. Against that, even a modest improvement in how quickly Bing and Yandex notice your work is worth having - particularly now that Bing's index feeds Copilot, which means "how fast does Bing see this page" has quietly become "how fast can an AI assistant cite it". On this site <a href="https://knowledgemarkg.com/blog/ga4-ai-assistant-channel-measuring-ai-traffic">AI assistants already send about as much traffic as Google organic does</a>, so that is not a hypothetical.</p>
<h2>What to actually check</h2>
<p>If you have IndexNow set up already and have never verified it beyond seeing a 2xx, this takes two minutes:</p>
<ol>
<li>Fetch your key file. It must return 200 and contain exactly the key, nothing else.</li>
<li>Submit one URL through the GET endpoint and confirm you get <strong>200</strong>, not 202.</li>
<li>Confirm your submissions are triggered by content changes, not by a cron job that fires whether anything changed or not.</li>
<li>Confirm drafts and noindexed pages are excluded.</li>
</ol>
<p>If step two gives you a 202 every time, your key is not validating and nothing you have submitted has counted. That is a quiet, invisible failure that can run for months - which is exactly the kind of bug worth spending two minutes to rule out.</p>
<h2>FAQ</h2>
<p><strong>What does a 202 response from IndexNow mean?</strong></p>
<p>It means the request was received but your key has not been validated yet - the specification calls it "IndexNow key validation pending". It is not a confirmation. An invalid key returns 202 as well, so a 202 alone tells you nothing about whether your URLs will actually be processed.</p>
<p><strong>Does Google use IndexNow?</strong></p>
<p>No. IndexNow is supported by Bing, Yandex, Seznam and Naver. Google said in 2021 it would evaluate the protocol and has never adopted it for indexing, so you still have to rely on your sitemap and Search Console for Google.</p>
<p><strong>How do I know my IndexNow key is actually working?</strong></p>
<p>Submit a single URL through the GET endpoint rather than a bulk POST. A 200 means the engine fetched your key file and validated it. A 202 means validation is still pending and proves nothing. Bulk submissions return 202 normally, which is why they are no use for verification.</p>
<p><strong>Where does the IndexNow key file go?</strong></p>
<p>At the root of the host whose URLs you are submitting - <code>https://example.com/&lt;key&gt;.txt</code> - containing the key and nothing else. It must be live before you submit anything, because IndexNow fetches it to validate every request.</p>
<p><strong>Is IndexNow worth setting up?</strong></p>
<p>For a site with modest authority that publishes or updates regularly, yes - it costs one static file and about thirty lines of code, with no quota or account to maintain. For a large site already crawled hourly the benefit is small, and it is never a route to faster Google indexing.</p>
<p><strong>Is IndexNow free?</strong></p>
<p>Yes. There is no account, no API key to apply for and no cost. You generate your own key, host it as a text file on your own domain, and submit over plain HTTP.</p>
<p><strong>When should my site submit URLs to IndexNow?</strong></p>
<p>When content actually changes - on publish, update and unpublish - rather than on a schedule. Skip drafts, scheduled posts and anything marked noindex, since submitting a page the crawler will then be told to ignore wastes the submission.</p>
<h2>Related on <a href="http://knowledgemarkg.com">knowledgemarkg.com</a></h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/cloudflare-speed-settings-brotli-vs-zstd-vs-gzip">Cloudflare Speed Settings That Help, and Brotli vs Zstd vs Gzip Measured</a></li>
<li><a href="https://knowledgemarkg.com/blog/reduce-unused-javascript-nextjs-lcp-render-delay">How We Fixed Unused JavaScript and LCP Render Delay in Next.js</a></li>
<li><a href="https://knowledgemarkg.com/blog/ga4-ai-assistant-channel-measuring-ai-traffic">I Measured AI Traffic for Two Months: What GA4's AI Assistant Channel Shows, and What It Hides</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Next.js Loads .env.local During Production Builds - And It Overrides .env.production]]></title><description><![CDATA[Yes - and it is loaded by next build, not just next dev. Proven with Next.js 16's own loader, plus the one-line rename that stops it.]]></description><link>https://knowledgemarkg.hashnode.dev/nextjs-env-local-overrides-env-production</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/nextjs-env-local-overrides-env-production</guid><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Devops]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 07:49:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/f1147b3d-8012-4567-a645-3bd1e306edeb.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on</em> <a href="https://knowledgemarkg.com/blog/nextjs-env-local-overrides-env-production"><em>knowledgemarkg.com</em></a><em>.</em></p>
<p>A deploy went out. The site loaded, the certificate was valid, the server was in a datacentre in London - and every API call in the browser console was going to <code>http://localhost:5080</code>.</p>
<p>Nothing in <code>.env.production</code> mentioned localhost. The server's own environment did not mention localhost. The value had been welded into the JavaScript bundle at build time, from a file I had forgotten was on my laptop: <code>.env.local</code>.</p>
<p>If you have ever searched <em>"Next.js not reading .env.local"</em>, or wondered whether <code>.env.local</code> overrides <code>.env.production</code>, the answer is the one that catches people out:</p>
<p><strong>Next.js loads</strong> <code>.env.local</code> <strong>during</strong> <code>next build</code><strong>, not just</strong> <code>next dev</code> <strong>- and it outranks</strong> <code>.env.production</code><strong>.</strong></p>
<h2>The precedence order, and the one line that matters</h2>
<p>Next.js merges up to five sources. The first one that defines a variable wins; later sources cannot overwrite it:</p>
<pre><code class="language-plaintext">process.env                 (real shell / service environment)
.env.$(NODE_ENV).local      (.env.production.local on a prod build)
.env.local                  (skipped ONLY when NODE_ENV=test)
.env.$(NODE_ENV)            (.env.production on a prod build)
.env
</code></pre>
<p>Read line three against line four. <code>.env.local</code> sits <em>above</em> <code>.env.production</code>. There is exactly one condition under which Next.js skips it, and it is not "production" - it is <code>NODE_ENV=test</code>.</p>
<p>That single ordering decision is the whole bug. The file most people think of as "my local development overrides" is, during a production build, a higher-priority source than the file literally named for production.</p>
<h2>Prove it on your own project in ten lines</h2>
<p>You do not have to trust an article about this, and you should not. Next.js publishes its env loader as a standalone package, <code>@next/env</code> - the same code <code>next build</code> calls. You can run it directly.</p>
<p>Three fixture files:</p>
<pre><code class="language-plaintext"># .env
NEXT_PUBLIC_API_URL=https://api.example.com

# .env.production
NEXT_PUBLIC_API_URL=https://api.prod.example.com

# .env.local
NEXT_PUBLIC_API_URL=http://localhost:5080
</code></pre>
<p>And a script that asks the loader what it did:</p>
<pre><code class="language-plaintext">const { loadEnvConfig } = require('@next/env');
process.env.NODE_ENV = 'production';

const r = loadEnvConfig(process.cwd(), false); // false = not dev
r.loadedEnvFiles.forEach(f =&gt; console.log('loaded:', f.path));
console.log('winner:', process.env.NEXT_PUBLIC_API_URL);
</code></pre>
<p>On Next.js 16.3.3 that prints:</p>
<pre><code class="language-plaintext">loaded: .env.local
loaded: .env.production
loaded: .env
winner: http://localhost:5080
</code></pre>
<p>A production build, with a <code>.env.production</code> sitting right there, resolving a public API URL to localhost. No warning, no error, exit code 0.</p>
<h2>Why NEXT_PUBLIC_ turns a slip into a permanent artifact</h2>
<p>Next.js treats two kinds of variable very differently, and the difference decides whether this is a five-second fix or a rebuild.</p>
<ul>
<li><strong>Server-only variables</strong> (no <code>NEXT_PUBLIC_</code> prefix), read in Server Components, route handlers and other server code, are read from the process environment <em>at runtime</em>.</li>
<li><code>NEXT_PUBLIC_</code> <strong>variables</strong> are <em>inlined at build time</em>. The compiler replaces <code>process.env.NEXT_PUBLIC_API_URL</code> with the literal string and ships it inside the chunk the browser downloads.</li>
</ul>
<p>So after a bad build, the wrong value is not sitting in config anywhere. It is text inside a <code>.js</code> file:</p>
<pre><code class="language-plaintext">grep -rl "localhost:5080" .next/static/chunks | head
</code></pre>
<p>If that returns anything, no amount of fixing the server's environment will help. This is why the follow-up question is always some version of <em>"I corrected the variable and restarted the app and it still calls localhost"</em>. Of course it does. You changed the server; the string is in the browser's download. Only a rebuild moves it.</p>
<h2>Why it breaks on your machine and not in CI</h2>
<p>This bug has a very specific shape: it follows the person, not the project.</p>
<p><code>create-next-app</code> adds <code>.env*.local</code> to <code>.gitignore</code> by default. In this repository the ignore rule is even broader - line 35 is just <code>.env*</code>. Which means:</p>
<ul>
<li>A clean CI checkout has <strong>no</strong> <code>.env.local</code>. Builds there are correct, every time.</li>
<li>A developer machine has one, because that is what it is for. Builds there silently pick it up.</li>
</ul>
<p>If your pipeline builds in CI, you may never meet this. If you build locally and ship the artifact - zip the output, upload it through a hosting panel, copy a folder to a VPS - you are exactly the person it happens to. That is how this site shipped it. The build ran on a laptop that had a perfectly reasonable <code>.env.local</code> pointing at a local API, and the resulting zip went to production with that value fossilised inside it.</p>
<p>It is the same class of problem as deploying in the wrong order, which I wrote about in <a href="https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core">choosing between a Windows and Linux VPS for ASP.NET Core</a>: nothing throws, the artifact is valid, and the mistake only becomes visible to real users.</p>
<h2>The fix is a rename, not a rule</h2>
<p>The obvious reaction is "delete <code>.env.local</code> before building". That works exactly until the first time somebody forgets, which is to say it does not work.</p>
<p>Rename the file instead:</p>
<pre><code class="language-plaintext">mv .env.local .env.development.local
</code></pre>
<p>Go back to the precedence list. <code>.env.$(NODE_ENV).local</code> is only consulted when <code>NODE_ENV</code> matches, so <code>.env.development.local</code> is invisible to a production build while still beating everything during <code>next dev</code>. Same fixture files, same loader, after the rename:</p>
<pre><code class="language-plaintext">NODE_ENV=production
  loaded: .env.production, .env
  winner: https://api.prod.example.com

NODE_ENV=development
  loaded: .env.development.local, .env
  winner: http://localhost:5080
</code></pre>
<p>Development keeps its overrides. The production build cannot see them even if it wants to. No checklist, no discipline, nothing to remember at 11pm before a deploy - which is the only kind of fix worth having.</p>
<h2>What you can still change without rebuilding</h2>
<p>Because the two variable types behave differently, "can I change this without a rebuild?" has a clean answer:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Resolved</th>
<th>Change it by</th>
</tr>
</thead>
<tbody><tr>
<td><code>NEXT_PUBLIC_*</code></td>
<td>Build time, inlined into client JS</td>
<td>Rebuilding. Nothing else.</td>
</tr>
<tr>
<td>Everything else, used in server code</td>
<td>Runtime, from the process environment</td>
<td>Updating the service env and restarting.</td>
</tr>
</tbody></table>
<p>With <code>output: "standalone"</code> the server reads its environment when the Node process boots, so database connection strings, API keys and third-party secrets really are runtime configuration. You can rotate a key and restart. You cannot rotate a <code>NEXT_PUBLIC_</code> value that way, ever.</p>
<p>The practical rule: <strong>anything the browser can see is a build input, not a config knob.</strong> If you genuinely need a browser-visible value to differ per environment without rebuilding, do not reach for an env var - expose it from a small API route and fetch it at runtime. That is a deliberate design decision with a cost, and it is better than discovering the constraint during an incident.</p>
<h2>The Docker version of the same trap</h2>
<p>Containerised builds hit this twice, and the second one surprises people who thought they had understood the first.</p>
<p>The first is timing. In a Dockerfile, <code>next build</code> runs while the <em>image</em> is being built. Anything you pass at <code>docker run</code> - <code>-e</code>, <code>--env-file</code>, a Compose <code>environment:</code> block, a Kubernetes ConfigMap - arrives long after the bundle was written. Server-side variables are fine, because the server reads them when the process starts. <code>NEXT_PUBLIC_</code> values are not, so they have to be build arguments:</p>
<pre><code class="language-plaintext">ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
</code></pre>
<pre><code class="language-plaintext">docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
</code></pre>
<p>The practical consequence is one people dislike: a <code>NEXT_PUBLIC_</code> value that differs between staging and production means <strong>two different images</strong>. One image promoted through environments cannot carry two different public API URLs. If that matters to you, serve the value from an API route instead and keep the image environment-agnostic.</p>
<p>The second trap is the one that actually bites. <code>.gitignore</code> and <code>.dockerignore</code> are separate files, and the Next.js starter gives you a thorough <code>.gitignore</code> and a thin <code>.dockerignore</code>. So this very ordinary line:</p>
<pre><code class="language-plaintext">COPY . .
</code></pre>
<p>happily copies your local <code>.env.local</code> into the build context, where <code>next build</code> finds it and ranks it above <code>.env.production</code> - exactly as it did on my laptop. You get the localhost bug inside a container that was supposed to be reproducible, and because the file is invisible in Git nobody looking at the repository can see why. Add <code>.env*.local</code> to <code>.dockerignore</code>, or apply the rename and stop worrying about which ignore file is which.</p>
<h2>Which env files should actually be committed</h2>
<p>Since none of this is obvious from the filenames, here is the whole set in one table:</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Commit it?</th>
<th>What it is for</th>
</tr>
</thead>
<tbody><tr>
<td><code>.env</code></td>
<td>Usually yes</td>
<td>Defaults shared by every environment. No secrets.</td>
</tr>
<tr>
<td><code>.env.production</code></td>
<td>Yes</td>
<td>Non-secret production values, such as a public API URL.</td>
</tr>
<tr>
<td><code>.env.development.local</code></td>
<td>No</td>
<td>Your personal dev overrides. Invisible to production builds.</td>
</tr>
<tr>
<td><code>.env.local</code></td>
<td>No - and prefer not to have one</td>
<td>Overrides every environment, including production builds.</td>
</tr>
<tr>
<td><code>.env.example</code></td>
<td>Yes</td>
<td>Documentation. Every key, no real values.</td>
</tr>
</tbody></table>
<p>Real secrets - database passwords, payment keys, tokens - should not be in any committed file. They belong in the hosting platform's environment settings, where they are read at runtime and can be rotated without a build. The committed files exist to answer "which variables does this app need?", which is what <code>.env.example</code> is for, and the question new developers waste their first morning on.</p>
<h2>A sixty-second audit</h2>
<p>Worth doing once on any Next.js project you deploy:</p>
<ol>
<li><code>ls -a | grep env</code> - list every env file that exists locally. Anything named <code>.env.local</code> or <code>.env.production.local</code> is a live hazard on a local build.</li>
<li>Run the <code>@next/env</code> snippet above with <code>NODE_ENV=production</code> and read which files it loads. Takes five seconds and removes all doubt.</li>
<li>After your next build, grep the output for values that should never reach production: <code>grep -rl "localhost" .next/static/chunks</code>.</li>
<li>Make step 3 a build step rather than a habit. A grep that exits non-zero is a better teammate than a wiki page.</li>
</ol>
<p>That last point is the general lesson. The staging script for this site asserts that a handful of required files are present before it will produce a deployable folder, because every one of those assertions exists thanks to something that went wrong silently once. Build-time checks are cheap. Finding out from a user that your production site is calling localhost is not.</p>
<p>If you are putting a Next.js front end over an existing .NET backend, the same build-versus- runtime split shapes a lot of the architecture - I went through that in detail in <a href="https://knowledgemarkg.com/blog/aspnet-mvc-to-react-migration">migrating an ASP.NET MVC monolith to a React front end</a>.</p>
<h2>FAQ</h2>
<p><strong>Does .env.local override .env.production in Next.js?</strong></p>
<p>Yes. Next.js resolves variables in the order process.env, .env.\((NODE_ENV).local, .env.local, .env.\)(NODE_ENV), .env - and the first source that defines a variable wins. Because .env.local is checked before .env.production, a leftover .env.local silently overrides your production values during next build.</p>
<p><strong>Is .env.local loaded during next build, or only next dev?</strong></p>
<p>It is loaded during both. The only time Next.js skips .env.local is when NODE_ENV is test. A production build reads it like any other env file, which is what makes this such a common and quiet failure.</p>
<p><strong>I changed a NEXT_PUBLIC_ variable on the server and restarted. Why is it still the old value?</strong></p>
<p>Because NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time. The old value is a literal string inside a file in .next/static/chunks that the browser downloads. Changing the server environment cannot alter a file that was written during the build - only a rebuild can.</p>
<p><strong>Why does the build work correctly in CI but not on my machine?</strong></p>
<p>Because .env*.local is gitignored by default, so it exists on developer machines and never in a clean CI checkout. CI builds get the correct values; local builds pick up the local override. Projects that build locally and upload the artifact are the ones exposed.</p>
<p><strong>Should I just delete .env.local before building?</strong></p>
<p>It works, but it depends on a human remembering every single time. Renaming it to .env.development.local is better: that file is only loaded when NODE_ENV is development, so a production build cannot see it, while next dev still gets your local overrides.</p>
<p><strong>Can I change environment variables at runtime with output: standalone?</strong></p>
<p>For server-side variables, yes - the standalone server reads the process environment when the Node process starts, so you can update a connection string or API key and restart. For NEXT_PUBLIC_ variables, no. Those are fixed at build time regardless of output mode.</p>
<p><strong>Does .env.production.local override .env.local?</strong></p>
<p>Yes. .env.$(NODE_ENV).local is the highest-priority file, above .env.local, which is above .env.production, which is above .env. Only real process environment variables outrank all of them.</p>
<h2>Related on <a href="http://knowledgemarkg.com">knowledgemarkg.com</a></h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents</a></li>
<li><a href="https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html">ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML</a></li>
<li><a href="https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache">Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header Before You Debug Anything</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML]]></title><description><![CDATA[Unstyled pages and ChunkLoadError after a Next.js deploy usually are not a webpack problem. Cached HTML is asking for chunks that no longer exist.]]></description><link>https://knowledgemarkg.hashnode.dev/chunkloaderror-nextjs-after-deploy-cdn-cached-html</link><guid isPermaLink="true">https://knowledgemarkg.hashnode.dev/chunkloaderror-nextjs-after-deploy-cdn-cached-html</guid><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[caching]]></category><dc:creator><![CDATA[Kausar Raza]]></dc:creator><pubDate>Mon, 02 Nov 2026 07:46:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae8385f19be44d2c35fd547/c5075854-f1c6-4399-8893-fc85ee3f9618.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Originally published on</em> <a href="https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html"><em>knowledgemarkg.com</em></a><em>.</em></p>
<p>You deploy. The site comes back with no styling, or half the interactive bits are dead, and the console says:</p>
<pre><code class="language-plaintext">ChunkLoadError: Loading chunk 478 failed.
(missing: https://example.com/_next/static/chunks/3ivcc6t1s04dh.js)
</code></pre>
<p>You check the server. The file is not there — but a file with a <em>different</em> name is, and it contains exactly the code that is missing. Rebuild, redeploy, same thing. Open the site in a private window and it is perfect.</p>
<p>Almost everything written about this error blames the service worker, or webpack's <code>output.publicPath</code>, or tells you to catch the import and retry. On a Next.js site behind a CDN the cause is usually none of those.</p>
<h2>Why the chunk name changes every build</h2>
<p>Next.js fingerprints its JavaScript. A real filename from this site:</p>
<pre><code class="language-plaintext">/_next/static/chunks/3ivcc6t1s04dh.js
</code></pre>
<p>That name is derived from the file's contents. Change one line of code and the chunk gets a new name, because the point of the hash is that the old URL can be cached forever and never go stale. It is a good design, and it has one consequence people forget: <strong>the HTML is the only thing that knows the current chunk names.</strong></p>
<p>Every page ships a <code>&lt;script src&gt;</code> naming the exact chunks that build produced. Deploy a new build and those names change. The old chunks are gone, because your deploy replaced the folder.</p>
<p>So if a visitor is handed <em>yesterday's HTML</em>, it asks for yesterday's chunks, and yesterday's chunks 404. The browser calls that ChunkLoadError. Nothing is wrong with the build.</p>
<h2>Who is handing out yesterday's HTML</h2>
<p>Normally nobody. HTML is not supposed to be cached hard — that is what the hashed assets are for. But it is very easy to end up caching it by accident:</p>
<ul>
<li><strong>A CDN cache rule that matches everything.</strong> This is what happened here. A Cloudflare Cache Rule was added to cache HTML at the edge, with the edge TTL taken from the origin's <code>s-maxage</code>. It worked exactly as configured — including after a deploy.</li>
<li><strong>A reverse proxy with</strong> <code>proxy_cache</code> <strong>on the HTML location.</strong> Same shape, different box.</li>
<li><strong>A hosting panel's "performance" or "page cache" toggle</strong>, which usually means "cache HTML" without saying so.</li>
<li><strong>A service worker</strong> serving a cached shell. This is the one everyone writes about, and it is the least likely of the four if you did not add one deliberately.</li>
</ul>
<p>One request tells you which world you are in:</p>
<pre><code class="language-plaintext">curl -s -o /dev/null -D - https://example.com/ | grep -i 'cf-cache-status\|^age:'
</code></pre>
<p>On this site that returns:</p>
<pre><code class="language-plaintext">cf-cache-status: HIT
Age: 145
</code></pre>
<p><code>HIT</code> means the HTML came from the edge, not from your app. <code>Age</code> is how many seconds ago it was stored — and if that number is larger than the time since your deploy, you have found your problem. Any CDN exposes an equivalent; look for <code>X-Cache</code>, <code>CF-Cache-Status</code>, or a growing <code>Age</code> on repeat requests.</p>
<h2>Narrowing it down in one pass</h2>
<p>Four caches can produce this, and they need different fixes. The response headers separate them faster than reading any code:</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>Which cache</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>cf-cache-status: HIT</code>, <code>Age</code> older than your deploy</td>
<td>CDN edge</td>
<td>Purge the CDN</td>
</tr>
<tr>
<td>No CDN header, <code>Age</code> grows on repeat requests</td>
<td>A reverse proxy in front of the app</td>
<td>Stop caching HTML in the server block</td>
</tr>
<tr>
<td><code>x-nextjs-cache: STALE</code> or <code>HIT</code></td>
<td>Next.js's own ISR cache</td>
<td>Request again; or revalidate the route</td>
</tr>
<tr>
<td>Headers all look current, only some visitors affected</td>
<td>A service worker on their machine</td>
<td>Fix the worker's update strategy</td>
</tr>
</tbody></table>
<p>The service worker is the one every search result talks about, so it is worth saying plainly when it <em>is</em> the answer: only if you registered one. A Next.js app does not ship a service worker by default. If you added one through a PWA plugin and it uses a cache-first strategy for navigation requests, it will keep serving an old shell long after the CDN is clean — and the giveaway is that the problem follows particular people rather than particular pages. DevTools → Application → Service Workers, tick "Update on reload", and reload.</p>
<h2>The fix, and the habit</h2>
<p>The fix is one action: <strong>purge the CDN cache as the last step of every deploy.</strong> On Cloudflare that is Caching → Configuration → Purge Everything. Not "purge by URL" — you do not know which pages a visitor will land on, and every cached page is naming dead chunks.</p>
<p>Then prove it, because a purge that did not happen looks identical to one that did:</p>
<pre><code class="language-plaintext">for i in 1 2; do
  curl -s -o /dev/null -D - https://example.com/ | grep -i cf-cache-status
done
</code></pre>
<p>The first request should be <code>MISS</code> (or <code>EXPIRED</code>) and the second <code>HIT</code>. Two HITs in a row straight after a deploy means the purge did not take.</p>
<p>Pages that must never be cached should say so themselves rather than relying on a rule. On this site <code>/blog</code>, <code>/pricing</code> and <code>/search</code> send <code>private, no-cache, no-store</code> and show <code>cf-cache-status: BYPASS</code>. That is worth checking deliberately — a logged-in page served from a shared edge cache is a much worse problem than a missing stylesheet.</p>
<p>If you cannot purge as part of the deploy, do not cache the HTML. The hashed assets are where the win is anyway; caching the document saves a few milliseconds and costs you this.</p>
<h2>Let the origin decide, not a dashboard rule</h2>
<p>The sturdier version of this is to stop expressing the policy as a CDN rule at all, and send it from the application instead. Two headers do the whole job, and they say opposite things on purpose:</p>
<pre><code class="language-plaintext">/_next/static/...   Cache-Control: public, max-age=31536000, immutable
/  (the document)   Cache-Control: public, s-maxage=300, stale-while-revalidate=...
</code></pre>
<p>The first is safe precisely because the filename contains a hash — a given URL's contents can never change, so a year is not a risk. The second lets the edge hold the document briefly and serve a slightly stale copy while it fetches a new one, which is the behaviour you actually want between deploys.</p>
<p>A CDN configured to respect the origin then needs no per-site rules, and a page that must never be cached can opt out by itself. That is how <code>/blog</code> on this site ends up <code>BYPASS</code> — it sends <code>no-store</code>, and nothing in a dashboard has to remember that it is special.</p>
<p>It still does not remove the need to purge after a deploy. Even a 300-second edge TTL is 300 seconds of visitors being handed dead chunk names.</p>
<h2>A deploy order that does not produce this</h2>
<ol>
<li><strong>Build with a cold data cache</strong> — <code>rm -rf .next/cache/fetch-cache</code> before <code>npm run build</code>, for the reason in the next section.</li>
<li><strong>Check the build output matches reality</strong> before shipping it. Count the pages it prerendered against what you expect.</li>
<li><strong>Upload the new files</strong>, leaving the old ones in place where you can. Hashed chunks have unique names, so the old and new sets can coexist — and a visitor mid-session still holding old HTML keeps working.</li>
<li><strong>Purge the CDN.</strong> Last, after the files are live, never before.</li>
<li><strong>Verify</strong> with two requests, as above.</li>
</ol>
<p>Step 3 is the one people skip because deleting everything feels cleaner. It is also the step that turns a five-minute window of broken pages into none, and on this stack deleting the whole folder has already cost real data once.</p>
<h2>The same bug wearing a different hat: a stale build cache</h2>
<p>There is a second way a deploy ships something old, and it catches people who have already fixed the first one.</p>
<p>Next.js keeps the data it fetched during a build in <code>.next/cache/fetch-cache</code>, and <strong>that directory survives between builds.</strong> A <code>fetch</code> with <code>revalidate: 3600</code> that was made an hour ago is still considered fresh, so a brand new build can prerender pages from the previous build's API responses.</p>
<p>I reproduced this an hour before writing this post. A build run with a warm cache prerendered <strong>38</strong> tool pages. The live API returns <strong>54</strong>. The code was correct; the build was answering from a cached copy of the catalogue. Deleting the directory and rebuilding produced all 54.</p>
<pre><code class="language-plaintext">rm -rf .next/cache/fetch-cache
npm run build
</code></pre>
<p>Nothing warns you. The build succeeds, exit code 0, no warnings — it just quietly ships a sitemap that is missing a third of your pages. If your build fetches a catalogue, a menu or a list of posts, clear this before a release build.</p>
<h2>The pattern worth keeping</h2>
<p>Three times now on this site a "deployment bug" has turned out to be something cached: <a href="https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache">a page that would not update</a> was Next.js's own cache serving STALE, a half-updated site was <a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">Windows locking files the running app had open</a>, and this one was the CDN.</p>
<p>The habit that catches all of them: <strong>when a deploy produces a result that makes no sense, read the response headers before you read your code.</strong> <code>cf-cache-status</code>, <code>x-nextjs-cache</code>, <code>age</code> and <code>etag</code> will tell you in one request whether you are even looking at the build you just shipped. Most of the time you are not, and there is no bug to find.</p>
<h2>FAQ</h2>
<p><strong>Why does a hard refresh fix it for me but not for visitors?</strong></p>
<p>A hard refresh asks the CDN for a fresh copy for you. Everyone else is still served the cached HTML until it expires or you purge it. That is also why the site looks fine in a private window - it is the same cache, not your browser.</p>
<p><strong>Should I just disable caching for HTML?</strong></p>
<p>If you cannot purge reliably as part of your deploy, yes. The large win from a CDN is the hashed static assets, which can be cached for a year safely. Caching the document saves a little latency and introduces this failure.</p>
<p><strong>How do I know whether it is the CDN or a service worker?</strong></p>
<p>Check the response headers first. A CF-Cache-Status or X-Cache HIT, or an Age header that grows on repeat requests, means a shared cache answered. If the headers look fresh and only some visitors are affected, look at the service worker in DevTools → Application.</p>
<p><strong>Does purging by URL work instead of purging everything?</strong></p>
<p>Rarely. Every cached page references the old chunk names, not just the one you changed, so you would have to purge every page you have ever served. Purge everything after a deploy and let the edge refill.</p>
<p><strong>Why did my build prerender fewer pages than my API returns?</strong></p>
<p>Almost certainly .next/cache/fetch-cache. Next keeps fetched API responses between builds and honours their revalidate window, so a new build can render from the previous build's data. Delete the directory before a release build.</p>
<p><strong>Is ChunkLoadError always a caching problem?</strong></p>
<p>No. It can be a genuinely failed network request, an ad blocker, or a misconfigured publicPath when assets are served from another domain. But if it started right after a deploy and goes away on its own after a while, that time is a cache TTL expiring.</p>
<p><strong>Can I automate the purge?</strong></p>
<p>Yes - Cloudflare has an API endpoint for purge_cache, and most CDNs have an equivalent. Put it at the very end of the deploy script, after the files are in place, and have it fail loudly. A purge that silently did not run looks exactly like one that did.</p>
<h2>Related on <a href="http://knowledgemarkg.com">knowledgemarkg.com</a></h2>
<ul>
<li><a href="https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache">Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header Before You Debug Anything</a></li>
<li><a href="https://knowledgemarkg.com/blog/cloudflare-speed-settings-brotli-vs-zstd-vs-gzip">Cloudflare Speed Settings That Help, and Brotli vs Zstd vs Gzip Measured</a></li>
<li><a href="https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows">Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents</a></li>
</ul>
]]></content:encoded></item></channel></rss>