@fastify/view
Templates rendering plugin support for Fastify.
@fastify/view
decorates the reply interface with the view
and viewAsync
methods for managing view engines, which can be used to render templates responses.
Currently supports the following templates engines:
In production
mode, @fastify/view
will heavily cache the templates file and functions, while in development
will reload every time the template file and function.
Note: For Fastify v3 support, please use point-of-view 5.x
(npm i point-of-view@5).
Note that at least Fastify v2.0.0
is needed.
Recent Changes
Note: reply.viewAsync
added as a replacement for reply.view
and fastify.view
. See Migrating from view to viewAsync.
Note: ejs-mate
support has been dropped.
Note: marko
support has been dropped. Please use @marko/fastify
instead.
Benchmarks
The benchmark were run with the files in the benchmark
folder with the ejs
engine.
The data has been taken with: autocannon -c 100 -d 5 -p 10 localhost:3000
- Express: 8.8k req/sec
- Fastify: 15.6k req/sec
Install
npm i @fastify/view
Quick start
fastify.register
is used to register @fastify/view. By default, It will decorate the reply
object with a view
method that takes at least two arguments:
- the template to be rendered
- the data that should be available to the template during rendering
This example will render the template using the EJS engine and provide a variable name
to be used inside the template:
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<p>Hello, <%= name %>!</p>
</body>
</html>
const fastify = require("fastify")()
const fastifyView = require("@fastify/view")
fastify.register(fastifyView, {
engine: {
ejs: require("ejs")
}
})
fastify.get("/", (req, reply) => {
reply.view("index.ejs", { name: "User" });
})
fastify.get("/", async (req, reply) => {
return reply.viewAsync("index.ejs", { name: "User" });
})
fastify.listen({ port: 3000 }, (err) => {
if (err) throw err;
console.log(`server listening on ${fastify.server.address().port}`);
})
Configuration
Options
Option | Description | Default |
---|
engine | Required. The template engine object - pass in the return value of require('<engine>') | |
production | Enables caching of template files and render functions | NODE_ENV === "production" |
maxCache | In production mode, maximum number of cached template files and render functions | 100 |
defaultContext | Template variables available to all views. Variables provided on render have precedence and will override this if they have the same name.
Example: { siteName: "MyAwesomeSite" } | {} |
propertyName | The property that should be used to decorate reply and fastify
E.g. reply.view() and fastify.view() where "view" is the property name | "view" |
asyncPropertyName | The property that should be used to decorate reply for async handler
Defaults to ${propertyName}Async if propertyName is defined | "viewAsync" |
root | The root path of your templates folder. The template name or path passed to the render function will be resolved relative to this path | "./" |
charset | Default charset used when setting Content-Type header | "utf-8" |
includeViewExtension | Automatically append the default extension for the used template engine if omitted from the template name . So instead of template.hbs , just template can be used | false |
viewExt | Override the default extension for a given template engine. This has precedence over includeViewExtension and will lead to the same behavior, just with a custom extension.
Example: "handlebars" | "" |
layout | See Layouts
This option lets you specify a global layout file to be used when rendering your templates. Settings like root or viewExt apply as for any other template file.
Example: ./templates/layouts/main.hbs | |
options | See Engine-specific settings | {} |
Example
fastify.register(require("@fastify/view"), {
engine: {
handlebars: require("handlebars"),
},
root: path.join(__dirname, "views"),
layout: "./templates/template",
viewExt: "handlebars",
propertyName: "render",
defaultContext: {
dev: process.env.NODE_ENV === "development",
},
options: {},
});
Layouts
@fastify/view supports layouts for EJS, Handlebars, Eta and doT. When a layout is specified, the request template is first rendered, then the layout template is rendered with the request-rendered html set on body
.
Example
<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
<%- body %>
<br/>
</body>
</html>
<p><%= text %></p>
fastify.register(fastifyView, {
engine: { ejs },
layout: "layout.ejs"
})
fastify.get('/', (req, reply) => {
const data = { text: "Hello!"}
reply.view('template.ejs', data)
})
Providing a layout on render
Please note: Global layouts and providing layouts on render are mutually exclusive. They can not be mixed.
fastify.get('/', (req, reply) => {
const data = { text: "Hello!"}
reply.view('template.ejs', data, { layout: 'layout.ejs' })
})
Setting request-global variables
Sometimes, several templates should have access to the same request-specific variables. E.g. when setting the current username.
If you want to provide data, which will be depended on by a request and available in all views, you have to add property locals
to reply
object, like in the example below:
fastify.addHook("preHandler", function (request, reply, done) {
reply.locals = {
text: getTextFromRequest(request),
};
done();
});
Properties from reply.locals
will override those from defaultContext
, but not from data
parameter provided to reply.view(template, data)
function.
Rendering the template into a variable
The fastify
object is decorated the same way as reply
and allows you to just render a view into a variable (without request-global variables) instead of sending the result back to the browser:
const html = await fastify.view("/templates/index.ejs", { text: "text" });
fastify.view("/templates/index.ejs", { text: "text" }, (err, html) => {
});
If called within a request hook and you need request-global variables, see Migrating from view to viewAsync.
Registering multiple engines
Registering multiple engines with different configurations is supported. They are distinguished via their propertyName
:
fastify.register(require("@fastify/view"), {
engine: { ejs: ejs },
layout: "./templates/layout-mobile.ejs",
propertyName: "mobile",
});
fastify.register(require("@fastify/view"), {
engine: { ejs: ejs },
layout: "./templates/layout-desktop.ejs",
propertyName: "desktop",
});
fastify.get("/mobile", (req, reply) => {
return reply.mobile("/templates/index.ejs", { text: "text" });
});
fastify.get("/desktop", (req, reply) => {
return reply.desktop("/templates/index.ejs", { text: "text" });
});
Minifying HTML on render
To utilize html-minifier-terser
in the rendering process, you can add the option useHtmlMinifier
with a reference to html-minifier-terser
,
and the optional htmlMinifierOptions
option is used to specify the html-minifier-terser
options:
const minifier = require('html-minifier-terser')
const minifierOpts = {
removeComments: true,
removeCommentsFromCDATA: true,
collapseWhitespace: true,
collapseBooleanAttributes: true,
removeAttributeQuotes: true,
removeEmptyAttributes: true
}
options: {
useHtmlMinifier: minifier,
htmlMinifierOptions: minifierOpts
}
To filter some paths from minification, you can add the option pathsToExcludeHtmlMinifier
with list of paths
const minifier = require('html-minifier-terser')
const options = {
useHtmlMinifier: minifier,
pathsToExcludeHtmlMinifier: ['/test']
}
fastify.register(require("@fastify/view"), {
engine: {
ejs: require('ejs')
},
options
});
fastify.get("/test", (req, reply) => {
reply.view("./template/index.ejs", { text: "text" });
});
Engine-specific settings
Mustache
To use partials in mustache you will need to pass the names and paths in the options parameter:
options: {
partials: {
header: 'header.mustache',
footer: 'footer.mustache'
}
}
fastify.get('/', (req, reply) => {
reply.view('./templates/index.mustache', data)
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.mustache', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
const render = mustache.render.bind(mustache, file)
reply.view(render, data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.mustache', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
Handlebars
To use partials in handlebars you will need to pass the names and paths in the options parameter:
options: {
partials: {
header: 'header.hbs',
footer: 'footer.hbs'
}
}
You can specify compile options as well:
options: {
compileOptions: {
preventIndent: true
}
}
To access defaultContext
and reply.locals
as @data
variables:
options: {
useDataVariables: true
}
To use layouts in handlebars you will need to pass the layout
parameter:
fastify.register(require("@fastify/view"), {
engine: {
handlebars: require("handlebars"),
},
layout: "./templates/layout.hbs",
});
fastify.get("/", (req, reply) => {
reply.view("./templates/index.hbs", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.hbs', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
const render = handlebars.compile(file)
reply.view(render, data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.hbs', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
Nunjucks
You can load templates from multiple paths when using the nunjucks engine:
fastify.register(require("@fastify/view"), {
engine: {
nunjucks: require("nunjucks"),
},
templates: [
"node_modules/shared-components",
"views",
],
});
To configure nunjucks environment after initialization, you can pass callback function to options:
options: {
onConfigure: (env) => {
};
}
fastify.get('/', (req, reply) => {
reply.view('./templates/index.njk', data)
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.njk', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
const render = nunjucks.compile(file)
reply.view(render, data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.njk', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
Liquid
To configure liquid you need to pass the engine instance as engine option:
const { Liquid } = require("liquidjs");
const path = require('node:path');
const engine = new Liquid({
root: path.join(__dirname, "templates"),
extname: ".liquid",
});
fastify.register(require("@fastify/view"), {
engine: {
liquid: engine,
},
});
fastify.get("/", (req, reply) => {
reply.view("./templates/index.liquid", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.liquid', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
const render = engine.renderFile.bind(engine, './templates/index.liquid')
reply.view(render, data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.liquid', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
doT
When using doT the plugin compiles all templates when the application starts, this way all .def
files are loaded and
both .jst
and .dot
files are loaded as in-memory functions.
This behavior is recommended by the doT team here.
To make it possible it is necessary to provide a root
or templates
option with the path to the template directory.
fastify.register(require("@fastify/view"), {
engine: {
dot: require("dot"),
},
root: "templates",
options: {
destination: "dot-compiled",
},
});
fastify.get("/", (req, reply) => {
reply.view("index", { text: "text" });
});
const d = dot.process({ path: 'templates', destination: 'out' })
fastify.get('/', (req, reply) => {
reply.view(d.index, data)
})
fastify.get('/', (req, reply) => {
reply.view({ raw: readFileSync('./templates/index.dot'), imports: { def: readFileSync('./templates/index.def') } }, data)
})
eta
const { Eta } = require('eta')
let eta = new Eta()
fastify.register(pointOfView, {
engine: {
eta
},
templates: 'templates'
})
fastify.get("/", (req, reply) => {
reply.view("index.eta", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.eta', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view(eta.compile(file), data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.eta', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
ejs
const ejs = require('ejs')
fastify.register(pointOfView, {
engine: {
ejs
},
templates: 'templates'
})
fastify.get("/", (req, reply) => {
reply.view("index.ejs", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.ejs', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view(ejs.compile(file), data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.ejs', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
pug
const pug = require('pug')
fastify.register(pointOfView, {
engine: {
pug
}
})
fastify.get("/", (req, reply) => {
reply.view("index.pug", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.pug', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view(pug.compile(file), data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.pug', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
twig
const twig = require('twig')
fastify.register(pointOfView, {
engine: {
twig
}
})
fastify.get("/", (req, reply) => {
reply.view("index.twig", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.twig', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view(twig.twig({ data: file }), data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.twig', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
art
const art = require('art-template')
fastify.register(pointOfView, {
engine: {
'art-template': art
}
})
fastify.get("/", (req, reply) => {
reply.view("./index.art", { text: "text" });
});
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.art', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view(art.compile({ filename: path.join(__dirname, '..', 'templates', 'index.art') }), data)
}
})
})
fastify.get('/', (req, reply) => {
fs.readFile('./templates/index.art', 'utf8', (err, file) => {
if (err) {
reply.send(err)
} else {
reply.view({ raw: file }, data)
}
})
})
Miscellaneous
Using @fastify/view as a dependency in a fastify-plugin
To require @fastify/view
as a dependency to a fastify-plugin, add the name @fastify/view
to the dependencies array in the plugin's opts.
fastify.register(myViewRendererPlugin, {
dependencies: ["@fastify/view"],
});
Forcing a cache-flush
To forcefully clear cache when in production mode, call the view.clearCache()
function.
fastify.view.clearCache();
Migrating from view
to viewAsync
The behavior of reply.view
is to immediately send the HTML response as soon as rendering is completed, or immediately send a 500 response with error if encountered, short-circuiting fastify's error handling hooks, whereas reply.viewAsync
returns a promise that either resolves to the rendered HTML, or rejects on any errors. fastify.view
has no mechanism for providing request-global variables, if needed. reply.viewAsync
can be used in both sync and async handlers.
Sync handler
Previously:
fastify.get('/', (req, reply) => {
reply.view('index.ejs', { text: 'text' })
})
Now:
fastify.get('/', (req, reply) => {
return reply.viewAsync('index.ejs', { text: 'text' })
})
Async handler
Previously:
fastify.get("/", async (req, reply) => {
const data = await something();
reply.view("/templates/index.ejs", { data });
return
})
Now:
fastify.get("/", async (req, reply) => {
const data = await something();
return reply.viewAsync("/templates/index.ejs", { data });
})
fastify.view (when called inside a route hook)
Previously:
fastify.get("/", async (req, reply) => {
const html = await fastify.view("/templates/index.ejs", { text: "text" });
return html
})
fastify.get("/", (req, reply) => {
fastify.view("/templates/index.ejs", { text: "text" }, (err, html) => {
if(err) {
reply.send(err)
}
else {
reply.type("application/html").send(html)
}
});
})
Now:
fastify.get("/", (req, reply) => {
const html = await fastify.viewAsync("/templates/index.ejs", { text: "text" });
return html
})
fastify.get("/", (req, reply) => {
fastify.viewAsync("/templates/index.ejs", { text: "text" })
.then((html) => reply.type("application/html").send(html))
.catch((err) => reply.send(err))
});
})
Note
By default views are served with the mime type text/html
, with the charset specified in options. You can specify a different Content-Type
header using reply.type
.
Acknowledgements
This project is kindly sponsored by:
License
Licensed under MIT.