Get eBook BundleVolume I index
Project Tooling and the First Milestone

Build, Preview, and First Project Review

Ishtmeet Singh @ishtms/July 20, 2026/13 min read
#react#vite#build#preview#code-review

So our app works in Vite's dev server. Nice.

Can we call it done now?

Well... not really. So far we've mostly proved that Vite can run our source code in development. We still haven't proved that TypeScript is happy, lint is happy, Vite can actually produce the production files, or that those built files even load correctly when served on their own.

And yes, sometimes dev works perfectly while the production build fails. Very fun when you find that five minutes before deployment.

So before we close this first project milestone, we'll run through the whole thing properly. Static checks, production build, preview, clean install, and then one final pass through the project itself.

Start from a known project state

First save your files.

I know, very advanced stuff.

Then make sure you're actually sitting inside the project directory and check which npm scripts are available.

bash
pwd
npm run

pwd should print your project root. And npm run should show the scripts we've been using, including dev, build, lint, preview, and typecheck.

If your dev server is currently running in the same terminal, stop it for now. The production build doesn't depend on the dev server being alive.

You could keep the dev server running in another terminal and build at the same time, that's completely fine. I'm stopping it here mostly because I want the output from each command sitting there clearly when something fails.

And something eventually will fail. We are programmers.

Run lint and TypeScript first

Let's start with lint.

bash
npm run lint

Then TypeScript:

bash
npm run typecheck

These commands are checking different things.

Oxlint checks your source against the rules configured for the project. TypeScript checks whether the types in your program actually agree with each other. Neither command opens Chrome, clicks your buttons, types into the search box, or checks whether the UI looks correct.

So passing both doesn't mean your app works.

But failing either one does tell us there's something in the source we should inspect before packaging it.

If lint finds an actual problem, fix the problem. Don't immediately do this:

js
/* oxlint-disable */

Yes, technically the warning disappears.

You have also told the tool to stop checking that code, which wasn't really what we were trying to achieve.

Sometimes disabling a rule for one very specific case is completely reasonable. Just make the exception small and leave enough context that somebody reading the code six months later knows why it's there.

Same story with TypeScript and any.

When TypeScript complains, first figure out what value you're actually dealing with. Throwing any on it because the red line is annoying usually just moves the bug somewhere else.

Now build the app

Once those checks pass, run:

bash
npm run build

For our TypeScript project, the build script is:

json
"build": "tsc -b && vite build"

The && means the second command only runs if the first one succeeds.

So TypeScript runs first. If the project has type errors, Vite doesn't continue and generate a fresh production build anyway.

Assuming everything passes, Vite processes the production module graph, resolves the imports, processes our assets and CSS, creates the output files, minifies the production JavaScript, and writes everything into dist by default.

You'll get something roughly like:

text
dist/
  assets/
    index-C8d3f1.js
    index-A2e9c4.css
  index.html
  react-mark.svg

Your filenames won't have those exact hashes, by the way.

That C8d3f1 kind of text changes depending on the file contents. Change the source, build again, and Vite can produce a different filename.

So please don't write deployment scripts that expect some exact generated filename such as:

text
assets/index-C8d3f1.js

That filename belongs to one build.

Actually read the build output

When Vite finishes, it prints the files it generated and their sizes.

Don't immediately ignore that output just because the command returned successfully.

For our small app, the numbers should at least look believable. If you suddenly see a massive JavaScript file for a project containing some lesson data and a few components, maybe we imported something we didn't intend to.

You can start by looking at direct dependencies:

bash
npm ls --depth=0

Though don't assume every installed package is automatically inside your browser bundle. A package can be installed and never imported into client code.

Also, a tiny package name in package.json can itself import other packages. So if bundle size starts looking strange, use an actual bundle analysis tool and inspect what ended up in the output.

For this milestone we're mainly checking that the build completes and that nothing obviously ridiculous got generated.

dist does not run itself

Now we've got production files sitting in dist.

Can we open dist/index.html directly and call that the test?

Better not.

The deployed app will normally be requested over HTTP, so we want to serve those built files through an HTTP server too. Vite gives us preview for this.

bash
npm run preview

The terminal will print a local URL. Open that.

It'll usually be on a different port from the Vite development server.

And this part is important: preview serves the files you already built. It isn't running your source through the normal development pipeline.

So if you edit src/App.tsx while preview is running, don't stare at the browser wondering why nothing changed.

You need another build.

text
edit source
  -> build again
  -> reload preview
  -> inspect the new output

Test it from a clean page load

Now open the preview URL in a private window, or otherwise clear whatever page state you were carrying from development.

Then use the app.

Search for a lesson. Change the completed filter. Edit the weekly goal. Reset the controls. Use Tab and Enter instead of only clicking everything with the mouse. Click labels and make sure the related controls respond properly.

For this project, I want to know that the screen loads without console errors and that every interaction we've built so far still works from the production files.

Then open the Network panel and reload.

Check that index.html, the JavaScript file, CSS, and images all come back successfully.

One slightly annoying situation is a page where index.html loads with 200 OK, but the JavaScript request fails afterward. The browser technically got the page, but your app still shows nothing because its client bundle never loaded.

So don't stop checking after the HTML request.

Asset paths can break after deployment

Our local preview runs from /, so the default Vite base is fine here.

A deployed app might also live at the origin root:

text
https://example.com/

But maybe you're publishing beneath a path instead:

text
https://example.com/reactbook/

Now asset URLs become something you need to pay attention to.

Vite uses its base configuration when generating URLs for imported production assets.

But this:

jsx
<img src="/react-mark.svg" alt="" />

starts with /.

That tells the browser to request the file from the origin root, so the request becomes:

text
https://example.com/react-mark.svg

