Why I Still Bet on Webpack and TypeScript for Big Apps

Actually, I should clarify – I tried migrating our main dashboard to Vite last Tuesday. But then I hit the plugin ecosystem wall. Again.

The reality is, if you’re building a standard React app in 2026, you probably don’t need Webpack. But if you’re dealing with a sprawling enterprise monolith that relies on custom module federation, obscure asset handling, or legacy browser support (yes, some banks still use ancient Chromium forks), Webpack isn’t just an option. It’s the only thing that doesn’t break.

I’ve spent the last three days re-configuring a TypeScript Webpack setup from scratch. It was painful. It was verbose. But it works exactly how I told it to, which is more than I can say for the “zero-config” magic that failed me earlier this week.

The “Modern” Webpack Stack (Yes, It Exists)

Webpack logo - Webpack JS logo
Webpack logo – Webpack JS logo” Sticker for Sale by hipstuff | Redbubble

Forget ts-loader. Seriously. If you are still using ts-loader in 2026, you are voluntarily choosing to waste your life waiting for builds. The biggest shift in the last two years hasn’t been abandoning Webpack, but gutting its internals to use faster transpilers. The SWC (Speedy Web Compiler) is a popular choice that can significantly speed up your Webpack builds.

I’m running this setup on Node 24.1.0, and the difference is night and day. By swapping out the default TypeScript compiler for SWC within the Webpack pipeline, I cut our cold start time from 42 seconds down to about 8 seconds. It’s not instant like Vite, but it’s fast enough that I don’t lose my train of thought.

The Config That Actually Works

Pay attention to the module rules. That’s where the magic happens.

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  // 'production' for your build pipeline, 'development' for local
  mode: 'development', 
  
  // The entry point. I stick to src/index.ts usually.
  entry: './src/index.ts',
  
  // Where the bundled mess goes
  output: {
    filename: 'bundle.[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true, // Cleans the dist folder before build. Lifesaver.
  },

  resolve: {
    extensions: ['.ts', '.js'], // Crucial. Don't forget .js or imports break.
  },

  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        use: {
          // using swc-loader instead of ts-loader
          // results: 4x faster builds on my M3 Macbook
          loader: 'swc-loader',
          options: {
            jsc: {
              parser: {
                syntax: 'typescript',
              },
            },
          },
        },
      },
    ],
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
  ],

  devServer: {
    static: './dist',
    port: 3000,
    hot: true, // HMR is non-negotiable in 2026
  },
  
  // Source maps are heavy, but necessary for debugging TS
  devtool: 'inline-source-map',
};

Writing TypeScript That Doesn’t Fight the Bundler

Webpack logo - Webpack: The Ultimate Guide. Webpack is a powerful static module ...
Webpack logo – Webpack: The Ultimate Guide. Webpack is a powerful static module …
// src/api/user.ts

interface UserData {
  id: number;
  name: string;
  email: string;
}

export class UserAPI {
  private baseUrl: string;

  constructor(url: string) {
    this.baseUrl = url;
  }

  // Async/Await works natively now, no polyfill hell needed
  async fetchUser(id: number): Promise<UserData | null> {
    try {
      const response = await fetch(${this.baseUrl}/users/${id});
      
      if (!response.ok) {
        throw new Error(HTTP Error: ${response.status});
      }

      const data: UserData = await response.json();
      return data;
    } catch (error) {
      console.error("Failed to fetch user:", error);
      return null;
    }
  }
}

// src/index.ts
import { UserAPI } from './api/user';

const initApp = async () => {
  const appDiv = document.getElementById('app');
  
  if (!appDiv) {
    // Webpack won't catch this at build time, but TS will warn if strictNullChecks is on
    throw new Error("DOM element #app not found");
  }

  const api = new UserAPI('https://jsonplaceholder.typicode.com');
  
  appDiv.innerHTML = '<p>Loading...</p>';

  const user = await api.fetchUser(1);

  if (user) {
    // specific DOM manipulation
    const userCard = document.createElement('div');
    userCard.className = 'user-card';
    userCard.innerHTML = 
      &lt;h2>${user.name}&lt;/h2>
      &lt;p>${user.email}&lt;/p>
    ;
    
    appDiv.innerHTML = ''; // Clear loading state
    appDiv.appendChild(userCard);
  } else {
    appDiv.innerHTML = '<p class="error">Failed to load user.</p>';
  }
};

// Execute
initApp();

The “Strict” Trap

If you see an error like ModuleResolutionKind mismatch, check your tsconfig.json. You want "moduleResolution": "bundler". Do not use "node" or "node16" if you are using Webpack 5+. It causes weird pathing issues with imports that don’t have extensions. The TypeScript Module Resolution documentation provides more details on this configuration.

Mateo Rojas

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *