DEV Community

Cover image for Setting up React + TypeScript + webpack app from scratch without create-react-app
Aleksei Berezkin
Aleksei Berezkin

Posted on • Edited on

Setting up React + TypeScript + webpack app from scratch without create-react-app

Artwork: https://code-art.pictures/
Code on artwork: React JS

Why bother if there is create-react-app?

Good question! In fact, if you're happy with create-react-app, feel free to use it 🙂 However, if you're curious about how everything works together, let's combine all the parts ourselves!

The full code

The complete code for this example is available here. You can simply copy-paste and use it, or follow along with this blog post.

Note on keeping it current

Dear reader and fellow developer, I strive to keep this post up to date. However, if you come across any inaccuracies or outdated information, please feel free to leave a comment. Thank you!

Structure of the project we are going to create

/hello-react
  /dist
    index.html
    main.da363aed60cf1cf68088.css
    main.db8733ab04253d079b1c.js
    main.db8733ab04253d079b1c.js.LICENSE.txt
  /src
    index.css
    index.tsx
  index.html
  package.json
  tsconfig.json
  webpack.config.mjs
Enter fullscreen mode Exit fullscreen mode

1. Install Node.js and npm

The steps for installing Node.js depend on your operating system. Visit the download page and follow the provided instructions.

npm doesn't require separate installation because it comes bundled with Node.js. To verify that everything is installed correctly on your system, refer to these instructions.

Sidenote: Node.js and npm are not the only options. There’s Deno, an alternative to Node.js, and Yarn, an alternative to npm. If you're unsure, I recommend sticking with Node.js and npm for now.

2. Create the project

Create the project root directory, hello-react, and run the npm init wizard from inside it:

mkdir hello-react
cd hello-react
npm init
Enter fullscreen mode Exit fullscreen mode

The wizard will guide you through creating an empty project by asking questions one by one. To automatically accept all default answers, you can append the -y parameter to the npm init command.

Once the wizard finishes, it creates the following file:

package.json

{
  "name": "hello-react",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}
Enter fullscreen mode Exit fullscreen mode

Not much, but... that's already a valid Node.js project! 🎊

3. Install TypeScript

From the project root directory, run the following command:

npm i --save-dev typescript
Enter fullscreen mode Exit fullscreen mode

4. Create tsconfig.json

This file contains the TypeScript configuration for your project. Create a tsconfig.json file in the project root directory and insert the following content:

tsconfig.json

{
  "compilerOptions": {
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": [
      "dom",
      "esnext"
    ],
    "strict": true,
    "sourceMap": true,
    "target": "esnext",
  },
  "exclude": [
    "node_modules"
  ]
}
Enter fullscreen mode Exit fullscreen mode

What do these options mean? Let’s take a look!

compilerOptions

  • esModuleInterop: Fixes default and namespace imports from CommonJS to TypeScript. This is generally required for compatibility.
  • jsx: Specifies how TypeScript should transpile JSX files (e.g., react-jsx for React).
  • module: Determines how TypeScript transpiles ES6 imports and exports. Setting it to esnext leaves them unchanged, which I recommend so webpack can handle this instead.
  • moduleResolution: Specifies how TypeScript resolves modules, which depends on the target runtime. Since our app is bundled with webpack, bundler is the best choice. Earlier versions used the node option, which would also work for our example.
  • lib: Specifies which libraries exist in your target environment, so TypeScript implicitly includes their types. Note: TypeScript cannot verify if these libraries are actually available at runtime — this is your promise. More details later.
  • strict: Enables all strict type-checking options in TypeScript for maximum type safety.
  • sourceMap: Enables TypeScript to emit source maps. We’ll configure webpack to exclude these in production builds.
  • target: Configures the target ECMAScript version, which depends on your users' environments. More details on this later.

exclude

  • exclude: Excludes libraries from type-checking and transpilation. However, your code is still checked against the type definitions provided by these libraries.

For the full tsconfig.json reference, check out the official documentation.

5. Install webpack, plugins and loaders

From the project root directory, run the following command. It’s a long one, so ensure you’ve scrolled enough and copied the entire line!

npm i --save-dev webpack webpack-cli webpack-dev-server css-loader html-webpack-plugin mini-css-extract-plugin esbuild-loader
Enter fullscreen mode Exit fullscreen mode

