Why Webpack 5 is the best bundler for Svelte

I spent some time learning and playing with Webpack 5 and I was impressed

· 9 min

Many new bundlers have popped up lately. You have Vite, Snowpack, WMR to name a few and they are all good in their own way. But there is one bundler that's paved way for them alll, one that's been there for eternity, measured in Internet time, and it's Webpack.

Webpack might not be the sexiest bundler, but it's mature, battle-tested and a lot of teams and projects around the world rely on it. It might not be the fastest bundler, but once it starts the developer experience is on par with other cool ESM bundlers thanks to the HMR support. The reloads are blazing fast!

The thing with Webpack is that it's a real DYI bundler. It's its curse, but also its beauty. It might take you some time to get to the setup you are happy with. The tweaking possibilities are virtually endless. You can tweak every parameter and optimize your production bundle in every imaginable way.

Read on to learn how to start!

Creating the base setuppermalink

Webpack in itself is just a library. You need to add a Webpack runner if you want something to happen. The standard, official runner is webpack-cli, but in this example we will use a lightweight runner instead called webpack-nano.

$ npm init -y
$ npm add -D webpack webpack-nano webpack-merge

Webpack is all about configuration and if you are not familiar with it, things can get quite intense and overwhelming. You can configure Webpack in many different ways, but the base configuration always has some common parts such a loaders and plugins.

Here is a pseudo code of what a configuration file might look like.

module.exports = () => {
return {
// the main app entry file
entry: {
app: ['./src/index.js']
},
// directory to output files to
output: {
path: './dist'
},
// how and if to generate source maps
devtool: 'eval-cheap-module-source-map',
// how to process different file types using Webpack loaders
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.[p]?css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
],
},
],
},
// which different Webpack plugins to use
plugins: [
new MiniCssExtractPlugin({ filename: 'app.css' }),
]
}
}

The SurviveJS Webpack book preaches another, more sensible approach, to structure your Webpack configuration.

The most common way to configure Webpack is to use different config files, one for each environment. We will not do that. Instead we will have one main config file called webpack.config.js where we will handle configuration for all of our environments and another helper file called webpack.parts.jsthat we will use to assemble the main configuration file with the help of the webpack-merge library. The only thing this library does is to merge the objects together.

Here is our starting point.

// config/webpack.config.js

const path = require('path')
const { merge } = require('webpack-merge')
const { mode } = require('webpack-nano/argv')

const common = merge([])

const development = merge([])

const production = merge([])

const getConfig = mode => {
switch (mode) {
case 'production':
return merge(common(mode), production, { mode })
case 'development':
return merge(common(mode), development, { mode })
default:
throw new Error(`Unknown mode, ${mode}`)
}
}

module.exports = getConfig(mode)

To run Webpack we can run this command with the different mode flag.

$ npx wp --config config/webpack.config.js --mode development
$ npx wp --config config/webpack.config.js --mode production

The two commands above will run Webpack in development and production configuration modes.

I like to keep Webpack configuration in a separate config directory. Therefore we need to tell Webpack explicitly where to find the config file.

Currently the configuration is empty so don't expect miracles to happen when you run them. Let's fix it.

Adding a dev serverpermalink

First thing we need to do is to add a development server and an entry index.html file that holds our application. There are two de facto standard Webpack plugins people often use, webpack-dev-server and html-webpack-plugin, but we will instead use lightweight alternatives - webpack-plugin-serve and mini-html-webpack-plugin.

$ npm add -D webpack-plugin-serve mini-html-webpack-plugin

Let's define parts for them to use in our Webpack parts file.

// config/webpack.parts.js

const path = require('path')
const { MiniHtmlWebpackPlugin } = require('mini-html-webpack-plugin')
const { WebpackPluginServe } = require('webpack-plugin-serve')

exports.devServer = () => ({
watch: true,
plugins: [
new WebpackPluginServe({
port: 3000,
static: path.resolve(process.cwd(), 'dist'),
historyFallback: true
})
]
})

exports.page = ({ title }) => ({
plugins: [new MiniHtmlWebpackPlugin({ publicPath: '/', context: { title } })]
})

