Quarto-Flask Integration Part 1

Author

erfx5361

Published

August 29, 2025

Introduction

I wanted to share how I integrated Quarto into my website, so I’m going to do a series of posts on this topic. I am not an aspiring front-end developer so I kept things simple to achieve my goal of building a functional website with Quarto and Flask where I can create interesting technical content. I spent some time understanding Quarto’s HTML rendering and figuring out how to integrate it into my site, so I wanted to document my process in case it can help someone out there!

About Quarto

For those who are not familiar, Quarto is a program devoted to publishing academic or technical content in a variety of formats such as websites/blogs, dashboards, reports, and books. Quarto features powerful integrations including support for publishing dynamic Python content. Source content is generated with Pandoc markdown and stored as .qmd files that can be rendered to multiple formats such as PDF, HTML, etc.

For example, here’s a cool graph I can create and execute on the fly.

Code
import matplotlib.pyplot as plt
import numpy as np
f = 10
T=3
oversample_rate = 30
fs = oversample_rate*f
t = np.arange(0, 1/f*T, 1/fs)

i = 1/np.sqrt(2)*np.cos(2*np.pi*f*t)
q = 1/np.sqrt(2)*np.sin(2*np.pi*f*t)
r = i+q

plt.plot(t,i, color='blue')
plt.plot(t,q, color='orange')
plt.plot(t,r, color='violet')
plt.title(f'Sin, Cos, Sum, f={f} Hz')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.show()

Post-Render Integration with a Custom Flask Site

Since I wanted to build unique web pages such as calculator tools, I did not want to use a basic Quarto website. If you want to use Quarto with your own custom website it requires some housekeeping to integrate the rendered .html files.

Quarto natively supports pre-render and post-render Python scripts, including environment variables based on your Quarto project that allow for relatively straightforward integration with a custom website. These scripts are run every time you render. This post will focus on changes introduced in my post-render script, post-render.py, to integrate Quarto outputs with my site.

Quarto has a lot of options for styling including custom CSS themes and customization of themes using Sass. Maybe I will dive into this at some point in the future but currently I am on easy mode as far as styling. I use a preconfigured Bootswatch theme for my site and I’m able to render Quarto content to that theme very simply in my project configuration as you will see.

I keep all my Quarto source content in the quarto_posts directory; this is also my Quarto project root directory. I keep it simple by using one /images child directory that holds all images loaded in .qmd documents. This content is within my Flask /app directory, but it is ignored in the .git repository. I have a separate .git repository for my Quarto source content.

├── forms.py
├── __init__.py
├── post-render.py
├── pre-render.py
├── quarto_posts
│   ├── images
├── routes.py
├── static
└── templates
    ├── index.html
    ├── posts

Quarto allows you to specify an output directory in a project _quarto.yml file stored within the project root directory. I set my output directory to /app/templates/posts since Flask expects all .html pages to be in /templates. This configuration file also allows you to set project-wide defaults that can be overridden for each post. Additionally, this is where pre- and post-render scripts are defined.

project:
  output-dir: ../templates/posts
  pre-render: python3 ../pre-render.py
  post-render: python3 ../post-render.py

format:
  html:
    standalone: false
    toc: true
    number-sections: false
    theme: cosmo

Note how simple it is to leverage Quarto’s built-in support for Bootswatch themes in the last line above.

Post-Render: Integrating the Structure

The first problem I came across when rendering files to the output directory is that the output includes a folder with static files for each .html file. Flask expects only .html files in its /templates directory and has a separate /static directory for static files, so I need to move these files. This folder has the same name as the .html file, but adds _files to the end. For example, post1.html has a corresponding post1_files directory as shown below.

├── post1_files
│   ├── figure-html
│   └── libs
│      ├── bootstrap
│      │   ├── bootstrap-c8a6...836.min.css
│      │   ├── bootstrap-f62b...a3a.min.css
│      │   ├── bootstrap-icons.css
│      │   ├── bootstrap-icons.woff
│      │   └── bootstrap.min.js
│      ├── clipboard
│      │   └── clipboard.min.js
│      └── quarto-html

