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 script1 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 up2.
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)
("Attempting to read: " .. file_path)
debug_logif not file_exists(file_path) then
("File not found: " .. file_path)
debug_logreturn {}
end
local lines = {}
local file = io.open(file_path, "r")
for line in file:lines() do
table.insert(lines, line)
end
file:close()
("Read " .. #lines .. " lines from: " .. file_path)
debug_logreturn lines
end
local function extract_categories(post_path)
local qmd_path = post_path .. "/index.qmd"
if not file_exists(qmd_path) then
("Blog post .qmd not found: " .. qmd_path)
debug_logreturn {}
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
("Found manual redirects at: " .. path)
debug_logreturn read_lines(path)
end
end
return {}
end
local function process_blog_posts(blog_dir)
("Looking for blog posts in: " .. blog_dir)
debug_log
local redirects = {}
local blog_posts = {}
local all_categories = {}
local dir_cmd = 'find "' .. blog_dir ..
'" -maxdepth 1 -mindepth 1 -type d'
("Running directory listing command: " .. dir_cmd)
debug_log
local handle = io.popen(dir_cmd)
for post_path in handle:lines() do
local post_name = post_path:match("([^/]+)$")
("Found potential blog post: " ..
debug_log(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)
("Added redirect: " .. old .. " → " .. new)
debug_log
-- Collect categories
local post_categories = extract_categories(post_path)
for _, cat in ipairs(post_categories) do
("Found category: " .. cat)
debug_logall_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
("Generating category redirects")
debug_logfor 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)
("Added category redirect: /tags/" .. tag)
debug_logcategory_count = category_count + 1
end
return redirects, category_count
end
local function write_redirects_file(project_root, redirects)
local out_file = project_root .. "/_redirects"
("Writing redirects to: " .. out_file)
debug_log
local f = io.open(out_file, "w")
if f then
for _, line in ipairs(redirects) do
f:write(line .. "\n")
end
f:close()
("Successfully wrote " .. #redirects .. " redirects")
debug_logreturn true
else
io.stderr:write("ERROR: Could not open output file for writing: " ..
out_file .. "\n")
return false
end
end
function Pandoc(doc)
("Starting redirect generation...")
debug_log
local project_root = system.get_working_directory()
("Working directory: " .. project_root)
debug_log
-- 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
("Running in Netlify environment: " .. netlify_build_dir)
debug_logend
local blog_dir = project_root .. "/blog"
("Blog directory path: " .. blog_dir)
debug_log
if not is_dir(blog_dir) then
("Blog directory not found, skipping redirect generation")
debug_logreturn doc
end
-- Load manual redirects
local manual_redirects = load_manual_redirects(project_root)
("Added " .. #manual_redirects .. " manual redirects")
debug_log
-- Process blog posts
local post_redirects, blog_posts, all_categories =
(blog_dir)
process_blog_posts
-- Generate category redirects
local category_redirects, category_count =
(all_categories)
generate_category_redirects
-- 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
("Redirect generation complete")
debug_logprint("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
file3.
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 site4. 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
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.↩︎
If you need to learn Lua, I recommend the Learn Lua in 15 Minutes resource, as well as Quarto’s Lua Cheatsheet.↩︎
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.↩︎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 isquarto preview
or a specific page is being rendered, it skips.↩︎