6. Create webpack.config.mjs

Create a file named webpack.config.mjs in the project root directory and insert the following content:

webpack.config.mjs

import HtmlWebpackPlugin from 'html-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'

const prod = process.env.NODE_ENV === 'production'

export default {
  mode: prod ? 'production' : 'development',
  devtool: prod ? undefined : 'source-map',
  entry: './src/index.tsx',
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        loader: 'esbuild-loader',
        options: {
          target: 'esnext',
          jsx: 'automatic',
        },
        resolve: {
          extensions: ['.ts', '.tsx', '.js', '.json'],
        },
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ]
  },
  output: {
    filename: '[name].[contenthash].js',
    path: import.meta.dirname + '/dist/',
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'index.html',
    }),
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash].css',
    }),
  ],
}
Enter fullscreen mode Exit fullscreen mode

A lot is going on here! Webpack configuration is arguably the most complex part of the entire setup. Let’s break it down step by step:

  • Setting a NODE_ENV variable: This is the typical way to differentiate between development and production modes. Later, you'll see how to set it in your script.
  • HtmlWebpackPlugin: Generates a dist/index.html file from a template index.html, which we’ll create shortly.
  • MiniCssExtractPlugin: Extracts styles into a separate file. Without this, styles would remain inline in index.html.
  • mode: Specifies whether your build is for development or production. In production mode, Webpack automatically minifies the bundle.
  • devtool: Configures source maps for easier debugging.
  • entry: Defines the module to execute first after your app loads in the client. This serves as the bootstrap to launch your application.
  • module.rules: Describes how different types of files are loaded (imported) into the bundle.
    • test: /\.(ts|tsx)$/: Processes TypeScript files using esbuild-loader. See an example here.
    • test: /\.css$/: Processes CSS files.
  • output:
    • filename: Sets the filename to include a content hash. This technique is known as “cache busting”.
    • path: Specifies the target directory for compiled files.
  • plugins: Lists all plugins and their settings.

6.1. Alternative: webpack config as a CJS file

We defined the webpack configuration in an ESM file. This is possible since version 4.5 and should work on recent node versions. However, if it doesn't work for you, you can switch your configuration to CJS.

webpack.config.js
First, change the file extension from .mjs to .js. Then, update the content as follows:
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const prod = process.env.NODE_ENV === 'production'

module.exports = {
  // (no changes)
  output: {
    // (no changes)
    // The only change:
    // replace import.meta.dirname with __dirname
    path: __dirname + '/dist/',
  },
  // (no changes)
}

Enter fullscreen mode Exit fullscreen mode

6.2. Alternative: use ts-loader instead of esbuild-loader

We used esbuild-loader to load TypeScript files. As of 2025, it is likely the fastest TypeScript loader for webpack.

However, according to the webpack documentation, ts-loader is the default recommended TypeScript loader. Unlike esbuild-loader, which is relatively new, ts-loader is older and more mature. If you have a more complex setup, ts-loader is more likely to offer reliable support.

Changing to ts-loader
npm uninstall esbuild-loader
npm i -D ts-loader
Enter fullscreen mode Exit fullscreen mode

webpack.config.mjs

