A Modern React TypeScript Web App Template ๐Ÿ“š

ยท

15 min read

Creating a new web application in TypeScript these days involves a lot of boilerplate and setup of various tools, which takes up lots of time and energy that could be better spent developing the product itself.

For a long time, there was create-react-app, and life was good. Creating a project skeleton to build on top of was as easy as npx create-react-app my-cool-web-app, but these days, create-react-app is no longer maintained and is outdated.

With this in mind, I created a template for my web app projects, offering all the usual tools and sane defaults & configurations with minimal bloat. It supports TypeScript, tests with Jest, builds with Webpack, automatically formats sources with Prettier, and lints with ESLint.

I named it react-ts-template, and this blog post will describe how I created it and explain how it works step-by-step. I hope this helps you learn why each component is required and how it fits in the application development environment.

The GitHub repo for this project can be found here: f3rno64/react-ts-template

With that in mind, let's get started!

I will be using yarn instead of npm throughout this post, and within the template itself. This is a matter of personal preference, and you can choose to stick with npm if you wish.q


Initial Setup

First, make a directory for your project and create a Git repository within it. Add a .gitignore in the root directory with the following contents, which account for the most common environments:

/node_modules
/coverage
/build
/dist
/.pnp
/.yarn/cache

.env
.idea
.pnp.js
.DS_Store
.eslintcache
.vscode/settings.json

npm-debug.log*
yarn-debug.log*
yarn-error.log*

todo
notes

react-app-env.d.ts
tsconfig.tsbuildinfo

Note that the tsconfig.tsbuildinfo implies we will enable incremental TypeScript builds

Now, run yarn init in the repo, provide the information it requests to initialize your package manifest. Next, set the yarn version to berry with yarn set version berry to benefit from significant performance improvements relative to the stable yarn version.

Create a LICENSE.md in the project directory and populate it with your chosen license. I recommend the MIT license, which I will reproduce below:

The MIT License (MIT)

Copyright (c) 2015 bitfinexcom

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Tooling

Now, we'll go ahead and set up the various tools we will be using and their configurations. This section is fairly lengthy as I wish to explain the need behind each component for those new to web app development.

React

Install react and react-dom along with their type definitions by running yarn add react react-dom and yarn add -D @types/react @types/react-dom. That's all we need for React for now; dependencies related to testing will be covered in a later section.

TypeScript

Install @types/node, typescript and ts-node with yarn add -D @types/node typescript ts-node. This provides the TypeScript compiler, which Webpack will use to compile our source code to JavaScript.

Then, create the configuration file typescript.json with the following contents:

{
  "include": ["**/*.ts", "**/*.tsx"],
  "compilerOptions": {
    "jsx": "react-jsx",
    "target": "es6",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "sourceMap": true,
    "incremental": true,
    "declaration": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "moduleResolution": "node"
  }
}

This relatively lean configuration could be easily expanded for a stricter build process. Feel free to tweak it to your needs; the documentation can be found here.

Webpack

Webpack is a tool we will be using to build all our source files into a compiled bundle optimized for production. It offers many features and configuration options, which you can read about here.

Webpack requires several dependencies for the loaders and plugins we will be using. Install them all with the following command: yarn add -D webpack webpack-cli webpack-dev-server copy-webpack-plugin ts-loader style-loader sass sass-loader css-loader.

Their roles are described below:

  • webpack and webpack-cli are responsible for building the bundled app for deployment and webpack-dev-server provides a development server with live-reload.

  • copy-webpack-plugin will be used to copy the /public directory (containing index.html and other static files) to /dist as they are separate from our source files and not built by Webpack.

  • ts-loader, style-loader, sass, sass-loader, css-loader are all loaders that Webpack will use to process and build our source files. They provide support for TypeScript and SASS stylesheets. PostCSS will be described separately in a later section.

Finally, let's create the configuration file for Webpack, aptly named webpack.config.ts with the following contents:

/* eslint-disable */
const path = require('path')
const CopyWebpackPlugin = require('copy-webpack-plugin')

const { NODE_ENV } = process.env

const config = {
  mode: NODE_ENV ?? 'development',
  entry: path.resolve(__dirname, 'src/index.tsx'),
  module: {
    rules: [
      {
        test: /.tsx?$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.s[ac]ss$/i,
        use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
      }
    ]
  },

  resolve: {
    extensions: ['.tsx', '.ts', '.js']
  },

  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },

  plugins: [
    new CopyWebpackPlugin({
      patterns: [{ from: 'public' }]
    })
  ]
}

module.exports = config

Note that it defines the entry point as src/index.tsx; this file will be responsible for rendering our React application within the #root element present in index.html. The rest of the configuration is self-explanatory, mainly defining rules for loaders to process specific files by their extensions.