exports.generateSourceMaps = ({ type }) => ({ devtool: type })

While on it, I also threw in source maps handling part. Now we can use these sections in our main configuration file.

// config/webpack.config.js

// ...
const parts = require('./webpack.parts')

const common = merge([
{ output: { path: path.resolve(process.cwd(), 'dist') } },
parts.page({ title: 'My Awesome App' }),
])

const development = merge([
{ entry: ['./src/index.ts', 'webpack-plugin-serve/client'] },
{ target: 'web' },
parts.generateSourceMaps({ type: 'eval-source-map' }),
parts.devServer()
])

We added another entry point to the configuration - webpack-plugin⁠-⁠serve/client. It's needed for live reloads to work. The serve plugin has many different configuration options
and supports HMR by default.

Also, Webpack normally writes and serves the files from disk. You might speed things up by leveraging webpack-plugin-ramdisk.

Adding Svelte supportpermalink

Next up is adding support for Svelte files. For that we will use Svelte's official svelte-loader.

$ npm add -D svelte svelte-preprocess svelte-loader

Now we have to define a Svelte part in our Webpack parts file. Notice that we also have to pass in the current mode as we need to pass them to Svelte compiler.

// config/webpack.parts.js

// ...

exports.svelte = mode => {
const prod = mode === 'production'

return {
resolve: {
alias: {
svelte: path.dirname(require.resolve('svelte/package.json'))
},
extensions: ['.mjs', '.js', '.svelte', '.ts'],
mainFields: ['svelte', 'browser', 'module', 'main']
},
module: {
rules: [
{
test: /\.svelte$/,
use: {
loader: 'svelte-loader',
options: {
compilerOptions: {
dev: !prod
},
emitCss: prod,
hotReload: !prod,
preprocess: preprocess({
postcss: true
})
}
}
},
{
test: /node_modules\/svelte\/.*\.mjs$/,
resolve: {
fullySpecified: false
}
}
]
}
}
}

Since Svelte is a global dependency we can add it to our base Webpack config.

// config/webpack.config.js

const common = merge([
{ output: { path: path.resolve(process.cwd(), 'dist') } },
parts.page({ title: 'My Awesome App' }),
parts.svelte(mode),
])

Svelte loader configuration is taken straight from Svelte's repo. There is no chance I could write this myself. If you ever looked at Svelte's standard Rollup configuration you will recognize a lot of things, so I will skip the explaining part.

One cool thing I like is that we have granular control over the order the resolved main fields in the package.json files of external libraries. It's something I missed in Snowpack for example.

Adding TypeScript supportpermalink

Adding TypeScript support is straight-forward. We could add Babel support instead because Babel is much faster than official TypeScript compiler, but we won't do it. Adding Babel only complicates things in my opinion.

$ npm add -D typescript ts-loader @tsconfig/svelte

We also need to add a minimal TypeScript cofig file for things to work.

{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "public/*"]
}

Adding TypeScript support to Webpack is a one-liner.

exports.typescript = () => ({
module: { rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }] }
})

Now we can use it in our main Webpack config.

// config/webpack.config.js

// ...
const common = merge([
{ output: { path: path.resolve(process.cwd(), 'dist') } },
parts.page({ title: 'My Awesome App' }),
parts.svelte(mode),
parts.typescript()
])

Since this is a global dependency we will add it to our common config (for now).

Adding PostCSS and Tailwind supportpermalink

You usually have to deal with CSS when building an app. Tailwind CSS is a popular choice today. Here is how to set everything up.

$ npm add -D css-loader postcss-loader postcss tailwindcss autoprefixer postcss-load-config
$ npx tailwindcss init

Create a minimal PostCSS config.

// postcss.config.js

module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Make sure to add the file pattern to the Tailwind config otherwise you will end up with a very large CSS file in production. If you want to get a small CSS file already in development make sure to enable Tailwind's JIT mode.

// tailwind.config.js