The contents of each _files directory are specific to each post; however, so far I have found the libs files are all identical for every rendered HTML document (so long as your rendered stylings are the same, I guess). The figure-html directory contains images that are generated by code such as Python-based plots.

Using the post1.html example, the first task for a post-render.py script is moving all post1_files to the appropriate directory. The subfolders within post1_files can be copied to a destination directory shared for all posts, app/static/posts. Merging all posts’ static content into one directoroy is acceptable for my purposes.

Quarto provides some environment variables exclusively to the pre-render and post-render scripts. I can read the output directory and a list of output files with these environment variables for use in my post-render script.

Code
    # list of files rendered
    output_files = os.getenv('QUARTO_PROJECT_OUTPUT_FILES').split('\n')
    # directory of rendered files
    output_dir = os.getenv('QUARTO_PROJECT_OUTPUT_DIR')

Here’s how I move the static files which are in a unique per-post directory (i.e. post1_files):

Code
def move_post_files(file, output_dir, posts_static_dir):
    file_name = os.path.splitext(file)[0]
    file_name_files_dir = file_name + '_files'

    # Construct the source and destination paths
    source_path = os.path.join(output_dir, file_name_files_dir)
    destination_path = posts_static_dir

    cut_paste_dir(source_path, destination_path)
cut_paste_dir()
import os
def cut_paste_dir(source_dir, destination_dir):
    """
    Recursively moves all contents from source_dir to destination_dir.
    Keeps existing content in destination_dir and overwrites files with the same name.
    If destination_dir does not exist, it is created.
    """

    if not os.path.exists(destination_dir):
        os.makedirs(destination_dir)  # Create the destination directory if it doesn't exist

    for item in os.listdir(source_dir):
        source_item = os.path.join(source_dir, item)
        destination_item = os.path.join(destination_dir, item)
        if os.path.isfile(source_item):
            if os.path.isfile(destination_item):
                os.remove(destination_item)  # Overwrite files with the same name
            shutil.move(source_item, destination_item)  # Move the item
        elif os.path.isdir(source_item):
            cut_paste_dir(source_item, destination_item)  # Remove existing directory with the same name
    os.rmdir(source_dir)  # Remove the source directory if it's empty

Here is an abbreviated tree for this Flask website /app directory after moving the post1_files folder structure to static/posts:

├── quarto_posts
│   ├── images
│   ├── _quarto.yml
│   └── post1.qmd
├── static
│   ├── css
│   ├── images
│   ├── js
│   └── posts
│       ├── figure-html
│       ├── images
│       └── libs
│           ├── bootstrap
│           ├── clipboard
│           └── quarto-html
└── templates
    ├── about.html
    ├── base.html
    ├── index.html
    ├── _navbar.html
    ├── posts
    │   └── post1.html
    ├── posts.html
    ├── _post_styling.html
    ├── tools
    └── tools.html

You may have noticed an additional posts/images directory. Images that are used in posts and stored in quarto_posts/images are also copied to the Quarto output directory (i.e templates/posts/images) upon rendering, so these need to get moved by the post-render script as well.

Image files are shared across posts so I can move them in one line.

Code
cut_paste_dir(os.path.join(output_dir, 'images'), posts_images_dir)

After cleaning up the directories, the next step I will cover is to cleaning up links within HTML outputs.

Post-Render: Fixing Quarto HTML Links

Links to all static files in Quarto-generated HTML files have to be updated. These includes lines of HTML for styling for every file. For example, these links from a Quarto demo were all updated in my post-render script:

<script src="../static/posts/libs/clipboard/clipboard.min.js"></script>
<script src="../static/posts/libs/quarto-html/quarto.js"></script>
<script src="../static/posts/libs/quarto-html/popper.min.js"></script>
<script src="../static/posts/libs/quarto-html/tippy.umd.min.js"></script>
<script src="../static/posts/libs/quarto-html/anchor.min.js"></script>
<link href="../static/posts/libs/quarto-html/tippy.css" rel="stylesheet">
...
<img src="../static/posts/figure-html/fig-polar-output-1.png" width="450" height="439" class="figure-img">

Fixing these static links is relatively straightforward (this is done per output file):

Code
    # replace quarto static file location with Flask location
    updated_content = content.replace(f'{file_name_files_dir}', f'{posts_static_dir}')

Images included in Quarto post content have to be addressed separately since they are not part of the static directory for rendered HTML. This part is a bit more involved since only images from quarto_posts/images should get updated paths, not just any image file. This function needs to update the first line below (Quarto-rendered HTML) to the second line:

<img src="images/pi-pad_schematic.png" class="img-fluid figure-img">
<img src="{[ url_for('static', filename='posts/images/pi-pad_schematic.png') ]}" class="img-fluid figure-img">

Note: I have used and will use {[ and ]} as my Jinja block characters within code blocks rather than double curly braces to avoid activating Jinja templating within posts.

I don’t plan on changing the directory structure except for in a significant overhaul, so I simply include the string literal images knowing this folder will be in my quarto_posts directory. Here’s the function I used to achieve this update to Quarto image links:

Code
def update_image_links(content):
    image_link_template = "{[ url_for('static', filename='') ]}"
    updated_content = content.splitlines()
    # match image links from quarto posts
    for index, line, in enumerate(content.splitlines()):
        if line.startswith('<img'):
            print(f'image line found: {line}')
            # regex requires no spaces or double quotes in filenames
            match = re.search(r'src="(images/.*)" ', line)
            if match:
                src = match.group(1)
                print(f'match found: {src}')
                updated_src = image_link_template.replace("''", "'"+'posts/'+src+"'")
                updated_line = line.replace(src, updated_src)
                print(f'updated image line: {updated_line}')
                updated_content[index] = updated_line
    updated_content = '\n'.join(updated_content)
    return updated_content

In the function above, I find the string starting with src="images\, but I only get the portion of the string within the perenthesis per the regular expression on line 9, which is just the string that defines the file location. Then I can insert this location into my new image link template.

Post-Render: Merging Quarto Styling

The last topic to cover for integration of the Quarto render is the styling. First, I separated my Navbar from base.html and create a separate _navbar.html so that my Quarto posts can inherit this without disrupting other settings.

Despite having the same Bootswatch theme as my Quarto project, my Navbar font sizes and colors were different on my Quarto posts. After some investigating, I found I am able to apply a style override from my Flask site styling to the Quarto .html files. This saved me some time since I didn’t have to deep dive into how the JavaScript/CSS/HTML was working within the Quarto .html files.

In the end, I ended up with two code blocks I needed to insert into each .html post to impose my site’s Navbar and certain style characteristics that fix the discrepancies onto the rendered files. With Jinja templating I only need to insert two lines, each representing a file with the needed inclusions. Here’s the change:

Code
def apply_flask_styling(content):

    # define Jinja template lines
    include_navbar = '{[ include "_navbar.html" ]}'
    include_style_overrides = '{[ include "_post_styling.html" ]}'

    updated_content = content.splitlines()
    # insert Jinja template lines
    for index, line, in enumerate(content.splitlines()):
        # assume styling appears first, insert style edits before existing quarto styling
        if line.startswith('<style'):
            updated_content.insert(index, include_style_overrides)
            styling_inserted = True
        # insert navbar after body, with one offset for added style line
        if line.startswith('<body') and styling_inserted:
            updated_content.insert(index + 2 , include_navbar)
            navbar_inserted = True
            break
    if not styling_inserted:
        raise ValueError("No <style> tag found in the file.")
    if not navbar_inserted:
        raise ValueError("No <body> tag found in the file.")
    
    updated_content = '\n'.join(updated_content)
    return updated_content

Now my posts’ styling blend seamlessly with my standard Flask Bootswatch theme (Cosmo).

Summary

You can find the post-render.py file here that brings all these steps together. With these post-render modifications and the pre-render integrations, all I have to do to integrate a new post into my site is run quarto render in my Quarto project directory.

That’s it for the changes that are made to the output files. Part 2 of this series will explore the pre-render.py script and how that was used to integrate the Quarto content into the Python structure of Flask.