One thing to note is the inclusion of the CopyWebpackPlugin, which is responsible for copying the contents of public/ into dist/.

This configuration should be expanded to fit your needs; it simply serves as a starting point.

PostCSS

PostCSS is a powerful tool providing quality-of-life features for working with CSS. It is included in the configuration with minimal plugins to offer a small but helpful starting point.

Install it, it's plugins, and it's Webpack loader with yarn add -D postcss postcss-flexbugs-fixes postcss-normalize postcss-preset-env postcss-loader.

Create its config file, postcss.config.js and populate it with the following contents to enable the installed plugins:

module.exports = {
  plugins: [
    ['postcss-preset-env', {}],
    ['postcss-normalize', {}],
    ['postcss-flexbugs-fixes', {}]
  ]
}

Browsers List

We need to add a section to package.json that describes the browsers our web app supports in development and production environments. Various tools use this to infer feature compatibility. Add the following JSON block to package.json:

"browserslist": {
  "production": [
    ">0.2%",
    "not dead",
    "not op_mini all"
  ],

  "development": [
    "last 1 chrome version",
    "last 1 firefox version",
    "last 1 safari version"
  ]
}

Prettier

Prettier is a tool that automatically formats source files to maintain a consistent style. I use it in my editor (neovim) to format whenever I save a file, but for this project, it will be accessible via a format script in the manifest.

Install with yarn add -D prettier and create the .prettierrc.json config file with the following contents:

{
  "trailingComma": "none",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true
}

Feel free to expand this configuration to your liking.

ESLint

We will use ESLint to check our sources for poor style or other inconsistencies. It is integrated into most code editors and runs as part of the testing process. I've included many plugins to provide a fairly strict configuration.

Install it, and it's presets and plugins with yarn add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-standard-with-typescript eslint-plugin-import eslint-plugin-react-hooks eslint-plugin-promise eslint-plugin-react eslint-plugin-jsx-a11y eslint-plugin-jest

Then, create the configuration file .eslintrc.js and populate it with the following contents to make use of all those presets and plugins:

/* eslint-env node */

module.exports = {
  root: true,
  env: {
    browser: true,
    es2022: true
  },

  plugins: ['@typescript-eslint', 'promise', 'jsx-a11y', 'jest', 'react'],
  extends: [
    'eslint:recommended',
    'standard-with-typescript',
    'plugin:@typescript-eslint/recommended',
    'plugin:import/recommended',
    'plugin:import/typescript',
    'plugin:react-hooks/recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:jsx-a11y/recommended'
  ],

  settings: {
    'import/resolver': {
      typescript: true,
      node: {
        extensions: ['.js', '.jsx', '.ts', '.tsx']
      }
    },

    react: {
      version: 'detect'
    }
  },

  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    project: ['./tsconfig.json'],
    ecmaFeatures: {
      jsx: true
    }
  },

  rules: {
    'import/no-named-as-default-member': 0
  }
}

Feel free to remove any plugins or presets depending on your needs or add more. You can also configure or turn off individual rules as your needs require.

Jest

Jest is the test runner we will be using to test the application components, together with @testing-library/react & it's related dependencies.

Install it with yarn add -D @types/jest jest jest-environment-jsdom ts-jest @testing-library/dom @test-library/jsdom @testing-library/react @testing-library/react-hooks @testing-library/user-event

Then create the jest.config.ts configuration file with the following contents:

export default {
  clearMocks: true,
  preset: 'ts-jest/presets/default-esm',

  coverageDirectory: '<rootDir>/coverage',
  collectCoverageFrom: [
    '**/*.{js,jsx,ts,tsx}',
    '!**/node_modules/**',
    '!**/coverage/**',
    '!**/public/**',
    '!**/mocks/**',
    '!**/dist/**',
    '!webpack.config.js',
    '!postcss.config.js',
    '!.eslintrc.js'
  ],

  transform: {
    '\\.(js|jsx|ts|tsx)$': [
      'ts-jest',
      {
        tsconfig: '<rootDir>/tsconfig.json',
        useESM: true,
        diagnostics: true
      }
    ]
  },

  moduleNameMapper: {
    '\\.(css|less|sass|scss)$': '<rootDir>/mocks/style.js'
  }
}

The only thing to note here is the moduleNameMapper entry, which maps all stylesheets to an empty style.js file so Jest doesn't import stylesheets when they are imported within a component under test.

Create the mocks directory and add style.js within it with the following contents:

module.exports = {}

Husky

We will use Husky to configure a pre-commit hook that will run our lint and test scripts. Install it with yarn add -D husky.

Then run the following commands to set the pre-commit hook:

npx husky add .husky/pre-commit "yarn lint && yarn test"
git add .husky/pre-commit

We will setup the prepare script in the next section

Manifest Scripts

Before we add the scripts block, install some utilities which we will be using with yarn add -D cross-env open-cli standard-version.

Then add the following scripts block to package.json:

  "scripts": {
    "start": "open-cli http://localhost:8080 && webpack serve",
    "build": "NODE_ENV=production webpack --progress --fail-on-warnings",
    "format": "prettier -w src/**",
    "lint": "eslint --ext .js,.jsx,.ts,.tsx src",
    "test": "cross-env NODE_OPTIONS='--experimental-vm-modules --no-warnings' npx jest",
    "test:coverage": "cross-env NODE_OPTIONS='--experimental-vm-modules --no-warnings' npx jest --coverage",
    "test:watch": "cross-env NODE_OPTIONS='--experimental-vm-modules --no-warnings' npx jest --watch",
    "test:snapshots": "cross-env NODE_OPTIONS='--experimental-vm-modules --no-warnings' npx jest -u",
    "update-version": "standard-version -a",
    "prepare": "husky install",
    "prepare-release": "npm run lint && npm test && npm run build",
    "release": "npm run prepare-release && npm run update-version"
  }

I will go ahead and describe each script below:

  • start - opens the application in a browser and runs the Webpack server

  • build - generates the production-ready build of the app in dist/

  • format - runs Prettier on all source files, formatting them for a consistent style

  • lint - runs ESLint and prints any errors & warnings

  • test - runs all tests in the source folder with Jest

  • test:coverage - like test but generates test coverage information in coverage/

  • test:watch - watches for changes to sources and runs tests when they change

  • test:snapshots - runs tests and updates Jest test snapshots

  • update-version - updates all dependencies to their latest version

  • prepare - installs the Husky pre-commit hooks

  • prepare-release - lints, tests, and builds the application

  • release - lints, tests, builds, bumps the version number, commits, and tags it


The Code Itself

Finally, it's time to start writing some code! We'll go through the structure of components & pages and create a basic component to serve as a starting point. I've purposefully left out routing for the time being to keep things simple.

Public Directory

Now it's time to make the public directory in the project root; this will contain static files such as index.html the page that will host our app, and other files like fonts, images, etc. It will be copied into the dist directory as part of the build process.

Created it with mkdir public and populate public/index.html with the following contents:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Project Title</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
  <html></html>
</html>

Project Sources

Finally, it's time actually to develop our application! Go ahead and create the src directory with mkdir src.

We need two new dependencies, prop-types for defining React component prop types, and classnames which is an excellent utility for compiling multiple class names into a single string. Install them with yarn add prop-types classnames.

Let's start with the root stylesheet, index.scss, which will import normalize.css, a CSS library that provides a consistent set of base styles for all browser environments. Install it with yarn add normalize.css. Now create and populate src/index.scss with the following content:

@import '~normalize.css';

html, body, #root {
  height: 100%;
}

Project Components

The structure we will follow here for our application components and pages is one that I have used in both professional and personal projects. It is clean and scales well. Each component resides inside its directory within src/components, and each page within src/pages.

Go ahead and make these directories with mkdir src/components and mkdir src/pages.

All components and pages are made up of the following five files:

  • const.ts - contains constants, such as CLASS_NAME, used by the component

  • style.scss - the component stylesheet, imported in index.tsx

  • types.ts - component type & interface definitions, such as ComponentProps

  • props.ts - exports component propTypes and defaultProps objects

  • index.tsx - the component itself, importing everything needed from the other files

Both src/pages and src/components should contain an index.ts file that imports all sub-directories and exports them together so they can be imported easily, like import * as C from './components'.

React Root

To create the entry point for our application, make and populate a src/index.tsx file with the following contents:

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

import * as C from './components'

import './index.scss'

const rootElement = document.getElementById('root')

if (rootElement === null) {
  throw new Error('Root element not found')
}

const root = createRoot(rootElement)

root.render(
  <StrictMode>
    <C.UI />
  </StrictMode>
)

This renders the UI component, which will be defined in the next section, into the #root element of our index.html page.


UI Component

I usually name the root application component UI, and, within it import all of the pages from src/pages and set up routing. For this template, it will simply render a Home page and expose a prop to allow for a custom class name.

I usually expose a className prop on all of my components, as a standard practice.

Create a src/components/ui folder, and populate src/components/ui/index.tsx with the following contents:

import type React from 'react'
import classNames from 'classnames'

import * as P from '../../pages'

import { CLASS_NAME } from './const'
import { type UIProps } from './types'
import { propTypes, defaultProps } from './props'

