# Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents

*Originally published on [knowledgemarkg.com](https://knowledgemarkg.com/blog/deploy-nextjs-on-iis-plesk-windows).*

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 `localhost:3000`. 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 `applicationHost.config`. 

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. 

## What you are actually working with 

Plesk's Windows Node.js support runs your app through **iisnode**, 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. 

That last part matters more than it sounds. It is where the 404 problem further down comes from. 

## Build for standalone output 

Set the output mode in `next.config.ts`: 

```
const nextConfig = {
  output: "standalone",
};
export default nextConfig;
``` 

A standalone build produces a self-contained `server.js` plus a pruned `node_modules` holding only what the app actually imports. On shared hosting, where you are not running `npm install` on the server, that is the difference between a 25 MB upload and a 400 MB one. 

The trap is that `next build` does **not** put everything you need into `.next/standalone`. Two directories are left out by design, and the app boots perfectly without them: 
 
- `.next/static` → copy to `.next/static` in the deploy folder 
- `public` → copy to `public` in the deploy folder 
 

Miss either one and you get a 200 response, correct HTML, and a completely unstyled page with no JavaScript. Nothing errors, nothing logs. 

## What the deploy folder has to look like 

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: 

```
deploy-folder/
  server.js            <- from .next/standalone
  app.js               <- the iisnode shim, written by hand
  web.config           <- written by hand
  package.json         <- written by hand, not the project one
  node_modules/        <- from .next/standalone, already pruned
  .next/
    BUILD_ID
    server/            <- from .next/standalone
    static/            <- copied separately, see above
  public/              <- copied separately, see above
``` 

Two of those are worth a comment. The `package.json` 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: `node_modules` 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. 

Finally, anything in `public/` 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. 

## The web.config that works 

Keep it minimal. Every section you add is a section that might be locked at server level: 

```
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <handlers>
      <add name="iisnode" path="app.js" verb="*" modules="iisnode" />
    </handlers>

    <rewrite>
      <rules>
        <rule name="DynamicContent">
          <match url="/*" />
          <action type="Rewrite" url="app.js" />
        </rule>
      </rules>
    </rewrite>

    <httpErrors existingResponse="PassThrough" />

    <iisnode loggingEnabled="false" devErrorsEnabled="false" />
  </system.webServer>
</configuration>
``` 

The handler path has to match a real file, so `app.js` is a two-line shim that requires the standalone server: 

```
process.env.PORT = process.env.PORT || 3000;
require("./server.js");
``` 

## Trap 1: one locked section takes the whole site down 

Shared hosting locks several `system.webServer` sections at server level - `<security><requestFiltering>` is the usual culprit. Writing a locked section into your site's `web.config` does not produce a message about that section. IIS fails to parse **the entire file**, and every URL returns an empty HTTP 500, including static files that never reach Node. 

It reads exactly like an application crash. It is a configuration parse error. The distinguishing signal is that `/favicon.ico` fails too - if Node were the problem, static files would still serve. 

Keep a known-good copy of `web.config` beside the live one so the rollback is a file copy rather than a debugging session. 

One correction to the folklore: `<httpErrors>` is widely *assumed* to be locked and frequently is not. Plesk's own "Custom error documents" feature writes that exact section into site `web.config` files, which it could not do if the section were locked. Test it rather than avoiding it - and test by loading your **homepage**, not a 404, because a locked section breaks everything, not just error pages. 

## Trap 2: IIS throws away your custom 404 page 

This one is invisible unless you go looking. Your `app/not-found.tsx` renders correctly, the status code is a correct 404, and visitors still get the grey IIS page reading *404 - File or directory not found.* 

Here is how to prove where it happens. Request a missing URL and read the headers: 

```
curl -s -D - -o /dev/null https://example.com/no-such-page
``` 

If the response carries `x-nextjs-cache` and `x-nextjs-prerender` 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. 

The cause is IIS's `existingResponse` behaviour, which defaults to replacing the body of any response with an error status. One line fixes it: 

```
<httpErrors existingResponse="PassThrough" />
``` 

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 *500* bodies - which is exactly why `devErrorsEnabled` is `false` in the config above. 

## Trap 3: environment variables are not what you think 

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. 

**`NEXT_PUBLIC_*` is inlined at build time.** Anything with that prefix is compiled into the JavaScript bundle during `next build`. 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. 

**`.env.local` loads in production builds too, and it wins.** It is not a development-only file, and it overrides `.env.production`. I wrote about [how .env.local silently overrides .env.production](https://knowledgemarkg.com/blog/nextjs-env-local-overrides-env-production) separately, because it once baked a localhost API URL into a live build. The safest habit is to name the file `.env.development.local`, which cannot load in a production build by accident. 

## Trap 4: deploy order, because Windows locks files 

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. 

The order that works: 
 
1. Put an `app_offline.htm` in the application root. IIS stops the app and serves that file for every request. 
2. Delete the old files. 
3. Extract the new build. 
4. Remove `app_offline.htm`. 
 

Before uploading, confirm the archive actually contains the change you made. A stale build zips just as happily as a fresh one: 

```
grep -rl "a string unique to this change" staging/.next/static/chunks | head
``` 

## What you do and do not have to redeploy for 

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: 
 
- **No redeploy needed** - new or edited content coming from your API or database, and anything a route handler builds from it. If your sitemap route has `revalidate = 3600`, a new page appears in it within the hour on its own. 
- **Redeploy needed** - anything compiled: components, metadata exported from a page, `next.config` redirects, and every `NEXT_PUBLIC_*` value. 
 

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 `x-nextjs-cache: STALE`, 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. 

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. 

## Debugging a 500 you cannot see 

If the app will not boot you get a 500 with an empty body and nothing to read. Turn iisnode's diagnostics on temporarily: 

```
<iisnode loggingEnabled="true" devErrorsEnabled="true" />
``` 

That writes stdout and stderr into an `iisnode` folder beside the app and puts the error detail in the response body. Read it, fix the boot failure, then **turn both back off** - especially with `existingResponse="PassThrough"` enabled, because together they will show stack traces to the public. 

One more: if Plesk's Node.js panel offers to "Auto-configure" the application, decline. It generates its own `web.config` and overwrites yours, including the handler path and the rewrite rule the standalone build depends on. 

## Is this the right place to host Next.js at all? 

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. 

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 [Windows VPS against Linux VPS for ASP.NET Core](https://knowledgemarkg.com/blog/windows-vps-vs-linux-vps-aspnet-core) and most of that reasoning carries over. If you are newer to the framework itself, the [complete Next.js tutorial](https://knowledgemarkg.com/blog/nextjs-15-tutorial-complete-guide-from-beginner-to-advanced-2025) is a better starting point than the host.

## FAQ

**Can you run Next.js on IIS without a reverse proxy?**

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.

**Why is my Next.js site loading with no CSS after deploying to IIS?**

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.

**Why does IIS show its own 404 page instead of my Next.js not-found page?**

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.

**Why does every URL return an empty 500 after I edited web.config?**

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.

**Why is my NEXT_PUBLIC environment variable empty in production?**

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.

**Why did my deploy only partly update the site?**

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.

**Is Plesk on Windows a reasonable place to host Next.js?**

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.

## Related on knowledgemarkg.com

- [Deploying ASP.NET Core to Plesk on Windows: Every Error I Hit](https://knowledgemarkg.com/blog/deploy-aspnet-core-plesk-500-errors)
- [ChunkLoadError After a Next.js Deploy: Your CDN Cached the HTML](https://knowledgemarkg.com/blog/chunkloaderror-nextjs-after-deploy-cdn-cached-html)
- [Your Next.js Page Is Not Updating: Read the x-nextjs-cache Header First](https://knowledgemarkg.com/blog/nextjs-page-not-updating-x-nextjs-cache)

