Automating Netlify Redirects in Quarto Using Lua

Save yourself from yourself by generating website redirects using a Lua script.

Netlify
Quarto
Web Development
Lua
Author

Paul Johnson

Published

June 4, 2025

Last year, I wrote a blog post about my process for generating Netlify redirects. It was a similar process to that used by many others, adapted slightly to work with my slightly different setup. It involved housing a couple chunks of code in the homepage (index.qmd in the root directory) that would generate redirects every time the homepage is rendered.

This worked fine. It got the job done. However, it bothered me that I had to remember to re-render the homepage every time I needed to update the redirects file. This relied too heavily on my remembering to do something. So, I’ve finally had a crack at fixing this and setting something up that will run without my intervention.

I’ve set up a Lua script that is called before the whole project is rendered. I figured a brief post outlining what I’ve done and how it works might be helpful to others trying to figure this out, and it should be relatively easy to adapt to your own needs.

The Old (Manual-ish) Setup

I won’t spend too long going over the old approach (you can find it in the blog post I wrote last year). It worked, and I never actually forgot to re-render the homepage to update the redirects. However, I didn’t love having to go through that extra step, and I certainly didn’t trust myself to remember this detail.

Creating dependencies is an unnecessary mess when all I want to do is generate some redirects from a relatively simple website structure.

The Automated Solution

Instead, I wrote a Lua script that is set to run before any Quarto document renders (using Quarto’s pre/post rendering functionality), which generates all my redirects in a file that is stored at the root for Netlify to pick up and use in deployment.

It’s essentially the same process I followed before. Still, by shifting it into a separate script that runs when Netlify renders the site (using the Quarto Netlify plugin), I no longer have to worry about running this before pushing a new post.

I chose Lua because it is already packaged up in Quarto’s installation, as Pandoc uses Lua as its filter language, and Netlify recognises it when deploying the site. That means no extra work is required with a Lua script. Lua is relatively simple, and if you have experience with R and/or Python, it shouldn’t take long to pick it up.