module.exports = {
// mode: 'jit',
purge: [
'./src/**/*.svelte'
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}

If we want Svelte component files to be extracted to a common CSS file, and we do, we need to use mini-css-extract-plugin.

// config/webpack.parts.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

// ...

exports.postcss = () => ({
loader: 'postcss-loader'
})

exports.extractCSS = ({ options = {}, loaders = [] } = {}) => {
return {
module: {
rules: [
{
test: /\.(p?css)$/,
use: [{ loader: MiniCssExtractPlugin.loader, options }, 'css-loader'].concat(
loaders
),
sideEffects: true
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css'
})
]
}
}

You can build mini-pipelines of loaders in Webpack. Tailwind is built on top of PostCSS, but you might be working in SASS in your project. Therefore we can pass different CSS loaders in the arguments to the extractCSS part.

// config/webpack.config.js

const common = merge([
{ output: { path: path.resolve(process.cwd(), 'dist') } },
parts.page({ title: 'My Awesome App' }),
parts.loadSvg(),
parts.svelte(mode),
parts.extractCSS({ loaders: [parts.postcss()] })
])

Our common Webpack config looks like this so far. Notice that we pass our PostCSS loader as an argument to the extractCSS function. Maximum flexibility!

Handling SVG assetspermalink

In Webpack version 4 you were forced to use a separate plugin to handle your assets, but Webpack 5 has built-in support for asset handling.

// config/webpack.parts.js

exports.loadSvg = () => ({
module: { rules: [{ test: /\.svg$/, type: 'asset' }] }
})

You can then use them like this in your code.

<!-- App.svelte -->

<script>

import icon from './assets/icon.svg'
</script>

{@html icon}

Asset handling in Webpack is a huge topic. I recommend that you take a look at Webpack's asset module documentation to learn more.

When it comes to SVG you can also optimize your SVGs by using svgo-loader and to other fancy stuff with other Webpack plugins. Search and you will find!

Dealing with environment variablespermalink

Webpack doesn't not have native support for environment variables, but it's just one dotenv-webpack plugin away.

// config/webpack.parts.js

const DotenvPlugin = require('dotenv-webpack')

// ...

exports.useDotenv = () => ({
plugins: [new DotenvPlugin()]
})

Whatever you put into your .env file will be resolved at compile time. Of course, if you have a real environment variable defined, for example in your build server, that will be used.

# .env file
KANYE_API=https://api.kanye.rest

That variable is now available in your code in process.env.KANYE_API.

Speeding things up with ESBuildpermalink

For larger projects TypeScript compiler can slow things down. Luckily there is an ESBuild plugin we can use to transpile our TypeScript files during development.

// config/webpack.parts.js
const { ESBuildPlugin } = require('esbuild-loader')

// ...
exports.esbuild = () => {
return {
module: {
rules: [
{
test: /\.js$/,
loader: 'esbuild-loader',
options: {
target: 'es2015'
}
},
{
test: /\.ts$/,
loader: 'esbuild-loader',
options: {
loader: 'ts',
target: 'es2015'
}
}
]
},
plugins: [new ESBuildPlugin()]
}
}

I don't recommend using ESBuild for your production build and it does not do any typechecking. This is not a problem for us. We can use ESBuild in our development environment and TypeScript in production.

// config/webpack.config.js

const development = merge([
{ entry: ['./src/index.ts', 'webpack-plugin-serve/client'] },
{ target: 'web' },
parts.generateSourceMaps({ type: 'eval-source-map' }),
parts.esbuild(),
parts.devServer()
])

const production = merge([
{ entry: ['./src/index.ts'] },
parts.typescript(),
])

Hopefully you start to get a feeling for how we are assembling our main Webpack configuration piece by piece from separate parts.

Bundling for productionpermalink

It's good idea to tree shake and minify your bundle when building for production. TerserJS plugin is built into Webpack 5. No need to install it separately. However, if you want to minimize CSS you can do it in two ways: setup cssnano in PostCSS config or use css-minimizer-webpack-plugin. Let's use the plugin.

$ npm add -D css-minimizer-webpack-plugin

Webpack gives you granular control of how your bundle can be optimized and it would require a separate article. We will instead use the default settings which are good enough.

exports.optimize = () => ({
optimization: {
minimize: true,
splitChunks: { chunks: 'all' }
runtimeChunk: { name: 'runtime' },
minimizer: [`...`, new CssMinimizerPlugin()]
}
})

Word of caution: When building a production bundle make sure to specify NODE_ENV=production in the package build script if you are using Tailwind CSS. Otherwise Tailwind will not purge your generated CSS file.

Other nicetiespermalink

There are tons of other Webpack plugins that you can use in your pipeline. Let's use two of them just for demonstration purposes.

// config/webpack.parts.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const WebpackBar = require('webpackbar')

// ...

// clean dist directory on build
exports.cleanDist = () => ({
plugins: [new CleanWebpackPlugin()]
})

// show a nice progress bar in the terminal
exports.useWebpackBar = () => ({
plugins: [new WebpackBar()]
})

Hopefully you get the idea of how to setup different Wepback parts and how webpack⁠-⁠merge merges them together.

Dependency analysispermalink

It's always interesting to know what dependencies and the size our production bundle consists of. There are a few plugins that can help you analyze and visualize this. The most popular one is webpack-bundle-analyzer.

// config/webpack.parts.js

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

// ...

exports.analyze = () => ({
plugins: [
new BundleAnalyzerPlugin({
generateStatsFile: true
})
]
})

This plugin analyzes your final bundle and opens up a browser with a chart in it.

Final Webpack configpermalink

And we are done. This is what our final Webpack configuration looks like.

// config/webpack.config.js

const path = require('path')
const { merge } = require('webpack-merge')
const parts = require('./webpack.parts')
const { mode, analyze } = require('webpack-nano/argv')

const common = merge([
{ output: { path: path.resolve(process.cwd(), 'dist') } },
parts.page({ title: 'Sh*t Kanye says' }),
parts.loadSvg(),
parts.svelte(mode),
parts.extractCSS({ loaders: [parts.postcss()] }),
parts.cleanDist(),
parts.useWebpackBar(),
parts.useDotenv()
])

const development = merge([
{ entry: ['./src/index.ts', 'webpack-plugin-serve/client'] },
{ target: 'web' },
parts.generateSourceMaps({ type: 'eval-source-map' }),
parts.esbuild(),
parts.devServer()
])

const production = merge(
[
{ entry: ['./src/index.ts'] },
parts.typescript(),
parts.optimize(),
analyze && parts.analyze()
].filter(Boolean)
)

const getConfig = mode => {
switch (mode) {
case 'production':
return merge(common, production, { mode })
case 'development':
return merge(common, development, { mode })
default:
throw new Error(`Unknown mode, ${mode}`)
}
}

module.exports = getConfig(mode)

Instead of having one giant configuration file we assembled our configuration file from different configuration parts that we have defined in a separate file. Feels a little like building with Lego, don't you think?

The last part we need to do is to add a few NPM script commands to our package.json.

"scripts": {
"build": "NODE_ENV=production wp --config config/webpack.config.js --mode production",
"build:analyze": "wp --config config/webpack.config.js --mode production --analyze",
"start": "wp --config config/webpack.config.js --mode development"
}

Libraries mentionedpermalink

Codepermalink

https://github.com/codechips/svelte-typescript-setups

Conclusionpermalink

You can't go wrong by betting on Webpack. Many big projects out there use it and they use it for a good reason. It might not be the fastest, but for sure it's the most reliable and flexible bundler on the market today. The tweaking possibilities are virtually endless.

Webpack also has a huge and mature ecosystem. It has no native support for ESM yet, like Vite or Snowpark, but it's in the roadmap. You have to keep up with all the cool kids, right?

The configuration is the hardest part. I suspect that I currently know around 10% of what you can do with and in Webpack, if not even less. The good new is that its documentation is excellent.

One major thing that scares most of the developers is Webpack's complex configuration. Usually it's abstracted away from you when you use some starter packs. My suggestion is to spend some time learning Webpack. It's time well invested. Bundlers come and go, but Webpack is here to stay.


If you liked what you read, it would make me really happy if you could share it on Twitter.