Skip to content
aviral gupta

// B4.2 · ~30 min · Beginner

A Vite project from scratch

After this lesson you can create a React project with Vite on your computer, explain which file puts your app on the page, and run, build and preview it.

Lesson 2 of 4 in B4 Setting up a project

You will be able to

  • Create a Vite React project with npm create vite, on a Node.js version Vite supports
  • Explain how index.html, src/main.jsx and src/App.jsx put the app on the page
  • Run the dev server, build to dist and preview the build with the npm scripts
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B4.1: building from scratch. Which Vite command does react.dev's page "Build a React app from Scratch" show?

  2. Predict · Activity 2 of 7

    Predict before you read on. npm run dev is running, and you open http://localhost:5173/. Which file of the project does the browser load first?

  3. Practice · Activity 3 of 7

    node --version prints v18.20.4. What does vite.dev say you need for Vite?

  4. Practice · Activity 4 of 7

    Match each file or folder of the new project to its job.

  5. Practice · Activity 5 of 7

    Fill in the module that src/main.jsx imports createRoot from.

    import { StrictMode } from 'react'
    import { createRoot } from '____'
    import './index.css'
    import App from './App.jsx'
    import { createRoot } from ''
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Your project's src/main.jsx wraps App in <StrictMode>, as the template does, and npm run dev serves it. App renders two Counter components, and Counter logs in its body. How many lines does the console show when the page loads?

    function Counter() {
      console.log('Counter');
      const [count, setCount] = useState(0);
      return <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>;
    }
    
    function App() {
      return (
        <>
          <Counter />
          <Counter />
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: your own Vite project. In a terminal, check node --version, then run npm create vite@latest my-app -- --template react, cd my-app, npm install and npm run dev, and open the Local address it prints. While it runs, replace src/App.jsx with your own component and save: the page updates. Stop the server with Ctrl+C, run npm run build and look into dist, then run npm run preview and open its address.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The template's shape, in the runner

A Vite project keeps its entry in src/main.jsx and its first component in src/App.jsx, and imports ./index.css. Here main.jsx only exports App: the runner does what src/main.jsx does, rendering it into #root inside <StrictMode>, and it applies index.css itself. App.jsx imports a Counter from its own file, with the extension, as files in src/ do. Counter uses the template's updater form setCount((count) => count + 1). The snapshot shows #root after the first render.

main.jsx

import App from './App.jsx';

// In a Vite project, src/main.jsx ends with
//   createRoot(document.getElementById('root')).render(
//     <StrictMode>
//       <App />
//     </StrictMode>,
//   )
// and imports './index.css'. The runner does both for this default export.
export default App;

App.jsx

import Counter from './Counter.jsx';

export default function App() {
  return (
    <>
      <h1>my-app</h1>
      <Counter />
      <p>
        Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
      </p>
    </>
  );
}

Counter.jsx

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((count) => count + 1)}>
      count is {count}
    </button>
  );
}

index.css

body {
  font-family: system-ui, sans-serif;
}

Run it with

npm run dev

Output

<!-- #root -->
<div id="root"><h1>my-app</h1><button>count is 0</button><p>Edit <code>src/App.jsx</code> and save to test <code>HMR</code></p></div>
  • <StrictMode> renders no element of its own: #root holds only what App returns.
  • Counter.jsx is imported as ./Counter.jsx, with its extension, as in a Vite project's src/ folder.
  • index.css is applied by the runner; in Vite, src/main.jsx needs import './index.css' for it.
  • In the browser the page stays live: click the button. In a Vite project, saving App.jsx would update it through HMR.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

Write the entry, as src/main.jsx does

src/main.jsx renders App into the element from index.html. Write that step as a function: start(rootElement) creates a root for rootElement, renders <App /> inside <StrictMode> into it, and returns the root. App.jsx is given. Run shows App through the default export; the tests put an empty element on the page, call start with it, and click the counter.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    createRoot alone displays nothing; the root needs a render call.

  2. Hint 2

    Copy the shape of the template: root.render(<StrictMode><App /></StrictMode>);

  3. Hint 3

    End the function with return root;

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

// For the runner, which renders this into #root.
export default App;

// What src/main.jsx does, as a function that works for any element.
export function start(rootElement) {
  const root = createRoot(rootElement);
  root.render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
  return root;
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

// For the runner, which renders this into #root.
export default App;

// What src/main.jsx does, as a function that works for any element.
export function start(rootElement) {
  const root = createRoot(rootElement);
  // TODO: render <App /> inside <StrictMode>, then return the root.
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {act, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {start} from './main.jsx';

// The page of index.html: an empty element for the app.
function indexHtml() {
  document.body.innerHTML = '<div id="app-root"></div>';
  return document.getElementById('app-root');
}

async function mount(element) {
  let root;
  await act(async () => {
    root = start(element);
  });
  return root;
}

async function unmount(root) {
  if (root && typeof root.unmount === 'function') await act(async () => root.unmount());
}

test('start shows App inside the element', async () => {
  const element = indexHtml();
  const root = await mount(element);
  const heading = within(element).queryByRole('heading', {level: 1});
  assert.equal(heading?.textContent, 'my-app', 'the element holds ' + JSON.stringify(element.innerHTML));
  await unmount(root);
});

test('The counter works after start', async () => {
  const user = userEvent.setup();
  const element = indexHtml();
  const root = await mount(element);
  const button = within(element).queryByRole('button', {name: 'count is 0'});
  assert.ok(button !== null, 'there is no button "count is 0" inside the element');
  await user.click(button);
  await user.click(button);
  assert.equal(button.textContent, 'count is 2', 'after two clicks the button reads ' + JSON.stringify(button.textContent));
  await unmount(root);
});

test('start returns the root', async () => {
  const element = indexHtml();
  const root = await mount(element);
  assert.equal(typeof root?.render, 'function', 'start returned ' + String(root) + ', not the root from createRoot');
  await unmount(root);
});

App.jsx

import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <h1>my-app</h1>
      <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
    </>
  );
}

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 2

One component per file

The tea shop's Header and Counter now live in Header.jsx and Counter.jsx, as files in a Vite project's src/ folder would. main.jsx still has an old copy of Counter that cannot count. Import Counter from ./Counter.jsx, delete the old copy, and render, below the header, one Counter labelled Green and one labelled Black. Each must count its own clicks.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Counter.jsx has a default export, so import it without braces: import Counter from './Counter.jsx';

  2. Hint 2

    Two components cannot share one name in a file, so the old function Counter must go.

  3. Hint 3

    Pass the label as a prop: <Counter label="Green" /> and <Counter label="Black" />.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import Header from './Header.jsx';
import Counter from './Counter.jsx';

export default function App() {
  return (
    <>
      <Header title="Tea shop" />
      <Counter label="Green" />
      <Counter label="Black" />
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import Header from './Header.jsx';

// TODO: Counter now lives in Counter.jsx. Import it from there,
// delete this old copy, and render one Counter per tea.
function Counter() {
  return <button>Tea: 0</button>;
}

export default function App() {
  return (
    <>
      <Header title="Tea shop" />
      <Counter />
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './main.jsx';

test('The page shows the Tea shop heading and two counters', () => {
  render(<App />);
  assert.equal(screen.getByRole('heading', {level: 1}).textContent, 'Tea shop', 'the heading');
  const labels = screen.queryAllByRole('button').map((button) => button.textContent);
  assert.deepEqual(labels, ['Green: 0', 'Black: 0'], 'the buttons read ' + JSON.stringify(labels));
});

test('Green counts its own clicks', async () => {
  const user = userEvent.setup();
  render(<App />);
  const green = screen.queryByRole('button', {name: 'Green: 0'});
  assert.ok(green !== null, 'there is no button "Green: 0"');
  await user.click(green);
  await user.click(green);
  assert.equal(green.textContent, 'Green: 2', 'after two clicks Green reads ' + JSON.stringify(green.textContent));
  const black = screen.queryByRole('button', {name: /^Black/});
  assert.equal(black?.textContent, 'Black: 0', 'clicking Green changed Black to ' + JSON.stringify(black?.textContent));
});

Header.jsx

export default function Header({ title }) {
  return <h1>{title}</h1>;
}

Counter.jsx

import { useState } from 'react';

export default function Counter({ label }) {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((count) => count + 1)}>
      {label}: {count}
    </button>
  );
}

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

Leaving out the StrictMode import

import { createRoot } from 'react-dom/client';

export default function App() {
  return <h1>my-app</h1>;
}

// Typed from src/main.jsx without its first line, for a second root
const preview = document.createElement('div');
document.body.append(preview);
createRoot(preview).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

What React prints

ReferenceError: StrictMode is not defined

Why, and the fix

<StrictMode> is a component from React, so it must be imported like any other: import { StrictMode } from 'react'. JSX does not import it for you. The run stops with Uncaught ReferenceError before anything renders in that root.

Renaming the component but not its export

import App from './App.jsx';

export default App;

What React prints

ReferenceError: App is not defined

Why, and the fix

export default App; at the end of a file exports a name that must exist in that file. After the rename there is no App in App.jsx, so the module fails while it loads, and nothing renders. Export the new name, export default Shop;, or write export default function Shop() in one go. The file that imports it may keep its own name: import App from './App.jsx' still works.

JSX in a .js file

import Counter from './Counter.js';

export default function App() {
  return <Counter />;
}

What React prints

SyntaxError: Unexpected token '<'

Why, and the fix

This course's runner turns JSX into JavaScript only in .jsx files, so Counter.js is loaded as plain JavaScript, where < cannot start an expression, and nothing runs. vite.dev names .jsx and .tsx as the files with JSX support, and the template's components are .jsx files. Rename the file to Counter.jsx and import it as ./Counter.jsx. The wording is V8's; Firefox and Safari word it differently.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Create the project

Check node --version first: Vite requires Node.js 20.19+ or 22.12+. Then run npm create vite@latest my-app -- --template react. With npm 7 and later, the extra -- is needed so that --template reaches create-vite. vite.dev lists the templates react, react-compiler, react-ts and react-compiler-ts; react.dev's page uses react-ts, which is TypeScript, while this course uses react, which is JavaScript. create-vite then prints the next steps: cd my-app, npm install, npm run dev. In a run on 05.10.2026 that installed vite 8.3.2 and react 19.3.0.

index.html, src/main.jsx, src/App.jsx

In a Vite project, index.html sits in the project folder, not in public/: it is the entry point. It holds an empty <div id="root"></div> and <script type="module" src="/src/main.jsx">. src/main.jsx imports StrictMode, createRoot, ./index.css and App from ./App.jsx, then calls createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>). src/App.jsx is the component you edit. Strict Mode renders components twice, in development only. In this course, your main.jsx plays App.jsx's part: the runner does main.jsx's job and applies .css files itself, so you never import CSS here, while a Vite project must.

Run, build, preview

package.json has the scripts. npm run dev runs vite, a development server that prints Local: http://localhost:5173/. Keep it running: when you save src/App.jsx, Hot Module Replacement (HMR) updates the open page, as the template's text "Edit src/App.jsx and save to test HMR" invites you to try. npm run build runs vite build and writes a bundled, optimised app into the dist folder. npm run preview runs vite preview, which serves dist at http://localhost:4173 so you can check the build locally. It is not meant as a production server: you deploy the dist folder.

Sources

Last reviewed October 5, 2026