There are three types of redirects handled by this script:

  • Manual Redirects - These are hand-written rules stored in a _manualredirects file, intended to redirect old pages when the website used a different structure.
  • Blog Post Redirects - Automatically generated rules that redirect from simple URLs (/blog/my-post-title) to the full dated URLs (/blog/2025-05-26-my-post-title).
  • Category Redirects - Dynamic redirects that map tag-style URLs (/tags/lua) to filtered blog views (/blog/#category=lua).

Redirects Using Lua

If you want to skip the details, here is the script in full.

Lua Code (Click to Expand)
-- build/redirects.lua
-- Generates redirects for blog posts and categories
local system = require("pandoc.system")

-- Toggle for debug output
local DEBUG = false

if not os.getenv("QUARTO_PROJECT_RENDER_ALL") then
    print("Skipping redirects...")
    os.exit()
end

local function debug_log(message)
    if DEBUG then
        io.stderr:write("[Redirects Debug] " .. message .. "\n")
    end
end

local function is_dir(path)
    local handle = io.popen('[ -d "' .. path ..
        '" ] && echo "yes" || echo "no"')
    local result = handle:read("*a"):gsub("%s+", "")
    handle:close()
    return result == "yes"
end

local function file_exists(path)
    local file = io.open(path, "r")
    if file then
        file:close()
        return true
    end
    return false
end

local function read_lines(file_path)
    debug_log("Attempting to read: " .. file_path)
    if not file_exists(file_path) then
        debug_log("File not found: " .. file_path)
        return {}
    end

    local lines = {}
    local file = io.open(file_path, "r")
    for line in file:lines() do
        table.insert(lines, line)
    end
    file:close()
    debug_log("Read " .. #lines .. " lines from: " .. file_path)
    return lines
end

local function extract_categories(post_path)
    local qmd_path = post_path .. "/index.qmd"
    if not file_exists(qmd_path) then
        debug_log("Blog post .qmd not found: " .. qmd_path)
        return {}
    end

    local content = read_lines(qmd_path)
    local categories = {}

    for _, line in ipairs(content) do
        local cat_match = line:match("^categories:%s*%[(.+)%]")
        if cat_match then
            for cat in cat_match:gmatch("([^,]+)") do
                cat = cat:gsub("^%s*(.-)%s*$", "%1") -- Trim whitespace
                table.insert(categories, cat)
            end
            break
        end
    end
    return categories
end

local function load_manual_redirects(project_root)
    local manual_paths = {
        project_root .. "/assets/_manualredirects",
        project_root .. "/build/_manualredirects"
    }

    for _, path in ipairs(manual_paths) do
        if file_exists(path) then
            debug_log("Found manual redirects at: " .. path)
            return read_lines(path)
        end
    end
    return {}
end

local function process_blog_posts(blog_dir)
    debug_log("Looking for blog posts in: " .. blog_dir)

    local redirects = {}
    local blog_posts = {}
    local all_categories = {}

    local dir_cmd = 'find "' .. blog_dir ..
        '" -maxdepth 1 -mindepth 1 -type d'
    debug_log("Running directory listing command: " .. dir_cmd)

    local handle = io.popen(dir_cmd)
    for post_path in handle:lines() do
        local post_name = post_path:match("([^/]+)$")
        debug_log("Found potential blog post: " ..
            (post_name or "unnamed"))

        -- Process only directories matching date pattern
        if post_name and post_name:match("^%d%d%d%d%-%d%d%-%d%d%-") then
            local title_slug = post_name:gsub("^%d%d%d%d%-%d%d%-%d%d%-", "")
            local old = "/blog/" .. title_slug
            local new = "/blog/" .. post_name

            table.insert(redirects, old .. " " .. new)
            table.insert(blog_posts, post_path)
            debug_log("Added redirect: " .. old .. " → " .. new)

            -- Collect categories
            local post_categories = extract_categories(post_path)
            for _, cat in ipairs(post_categories) do
                debug_log("Found category: " .. cat)
                all_categories[cat] = true
            end
        end
    end
    handle:close()

    return redirects, blog_posts, all_categories
end

local function generate_category_redirects(all_categories)
    local redirects = {}
    local category_count = 0

    debug_log("Generating category redirects")
    for category, _ in pairs(all_categories) do
        local encoded_category = category:gsub(" ", "%%20")
        local tag = category:lower():gsub(" ", "-")
        table.insert(redirects, "/tags/" .. tag .. " " ..
            "/blog/#category=" .. encoded_category)
        debug_log("Added category redirect: /tags/" .. tag)
        category_count = category_count + 1
    end

    return redirects, category_count
end

local function write_redirects_file(project_root, redirects)
    local out_file = project_root .. "/_redirects"
    debug_log("Writing redirects to: " .. out_file)

    local f = io.open(out_file, "w")
    if f then
        for _, line in ipairs(redirects) do
            f:write(line .. "\n")
        end
        f:close()
        debug_log("Successfully wrote " .. #redirects .. " redirects")
        return true
    else
        io.stderr:write("ERROR: Could not open output file for writing: " ..
            out_file .. "\n")
        return false
    end
end

function Pandoc(doc)
    debug_log("Starting redirect generation...")

    local project_root = system.get_working_directory()
    debug_log("Working directory: " .. project_root)

    -- Check Netlify environment (preserved for potential future use)
    local netlify_build_dir = os.getenv("NETLIFY") and
        os.getenv("NETLIFY_BUILD_BASE")
    if netlify_build_dir then
        debug_log("Running in Netlify environment: " .. netlify_build_dir)
    end

    local blog_dir = project_root .. "/blog"
    debug_log("Blog directory path: " .. blog_dir)

    if not is_dir(blog_dir) then
        debug_log("Blog directory not found, skipping redirect generation")
        return doc
    end

    -- Load manual redirects
    local manual_redirects = load_manual_redirects(project_root)
    debug_log("Added " .. #manual_redirects .. " manual redirects")

    -- Process blog posts
    local post_redirects, blog_posts, all_categories =
        process_blog_posts(blog_dir)

    -- Generate category redirects
    local category_redirects, category_count =
        generate_category_redirects(all_categories)

    -- Combine all redirects
    local all_redirects = {}
    for _, redirect in ipairs(manual_redirects) do
        table.insert(all_redirects, redirect)
    end
    for _, redirect in ipairs(post_redirects) do
        table.insert(all_redirects, redirect)
    end
    for _, redirect in ipairs(category_redirects) do
        table.insert(all_redirects, redirect)
    end

    -- Write redirects file
    if write_redirects_file(project_root, all_redirects) then
        debug_log("Redirect generation complete")
        print("Redirects written to _redirects (" ..
            #all_redirects ..
            " entries, " ..
            #blog_posts .. " posts, " .. #manual_redirects ..
            " manual, " .. category_count .. " categories)")
    end

    return doc
end

Key Components

For anyone interested in how the script works, however, here are some details.

Environment Detection

local project_root = system.get_working_directory()
local netlify_build_dir = os.getenv("NETLIFY") and os.getenv("NETLIFY_BUILD_BASE")

The script first determines where it’s running - locally during development or on Netlify during deployment. This flexibility ensures it works in both environments.

File System Operations

Lua provides basic file system operations, which the script uses:

  • Directory checking - Verifying that blog directories exist.
  • File reading - Processing blog post metadata and manual redirect files.
  • File writing - Creating the final _redirects file.

Pattern Matching

One of Lua’s strengths is its pattern-matching system. The script uses this to find my blog posts based on the pattern of ISO dates at the start of a blog post folder.

if post_name and post_name:match("^%d%d%d%d%-%d%d%-%d%d%-") then
    local title_slug = post_name:gsub("^%d%d%d%d%-%d%d%-%d%d%-", "")

This finds blog posts with names like 2025-05-26-my-post-title and extracts just the title part. If you use a different structure for your posts, you will need to adjust this accordingly.

Category Processing

The script reads each blog post’s metadata to extract categories.

local cat_match = line:match("^categories:%s*%[(.+)%]")

This looks for lines like categories: [Netlify, Quarto, Web Development, Lua] in the blog post’s YAML header.

Wrapping Up

Now, every time I publish a new blog post or add categories, the redirects are automatically updated without any manual intervention. This process also runs every time I render the entire site. This might be overkill. I could probably set it up to only run when it detects a fresh deployment to Netlify, but this feels like a good way to spot any issues or errors and will make debugging a little easier.

There isn’t that much to what I’ve done, but I figured it was worth sharing because others might like to do something similar. It shouldn’t require significant editing, but it will involve adjusting to fit the structure of your website. Hopefully, this is of value to at least one other person!

Acknowledgments

Preview image by Dagny Reese on Unsplash.

Support

If you enjoyed this blog post and would like to support my work, you can buy me a coffee or a beer or give me a tip as a thank you.

Footnotes

  1. Full disclosure: I put together the script with help from Claude AI, as I had no prior experience with Lua. I’m not claiming to have written this from scratch, but I still wanted to share it, as it may be valuable to others.↩︎

  2. If you need to learn Lua, I recommend the Learn Lua in 15 Minutes resource, as well as Quarto’s Lua Cheatsheet.↩︎

  3. My manual redirects file is located in the build folder, but if yours (or your redirects script) is stored elsewhere, you will need to update this accordingly.↩︎

  4. One of the first steps in the script checks if the entire site is rendering:

    if not os.getenv("QUARTO_PROJECT_RENDER_ALL") then
        print("Skipping redirects...")
        os.exit()
    end

    If the command being called is quarto render, then the redirects process runs in full. However, if it is quarto preview or a specific page is being rendered, it skips.↩︎

Reuse

Citation

For attribution, please cite this work as:
Johnson, Paul. 2025. “Automating Netlify Redirects in Quarto Using Lua.” June 4, 2025. https://paulrjohnson.net/blog/2025-06-04-automating-netlify-redirects-with-lua/.