// ...
export default {
  // ...
  module: {
    rules: [
//    {
//      test: /\.(ts|tsx)$/,
//      exclude: /node_modules/,
//      loader: 'esbuild-loader',
//      options: {
//        target: 'esnext',
//        jsx: 'automatic',
//      },
//      resolve: {
//        extensions: ['.ts', '.tsx', '.js', '.json']
//      },
//    },
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        resolve: {
          extensions: ['.ts', '.tsx', '.js', '.json'],
        },
        use: 'ts-loader',
      },
      // ...
    ],
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

7. Add scripts to package.json

Add the following start and build scripts to your package.json:

package.json (Linux, OS X)

{
  ...
  "scripts": {
    "start": "webpack serve --port 3000",
    "build": "NODE_ENV=production webpack"
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

package.json (Windows PowerShell)
The build command has to be different:

{
  ...
  "scripts": {
    "start": "webpack serve --port 3000",
    "build": "set NODE_ENV=production && webpack"
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode
  • start: Launches a development server on port 3000. The dev server automatically watches your files and rebuilds the app as needed.
  • build: Builds your app for production. The NODE_ENV=production sets the NODE_ENV variable, which is checked in the first line of webpack.config.mjs.

8. Create index.html template

HtmlWebpackPlugin can generate an HTML file even without a template. However, you'll likely need one, so let's create it in the project root directory. This is the file we referenced in the webpack.config.mjs plugins section.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="initial-scale=1, width=device-width"/>
  <title>Hello React</title>
</head>
<body>
  <div id="app-root">App is loading...</div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Remember to update lang="en" to the actual language of your application, if it differs
  • The <meta name="viewport" .../> tag is essential for responsive design, enabling your layout to adapt to various device sizes
  • If you're targeting IE, be sure to add also <meta http-equiv="x-ua-compatible" content="ie=edge"/> in the <head> section

9. Install React

From the project root directory, run the following command:

npm i react react-dom
Enter fullscreen mode Exit fullscreen mode

And then:

npm i --save-dev @types/react @types/react-dom
Enter fullscreen mode Exit fullscreen mode

10. Create src/index.tsx

This is the entry point of your application, which we referenced in webpack.config.mjs. You can also update the main field in your package.json to point to the same file, though it's not strictly necessary.

src/index.tsx

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

const container = document.getElementById('app-root')!
const root = createRoot(container)
root.render(<h1>Hello React!</h1>)
Enter fullscreen mode Exit fullscreen mode

Note: The createRoot() API is new to React 18. If you are using an older version of React, you can refer to this blog post for guidance and use the following code:

src/index.tsx in React 17
import React from 'react'
import ReactDOM from 'react-dom'

ReactDOM.render(
    <h1>Hello React!</h1>,
    document.getElementById('app-root'),
)
Enter fullscreen mode Exit fullscreen mode

11. Create src/index.css and import it to src/index.tsx

To ensure our CSS plugin works, let's apply some simple styles.

src/index.css

body {
  color: blue;
}
Enter fullscreen mode Exit fullscreen mode

src/index.tsx

import './index.css'
// The rest app remains the same
// ...
Enter fullscreen mode Exit fullscreen mode

12. Run dev server

It's been a long path, but we're almost there! Let's run the development server:

npm start
Enter fullscreen mode Exit fullscreen mode

Now open http://localhost:3000/ in your browser — you should see the colored greeting:

Hello React!

Now try modifying src/index.tsx, for example, change the message. The app should reload and show the updated text. You can also try changing the styles — they should be picked up without needing to restart the server.

13. Build your app for production

From the project root directory, run the following command:

npm run build
Enter fullscreen mode Exit fullscreen mode

This will generate a dist folder containing the bundled files. To serve the files as they would appear in production, you'll need a small utility called serve and a corresponding script. Add the dependency:

npm i --save-dev serve
Enter fullscreen mode Exit fullscreen mode

Next, update your scripts in package.json:

package.json

{
  ...
  "scripts": {
    ...
    "preview": "serve dist -p 3000"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

...and run npm run preview. Open http://localhost:3000/ — you should see the greeting!

14. Targeting older environments

This part is a bit more advanced, so feel free to come back to it once you're comfortable with the basic setup.

14.1. Target ES version

The target ES version is set in two places:

  • tsconfig.json: under compilerOptions.target
  • webpack.config.mjs: in the esbuild-loader configuration

It depends on your app's audience. So, who is your target user?

  • You and your team — If you're using modern tools, it's safe to leave it as esnext. You probably don’t need anything outdated 🙂
  • Average Internet user — I’d recommend targeting es<currentYear-3>. For example, at the year of this writing, which is 2021, you would target es2018. Why not esnext? Sometimes even seemingly modern browsers miss support for certain features. For instance, Xiaomi MIUI Browser 12.10.5-go, released in May 2021, doesn’t support the nullish coalescing operator. Here's a pen to test it in that browser. What’s your result?
  • IE users — If you're supporting Internet Explorer, the target must be es5. Be aware that some ES6+ features can get bloated when transpiled to ES5.

14.2. Select target libs

The libraries are set in tsconfig.json under compilerOptions.lib, and this option also depends on your guess about your target user.

Typical libs you may use:

  • dom — Includes all APIs provided by the browser.
  • es..., for example, es2018 — Includes JavaScript built-ins that come with the corresponding ES specification.

Important: Unlike Babel, these options don’t automatically add any polyfills. So, if you’re targeting older environments, you will need to manually add polyfills, as described in the next section.

14.3. Add polyfills

Polyfills are required depending on the APIs your app needs.

Here are some popular polyfills you may use:

  • core-js — for missing Set, Map, Array.flatMap, etc.
  • raf — for missing requestAnimationFrame.
  • whatwg-fetch — for missing fetch. Note: this doesn't include a Promise polyfill. It’s included in core-js above.

Given that we decided to use all of them, the setup is as follows:

npm i core-js raf whatwg-fetch
Enter fullscreen mode Exit fullscreen mode

index.tsx

import 'core-js/features/array/flat-map'
import 'core-js/features/map'
import 'core-js/features/promise'
import 'core-js/features/set'
import 'raf/polyfill'
import 'whatwg-fetch'

// The rest app remains the same
// ...
Enter fullscreen mode Exit fullscreen mode

14.4. Tweaking webpack runtime

It may be surprising, but even if you transpile to ES5, webpack may still emit code containing ES6+ constructs. So, if you are targeting older browsers like Internet Explorer, you might need to turn off these features. For more details, check this documentation page.

Is it fair to add so many polyfills?

No, it's not always justified, as most users have modern browsers. Adding unnecessary polyfills wastes both runtime and bandwidth. The best approach would be to create two separate bundles: one for modern environments and another for older ones, and then load only the appropriate one based on the user's environment. This approach is beyond the scope of this tutorial.

You're done!

I know that wasn't easy 😅 But I'm sure these concepts are no longer a puzzle for you. Thanks for staying with me throughout this journey!

What's next?

Once you've completed this setup and your app's basic logic, you might need some kind of SSR. The next post in this series explores a convenient and useful way to prerender your app without requiring server-side workers. Have fun!

Top comments (17)

Collapse
 
dantincu profile image
Daniel Tincu

Great resource!

Just wanted to make a small addition regarding the build script:

"NODE_ENV=production webpack"
Enter fullscreen mode Exit fullscreen mode

On windows, this doesn't work. Replacing it with:

"SET NODE_ENV=production & webpack"
Enter fullscreen mode Exit fullscreen mode

will make it work on windows.

Cheers :)

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thanks, added a different fragment for Windows

Collapse
 
staylor profile image
Simon Taylor

@alekseiberezkin awesome article, thankyou!

Collapse
 
martindwyer profile image
Martin Dwyer

Excellent resource! Thanks for keeping it up to date!

Collapse
 
yoet92 profile image
Yoel Duran

Great job. congrats

Collapse
 
mrochev92 profile image
Miguel Roche

@alekseiberezkin is there any chance of changing webpack for esbuild? thanks for the article

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thx for the suggestion. You are right, esbuild is not more an “emerging” tool but a comprehensive alternative. Unfortunately I can't give any estimate when I'm able to look at it closely because right now I'm not working in frontend fulltime. However if I decide writing about something here, that'd be among the first topics to consider.

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Hi, I've added the chapter 6.1. about using esbuild-loader in the existing webpack setup. I believe it's the best compromise between esbuild speed and webpack maturity.

Collapse
 
9812598 profile image
Aleksandr Kobelev • Edited

Please add

meta name="viewport" content="width=device-width, initial-scale=1"
to index.html

I spent a lot of time trying to understand why 'min-width' doesn’t work for me.

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thanks for the suggestion, added to section 8

Collapse
 
jotatenerife profile image
JuanJo Yanes

great.

Collapse
 
awicone profile image
Vadim Ryabov

12.1 - compilerOptions.ta*R*get 😉

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thx, fixed

Collapse
 
vishalraj82 profile image
Vishal Raj

@alekseiberezkin Thank you for the article. Will definitely try this. Meanwhile, I have a similar article, that you might like - React from scratch

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thanks for your link. Will keep it in mind if I need Babel.

Collapse
 
ivangsp profile image
Ivan gsp

Thanks @alekseiberezkin for the tutorial, it was really helpful but I think you missed to add@types/react and @types/react-dom

Collapse
 
alekseiberezkin profile image
Aleksei Berezkin

Thank you so much, updated the post