digital hedgehog illustration

How to use Flask with Webpack

I had a hard time figuring out how to set up the Flask with Webpack. So this is the solution I came up with.

Introduction

I have probably spent a week or two looking for some "best practices" for this problem: How to set up a Webpack for multi-page apps or websites? I haven't found many resources on the internet, so I decided to dedicate a blog post to it. Hopefully, you will find this helpful. So let's start!

Project Setup

Go on and create flask-with-webpack to try this out. The project structure should be like this.

flask-with-webpack
|- app
|   |- __init__.py
|   |- asset.py
|   |- templates/
|   |     |- index.html
|   |- static/
|- assets/

assets folder will be where our unbundled frontend code leaves. app folder will contain our python code.

This will be our boilerplate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from flask import Flask, render_template

app = Flask(__name__)


@app.route('/')
def index():
    return render_template("index.html")

if __name__ == "__main__":
    app.run()

And our jinja2 template should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Flask With Webpack</title>
</head>
<body>
    <h1>Hello World</h1>
</body>
</html>

Let's do the frontend now: Go into the assets folder and run:

npm init -y

If npm and node are correctly installed, this should create package.json inside the assets folder. While you are still there, please run the following command to install our dev dependencies.

npm install -D webpack webpack-cli

Our webpack config file should be like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'main.js',
    path: path.resolve(__dirname, '..','app', 'static', 'dist'),
    clean: true
  },
};

Pay attention to the 7th line; we directed the output to the static folder of our flask app, so our bundled assets go there.

Now let us update our package.json:

{
  "name": "assets",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack --mode=development",
    "watch": "webpack --mode=development --watch",
    "prod": "webpack --mode=production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^5.65.0",
    "webpack-cli": "^4.9.1"
  }
}

We added dev, prod, and watch scripts. This is just a convenient way to run a webpack. We are going to use the watch script mostly during our development process.

Also, we need to create our entry point. Let it be something simple:

console.log("Hello World");

Let's run npm run dev to test this out. If you did everything well far, app/static/dist/main.js should be created.

From this point, one could argue that we are almost there, and now all we have to do is to call url_for("static", filename="dist/main.js") inside our template, but that won't help us with problems on the production environment where we still have to deal with the cache-busting.

Cache busting

Cache busting is the process of uploading a new file to replace an existing file that is already cached. We should apply this in the production environment, where current users have old static files cached, and if we changed something in these files (images, CSS or JS) we expect that to reflect on their browsers.

app-name
|- app
   |- __init__.py
   |- templates/
   |  |- index.html
   |- static/
      |- dist/
        |- main.js
|- assets
   |- webpack.config.js
   |- package.json
   |- package-lock.json
   |- node_modules/
   |- src/
   |  |- index.js

Webpack manifest plugin will help us deal with this issue, alongside the flask extension we will create. Let's install the webpack plugin first in our assets folder.

npm install -D webpack-manifest-plugin

Then, update webpack.config.js.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const path = require('path');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');


module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash].js',
    publicPath: '/static/dist/',
    path: path.resolve(__dirname, '..','app', 'static', 'dist'),
    clean: true
  },
  plugins: [
      new WebpackManifestPlugin()
  ]
};

First, we import the plugin in add it to the plugins section. Next, we update filename and publicPath inside webpack output. Run npm run dev again. In our app/static/dist folder, there should be a new file, manifest.json, with content similar to this:

{
  "main.js": "/static/dist/main.a515dae3a1276df56c7b.js"
}

Let's now create a simple extension that uses this file that helps us load the correct content.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import json, os

from flask import current_app


class Asset:
    def __init__(self, app=None):
        self.app = app
        self.assets = {}
        if app is not None:
            self.init_app(app)

    def init_app(self, app):
        self.manifest_path = os.path.join(app.static_folder, "dist", "manifest.json")
        self._get_webpack_assets(app)

        if app.config.get("DEBUG"):
            app.before_request(self.reload_webpack_assets)

        app.context_processor(lambda: {"asset": self})

    def url_for(self, file):
        return self.assets.get(file)

    def reload_webpack_assets(self):
        self._get_webpack_assets(current_app)

    def _get_webpack_assets(self, app):
        with app.open_resource(self.manifest_path) as manifest:
            self.assets = json.load(manifest)

Code should be self-explanatory, tricky parts are these highlighted lines. We want to make sure that if the app runs in development mode before each request, we reload manifest.json cause content could change in the meantime. We also want to make the extension available in templates, so we use app.context_processor.

Now we have to initialise this extension like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from flask import Flask, render_template
from app.asset import Asset

app = Flask(__name__)
Asset(app)


@app.route('/')
def index():
    return render_template("index.html")

if __name__ == "__main__":
    app.run()

And our last step is to use the asset extension in our index.html like this:

1
<script src="{{ asset.url_for("main.js") }}"></script>

