“extensionless” urls with 11ty

Lots of web software is configured to create and serve web files/pages with an .html extension/suffix. That includes 11ty, which by default creates an index.html for each content template. It includes Browsersync — the hot-reload server invoked when you run npx @11ty/eleventy --serve — which determines the Content-Type response header based on the output file's extension. And it includes Apache HTTP server, which, like Browsersync, uses the extension to map a file to a Content-Type header.

And yet, even if your software defaults to .html, it is not mandatory for the web. There is no requirement that certain characters be attached to your web page urls. In this article, I'll explain how to make clean urls with Apache, Browsersync, and 11ty.


Suppose you have a server running Apache at, you use static files to store web pages, and one of those files is called foo.html. If a browser requests, Apache will include the following header with the page:

Content-Type: text/html; charset=utf-8

A few things to note:

  1. the header determines the content type
  2. the url does not
  3. the last part of the url (.html) is not a file extension, even if it sort of looks like one

That means you can remove the url suffix if you want to, but first you must change how Apache handles extensionless files. In your Apache configuration, add a FilesMatch section with a regular expression as follows:

<FilesMatch "^[^.]+$">

Any configurations inside that FilesMatch block will apply only to files that conform to the regular expression. In this case, the regular expression specifies no dot . character in the name, so the file foo matches, but files such as foo.jpeg, foo.js, etc. do not.

Next we add a ForceType directive inside that block:

<FilesMatch "^[^.]+$">
ForceType 'text/html; charset=utf-8'

ForceType, as its name suggests, forces Apache to treat all files as type text/html. But since it's in a FilesMatch block, the directive will only apply to extensionless files. Now, the url will be sent as text/html, the same if it were


By default, 11ty sort of creates clean urls. Given the source file

title: Foo
Lorem ipsum dolor sit amet, consectetur adipiscing elit....

11ty makes a directory named /foo/ and writes the page content to index.html inside that directory. And it links to the page using the directory as the address:

<a href="/foo/">Foo</a>

This works because servers like Apache will accept a request for /foo/, find the directory /foo/, and return the default file, which is typically index.html. So 11ty's default url is almost clean, but there's still an extraneous trailing slash / at the end.

In addition, having every page output as index.html can make working with your output files difficult. Suppose you wanted to compare the output of content templates named,, and You would have to examine three different files all named index.html. Distinguishing one from the others could be tricky.

Coda text editor on Mac OSX with three different files named index.html

To change this default, set the permalink in the file's front matter:

title: Foo
peramlink: foo
Lorem ipsum dolor sit amet, consectetur adipiscing elit....

Now 11ty will write foo instead of foo/index.html, and make links using /foo instead of /foo/. And we already got Apache to serve /foo as text/html. In principle, all is well. But...

Browsersync (11ty's server)

...there's one more problem. If you use the --serve switch to run the 11ty test server and you try to navigate to your Foo page, the server sends the wrong header:

Content-Type: application/octet-stream

And your browser, instead of displaying Foo, prompts you to download it. That makes the hot-reload test server all but useless. So our last task is to configure that server, called Browsersync, to mimic the way Apache behaves (assuming you've changed your Apache config as shown in the first part of this article).

In your .eleventy.js configuration file, add an eleventyConfig.setBrowserSyncConfig function:

module.exports = function(eleventyConfig) {
// other config directives that you already have,
// like addFilter, addWatchTarget, etc.

middleware: [
function (req, res, next) {
if (/^[^.]+$/.test(req.url)) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');

// my 11ty directories; N.B. yours might be different!
return {
dir: {
input: 'src',
layouts: '_templates',
output: 'public_html'

The new function tests the browser req, that is, the request from the browser, using a regular expression. You may have noted that the regular expression being tested against, ^[^.]+$, is the same as the one in the Apache FilesMatch tag in the first part of this article. It works the same way: if the url does not contain a dot . character, Browsersync sends this header:

Content-Type: text/html; charset=utf-8

Now the hot-reload server is working, Apache is working, and you have “extensionless” urls with 11ty.

Thanks to GitHub user Paul Shryock for the setBrowserSyncConfig idea.