REACT (https://www.kirupa.com/react/index.htm)
BOOK (https://www.amazon.com/exec/obidos/ASIN/0134546318/kirupacom)
Deploying Your React App
by kirupa (https://www.kirupa.com/me/index.htm) | filed under Learning React (https://www.kirupa.com/react/index.htm)
You have a React app that works great on your computer. The only person who can see it is you, and only while npm run dev is running. That's a little lonely. At some point, you'll want to put your app on the internet so that other people (your friends, your users, a hiring manager) can open a link and see it.
This is called deploying, and it sounds scarier than it is. For most React apps, deploying means taking a folder of plain files and handing it to a service that will serve those files to anyone who asks. In this tutorial, we'll go from running a build to having a live URL. Along the way, we'll deal with the things that trip people up the first time: environment variables, apps that live in a sub-folder, and the dreaded "404 on refresh".
Onwards!
What Deploying Actually Means
When you run your app with the dev server, Vite is doing a lot of work on the fly. It turns your JSX into JavaScript, serves your modules one at a time, and refreshes the page when you save. That is wonderful while you are building, but none of it is what you want in front of real visitors.
For the real thing, we do a build. Vite takes your source code, bundles it into a small number of files, minifies them, and puts the result in a folder. That folder is the only thing you need to deploy. A host (Cloudflare Pages, Netlify, Vercel, GitHub Pages, or your own server) copies those files to servers around the world, and a visitor's browser downloads them and runs your React code. The whole flow looks like this:
[Image: Five boxes connected by arrows: Source (.jsx plus imports), Build (npm run build), dist/ (static files), Host plus CDN (copies of dist near your visitors), and Browser (downloads files and runs the JS).] (https://www.kirupa.com/react/images/deploy_pipeline_144.png)
Notice what is missing from the right side of that diagram: Node. A standard Vite and React app is what's called a single-page app (or SPA), and once it is built, it is just HTML, CSS, and JavaScript files. Any host that can serve files can serve your app. This is why deploying one is so cheap, and often free.
There is one important exception. If your app needs code running on a server (rendering pages on the server, talking to a database, keeping secrets), then static hosting isn't enough. We'll get to that near the end.
Our Example App
For the rest of this tutorial, we'll use a tiny app called Leafy. It was created the same way we created our projects in Easily Set Up Your React Dev Environment (https://www.kirupa.com/react/setting_up_react_environment.htm), with npm create vite@latest and the React template, and then I installed React Router with npm install react-router. It has a home page that lists three plants, a page for each plant at addresses like /plants/pothos, and an About page. If any of that routing looks unfamiliar, Creating a Single-Page App using React Router (https://www.kirupa.com/react/creating_single_page_app_react_using_react_router.htm) covers it.
Two details of Leafy will matter later. The first is that the About page is loaded lazily, which we'll see in a moment:
import { lazy, Suspense } from "react";
const About = lazy(() => import("./About.jsx"));
The second is that our router is told where the app lives, using something Vite gives us called BASE_URL. Here is main.jsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
);
You can ignore the basename for now. We'll explain it when we talk about sub-folders. Everything else here is what you have seen before.
Building for Production
From the root of your project, run the build script that the Vite template gave you:
npm run build
This runs vite build. For Leafy, here is what it printed on my machine:
vite v8.3.5 building client environment for production...
transforming...
✓ 94 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-C0pfjVTI.css 0.23 kB │ gzip: 0.18 kB
dist/assets/About-CiqIHScK.js 0.27 kB │ gzip: 0.21 kB
dist/assets/index-Q6BDWuvs.js 259.12 kB │ gzip: 82.43 kB
✓ built in 111ms
Your numbers and file names will be different, but the shape will look the same. Vite created a new folder called dist (short for "distribution") and put everything a browser needs inside it. Let's look at what is in there:
[Image: A green box labeled dist/ containing index.html, favicon.svg, and an assets folder with three files: index-Q6BDWuvs.js, About-CiqIHScK.js, and index-C0pfjVTI.css. Notes point out that index.html is the small front door, the hash changes whenever the contents change, and the About file is its own chunk from React.lazy.] (https://www.kirupa.com/react/images/deploy_dist_folder_144.png)
There are a few things worth pointing out here. The first is index.html. Open it and you'll see that it is nearly the same file you started with, except that the script tag now points to a built file instead of /src/main.jsx:
The second thing is the funny-looking part of each file name, like Q6BDWuvs. This is a hash that Vite computes from the contents of the file. Change a single character in your code, rebuild, and the hash changes. Don't change anything, and the name stays the same. This will turn out to be a big deal for caching, which we'll look at shortly.
The third is that there are two JavaScript files. The big one, index-Q6BDWuvs.js, contains React, React Router, and our app. The tiny one, About-CiqIHScK.js, exists because of that lazy(() => import(...)) line from earlier. Vite saw the dynamic import() and split the About page into a separate file, which the browser only downloads when someone visits /about. This is called code splitting, and we dig into it in Keeping Your UI Responsive (https://www.kirupa.com/react/keeping_your_ui_responsive.htm).
Finally, the numbers on the right of each line tell you how big each file is, and the gzip number is roughly how much is sent over the network, because hosts compress files before sending them. That second number is the one that matters to your visitors.
Everything in dist is what we'll deploy. Your source code, node_modules, and config files stay behind.
Previewing the Build Locally
Before you send anything to the internet, it is a good idea to run the production build the way a visitor would see it. The dev server won't do, because it is running your source and not the built files. Vite has a command for this:
npm run preview
This starts a small web server that serves the dist folder. You should see something like this:
➜ Local: http://localhost:4173/
➜ Network: use --host to expose
Open that address and click around. Things that only break in a production build (a missing environment variable, a wrong path, a component that behaves differently without development warnings) show up here, on your own machine, where nobody is watching. The Vite documentation is clear that vite preview is for checking your build, and that it isn't meant to be a production server. So use it as a rehearsal and not as your host.
One more thing to know. vite preview is generous. If you ask it for an address that doesn't exist, like /plants/pothos, it quietly sends back your index.html. Many real hosts don't do this by default, and that difference is the source of one of the most common deployment headaches. We'll get to it in a bit.
Environment Variables
Apps often need values that change between your computer and the live site. A good example is the address of an API: while developing, you may point at http://localhost:3000, and in production, at something like https://api.leafy.example. Environment variables are the usual way to handle this, and Vite has a built-in system for them.
Create a file called .env in the root of your project (next to package.json) and put one variable on each line:
VITE_APP_NAME=Leafy
VITE_API_URL=http://localhost:3000
You can then read those values in your code from import.meta.env:
function Home() {
return
{import.meta.env.VITE_APP_NAME}
;
}
Notice that both variables start with VITE_. That prefix is required. Vite only makes variables with that prefix visible to your code. Anything else in the file is ignored by your app. Restart the dev server after editing a .env file so that it picks up the change.
Vite also gives you a few built-in values on import.meta.env, and they are handy for deployment: MODE (the string "development" or "production"), DEV and PROD (booleans), and BASE_URL, which we'll use shortly.
Different Values for Production
Vite loads more than one kind of env file. A file called .env.production is only loaded when you run npm run build, and its values win over the ones in .env. So you can leave your local address in .env and add this next to it:
VITE_API_URL=https://api.leafy.example
I tried this to be sure. I read import.meta.env.VITE_API_URL on a page, ran a build, served it, and the page showed https://api.leafy.example and not the localhost address. Variables that are set in your shell when you run the build beat both files, which is how most hosts do it. If you ran VITE_API_URL=https://other.example npm run build, that value would be used.
That last point is useful, because every host we'll look at has a spot in its dashboard to add environment variables for your build. You type in VITE_API_URL and a value, and the host provides it when it runs npm run build.
These Are Not Secrets
Here is the part that catches people. The values of VITE_ variables are copied into your JavaScript at build time. They end up as plain text inside the files in dist, and anyone who opens your site can read them. To make this concrete, I built the app and searched the output:
grep -o "api.leafy.example" dist/assets/*.js
And it found it:
dist/assets/About-p2_dQgzK.js:api.leafy.example
A web address is fine to show the world. A private API key, a database password, or a token that can spend money is not. If you put one behind VITE_, it ships to every visitor. I also added a variable called API_SECRET (no prefix) to .env and printed it from the app. It came out as undefined, which is Vite doing its job and keeping it out of the bundle. But that only helps if your code never needed it in the browser in the first place.
The rule is simple. Anything that runs in the browser is public, no matter how you hid it. If you need a real secret (calling a paid API, talking to a database), that call needs to happen on a server that you control, such as a serverless function or a framework with a backend. The browser then talks to that server. We'll come back to this in the section on when a static site isn't enough.
One more habit: .env.local and other .env.*.local files are meant for values that are specific to your machine. The Vite template's .gitignore already ignores *.local, so those files stay out of your repository.
Hosting Under a Sub-Folder with base
The built index.html refers to its files with addresses that start with a slash, like /assets/index-Q6BDWuvs.js. That works perfectly if your app lives at the root of a domain, such as leafy.example.com. It breaks if your app lives in a sub-folder, such as username.github.io/leafy/, because the browser would ask for username.github.io/assets/... and not username.github.io/leafy/assets/....
The fix is Vite's base option. Open vite.config.js and set it to the folder where the app will live, with a slash on both ends:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/leafy/',
plugins: [react()],
})
Rebuild, and the generated HTML changes to match:
This setting is also where import.meta.env.BASE_URL comes from. Its value is whatever you put in base (and "/" if you didn't set it). That is why we passed it to BrowserRouter as the basename in main.jsx. It tells React Router that our routes start after /leafy/, so a route of /about really lives at /leafy/about. I ran vite preview with this base, and /leafy/, /leafy/plants/pothos, and /leafy/about all rendered correctly, including the lazy About chunk, with no console errors.
If you are deploying to the root of a domain (Cloudflare Pages, Netlify, and Vercel all give you one), leave base alone. You mostly need this setting for GitHub Pages project sites, or when you share one domain among several apps.
The 404 on Refresh Problem
Now for the big one. You deploy your app, open the home page, click around, and everything works. You click on a plant, and the address bar says /plants/pothos. Great. Then you hit refresh (or paste that link to a friend), and the browser shows a 404 error.
Why does this happen? When you click a link inside a React Router app, React Router intercepts the click, changes the address bar, and swaps in the right component. The server never hears about it. When you refresh, though, the browser does what browsers have always done: it asks the server for a file at /plants/pothos. In our dist folder, there is no such file. There is only one real page (index.html), and our router creates all of the other pages in the browser. The diagram below shows the three situations:
[Image: Three rows. Clicking a link inside the app goes to React Router, which updates the URL and swaps the page, with no server involved. Refreshing with no fallback goes from the browser to a static host that has no file by that name, resulting in a 404 Not Found. Refreshing with an SPA fallback goes from the browser to a static host that sends index.html instead, and React Router reads the URL and shows the page.] (https://www.kirupa.com/react/images/deploy_spa_fallback_144.png)
I wanted to see this happen for real, so I served the dist folder with a plain static file server, using a tool called serve:
npx serve dist
The home page loaded fine. When I loaded /plants/pothos directly, this is what I got:
[Image: A plain white page with the large text 404 and the message The requested path could not be found.] (https://www.kirupa.com/react/images/deploy_404_on_refresh_144.png)
That is the 404 on refresh problem in the wild. (I didn't use npm run preview for this, because as we saw, it hides the problem by always falling back to index.html.)
The fix is to tell the server that, for any address it doesn't have a file for, it should send index.html instead. Then React loads, React Router reads the address bar, and it shows the right page. This is usually called an SPA fallback (or a rewrite, or "history fallback"). The serve tool has a flag for it:
npx serve -s dist
With that flag, loading /plants/pothos directly works:
[Image: The Leafy app showing a navigation bar with Home and About links, and a page with the heading Pothos and the text Happiest in: Low to medium light.] (https://www.kirupa.com/react/images/deploy_pothos_page_144.png)
I checked all of this in headless Chrome. With the fallback on, /, /plants/pothos, and /about all rendered, with no console errors. Visiting an address that doesn't match any route, like /nope, rendered the "Page Not Found" component from our own router, and not a server error. Without the fallback, only / worked, and /plants/pothos returned a real 404.
That last detail brings up a design point. Once every address returns index.html, your server will never say "not found" again. The job of showing a nice not-found page belongs to your app. That is why Leafy has a catch-all at the end of its routes. Make sure you have one.
Every host has its own way to turn the fallback on. Here is the short version:
- Cloudflare Pages: nothing to do. If your project doesn't have a top-level 404.html, Pages assumes it is a single-page app and serves your root page for any path that isn't a real file. (If you add a 404.html, you turn this behavior off.)
- Netlify: add a file called _redirects to your public folder with one line, /* /index.html 200. The 200 makes it a rewrite, so the address bar keeps showing /plants/pothos.
- Vercel: add a vercel.json file to the root of your project with a rewrite rule. We'll see it in the Vercel section below.
- GitHub Pages: there is no rewrite setting. The common trick is to make a copy of index.html named 404.html, which GitHub Pages serves for unknown paths. We'll do that in the GitHub Pages section below.
If you'd rather not touch your host at all, there is a way out. React Router has a HashRouter, which puts the route after a # in the address (like /#/plants/pothos). Servers never see anything after the #, so refreshing always loads index.html. It works anywhere, but the addresses are uglier, so I'd treat it as a fallback plan.
For the Netlify rule, I put a _redirects file in public and ran a build. Vite copied it to the root of dist untouched, which is exactly where Netlify looks for it. Anything in your public folder is copied into dist as is, and that is the right place for files like this one.
Putting Your App on the Internet
Time for the fun part. We'll look at four hosts: Cloudflare Pages, Netlify, Vercel, and GitHub Pages. All four can host a Vite app for free for small projects. The instructions below follow each service's documentation at the time I wrote this, but dashboards get redesigned from time to time, so if a button has moved, trust the host's docs over me.
The first three work in nearly the same way, so let's go through the general idea once. You push your project to a Git repository on GitHub (or GitLab). You tell the host about the repository. You give it two settings: the build command, which is npm run build, and the output folder, which is dist. From then on, every time you push a commit, the host builds and deploys your app automatically. Pull requests usually get their own preview URLs, too. All three of these hosts have the settings for environment variables in the same project area, and you should add your VITE_ variables before the first build.
Cloudflare Pages
Cloudflare Pages treats a single-page app as the default, so it is the least work for us.
1. Push your project to GitHub.
1. In the Cloudflare dashboard, go to Workers & Pages, and choose Create application, then Pages, then Import an existing Git repository.
1. Pick your repository. When it asks about build settings, set the production branch to main, the build command to npm run build, and the build directory to dist.
1. Save and deploy. When it finishes, your app is live on a *.pages.dev address.
If you don't want to involve Git, you can upload a build you made yourself using Cloudflare's command line tool. After running npm run build, you create a project once with npx wrangler pages project create and then deploy with:
npx wrangler pages deploy dist
You'll need to log in with npx wrangler login first. Pages also offers a drag-and-drop option in the dashboard where you give it your dist folder. One caveat: a project created with direct upload can't be switched over to Git-based deploys later, so if you want automatic deploys on every push, start with the Git option.
Netlify
On Netlify, the Git route is much like what we just did. Push your project to a Git repository, go to app.netlify.com/start, and import the project. Check that the build command is npm run build and the publish directory is dist, and click Deploy. Before you do, add the SPA rule we talked about. Create public/_redirects:
/* /index.html 200
Netlify also has a command line tool if you prefer to deploy from your terminal:
npm install -g netlify-cli
netlify login
netlify init
netlify deploy --dir=dist
The netlify deploy command gives you a draft URL to check out first. When you are happy with how it looks, run netlify deploy --dir=dist --prod to publish it for real.
Vercel
Vercel recognizes Vite projects and fills in the right settings for you. Push to Git, import the project at vercel.com/new, and deploy. You can also use the command line from your project folder:
npm install -g vercel
vercel
That gives you a preview deployment. Add --prod when you want to deploy to production. For the SPA fallback, Vercel's Vite guide has you create a vercel.json file in the root of your project (not in public) with a rewrite:
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
It says that any path gets index.html in response, and Vercel will still serve real files (like your JavaScript in assets) when they exist.
GitHub Pages
GitHub Pages is a great fit if your code is already on GitHub, but it needs the most setup. There are three pieces: the base, a workflow that builds your app, and the 404 trick.
First, the base. If your site will be at https://username.github.io/leafy/ (a repository named leafy), set base: '/leafy/' in vite.config.js, just like we did earlier. If you're using a custom domain or a repository named username.github.io, leave the base alone.
Next, in your repository on GitHub, go to Settings, then Pages, and under Build and deployment, choose GitHub Actions as the source. Then add a file named .github/workflows/deploy.yml. This is based on the sample in Vite's documentation, with one extra step (the highlighted line) that makes the 404 copy:
name: Deploy to GitHub Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npm run build
- run: cp dist/index.html dist/404.html
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: ./dist
- id: deployment
uses: actions/deploy-pages@v5
Push this to main and GitHub builds and publishes your site. You can follow along in the Actions tab. (The version numbers on those actions will move forward over time. The Vite documentation keeps its sample current.)
That cp dist/index.html dist/404.html step is the 404 trick. GitHub Pages serves a file called 404.html whenever someone asks for a path that doesn't exist. Since that file is a copy of our app's index.html, our app loads, React Router reads the address, and the right page shows up. It's a bit of a hack, because the server still reports the page as a 404 under the hood even though the visitor sees the right content. For a hobby project, this is fine. If that bothers you, host on one of the other three.
Caching and Hashed File Names
Remember those hashes in the file names? Here is why they matter. A browser (and a CDN) would love to keep a copy of your JavaScript so that it doesn't have to download it again the next time a visitor arrives. The risk with caching is staleness. If you deploy a new version and a visitor's browser holds on to the old app.js, they'd see the old app.
Hashed file names solve this nicely. If a file's name contains a hash of its contents, then that file can never change. If the contents change, it becomes a different file with a different name. This means that everything in assets can be cached for a very long time, and your new deployment just points to new names.
The one file that can't have a hashed name is index.html, because visitors need a stable address to find it. It is the front door. That means index.html should be revalidated often (or not cached for long), while the files in assets can be cached for as long as the host will allow. Here's what the situation looks like when you deploy an update:
- A returning visitor's browser asks for index.html and gets the new one.
- The new index.html points at index-NEWHASH.js, which the browser has never seen, so it downloads it.
- Files whose contents didn't change (a big library chunk, say) keep the same name, and the browser reuses its cached copy.
The good news is that the hosts above already do something reasonable here, and Cloudflare, for example, documents that it keeps assets cached until your next deployment. You rarely have to touch this. If you do want to control it yourself, you'd typically do it with a headers setting. On Netlify, a file called public/_headers looks like this:
/assets/*
Cache-Control: public, max-age=31536000, immutable
That tells browsers they can keep anything under /assets/ for a year and don't need to check back. It is safe only because the file names are hashed. Never do this for index.html.
Checking Performance
Before you share your link, it's worth taking a quick look at how heavy your app is. You don't need to go deep here. A few quick checks will tell you whether something is off.
Read the Build Output
You already have the best first check. Look at the gzip column from npm run build. Our Leafy app shows about 82 kB for its main JavaScript file after compression, and most of that is React, React DOM, and React Router. If you add a big charting library and that number jumps, you'll notice. If your app grows large enough, Vite will also print a warning that some chunks are larger than 500 kB after minification. That's a sign to look into splitting your code.
The easiest split to make is the one we already made: load routes lazily with lazy() and import(), so that visitors only pay for the pages they open. Because lazy components suspend while they load, you also need a Suspense boundary above them (we have one in Leafy's App). Suspense and Error Boundaries (https://www.kirupa.com/react/suspense_and_error_boundaries.htm) explains how that works.
See What Is in the Bundle
When the number is bigger than expected, you want to know what's taking up the space. A plugin called rollup-plugin-visualizer builds a picture of your bundle where each library is a box sized by how much space it takes. Install it as a dev dependency:
npm install --save-dev rollup-plugin-visualizer
Then add it to your vite.config.js:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [react(), visualizer({ filename: 'stats.html', gzipSize: true })],
})
Run npm run build again and a stats.html file shows up in your project folder. Open it in your browser:
[Image: A treemap of the Leafy bundle. One huge box labeled react-dom-client.production.js fills most of the space, with smaller boxes for react, scheduler, and react-router beside it.] (https://www.kirupa.com/react/images/deploy_bundle_visualizer_144.png)
In our case, react-dom is by far the biggest box. That's normal, and it's why a small app still starts around 80 kB gzipped. Remove the plugin (or leave it out of your commits) once you've had your look, since you don't need a stats file on every build.
Run Lighthouse
Lighthouse is built into Chrome. Open DevTools, find the Lighthouse panel, and run it against your deployed site (or your preview server). It checks performance, accessibility, best practices, and SEO, and gives you a list of specific things to fix. Treat the scores as a to-do list and not a report card.
When I ran it against Leafy's production build on my own machine, performance and best practices came back at 100, and the page painted in about 1.4 seconds. Accessibility and SEO were lower, and the reasons were useful: Leafy's link colors didn't have enough contrast, its links were too small as touch targets, and the page had no meta description. All three are quick fixes. Your numbers will differ, so run it on a deployed URL if you can, since your local machine and the real internet behave differently.
A Pre-Launch Checklist
Right before you share your link, run through this list. Each item takes a minute or two, and each one catches something I have seen go wrong:
- Run the production build locally. Run npm run build and npm run preview and click through every page. Fix any errors in the console.
- Environment variables. Confirm that every VITE_ variable you use is set on your host, and that none of them is a secret.
- The page title and meta tags. The template's index.html has a that says the name of your project folder. Give your app a real title, and add a description meta tag, since that is what shows up in search results and link previews.
- A favicon. Replace the default one from the template, or at least check that the one you have is the one you want.
- A not-found route. Add a catch-all route so that bad addresses show a friendly page. Test a refresh on a deep link, too.
- An error boundary. If a component throws, an error boundary stops the whole page from going blank. See Suspense and Error Boundaries (https://www.kirupa.com/react/suspense_and_error_boundaries.htm).
- An accessibility pass. Tab through your app with only the keyboard and run Lighthouse's accessibility check. Accessibility in React (https://www.kirupa.com/react/accessibility_in_react.htm) goes into the details.
- Remove leftover debugging. Search for console.log and debugger. If something is in there that you'd be embarrassed for a stranger to see in DevTools, take it out. If you get stuck, Debugging React Apps (https://www.kirupa.com/react/debugging_react_apps.htm) can help.
- Run your tests. If you wrote any (see Testing React Apps (https://www.kirupa.com/react/testing_react_apps.htm)), run them before you deploy. Better yet, have your host or GitHub Actions run them before every deploy.
When a Static Site Isn't Enough
Everything so far assumed that your app is a pile of static files, where the browser does all of the work. That is a great default. It's cheap, it's fast, it's hard to break, and a lot of apps never need more.
There are times when you'll outgrow it. If you need pages that arrive already filled in with data (for faster first loads or for search engines), you want server rendering. If you need to keep secrets, talk to a database, or check who someone is before showing a page, you need code that runs on a server. And if you want your routing and data loading to be handled by something with opinions, then you want a framework.
In the React world, that usually means a framework like Next.js or React Router's framework mode. They still use everything you've learned about components, state, and hooks. What changes is where some of that code runs, and how you deploy it: instead of a folder of static files, you deploy something that has a server part as well. We introduce these ideas in Server Components and React Frameworks (https://www.kirupa.com/react/server_components_and_frameworks.htm). The hosts we used above, by the way, aren't limited to static files. Cloudflare, Netlify, and Vercel can all run server code, so you won't have to start over somewhere new.
My advice is to start with a static Vite app, like the one we built here. Ship it. If you hit a wall that needs a server, you'll know exactly why you need one, and moving to a framework will make a lot more sense.
Conclusion
Deploying a React app comes down to a short list of ideas. You run npm run build to turn your source into a dist folder of plain files with hashed names, and you check it locally with npm run preview. You use VITE_ environment variables for values that change between environments, and you remember that anything in them is public. You set base if your app lives in a sub-folder. And you make sure your host sends index.html for addresses it doesn't recognize, so that refreshing a page like /plants/pothos works.
After that, it is just a matter of picking a host, giving it your build command and your output folder, and letting it do the rest every time you push.
This also brings us to the end of this series. You've gone from writing your first component to putting a real app on the internet, and that is a big deal. If you're wondering where to go from here, what to build next, or what to learn in what order, head back to Your React Learning Path (https://www.kirupa.com/react/your_react_learning_path.htm). Pick a project, build it, and ship it. I'm looking forward to seeing what you make!