And that's it! If you get this far, you should have this code working. From this point, you can add new plugins to your webpack and tweak it to work in different ways. I would recommend adding CleanWebpackPlugin and MiniCSSExtractPlugin first.

Bonus - handling CSS

Let see how we can use Webpack to compile our styles. First, we are going to create styles.scss cause we want to extend Bulma, for example.

app-name
|- app
   |- __init__.py
   |- templates/
   |  |- index.html
   |- static/
      |- dist/
        |- main.js
|- assets
   |- webpack.config.js
   |- package.json
   |- package-lock.json
   |- node_modules/
   |- src/
   |  |- index.js
   |  |- styles.scss

We need to install some new packages:

npm install bulma
npm install -D css-loader sass-loader node-sass mini-css-extract-plugin

Let's copy-paste Bulma example for sake of this tutorial.

@charset "utf-8";

// Import a Google Font
@import url('https://fonts.googleapis.com/css?family=Nunito:400,700');

// Set your brand colors
$purple: #8A4D76;
$pink: #FA7C91;
$brown: #757763;
$beige-light: #D0D1CD;
$beige-lighter: #EFF0EB;

// Update Bulma's global variables
$family-sans-serif: "Nunito", sans-serif;
$grey-dark: $brown;
$grey-light: $beige-light;
$primary: $purple;
$link: $pink;
$widescreen-enabled: false;
$fullhd-enabled: false;

// Update some of Bulma's component variables
$body-background-color: $beige-lighter;
$control-border-width: 2px;
$input-border-color: transparent;
$input-shadow: none;

// Import only what you need from Bulma
@import "../node_modules/bulma/sass/utilities/_all.sass";
@import "../node_modules/bulma/sass/base/_all.sass";
@import "../node_modules/bulma/sass/elements/button.sass";
@import "../node_modules/bulma/sass/elements/container.sass";
@import "../node_modules/bulma/sass/elements/title.sass";
@import "../node_modules/bulma/sass/form/_all.sass";
@import "../node_modules/bulma/sass/components/navbar.sass";
@import "../node_modules/bulma/sass/layout/hero.sass";
@import "../node_modules/bulma/sass/layout/section.sass";

Finally, we need to update our webpack.config.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const path = require('path');
const {WebpackManifestPlugin} = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');


module.exports = {
    entry: {
        main: './src/index.js',
        styles: '/src/styles.scss'
    },
    output: {
        filename: '[name].[contenthash].js',
        publicPath: '/static/dist/',
        path: path.resolve(__dirname, '..', 'app', 'static', 'dist'),
        clean: true
    },
    module: {
        rules: [{
            test: /\.scss$/,
            use: [
                MiniCssExtractPlugin.loader,
                {
                    loader: 'css-loader'
                },
                {
                    loader: 'sass-loader',
                    options: {
                        sourceMap: true,
                    }
                }
            ]
        }]
    },
    plugins: [
        new WebpackManifestPlugin(),
        new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css'
        })
    ]
};

So first, we import MiniCssExtractPlugin and add it to the plugins section. Then, we add a new entry - styles in the appropriate area. I won't go into explaining the rules section cause you could check official webpack documentation about that.

Now, if we run npm run dev new style file will be generated and manifest.json will look something like this:

{
  "main.js": "/static/dist/main.544743f724b236ee60fe.js",
  "styles.css": "/static/dist/styles.3a4a4455de45c7b54bde.css",
  "styles.js": "/static/dist/styles.31d6cfe0d16ae931b73c.js"
}

But wait, in this process, we also got styles.js that appears to be empty. This is a drawback of Webpack, but fortunately, the webpack-remove-empty-scripts plugin will eliminate empty js files. So let install it.

npm install -D webpack-remove-empty-scripts

Update your webpack.config.js:

const RemoveEmptyScriptsPlugin = require('webpack-remove-empty-scripts')

module.exports = {
    // ...
    plugins: [
        // ...
        new RemoveEmptyScriptsPlugin(),
        // ...
    ]
};

Now, if we run npm run dev again, styles.js will disappear, and there won't be any junk! We can include these styles in the same way we did with the main.js script.


Comments

User-131

User-131 2 years ago
Thank you for this great post. I have a python flask application with a simple html form to be rendered. I have a couple JS packages that I needed to use with my application. I wanted to bundle all JS files and provide a single source in my html file. I got the web service running but the html page does not render and I get a 404. Can you please help? What am I missing?

StefanJeremic

StefanJeremic 2 years ago
Hey @User-131, thanks for reaching out! Unfortunately, without digging into the code, I'm unable to help. Feel free to post the link to the Github page of your project so I can take a look. Also, check some basic flask tutorials that will give you a good introduction, like the Flask Mega Tutorial from Miguel Grinberg.

User-133

User-133 2 years ago
Thanks, very good article

Leave a Comment

Please Log In to post a comment.