Lume 3 was released - Adolfina Casás
by Óscar Otero
17 min read
After launching Lume 2 almost a year and a half ago, a new major version of Lume is here!
This version is dedicated to all Galician cantareiras and pandereteiras—women who sing and play the tambourine (or pandeireta), one of the most important instruments in Galician traditional music. One of these cantareiras was Adolfina Casás Rama (1912-2009), an ancestor of my friend Miriam Casás. She worked in agriculture, where she often sang. She was known for her wit and the variety of styles employed.
Thanks to the recollidas, where musicians and musicologists recorded these traditional songs sung by anonymous women, we can now enjoy this musical heritage performed by contemporary musicians with innovative arrangements.
Some examples include Xabier Díaz & Adufeiras de Salitre, Xosé Lois Romero & Aliboria, and Berrogüetto (apologies for the video quality, but I really love that song).
For more disruptive artists, check out Tanxugueiras or Baiuca.
Why Lume 3?
To many developers, including myself, breaking changes can be frustrating. Software updates that force you to revisit a project just to ensure it continues working as before often feel like a waste of time. This is one of the reasons I enjoy working with Web APIs—they are stable, reliable, and designed to just work without introducing unnecessary disruptions.
I strive to bring a similar philosophy to Lume by minimizing breaking changes whenever possible. In fact, I initially had no plans to release a new major version of Lume. However, after receiving numerous reports about certain behaviors and limitations, I realized it was necessary to revisit some design decisions. This effort aims to finally deliver the simple, intuitive static site generator I have always envisioned, hoping that Lume 4 won't be necesary in a long time, or never.
Note
TL/DR: There's a step-by-step guide to migrate to Lume 3 in the documentation.
The main problem
The site.copy()
function allows you to copy files from the src
folder
without reading the content, which is faster and consumes less memory. But it
has one big drawback: the files are not processed.
For example, let's say you have the following configuration:
site.copy("/assets");
site.use(postcss());
When Lume builds your site, the files inside the /assets
folder are copied
as-is. If the folder contains CSS files, they won't be processed by Postcss.
Learn more about
this issue on GitHub.
This behavior is confusing and many people reported this as a bug. And they are right: Lume should be clever enough to not delegate the decision of whether a file must be loaded or copied.
The solution: site.add()
In Lume 3, the site.loadAssets()
, site.copyRemainingFiles()
and
site.copy()
functions were removed, and now there is a single function for
everything: site.add()
.
The add()
function simply tells Lume that you want to include some files in
your site, but without specifying how this file must be treated. Lume will load
the file if it needs to (for example, if it needs to be processed), or will copy
it if no transformations are needed.
site.add("/assets");
site.use(postcss()); // CSS files in /assets will be processed too!
To upgrade from Lume 2 to Lume 3, just replace the site.loadAssets()
,
site.copyRemainingFiles()
, and site.copy()
functions with site.add()
.
For example:
// Lume 2
site.loadAssets([".css"]);
site.copy("/assets", ".");
site.copyRemainingFiles(
(path: string) => path.startsWith("/articles/"),
);
// Lume 3
site.add([".css"]);
site.add("/assets", ".");
site.add("/articles");
Update: Some users have reported that
site.copy()
remains useful in specific scenarios. For instance, if you need to copy a CSS file without processing it. As a result, thesite.copy()
function was reintroduced in Lume 3.0.1 to address these edge cases.
Copy remote files
site.add()
can add files from the src
folder as well as remote files. In
Lume 2, this was possible with the remoteFile
function:
// Lume 2
site.remoteFile("styles.css", "https://example.com/theme/styles.css");
site.copy("styles.css");
Lume 3 makes this use case easier:
// Lume 3
site.add("https://example.com/theme/styles.css", "styles.css");
The site.add()
function also accepts npm
specifiers:
site.add("npm:normalize.css", "/styles/normalize.css");
Internally, this uses jsDelivr to download the file. In this example,
npm:normalize.css
is transformed to
https://cdn.jsdelivr.net/npm/normalize.css
. Note that only one file is copied,
not all package files.
Note
site.remoteFile
is still required in Lume 3 for files not directly exported
to the dest folder, like _data
, _components
or _includes
files.
More info in the documentation page.
Plugins no longer load files automatically
In Lume 2, some plugins configure Lume to load files with a certain extension automatically. For example, Postcss not only processes the CSS code but also configures Lume to load all CSS files:
// All .css files are loaded and processed
site.use(postcss());
In some cases, this is what you want. But if you don't want to load all CSS
files, this behavior makes Lume load everything, and you have to use the
site.ignore()
function or move the unwanted files to a folder starting with
_
.
In addition to that, this behavior is not fully transparent. You have to read the documentation to know what the plugin is doing.
In short, this approach causes more harm than good.
In Lume 3, thanks to the site.add()
function, it's very easy to add new files
(and only the files that you want), so plugins no longer load files by
default. You have to explicitly add them, which is more intuitive:
// Lume 2
site.use(postcss());
// Lume 3
site.add([".css"]);
site.use(postcss());
Another benefit is you have better control of all entry points of your assets. For example, for esbuild:
// Lume 3
site.add("main.ts");
site.use(esbuild()); // Only main.ts is bundled
This change affects the svgo
, transform_images
, picture
, postcss
,
sass
, tailwindcss
, unocss
, esbuild
and terser
plugins.
JSX
One JSX plugin
Lume started supporting JSX
as a template engine thanks to the jsx
plugin
that uses React under the hood. Later, the jsx_preact
plugin was added to use
Preact, a smaller and more performant alternative to React.
Having two JSX plugins for the same purpose is useless and adds unnecessary complexity (for example, combined with the MDX plugin).
Moreover, both libraries are frontend-first libraries, with features like hooks, event callbacks, hydration, etc, that are not supported at build time, so some people were confused about what they can or cannot do in Lume.
Lume 3 has only one JSX plugin, and it doesn't use React or Preact but
SSX, a TypeScript library created
specifically for static sites which is faster than React and Preact and more
ergonomic. It allows creating asynchronous components, inserting raw code like
<!doctype html>
, and comes with great documentation including all HTML
elements and attributes, with links to MDN.
Lume 3 uses lume/jsx-runtime
import source for all JSX and MDX files. So you
only have to configure the compilerOptions
setting of deno.json
as following
(other options have been omited for brevity):
{
"imports": {
"lume/jsx-runtime": "https://deno.land/x/ssx@v0.1.8/jsx-runtime.ts"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "lume"
}
}
This allows to upgrade the library (or even replace it with something else) easily.
Note
With the esbuild
plugin you still can use React or Preact in Lume but for
what they were created for: the frontend.
.page
Subextension for JSX and TSX Pages
Lume requires the .page
subextension for certain file types like .ts
, .js
,
or .json
to distinguish between files used to generate pages and those
intended for browser execution. For instance, index.page.js
generates the
index.html
page, while index.js
is a JavaScript file executed by the
browser.
Starting with Lume 3, the .page
subextension is also applied to .jsx
and
.tsx
files. This change allows the .jsx
and .tsx
extensions to be
exclusively used for browser-side code (after processing with the esbuild
plugin).
Lume 2:
- /index.jsx
Lume 3:
- /index.page.jsx
If you prefer the Lume 2 behavior (where this differentiation is not required),
you can configure the plugin to remove the .page
subextension:
site.use(jsx({
pageSubExtension: "", // Reverts to Lume 2 behavior
}));
More info in the plugin documentation.
Improved Lume components
Async components
One of the main limitations of Lume 2's components was that they were synchronous. This was to support JSX components that were synchronous with React and Preact. With SSX, we don't have this limitation anymore, and all components are async.
For example, you can create a component in JSX that returns a promise:
// _components/salute.jsx
export default async function ({ id }) {
const response = await fetch(`https://example.com/api?id=${id}`);
const data = await response.json();
return <strong>Hello {data.name}</strong>;
}
This component can be used in any other template engine, like JSX:
export default async function ({ comp }) {
return (
<p>
<comp.Salute id="23" />
</p>
);
}
Or Vento:
<p>{{ comp.Salute({ id: 23}) }}</p>
Folder components
Lume components not only generate HTML code but can also export the CSS and JS
code needed to run it on the browser. The code must be exported in the variables
css
and js
. For example:
---
css: |
.mainTitle {
color: red;
}
---
<h1 class="mainTitle">{{ name }}</h1>
The problem with this approach is the CSS and JS code is not treated as CSS and JS code by your code editor, so there's no syntax highlighting.
In Lume 3, it's possible to create a component in a folder, with the CSS and JS code in different files. To do that, you use the following structure:
|_ _components/
|_ button/
|_ comp.vto
|_ style.css
|_ script.js
Any folder containing a comp.*
file will be loaded as a component using the
folder name as the component name, and the style.css
and script.js
files
will be loaded as the CSS and JS code for the component. This makes the creation
of components more ergonomic, especially for cases with a lot of CSS and JS
code.
Additionally, it's possible to add a script.ts
file instead script.js
to use
TypeScript. Lume will compile it to JavaScript automatically.
Better interoperability
In Lume 2 components created with text-based engines, like Vento didn't work well for JSX templates. For example, let's say we have the following Vento component:
<button>{{ content }}</button>
and we want to use it in a JSX page:
export default function ({ comp }) {
return <comp.Button>Click here</comp.Button>;
}
Due JSX escapes the string values, the output code is this:
<button>Click here</button>
To fix it, we need to create a container element with the
dangerouslySetInnerHTML
attribute:
export default function ({ comp }) {
return (
<div
dangerouslySetInnerHTML={{
__html: <comp.Button>Click here</comp.Button>,
}}
/>
);
}
In Lume 3, thanks to SSX this is no longer necessary. Components are fully
interoperable and you can insert JSX components in Vento and viceversa. And to
make them even more interchangeable, the content
and children
variables are
equivalent.
export default function ({ comp }) {
return (
<>
// This works
<comp.Button>Click here</comp.Button>
// This also works
<comp.Button content="Click here" />
</>
);
}
Default data in components
In Lume 3, components can have extra data that will be used as default values. This data is like layout data but applied to components.
Let's see the following _components/title.vto
component as an example :
---
title: Hello world
---
<h1>{{ title }}</h1>
Now you can use the component with the default title.
{{ await comp.title() }}
<!-- <h1>Hello world</h1> -->
Or with a custom title
{{ await comp.title({ title: "New title" }) }}
<!-- <h1>New title</h1> -->
Global cssFile, jsFile and fontsFolder
As mentioned, Lume components can output CSS and JS code. However, some plugins
output code too. For example, google_fonts
generates the CSS code needed to
load the fonts, prism
and code_highlight
export the CSS code with the
themes, and katex
(that didn't generate CSS code in Lume 2) now generates the
CSS code automatically so you don't need to copy manually the CSS code.
Additionally, some plugins download also font files (specifically,
google_fonts
and katex
).
In Lume 2, you have to configure how to export the generated code for every plugin individually. In Lume 3 there are three global options that will be used by default by all plugins and components:
const site = lume({
cssFile: "/style.css", // default value
jsFile: "/script.js", // default value
fontsFolder: "/fonts", // default value
});
All extra code generated by components and the plugins code_highlight
,
google_fonts
, prism
, katex
and unocss
will be stored there.
Of course, you can still change the code destination for a specific plugin:
site.use(unocss({
cssFile: "/unocss-styles.css",
}));
Tailwind 4
The tailwindcss
plugin was upgraded to use
Tailwind 4. The new version is
faster than v3 and no longer needs Postcss to work. There are many changes in
the configuration (especially the CSS-first configuration) so take a look at the
upgrade guide if you want to
upgrade your projects from v3 to v4.
// Lume 2
site.use(tailwindcss());
site.use(postcss());
// Lume 3
site.use(tailwindcss());
site.add("style.css");
If you don't want to upgrade to v4, it's still possible to continue using Tailwind 3 with the postcss plugin:
import tailwind from "npm:tailwindcss@^3.4";
site.use(
postcss({
plugins: [tailwind()],
}),
);
More info in the plugin documentation.
Processors improvements
site.process()
and site.preprocess()
are among Lume's most used features.
Lume 3 brings some improvements here to make them easier to use.
page.document
no longer returns undefined
One of the many uses of processors is to manipulate HTML pages using the
page.document
property. But this property can return undefined
if the page
is not HTML or cannot be parsed, so you have to check the variable type before
using it:
site.process([".html"], (pages) => {
for (const page of pages) {
const document = page.document;
if (!document) {
continue;
}
const title = document.querySelector("title");
}
});
In Lume 3, page.document
always returns a Document
instance or throws an
exception if the page cannot be parsed. This allows us to omit the type check:
site.process([".html"], (pages) => {
for (const page of pages) {
const title = page.document.querySelector("title");
}
});
New page properties
The page.content
variable containing the content of the page can be a string
or a Uint8Array
, depending on how this page has been loaded. For example,
HTML, CSS or JS pages have the content as a string, but images or other binary
files are loaded as Uint8Array
.
To process these files in Lume 2 you have to check the content type:
site.process([".css"], (pages) => {
for (const page of pages) {
const content = page.content;
if (typeof content === "string") {
page.content = "/* © 2025 */" + content;
}
}
});
In Lume 3, pages have two new properties: page.text
and page.bytes
(inspired
by the same properties of the
Request
object). As you may guess, page.text
allows to work with the page content as
strings, making the conversions automatic, and page.bytes
does the same but
for Uint8Array
.
site.process([".css"], (pages) => {
for (const page of pages) {
page.text = "/* © 2025 */" + page.text;
}
});
Omit *
wildcard
In Lume 2, the *
wildcard allows you to process all pages:
site.process("*", (pages) => {
// Process all pages
});
In Lume 3, the first argument can be omitted:
site.process((pages) => {
// Process all pages
});
The order of some plugins is now more important
In Lume 2, the order in which some plugins are registered doesn't matter. Let's
see this example from the sitemap
plugin:
site.use(sitemap()); // Generate the sitemap file
site.use(basePath()); // Add the base path to all URLs
The sitemap plugin is registered before basePath, so you may think the sitemap file is generated before adding the base path prefix to all URLs. But internally, the sitemap plugin is executed using the "beforeSave" event, which is triggered at the end, just before saving all files to the _site folder. So internally the basePath plugin is executed before.
This was designed so you don't have to think about the order of the plugins when using them. But this behavior has two problems:
- There are many plugins in which the order matters. For example, if you combine SASS and Postcss you have to process the SCSS files first and pass the result to Postcss. This inconsistency makes you wonder in which plugins the order is important or not.
- It's not possible to use a processor to modify the output of these plugins. For example, if you want to compress the sitemap file with brotli or gzip, is not possible because the sitemap will be always generated at the end.
To make Lume more transparent and intuitive, many plugins using events were changed to use processors, which respect the order in which they are registered in the _config.ts file.
The affected plugins are: code_highlight
, decap_cms
, favicon
, feed
,
google_fonts
, icons
, prism
, robots
, sitemap
, and slugify_urls
.
To help with this transition, Lume 3 comes with a lint plugin that warns you when the order of some plugins is not correct.
esbuild uses esbuild-deno-loader
to resolve dependencies
Deno is becoming a complicated runtime, especially for everything related to
module resolution. It supports three completely different types of packages
(HTTP, NPM, and JSR), with different behaviors, inconsistencies, and
incompatibilities between them. In addition to the usual complexity of NPM, in
Deno a package can be located in different places, depending on the variable
nodeModulesDir
, if the file package.json
is found, if the node_modules
folder exists, etc. JSR is not much better, because the resolution of a package
depends on the combination of imports
, exports
, and patch
keys in
different deno.json
and deno.jsonc
files. And the addition of workspaces
adds a new layer of complexity.
In Lume 2, the esbuild
plugin delegates all this complexity to
esm.sh, which transforms any NPM or JSR package to simple
HTTP imports that are easier to manage. But this solution has its problems with
multiple configuration options (deps
, pin
, alias
, standalone
, exports
,
etc) and there are many packages that don't work well after passing them through
esm.sh.
In Lume 3 the esbuild
plugin uses the
esbuild-deno-loader plugin created
by Luca Casonato, a member of the Deno team. This will make your bundled code
more reliable and compatible with how Deno works.
basename
improvements
In Lume 2, the basename
variable allows changing the name of a file or
directory. When missing, it's automatically defined by Lume using the page
filename. For example, the page /posts/first-post.md
has the basename
first-post
.
In Lume 3 this variable uses the final URL of the page, instead of the source
filename. For example, if the /post/first-post.md
page generates a different
URL (say /post/other-name/
) the basename
is other-name
.
Additionally, the basename no longer accepts "index" as a value. For example,
the basename for the /post/hello-world/index.md
is hello-world
(the folder
name) instead of index
(the filename).
These changes will make this variable more consistent across all pages, no
matter how the URL is generated. It's especially important for the nav
plugin
that uses this variable to sort pages alphabetically.
Date detection from filepath is disabled by default
Lume 2 detects automatically the date
value from the files and folders paths
and remove it. For example, the file /posts/2020-06-21_hello-world.md
outputs
the page /posts/hello-world/
(without the date).
Some people don't want this behavior and prefer to keep the date in the output
URL. Following the Lume's philosophy of having a light core and provide extra
features through plugins, this feature was removed from the core and the new
extract_date
plugin was created to enable it.
import lume from "lume/mod.ts";
import extractDate from "lume/plugins/extract_date.ts";
const site = lume();
site.use(extractDate());
export default site;
By default the plugin provides the same behavior of Lume 2, but it's possible to extract the date without removing it from the URL:
import lume from "lume/mod.ts";
import extractDate from "lume/plugins/extract_date.ts";
const site = lume();
site.use(extractDate({
remove: false, // Keep the date
}));
export default site;
Removed plugins
In addition to jsx_preact
, two more plugins were removed in Lume 3: liquid
and on_demand
.
Liquid lets you using LiquidJS as a template engine to
build pages. The syntax is very similar to Nunjucks and the library is actively
maintained but it has a big limitation: it's not possible to invoke functions.
This makes this template engine useless in Lume because it's not possible to use
helpers like search
or nav
to search pages or build the navigation. The
plugin has been deprecated for a while, and it was removed in Lume 3.
The on_demand
plugin was mainly an experiment to see if it was possible to add
some dynamic behavior to Lume sites. But it never worked well, the
implementation was a bit hacky to make it work on Deno Deploy, and it was too
limited. Lume has the router for simple use
cases, and for complex cases, maybe you have to use a different framework. The
purpose of Lume never was to become into one-size-fits-all solution.
Removed some customization
The following removals aim to improve the stability and interoperability between plugins.
extensions
option
In Lume 2, some plugins have the extensions
option to configure which files
you want to process. You rarely need to modify this option because Lume provides
sensible defaults. For example, the default value for
Postcss plugin is [".css"]
:
site.use(postcss({
extensions: [".css"], // <- You don't need this
}));
In most cases, this option doesn't make sense, because you can set any value but the plugin expects a specific format, like HTML pages to use DOM API or CSS code to process:
site.use(postcss({
extensions: [".html"], // <- This breaks the build
}));
In Lume 3, this option was removed in many plugins:
- purgecss, postcss, and lightningcss always process
.css
files. - sass always processes
.scss
and.sass
files. - svgo always processes
.svg
files. - check_urls, base_path, relative_urls and modify_urls process
.css
and.html
files. - filter_pages processes all extensions.
- code_highlight, fff, inline, json_ld, katex, metas, multilanguage, og_images,
and prism always process
.html
pages.
Name option
There are other plugins that register filters or helpers that you can use in
your pages. In Lume 2 you could customize the name of these elements. For
example, it's possible to use a different key to store the data for the metas
plugin:
site.use(metas({
name: "opengraph",
}));
Or the filter name of the date
plugin:
site.use(date({
name: "get_date",
}));
Changing the default name of the plugins have two problems:
- The types declared by the plugin don't change, so even if you change the key
metas
toopengraph
,Lume.Data.metas
still exist. - This breaks the interoperability between plugins. For example,
picture
andtransform_images
depend on the same key name. If you change it for only one plugin, the other won't work.
In Lume 3, the name
option was removed in the following plugins, so it's no
longer possible to change it to something else: date
, json_ld
, metas
,
nav
, paginate
, picture
, reading_info
, search
, transform_images
,
url
and postcss
.
Other options removed
- cache option in
transform_images
,favicon
andog_images
attribute
option ininline
.- Components are always in the
comp
variable. The option to customize this variable name has been removed.
Most Lume users don't change these options, so most likely these removals don't affect your upgrade to Lume 3.
Other changes
Temporal API enabled by default
The Temporal proposal provides
standard objects and functions for working with dates and times. It's being
implemented in all browsers and it's supported by Deno with the
unstable-temporal
flag. Lume 2 uses
a polyfill, but Lume 3
uses the Deno implementation, which requires to enable it in deno.json
file:
{
"unstable": ["temporal"]
}
Deno LTS support
As of version 3, Lume will support at least the most recent Deno LTS version (and probably some older versions too). Lume 3.0 supports Deno 2.1 and greater. More info about Deno LTS releases.
Removed automatic doctype
Lume 2 automatically added <!doctype html>
to any HTML pages that were missing
it. The original reason was because JSX doesn't allow adding this directive, so
it was difficult to create HTML pages with only JSX. However, some users don't
want this behavior because they create files with fragments of HTML. In Lume 3,
it is possible to add the doctype
directive in JSX (thanks to SSX) so this
behavior is no longer needed and was removed.
More changes
As always, you can see the CHANGELOG.md file for a complete list of all changes with more details.
One last thing
While the development server is running, Lume 3 features a debug bar that provides valuable insights, including warnings and issues flagged by plugins.
The Lume debug bar can be extended easily by plugins or directly in the
_config.ts
. For example, let's create a simple tab to list all pages without
title:
function createTab() {
// Create a collection in the debug bar
const collection = site.debugBar?.collection("Pages without title");
// The debug bar is enabled if the collection was created
if (collection) {
collection.icon = "file";
// Add items to the collection
collection.items = site.pages
.filter((page) => page.outputPath.endsWith(".html")) // Only HTML pages
.filter((page) => !page.data.title) // No title
.map((page) => ({
title: page.data.url,
actions: [
{
text: "Visit",
href: page.data.url,
},
],
}));
}
}
// Run this function after building and updating the site
site.addEventListener("afterBuild", createTab);
site.addEventListener("afterUpdate", createTab);
That's it! Our custom tab is now part of the Lume debug bar, and it has already identified two pages without titles!
The Lume debug bar is still an experimental feature, but it has been very well received since its introduction in our Discord community. Developers are already working on plugins to enhance the debug bar with features like HTML validator reports, accessibility checks, and SEO analysis.
Keep in mind that the debug bar is only visible when running Lume with
deno task serve
. It is not included in production builds. If you wish to
disable this feature completely, you can do so by editing the _config.ts
file:
const site = lume({
server: {
debugBar: false, // disable the debug bar
},
});
Thanks!
All this work wouldn't be possible without the help from all people that contribute to Lume. Thanks to everyone that sponsor Lume or directly me. Thanks also to people that have been testing Lume 3 in the latest months or even using it in real projects, reporting bugs and providing feedback (specially Tim Post and Rick Cogley), and thanks to Pyrox for reviewing the grammar of this post.