even if your app itself lives at:

text
https://example.com/reactbook/

And now your image can disappear after deployment even though it worked perfectly at localhost.

For this milestone we're serving at /, so we don't need to change anything. But once you know the real hosting path, test the build from that path too.

Client-side routes bring another server problem

Our app currently has one page and no client router, so this doesn't affect us yet.

But suppose later we add a URL such as:

text
/lessons/state

You click a React Router link inside the already-loaded app and it works.

Then you copy /lessons/state into a new browser tab and suddenly the server returns 404.

Why?

Because on a fresh request the browser asks the server for /lessons/state. The server has to know that this URL belongs to the client application and return the application's index.html.

React can't configure your hosting server from inside a component.

For a static single-page app, the host normally needs a rewrite or fallback rule for the client routes you support. Frameworks with their own server routing handle this through their server setup or deployment adapter.

We don't have such routes yet, so there's nothing to configure today. But once routes arrive, direct URL loading becomes part of deployment testing.

Client environment variables get baked into the build

Vite lets client code read environment values through import.meta.env.

For example:

js
const apiOrigin = import.meta.env.VITE_API_ORIGIN;

When you create the production build, Vite processes those references for the selected mode.

So imagine you build with:

env
VITE_API_ORIGIN=https://api.example.com

You now have production JavaScript generated using that value.

Changing some environment setting later doesn't magically rewrite JavaScript that's already sitting inside dist.

If your hosting setup needs runtime configuration, you'll need some other mechanism for that, maybe HTML generated by a server or a runtime config request.

Our current project doesn't need an API origin, so there's no reason to invent one just so we can say we have environment variables.

And one more thing people eventually learn the unpleasant way: anything sent to browser JavaScript is visible to the user.

Don't put secrets in VITE_* variables.

Try the project from a clean install

Your current node_modules can sometimes hide problems.

Maybe you installed something weeks ago and later removed it from package.json, but the package is still sitting locally. Your machine keeps working, everybody feels happy, then CI installs from scratch and immediately explodes.

So we should also prove that the project records are enough to recreate the installation.

From a clean checkout or CI environment, run:

bash
npm ci
npm run lint
npm run typecheck
npm run build

npm ci installs from the lockfile and expects the lockfile to agree with package.json.

If npm install works on your existing machine but this clean sequence fails elsewhere, investigate why. Copying your old node_modules directory into the new environment would only hide whatever is wrong.

And yes, once this becomes a shared project, these same checks should run in CI. You don't want "works on my laptop" becoming the release process.

Now review the actual React code

At this point the tooling has done its checks. Let's look at the application itself once more.

Start with the components.

Component names should use uppercase names, and component functions should stay at module level rather than being created inside other components for no reason. Each component should also have some understandable UI job. If you're reading LessonCard, you should be able to tell why that component exists without opening six other files.

For repeated lessons, keys should come from stable lesson IDs. And don't add wrapper elements just because JSX looks lonely without one. If the DOM needs the element, fine. If it doesn't, we don't need extra markup.

Then look at props.

The TypeScript types should describe what the component actually requires. Optional props should have sensible behavior when omitted. Callback props should describe what happened or what the child wants done, something such as onOpen(lessonId), instead of exposing random implementation details from inside the component.

Also don't mutate props while rendering. Same goes for objects nested inside props.

Then state.

Our app stores things users independently change, such as query text, the selected filter, and the goal input. But filtered lesson results and counts are calculated from existing values during render. We don't need another state variable every time we can calculate a value from state we already have.

And if a state update depends on the previous state, use the updater form.

Also notice we didn't add an Effect just to calculate some filtered list. Effects are for synchronizing with something outside React's render calculation, not for every bit of JavaScript that uses state.

Then look at the HTML we're returning.

Actions should generally use buttons. Navigation should use links. Inputs should have labels. Checkboxes should get their state through checked. Buttons inside forms should say what type they are when the default submit behavior isn't intended.

And don't remove visible focus indication because somebody decided the outline looked ugly.

Keyboard users still need to know which control currently has focus.

Finally look at the project files themselves. package.json should contain the scripts we're actually using. The lockfile should be committed. node_modules isn't source code. dist isn't source code either. React and React DOM should be on compatible versions, and the Node/Vite versions used by the project should satisfy the versions required by the tooling.

A review comment saying "looks clean" doesn't tell the next person much. Better to point at something they can actually check.

Break a few things on purpose

Before we call the milestone finished, let's verify that our feedback tools actually catch problems.

Change an import path to something invalid and run the project. The terminal should tell you which import failed and from which file.

Restore it.

Now pass a prop with the wrong type and run:

bash
npm run typecheck

TypeScript should reject it.

Restore that too.

Remove a key from the lesson list and run the app in development. React should warn about the missing key.

Restore it.

Then put something into the search box that matches no lessons. The UI should explain that nothing matched instead of giving you a mysterious empty section.

We're not trying to test every possible failure manually forever. These little checks are just proving that the feedback systems we've set up are actually responding.

Later, automated tests take over a lot of this work.

Our first milestone is done

So what do we have now?

A browser document loads our Vite-built entry file. That entry creates one React root. The root renders our component tree. Components receive props, hold some local state, derive other values during render, and React DOM updates the browser DOM when the rendered output changes.

Then we proved the project can survive more than npm run dev.

text
browser document
  -> Vite-built client entry
     -> React root
        -> component tree
           -> props and local state
              -> DOM output and events

We ran lint. We ran TypeScript. We created a production build. We served that build using preview, tested it from a clean load, checked its network requests, and confirmed the project can install from its recorded dependencies.

That's a much better stopping point for the first project than "well, it opened in Vite once, so I guess we're done."