How to create a website using Node.js and Express
This example creates a website using Node.js to provide logical website behavior. Using the Express.js framework, the website is implemented as a web application, with logical routing to other sections of the website.
The HTML (hypertext markup language) and CSS (cascading style sheets) is based on our responsive website design using CSS Grid and Flexbox. The HTML is refactored as a template, so layout code can be reused when adding new pages.
Install Node
Node.js, also called Node, is a runtime environment for writing server-side applications in JavaScript.
If Node is already installed on your computer, you can skip this section and proceed to make a new Express app.
Download the Node installer from the official Node.js downloads website. Choose the LTS (long term support) version for your operating system.
Windows and macOS
Open and run the Node installer (.msi on Windows, .pkg on macOS).
On Windows, at the installation screen labeled Tools for Native Modules, check the box Automatically install the necessary tools.
Linux
On Linux systems, you can install Node using your package manager, install the compiled binaries manually, or build Node from source. For detailed information, refer to the official Node.js installation wiki.
All operating systems
When installation is complete, open a terminal or command prompt window. Run the following command to update npm, the Node package manager. The -g (global) switch specifies that the software is installed system-wide, not only the current Node app.
Windows
npm install -g npm
Linux and macOS
sudo npm install -g npm
Finally, use npm to globally install the express-generator application.
Windows
npm install -g express-generator
Linux and macOS
sudo npm install -g express-generator
Make a new Express app
In a terminal or command prompt window, generate a new Express.js app. In our example, the app name is myapp, and the view engine is specified as pug.
express myapp --view="pug"
Change directory to the new Express app.
cd myapp
In the Express app directory, use npm install to download and install the required dependencies, as listed in the package.json file.
npm install
If any security updates are available for the installed dependencies, a notification is displayed.
found 1 low severity vulnerability run `npm audit fix` to fix them, or `npm audit` for details
If so, apply the security updates.
npm audit fix
Install nodemon
In the Express app directory, install nodemon. The option --save-dev indicates that nodemon is a development dependency. It is not used in the application itself, but is a tool used during development.
npm install --save-dev nodemon
Add a development startup script
A development startup script provides a way to start your web application with options that help you develop the app, such as verbose error messages.
In a text editor, open the file package.json in the app directory. This JSON (JavaScript Object Notation) file specifies the dependencies used by your Node app. Additionally, it contains named startup scripts that start the application in different ways.
In package.json, locate the "scripts" entry. By default, it contains only one script ("start").
"scripts": { "start": "node ./bin/www" },
Add a new line that defines a script devstart as follows.
Linux and macOS
"scripts": { "start": "node ./bin/www", "devstart": "DEBUG=myapp:* nodemon ./bin/www" },
Windows
"scripts": { "start": "node ./bin/www", "devstart": "SET DEBUG=myapp:* & nodemon ./bin/www" },
These scripts ("start" and "devstart") can be executed by running the command npm run scriptname.
The command npm run devstart starts the app with two additional development features enabled.
- The DEBUG environment variable is set, specifying that the console log and error pages, such as HTTP (hypertext transfer protocol) 404, display additional information, like a stack trace.
- In addition, nodemon monitors certain important website files. If you modify these files, such as redesigning a page or modifying static content, nodemon automatically restarts the server to reflect the changes.
Start the web server in development mode.
npm run devstart
If the Windows Firewall blocks the web server application, click Allow Access.
Preview the web app
When the application is running, your computer acts as a web server, serving HTTP on port 3000.
To preview the website, open a web browser to the address localhost:3000.
Any device connected to your local network can view the application at address ipaddress:3000, where ipaddress is the local IP address of the computer running the app.
If you're unsure what the computer's local IP address is, see: How to find my IP address.
To preview the website on a mobile device, connect its Wi-Fi to your local network, and open the address in a browser.
HTML templates
Our example uses the CSS, JavaScript, and HTML from the how to create a responsive website using CSS Grid and Flexbox. The CSS and JavaScript are used verbatim. The HTML is refactored to a templating language.
Using a templating language, the layout code is written only once, and inherited by other pages.
The software that converts a template to its final format is called a template processor. In the context of HTML, a template processor is called a view engine.
Express.js supports several view engines, including Pug.
Overview of Pug
The Pug language describes HTML documents, in a way that provides benefits and additional features. Pug files are rendered to HTML when the user requests them.
Pug's language syntax removes the need for tags to be closed, or enclosed in brackets. It also supports inherited templates, iteration, conditionals, and JavaScript evaluation.
Example HTML to Pug conversion
These are the first few lines of the HTML from the how to create a responsive website using CSS Grid and Flexbox.
<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="utf-8"> <title>Title</title> <link rel="stylesheet" href="index.css"> <script src="index.js"></script> </head> <body> <div id="menu"> <section id="menuitems"> <div class="menubutton"> <h1 onclick="menuToggle('hide')" class="menubutton">☰</h1>
In Pug, the same HTML can be written like this.
doctype html html head meta(name="viewport" content="width=device-width, initial-scale=1") meta(charset="utf-8") title Title link(rel="stylesheet", href="index.css") script(src="index.js") body #menu section#menuitems .menubutton h1.menubutton(onclick="menuToggle('hide')") ☰
Element tags are written without brackets. Child elements are indented. The level of indentation determines the scope of an element, so closing tags are not necessary.
The "id" and "class" CSS selectors can be written as element#id, element.class, element#id.class, etc. If no element is specified, the element is assumed to be a div. For example, <div class="foo"> in HTML can be written as .foo in Pug.
After the element name and its selectors, attributes can be specified in parentheses, as a comma-delimited list. For example:
HTML
<div class="button" onmouseover="glow()" onclick="start()">
Pug
.button(onmouseover="glow()", onclick="start()")
Listing multiple elements in one line
If the element is followed by a colon (:), it can be followed by a child element on the same line. The following two sections of Pug produce the same HTML output.
a(href="/home") p Home
a(href="/home"): p Home
Both of the above are rendered to the following HTML.
<a href="/home"><p>Home</p></a>
Evaluating JavaScript
If the element is followed by an equals sign (=), everything that follows on that line is interpreted as buffered code. The code is evaluated as JavaScript, and the output is "buffered" (included as the element's content). In its simplest form, buffered code can be the name of a variable, passed by the application.
For example, the app router for the home page, index.js, passes the variable title with the value "Our Farm Stand" to the method express.Router(), which passes it to Pug. When Pug renders layout.pug, the following line:
title= pagetitle
...is interpreted as:
title Our Farm Stand
...which is rendered as the following HTML:
<title>Our Farm Stand</title>
Template inheritance
Pug documents can inherit other Pug documents using the keywords extends and block.
For example, you can create a basic website layout, layout.pug, with shared elements of the page.
doctype html html head title Page Title body p Content block foo
The block foo statement says "insert a block of content here, named foo, specified in another Pug document that inherits this template."
Documents that inherit layout.pug must begin with the statement extends layout, and contain a block foo statement at the top indentation level (at the beginning of a new line). The children of this "block foo" statement are inserted in the template at the location of the corresponding block.
A Pug document can inherit layout.pug like the following.
extends layout block foo p This is the home page.
When the document is rendered, the Pug engine loads the file layout.pug. The line block foo in layout.pug is replaced with p This is the home page.
Overview of the default Express app
The default structure of the Express app is listed here, with descriptions of each file and directory.
myapp/ (Contains the entire Express app) ├─ app.js The core logic of the Express app. ├─ bin/ (Contains the app's executable scripts) │ └─ www A wrapper that runs app.js. ├─ node_modules/ (Contains dependencies installed by npm) ├─ package-lock.json JSON manifest of installed dependencies. ├─ package.json JSON of dependencies and config specific to your app. ├─ public/ (Files downloaded by the user's web browser) │ ├─ images/ (Contains client-accessible image files) │ ├─ javascripts/ (Contains client-accessible JavaScript files) │ └─ stylesheets/ (Contains client-accessible CSS) │ └─ style.css The site's CSS stylesheet. ├─ routes/ (Contains logic for individual site routes) │ ├─ index.js Logic of the "index" route (/). │ └─ users.js Logic of the "users" route (/users). └─ views/ (Contains HTML templates) ├─ error.pug View displayed for error pages, such as HTML 404. ├─ index.pug View displayed for the site root (/). └─ layout.pug View template of layout shared by all pages.
Core functionality of the website is defined in app.js. Routes are named and specified in this file.
A route is a page or section of the site with a unique path in the URL (uniform resource locator), such as www.example.com/search, www.example.com/login, etc. These routes are named, and associated with route logic scripts, in app.js.
Route logic scripts are stored in the routes folder. When a user requests a route, its route logic script processes the HTTP request data and sends a response.
The views folder contains the HTML templates, called views, which are processed by the view engine (Pug).
Implementation: JavaScript, CSS, and Pug
The following code implements the Express web app.
App file structure
myapp/ ├─ app.js App core logic ├─ bin/ │ └─ www ├─ node_modules/ ├─ package-lock.json ├─ package.json ├─ public/ │ ├─ images/ │ ├─ javascripts/ │ │ └─ menu.js Implements menu toggle │ └─ stylesheets/ │ └─ style.css Stylesheet ├─ routes/ │ ├─ about.js Logic for route /about │ ├─ advice.js Logic for route /advice │ ├─ contact.js Logic for route /contact │ ├─ index.js Logic for route / │ ├─ recipes.js Logic for route /recipes │ ├─ tips.js Logic for route /tips │ └─ users.js Not used, can be deleted └─ views/ ├─ about.pug View for route /about ├─ advice.pug View for route /advice ├─ contact.pug View for route /contact ├─ error.pug ├─ index.pug View for route / ├─ layout.pug View template shared by all pages ├─ recipes.pug View for route /recipes └─ tips.pug View for route /tips blue = modified, green = new, red = not used
myapp/app.js
The core app logic is essentially the same as the default Express app, with additional routes defined. The "users" route is removed.
// core dependencies var createError = require('http-errors'); var express = require('express'); var path = require('path'); var cookieParser = require('cookie-parser'); var logger = require('morgan'); // create route objectsvar indexRouter = require('./routes/index');var aboutRouter = require('./routes/about');var contactRouter = require('./routes/contact');var tipsRouter = require('./routes/tips');var recipesRouter = require('./routes/recipes');var adviceRouter = require('./routes/advice'); // the app object var app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); // app config app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); // tell the app to use these routesapp.use('/', indexRouter);app.use('/about', aboutRouter);app.use('/contact', contactRouter);app.use('/tips', tipsRouter);app.use('/recipes', recipesRouter);app.use('/advice', adviceRouter); // catch 404 and forward to error handler app.use(function(req, res, next) { next(createError(404)); }); // error handler app.use(function(err, req, res, next) { // set locals, only providing error in development res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; // render the error page res.status(err.status || 500); res.render('error'); }); // expose this app to scripts that require it, i.e. myapp/bin/www module.exports = app;
myapp/routes/layout.pug
The layout.pug file contains the core layout of the page, which is shared by every page on the site. It contains everything required to display a page, except for the mainbody content (block mainbody).
doctype html html head title= pagetitle meta(charset="utf-8") meta(name="viewport" content="width=device-width, initial-scale=1") script(src="/javascripts/menu.js") link(rel="stylesheet", href="/stylesheets/style.css") body #menu section#menuitems .menubutton h1.menubutton(onclick="menuToggle('hide')") ☰ a(href="/") h3.menuhead Our Farm Stand a(href="/tips") h3.sectrule Tips for living well a(href="/recipes") h3 Recipes a(href="/advice") h3 Homesteading advice a(href="/about") h3.sectrule About Us a(href="/contact") h3 Contact Us #container #header a(href="/") h1.logo Our Farm Stand .headspace h1.menubutton(onclick="menuToggle('show')") ☰ h1.placeholder ☰ h2.navitem a(href="/about") .clickable-area About Us h2.navitem a(href="/contact") .clickable-area Contact Us #panel.left section#sections .sectionlink a(href="/tips") .clickable-area Tips for living well .sectionlink a(href="/recipes") .clickable-area Recipes .sectionlink a(href="/advice") .clickable-area Homesteading advice block mainbody #panel.right h3 Our friends section#partners.tall .partnerlink a(href="/") .clickable-area Green Valley Greens .partnerlink a(href="/") .clickable-area Turkey Hill Farm .partnerlink a(href="/") .clickable-area Burt's Maple Syrup .partnerlink a(href="/") .clickable-area Only Organic Seeds #footer p Copyright © 2020 Alice & Bob's Farm Stand
myapp/views/index.pug
The index.pug file extends layout.pug, and contains mainbody content for the route /.
extends layout block mainbody #mainbody section.mainbodyitems h3 Announcements section.announcements .announceitem h4.title Open for business p.date Jan. 15 p Renovations of our new storefront are complete, and we're open for business. h3 Items for sale section.forsaleitems table tr th Item th Description th Price th.qty Qty tr td Milk td Good source of calcium. td.price $2 span.perunit / half gal. td.qty 3 tr td Eggs td Great for breakfast and baking. td.price $4 span.perunit / doz. td.qty 6 tr td Whole chicken td Perfect for roasting. td.price $5 span.perunit / lb. td.qty 4 h3 Upcoming events section .eventitem h4.title Cider Fest p.date October 20, 2pm–6pm p Celebrate the season with fresh-pressed cider from our orchards. .eventitem h4.title Bread baking workshop p.date December 13, 9am–noon p Learn how to create and cultivate a sourdough starter. h3 Message of the day section .motditem p Eat better food. Support your local farm stand. h3#partners.wide Our friends section#partners.wide .partnerlink.wide a(href="") .clickable-area Green Valley Greens .partnerlink.wide a(href="") .clickable-area Turkey Hill Farm .partnerlink.wide a(href="/") .clickable-area Burt's Maple Syrup .partnerlink.wide a(href="") .clickable-area Only Organic Seeds .bodyspace
myapp/routes/index.js
The file index.js contains logic for the route /.
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('index', { pagetitle: 'Our Farm Stand' }); }); module.exports = router;
myapp/public/javascripts/menu.js
The file menu.js contains the JavaScript from the Grid and Flexbox example. It implements the menu toggle function.
function menuToggle(state) { var ele = document.getElementById('menu'); switch(state) { case 'show': ele.style.opacity=1; ele.style.color='rgb(96, 96, 96)'; ele.style.visibility='visible'; ele.style.transition='visibility 0s, opacity 0.3s'; break; case 'hide': ele.style.opacity=0; ele.style.color='black'; ele.style.visibility='hidden'; ele.style.transition='visibility 0.3s, opacity 0.3s'; break; } }
myapp/public/stylesheets/style.css
The file style.css contains the CSS from the Grid and Flexbox example.
/* element styles */ * { margin: 0; /* by default, all elements (selector *) have no margin */ } html { width: 100%; /* 100% width of parent (root) element */ height: 100vh; /* 100% height of viewport */ background: rgb(0, 0, 0, 0.1); /* 10% black */ font-size: 1.0em; /* our root font size */ font-family: Arial, Helvetica, sans-serif; /* default font */ } body { min-height: 100%; } section { padding: 0.5rem; flex-grow: 1; /* in a flexbox, sections expand along flex axis */ } h1 { /* Website name in header */ font-size: 2.0rem; font-weight: normal; } h2 { /* About, Contact */ font-size: 1.25rem; } h3 { /* Section headings */ font-size: 1.2rem; padding: 0.5rem; } h4 { /* Section item title */ font-weight: normal; padding: 0.5rem; } p { /* Section item body */ padding: 0.5rem; } a:link, a:visited { /* anchor links, and visited anchor links */ color: black; text-decoration: none; /* disable underline */ } a:hover { /* when anchor link is hovered */ color: rgb(25, 25, 25); } a:active { /* when anchor link is clicked */ color: rgb(96, 96, 96); } /* component styles */ #container { display: grid; height: 100vh; grid-template-columns: [left] 10rem auto 10rem [right]; grid-template-rows: [top] 5rem auto 5rem [bottom]; /* header height fits its content */ grid-template-areas: "head head head" "panleft mainbody panright" "foot foot foot"; } #header { grid-area: head; /* corresponds to name in template */ background: rgb(0, 0, 0, 0.2); /* 20% black */ display: flex; flex-direction: row; justify-content: space-between; align-items: baseline; /* site name and nav item text aligns baseline */ padding: 1.0rem; } #panel { /* for element id="panel" */ display: flex; /* this element is a flexbox parent */ flex-direction: column; /* its child elements flex vertically */ padding: 0.5rem; background: rgb(0, 0, 0, 0.1); /* 10% black */ } #panel.left { /* for element id="panel" and class="left" */ grid-area: panleft; /* this element fills a grid area */ } #panel.right { grid-area: panright; } #footer { grid-area: foot; display: flex; /* this element is a flexbox parent */ flex-direction: column; /* its child elements flex vertically */ justify-content: center; /* horizontal center footer content */ align-items: center; /* vertical center footer content */ padding: 0.5rem; background: rgb(0, 0, 0, 0.2); } #mainbody { /* for element id="mainbody" */ display: flex; /* this element is a flexbox parent */ flex-direction: column; /* its child elements flex vertically */ grid-area: mainbody; justify-self: center; /* fixed-width mainbody always centered */ width: 100%; min-width: 22.5rem; /* mainbody width can't go < 22.5rem */ } div#panel, div#mainbody { /* extra space under header */ padding-top: 0.5rem; } #partners, #sections { /* for element id="partners" or id="sections" */ display: flex; /* this element is a flexbox parent */ flex-direction: row; /* its child elements flex horizontally */ flex-wrap: wrap; /* its child elements can wrap to next line */ align-content: flex-start; /* child elements start in upper left */ } #partners.wide { /* for element id="partners" and class="wide" */ display: none; /* by default, do not display this element */ } #menu { position: absolute; /* menu position unaffected by other elements */ right: 0; /* zero pixels from the right boundary */ background: rgb(239, 239, 239); border: 0.15rem solid rgb(0, 0, 0, 0.4); visibility: hidden; /* visibility property supports transitions */ opacity: 0; /* opacity + visibility transition = menu fade effect */ z-index: 1; /* ensure menu appears over all other content */ } #menuitems { /* menu is implemented as a flexbox container */ display: flex; flex-direction: column; padding: 1rem; } #menuitems h3 { border-top: 0.15rem solid rgb(0, 0, 0, 0.1); /* light horizontal rule */ } #menuitems .sectrule { border-color: rgb(0, 0, 0, 0.25); /* darker horizontal rule */ } #menuitems .menuhead { border-top: none; } #menuitems h3:hover { background-color: rgb(0, 0, 0, 0.1); /* gray of rollover menuitems */ } .menubutton { text-align: right; cursor: pointer; /* indicates it can be clicked like a link */ user-select: none; /* user cannot select the button as text */ } #menuitems .alignright { text-align: right; /* right-aligned menu item text (unused) */ } #header h1.menubutton { display: none; /* in default view (landscape), hide menu button */ border: 0.15rem solid rgb(0, 0, 0, 0); /* (invisible) alignment shim */ } #header .placeholder { /* this invisible button is rendered when menu */ color: rgb(0, 0, 0, 0); /* button is hidden, so header height matches. */ user-select: none; /* user can't select text of invisible button */ } .sectionlink, .partnerlink { border-radius: 0.25rem; /* give this element a slight rounded edge */ font-weight: normal; font-size: 1.1rem; padding: 0.5rem; width: 7rem; /* fixed width for these items */ margin-bottom: 1rem; /* slight margin for readability */ background: rgb(0, 0, 0, 0.1); } .sectionlink:hover, .partnerlink:hover { background-color: rgb(0, 0, 0, 0.065); /* brighten bg on mouse hover */ } .partnerlink { height: 7rem; /* partner elements are additionally fixed height */ } .partnerlink.wide { margin: 0.5rem 1rem 0.5rem 0; /* margins for spacing if they wrap */ } .clickable-area { /* use whenever a clickable area excludes margins */ height: 100%; /* clickable area spans height of parent */ } .eventitem, .announceitem, .motditem { margin-bottom: 0.5rem; /* slight margin for readability */ } .title { /* e.g., "Open for business" */ font-style: italic; font-weight: normal; font-size: 1.1rem; } .date, .ingredient { /* e.g., January 1, 2021 */ font-style: italic; font-size: 0.9rem; padding: 0 0 0.01rem 0.5rem; color: rgb(0, 0, 0, 0.5); } .navitem { /* About, Contact */ font-weight: normal; padding: 0 0.5rem 0 1rem; } .headspace, .panspace, .footspace, .bodyspace { flex-grow: 1; /* these elements expand on flex axis to fill space */ } /* table styles ("items for sale") */ table { border-collapse: collapse; /* pixel-adjacent table cells */ width: 100%; margin-bottom: 1rem; } th { text-align: left; } tr { margin: 4rem 0 0 0; border-bottom: 0.15rem solid rgb(0, 0, 0, 0.2); /* horizontal rule */ } td, th { padding: 0.5rem; vertical-align: top; } td.price { white-space: nowrap; /* white space in price does not wrap line */ } td.qty, th.qty { text-align: center; } span.perunit { opacity: 0.5; } /* responsive styles applied in portrait mode */ @media screen and (max-width: 45rem) { /* if viewport width < 45rem */ #panel.left { grid-column-end: left; /* panel grid area shrinks to nothing */ } #panel.right { grid-column-start: right; /* panel grid area shrinks to nothing */ } #partners.tall { display: none; /* hide partners in panel (overwrites display: flex) */ } #partners.wide { display: flex; /* show partners in body (overwrites display: none) */ } #panel, /* these disappear from layout */ #header .placeholder, .navitem { display: none; } #mainbody { grid-column-start: left; /* mainbody now starts at left edge */ grid-column-end: right; /* mainbody now ends at right edge */ } #header h1.menubutton { /* display the header menu button */ display: inline; /* overwrites display: none */ } }
Secondary routes
The following files contain the logic for secondary routes — About, Advice, Contact, etc.
myapp/routes/about.js
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('about', { pagetitle: 'About Us' }); }); module.exports = router;
myapp/routes/advice.js
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('advice', { pagetitle: 'Homesteading Advice' }); }); module.exports = router;
myapp/routes/contact.js
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('contact', { pagetitle: 'Contact Us' }); }); module.exports = router;
myapp/routes/recipes.js
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('recipes', { pagetitle: 'Recipes' }); }); module.exports = router;
myapp/routes/tips.js
var express = require('express'); var router = express.Router(); router.get('/', function(req, res, next) { res.render('tips', { pagetitle: 'Tips For Living Well' }); }); module.exports = router;
Secondary views
The following views inherit layout.pug.
myapp/views/about.pug
extends layout block mainbody #mainbody section#mainbodyitems p Alice & Bob have been operating their farm stand since 1992.
myapp/views/advice.pug
extends layout block mainbody #mainbody section#mainbodyitems h3 Homesteading Advice p Never, ever stand behind a heifer.
myapp/views/contact.pug
extends layout block mainbody #mainbody section#mainbodyitems h3 Alice & Bob p 1344 Chattanooga Way p Homestead, VT 05401 p (802) 555-5555
myapp/views/recipes.pug
extends layout block mainbody #mainbody section#mainbodyitems h3 Alice's Recipes p b No-knead next-day dutch oven bread p.ingredient 1/4 tsp active dry yeast p.ingredient 3 cups all-purpose flour p.ingredient 1 1/2 tsp salt p.ingredient Cornmeal or wheat bran for dusting p In a large bowl, dissolve yeast in water. p Add the flour and salt, stirring until blended. p Cover bowl. Let rest at least 8 hours, preferably 12 to 18, at warm room temperature, about 70 degrees. p When the surface of the dough is dotted with bubbles, it's ready to be folded. Lightly flour a work surface. Sprinkle flour on the dough and fold it over on itself once or twice. Cover loosely and let it rest about 15 minutes. p Using just enough flour to keep the dough from sticking, gently shape it into a ball. Generously coat a clean dish towel with flour, wheat bran, or cornmeal. Put the seam side of the dough on the towel. Cover with another towel and let rise for 1 to 2 hours. p Heat oven to 475°. Cover and bake for 30 minutes.
myapp/views/tips.pug
extends layout block mainbody #mainbody section#mainbodyitems h3 Alice's Tips p Always rise before the sun. p Never use fake maple syrup. p If the bear is black, be loud, attack. p If the bear is brown, play dead, lie down.
Appearance
In portrait mode, secondary routes are accessed in the menu.
In landscape mode, they're accessible from the header and left panel.