Knowledge Mark G
Hire Me

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

Most guides to running Next.js on IIS assume a VPS you control and a reverse proxy to PM2. On Plesk shared Windows hosting you have neither. Here is the setup that works, and the four failures that cost me the most time.

Kausar RazaBy Published on · 8 min read · 9 views
Share:

Web Development

Deploying Next.js on IIS and Plesk: The Windows Traps Nobody Documents
  • Hosting
  • Windows Server
  • DevOps
  • Web Development

Need help with this?

I build and fix business websites - fast, mobile-first and built to bring enquiries.

Get in touch

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 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 and most of that reasoning carries over. If you are newer to the framework itself, the complete Next.js tutorial is a better starting point than the host.

Frequently asked questions

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.

Join the conversation

No comments yet — be the first to share what you think.

Leave a comment

Never published.

Optional.

Keep reading

Related articles

View all