import './style.scss'

const UI: React.FC<UIProps> = (props: UIProps) => {
  const { className } = props
  const finalClassName = classNames(CLASS_NAME, className)

  return (
    <div className={finalClassName}>
      <P.Home />
    </div>
  )
}

UI.propTypes = propTypes
UI.defaultProps = defaultProps

export default UI
export { CLASS_NAME, type UIProps }

Similarly, create and populate src/components/ui/const.ts with the following contents:

export const CLASS_NAME = 'component-ui'

I always prefix component class names with component, and page class names with page.

Do the same for src/components/ui/props.ts:

import PropTypes from 'prop-types'

export const propTypes = {
  className: PropTypes.string
}

export const defaultProps = {}

And src/components/ui/types.ts:

export interface UIProps {
  className?: string
}

And finally, the stylesheet src/components/ui/style.scss:

.component-ui {
  height: 100%;
}

Don't forget to add the component to src/components/index.ts so it can be easily imported elsewhere. The contents of the file should be:

import UI from './ui'

export { UI }

UI Component Tests

In an ideal world, all components and tests should be thoroughly tested, at least with unit tests. For now, we will validate the component against a snapshot to notice any changes in the future and verify any custom class name is applied. Create a src/components/ui/__tests__ folder and populate src/components/ui/__tests__/ui.test.tsx with the following contents:

/**
 * @jest-environment jsdom
 */

import '@testing-library/jest-dom'
import { render } from '@testing-library/react'

import UI from '../'

describe('components:ui', () => {
  it('matches snapshot with default test props', async () => {
    const { container } = render(<UI />)

    expect(container).toMatchSnapshot()
  })

  it('uses the provided class name', async () => {
    const className = 'test-class'
    const { container } = render(<UI className={className} />)

    expect(container).toMatchSnapshot()
    expect(container.firstChild).toHaveClass(className)
  })
})

Home Page

Finally, let's create the page that our UI component renders. As this is an application template, the Home page will only render a vertically & horizontally centered message to show that everything is working as it should.

To keep things short, I'll simply list the files below without much explanation. The internals are similar to the UI component, as the page doesn't do much.

First, we have src/pages/home/index.tsx:

import type React from 'react'
import classNames from 'classnames'

import { CLASS_NAME } from './const'
import { type HomeProps } from './types'
import { propTypes, defaultProps } from './props'

import './style.scss'

const Home: React.FC<HomeProps> = (props: HomeProps) => {
  const { className } = props
  const finalClassName = classNames(CLASS_NAME, className)

  return (
    <div className={finalClassName}>
      <div className={`${CLASS_NAME}-content-wrapper`}>
        <h2>React TypeScript Web App Template</h2>
      </div>
    </div>
  )
}

Home.propTypes = propTypes
Home.defaultProps = defaultProps

export default Home
export { CLASS_NAME, type HomeProps }

Then src/pages/home/const.ts:

export const CLASS_NAME = 'page-home'

Also src/pages/home/props.ts:

import PropTypes from 'prop-types'

export const propTypes = {
  className: PropTypes.string
}

export const defaultProps = {}

And src/pages/home/types.ts:

export interface HomeProps {
  className?: string
}

Then finally src/pages/home/style.scss:

.page-home {
  height: 100%;

  .page-home-content-wrapper {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    background: #eee;

    h2 {
      color: #000;
    }
  }
}

Also, as before, include the page in src/pages/index.ts for easy importing elsewhere:

import Home from './home'

export { Home }

Home Page Tests

We will create a basic file with unit tests as with the UI component. The only difference from the UI component tests is that we will verify the header text is rendered. Create a src/pages/home/___tests__ folder and populate src/pages/home/__tests__/home.test.tsx with the following content:

/**
 * @jest-environment jsdom
 */

import '@testing-library/jest-dom'
import { render } from '@testing-library/react'

import Home from '../'

describe('pages:home', () => {
  it('matches snapshot with default test props', async () => {
    const { container } = render(<Home />)

    expect(container).toMatchSnapshot()
  })

  it('uses the provided class name', async () => {
    const className = 'test-class'
    const { container } = render(<Home className={className} />)

    expect(container).toMatchSnapshot()
    expect(container.firstChild).toHaveClass(className)
  })

  it('renders the template header text', async () => {
    const { container } = render(<Home />)

    expect(container).toMatchSnapshot()

    const header = container.querySelector('h2')

    expect(header).toHaveTextContent('React TypeScript Web App Template')
  })
})

Conclusion

Finally, we are done! I hope you've learned something from my explanation of the construction of this template.

To use it, fork it on GitHub and start filling in the internals for your application and its needs. Feel free to tweak the various config files to your liking.

ย