Have you ever wondered why we develop a React application using:
npm run devbut when it is time to deploy the exact same application, everyone suddenly tells us to run:
npm run buildWhy can't we simply install Node.js on the production server, run npm run dev, and call it a day?
After all, the application works perfectly fine on our laptop.
This is a very reasonable question, especially when you're starting to learn how frontend applications are deployed.
The short answer is:
You can run
npm run devin production. But you usually shouldn't.
To understand why, we need to look at what actually happens when we develop and build a modern frontend application.
What Actually Happens When You Run npm run dev?
Let's take a typical React application created using Vite.
Your project might look something like this:
my-react-app/
├── public/
├── src/
│ ├── components/
│ ├── App.jsx
│ └── main.jsx
├── package.json
└── vite.config.jsDuring development, you probably run:
npm run devYour package.json might contain:
{
"scripts": {
"dev": "vite"
}
}When you execute this command, Vite starts a development server.
Conceptually, the architecture looks like this:
Browser
│
│ HTTP
▼
┌──────────────────────┐
│ Vite Development │
│ Server │
│ │
│ • Transforms JSX │
│ • Resolves modules │
│ • Generates maps │
│ • Watches files │
│ • Provides HMR │
└──────────┬───────────┘
│
▼
src/*.jsxThis development server does quite a lot of work for us.
For example, browsers don't directly work with our development environment in exactly the same way that we write it.
We may be using:
- JSX
- TypeScript
- npm packages
- environment variables
- imported CSS
- React components
- development source maps
Vite sits between our source code and the browser and provides the development experience around all of this.
The Development Server Is Built for Developers
There's another important feature you probably use every day without thinking much about it.
Suppose you change:
<h1>Hello World</h1>to:
<h1>Hello Universe</h1>and save the file.
The browser updates almost immediately.
You didn't rebuild your application.
You didn't restart the server.
You probably didn't even refresh the browser.
That's because tools such as Vite provide Hot Module Replacement, commonly called HMR.
The development server watches your files:
You edit App.jsx
│
▼
Vite detects the change
│
▼
Transforms the changed module
│
▼
Sends the update to the browser
│
▼
Browser updates the applicationThis is fantastic while developing.
But think about production for a moment.
Are your users editing App.jsx?
No.
Do production users need HMR?
No.
Does production need to continuously watch your source files?
Usually, no.
So we're running functionality that exists primarily to make developers productive, not to serve production users efficiently.
So What Does npm run build Do?
Now let's run:
npm run buildWith Vite, this usually produces a directory called:
dist/For example:
dist/
├── index.html
└── assets/
├── index-Bx73a91.js
├── index-D2x91s.css
└── logo-C91ab3.svgSomething very important has happened here.
Our development source code has been converted into production-ready assets.
Conceptually:
React source code
│
│
▼
┌──────────────────────┐
│ Production Build │
│ │
│ JSX transformation │
│ Bundling │
│ Tree shaking │
│ Minification │
│ Code splitting │
│ Asset processing │
│ Content hashing │
└──────────┬───────────┘
│
▼
dist/
│
├── index.html
├── *.js
├── *.css
└── images/assetsThe work that the development tooling was doing repeatedly during development has now largely been done once, ahead of time.
Your React Application Becomes Static Files
This is probably the most important concept in this entire article.
A typical client-side React application does not need Node.js to execute React in production.
After building the application, you essentially have files such as:
index.html
app.js
styles.css
logo.svgThese are just static files.
A browser can request them over HTTP:
GET /index.html
GET /assets/index-Bx73a91.js
GET /assets/index-D2x91s.cssThe server doesn't need to understand React.
It doesn't need to understand JSX.
It doesn't even need to know that the application was originally built using React.
Its job is simply:
"Someone requested this file. Send them this file."
The browser downloads the JavaScript and executes your React application.
That distinction is extremely important.
Server
│
│ sends HTML/JS/CSS
▼
Browser
│
│ executes JavaScript
▼
React application runsReact is primarily running in the user's browser, not on your web server.
This Is Why React Can Be Hosted on S3
Once you understand that a production React build is mostly static files, something else starts making sense.
Why can we host React applications using object storage such as Amazon S3?
Because S3 doesn't need to execute React.
Imagine our CI/CD pipeline runs:
npm ci
npm run buildWe now have:
dist/
├── index.html
└── assets/
├── index-Bx73a91.js
└── index-D2x91s.cssWe upload those files to S3.
Our architecture can then look like:
Developer
│
│ git push
▼
CI/CD Pipeline
│
│ npm ci
│ npm run build
▼
dist/
│
│ upload
▼
┌───────────────┐
│ Amazon S3 │
│ │
│ index.html │
│ *.js │
│ *.css │
│ images │
└───────┬───────┘
│
▼
CloudFront
│
▼
UsersNotice what is missing.
There is no:
npm run devThere may not even be a Node.js server.
There is no continuously running frontend application process that we need to manage.
Node.js was useful during the build process.
Once the build is finished, our hosting layer primarily needs to serve the generated files.
But Couldn't We Just Run npm run dev on an EC2 Server?
Yes.
Technically, you could do something like:
git clone <repository>
cd frontend
npm install
npm run dev -- --host 0.0.0.0You could then place that server behind a load balancer:
Internet
│
▼
Load Balancer
│
▼
EC2
│
▼
Node.js
│
▼
Vite Dev Server
│
▼
React source codeAnd yes, your application may work.
But now ask yourself:
Why are we running all of this infrastructure?
Our production environment now needs to care about:
Node.js runtime
+
npm dependencies
+
Vite
+
development server
+
filesystem watchers
+
HMR infrastructure
+
source transformationAll we ultimately wanted to deliver was:
HTML
CSS
JavaScript
ImagesThat's unnecessary complexity for a frontend that can be served statically.
Think About 10,000 Users
The difference becomes easier to understand when we think about scale.
Suppose 10,000 users visit our application.
If we're relying on a running application server, the architecture might look something like:
10,000 users
│
▼
Load Balancer
│
▼
Application Servers
│
▼
Node.js Processes
│
▼
Frontend AssetsNow we potentially need to think about:
- CPU
- memory
- server availability
- process crashes
- health checks
- scaling
- Node.js versions
- dependency vulnerabilities
- container security
- operating system patching
Compare that with static hosting:
10,000 users
│
▼
CDN
┌───┼───┐
▼ ▼ ▼
Edge Edge Edge
Cache Cache Cache
│
└───────┐
▼
Object StorageA CDN such as CloudFront can cache your JavaScript, CSS, images, and other assets at edge locations.
Many user requests can therefore be served directly from the CDN cache.
Your origin may not even receive most of those requests.
Production Builds Also Help With Browser Caching
You might have noticed filenames like this after running a build:
index-Bx73a91.jsWhy doesn't the build simply generate:
index.jsOne reason is cache busting.
Imagine version 1 of your application produces:
index-Bx73a91.jsA browser or CDN can cache this file for a long time.
Now you deploy a new version.
The build might produce:
index-Xa82b14.jsBecause the contents changed, the generated hash changed.
The browser sees a completely different URL:
/assets/index-Bx73a91.jsversus:
/assets/index-Xa82b14.jsTherefore, we can aggressively cache static assets without worrying that users will keep receiving an old version forever.
A simplified deployment might look like:
Version 1
index.html
│
└── index-Bx73a91.js
Version 2
index.html
│
└── index-Xa82b14.jsThis is one of the reasons production builds and CDNs work so well together.
What About Nginx?
You may have seen production Dockerfiles that do something like:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/htmlAt first glance, this might be confusing.
If React doesn't need a server, why are we running Nginx?
Because something still needs to speak HTTP and deliver the files.
Nginx isn't executing React.
It's simply doing something like:
Browser
│
│ GET /
▼
Nginx
│
▼
index.html
Browser
│
│ GET /assets/app.js
▼
Nginx
│
▼
app.jsSo these are two very different architectures:
Development
Browser
│
▼
Vite Development Server
│
▼
Source Codeand:
Production
Browser
│
▼
Nginx
│
▼
Pre-built Static FilesNginx is acting as a highly optimized static web server.
The same role could instead be handled by something such as S3 + CloudFront.
Does npm run build Always Mean Static Files?
No, and this is an important distinction.
Everything we've discussed so far applies primarily to a client-side Single Page Application, such as a typical React application built using Vite.
Other frameworks may work differently.
For example, a framework that performs server-side rendering may require:
Source Code
│
▼
npm run build
│
▼
Production Application
│
▼
Node.js Server
│
▼
UsersIn that situation, the server is doing actual work at runtime.
It may be:
- rendering HTML
- executing server-side code
- handling API routes
- performing authentication
- running middleware
So we should distinguish between three different concepts.
Development server
npm run dev
│
▼
Development tooling
│
▼
Fast development experienceStatic production application
npm run build
│
▼
HTML + CSS + JavaScript
│
▼
S3 / CDN / NginxServer-rendered production application
npm run build
│
▼
Production application
│
▼
npm start
│
▼
Node.js / Container / ServerThe exact deployment architecture therefore depends on the framework and how the application is rendered.
Development and Production Have Different Goals
This is ultimately the reason we have separate development and production commands.
A development environment optimizes for developer experience.
We want:
- fast startup
- hot reload
- useful errors
- source maps
- file watching
- easy debugging
Production optimizes for users and operational efficiency.
We want:
- smaller assets
- fast downloads
- caching
- reliability
- security
- scalability
- predictable deployments
Those are different requirements.
So it makes sense that we use different tooling for each environment.
A Simple Mental Model
If you remember only one thing from this article, remember this:
npm run devprepares your application continuously while you're developing it.
While:
npm run buildprepares the application once so production doesn't have to.
For a typical React/Vite SPA, the deployment process therefore looks like:
Git Repository
│
│ git push
▼
CI/CD Pipeline
│
├── npm ci
│
└── npm run build
│
▼
dist/
│
▼
Object Storage
│
▼
CDN
│
▼
Browser
│
▼
React executes hereOnce you understand this, a lot of frontend deployment concepts suddenly become easier to reason about.
Why can React be hosted on S3?
Because the production output is static files.
Why put CloudFront in front of it?
Because those static files are excellent candidates for CDN caching.
Why don't we need Node.js running permanently?
Because Node.js was primarily needed to build the client-side application, not execute it for every user.
And why shouldn't we normally run npm run dev in production?
Not because it is impossible.
But because the development server is solving a problem that production no longer has.
Where Do We Go From Here?
There is one interesting question left:
If React can simply be hosted as static files, why do we so often see React applications packaged into Docker containers and served through Nginx on ECS or Kubernetes?
That deserves its own explanation.
Because once we understand the difference between:
React
Node.js
Nginx
Docker
S3
CloudFrontand the role each one actually plays, choosing the right frontend deployment architecture becomes much easier.