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/staticin the deploy folderpublic→ copy topublicin 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:
- Put an
app_offline.htmin the application root. IIS stops the app and serves that file for every request. - Delete the old files.
- Extract the new build.
- 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.configredirects, and everyNEXT_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.







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