Build a one-page site
A freelancer or a small business often needs one page, not a site: a hero, the
services, a few words about who you are, and a way to get in touch, all on one
scrolling page. In this tutorial you build that page with each section in its
own Markdown file. @nera-static/plugin-one-page merges the sections into the
home page, @nera-static/plugin-page-navigation adds the in-page links,
@nera-static/plugin-contact-form adds a contact form without a backend, and
@nera-static/plugin-social-media-links puts your profiles in the footer.
Want a finished business site to edit instead? The business starter sets one up with a theme, sample pages for services, about, opening hours and contact, a contact form and social links:
npx @nera-static/nera new my-site --starter business
Its files won't match the steps below: the starter builds a site of several pages, while this tutorial builds a single page by hand, to show how the pieces fit together.
1. Scaffold and install
npx @nera-static/nera new my-site
cd my-site
npm install @nera-static/plugin-one-page @nera-static/plugin-page-navigation \
@nera-static/plugin-contact-form @nera-static/plugin-social-media-links
Nera finds all four plugins on its own. Three of them ship templates. Publish them into your views folder, where they are yours to edit:
npx nera-page-navigation
npx nera-contact-form
npx nera-social-media-links
On a nera new site they land in theme/views/vendor/plugin-<name>/, and the
contact form's client script in theme/assets/js/contact-form.js.
plugin-one-page ships no template and needs no config file.
2. The target page
The page the sections are merged into is an ordinary page. Here it is the home page, and its own content is the hero:
---
layout: pages/one-page.pug
title: Studio Lena
description: Web design for small businesses in Berlin
---
# Websites for small businesses
I design and build fast, simple websites for shops, studios and practices.
Give it a template of its own. It grows in the next steps; for now it renders the content:
extends ../layouts/layout
block content
main#main
!= content
3. One file per section
Each section is a Markdown file that names its target page in
add_to_page and its position in add_to_page_order. Keep them
together in pages/sections/:
---
title: Services
add_to_page: /index.html
add_to_page_order: 1
anchor_id: services
content_wrapper_attributes:
class: section section--services
---
## Services
- **New websites**: from the first sketch to the live site.
- **Redesigns**: a fresh look for the site you already have.
- **Care plans**: updates and small changes every month.
---
title: About
add_to_page: /index.html
add_to_page_order: 2
anchor_id: about
content_wrapper_attributes:
class: section section--about
---
## About
I'm Lena, a web designer in Berlin. I have built sites for small businesses
since 2015.
---
title: Contact
add_to_page: /index.html
add_to_page_order: 3
anchor_id: contact
content_wrapper_attributes:
class: section section--contact
---
## Contact
Tell me about your project. The form opens your own email program with the
message ready to send.
Run npx nera build. Until step 7 it also warns that plugin-contact-form has
no recipient configured; the form simply does not render yet.
public/index.html now holds the hero followed by the three sections, in their
order (shortened):
<main id="main">
<h1>Websites for small businesses</h1>
<p>I design and build fast, simple websites for shops, studios and practices.</p>
<section class="section section--services">
<a id="services"></a>
<h2>Services</h2>
<ul>
<li><strong>New websites</strong>: from the first sketch to the live site.</li>
…
</ul>
</section>
<section class="section section--about">
<a id="about"></a>
<h2>About</h2>
…
</section>
<section class="section section--contact">
<a id="contact"></a>
<h2>Contact</h2>
…
</section>
</main>
The sections are always appended after the target page's own content.
Each one is wrapped in a <section>; content_wrapper_tag sets another
element, and content_wrapper_tag: '' drops the wrapper. The wrapper
attributes are a plain mapping or a list of { attribute, value } entries, and
their values are HTML-escaped. Quote a value that contains : :
content_wrapper_attributes:
style: 'background-color: #f5f5f5;'
Unquoted, the frontmatter does not parse: the build logs
❌ Failed to process page …: Nested mappings are not allowed in compact mappings …, drops the page and still exits 0. nera validate reports it as a
yaml-parse error.
add_to_page must match exactly
add_to_page is compared literally with the target page's href, which always
starts with / and ends in .html: pages/index.md is /index.html,
pages/de/index.md is /de/index.html. With add_to_page: index.html nothing
is merged, and the build says so:
⚠️ one-page: no page has href "index.html" — 1 section(s) targeting it were not merged. `add_to_page` must match a page's href exactly, e.g. "/index.html" for pages/index.md
4. Anchors
Each section gets an empty <a id="…"> in front of its content, so
/index.html#services jumps to it. anchor_id sets the id. Without it,
the plugin derives one from the section's first <h1>, slugified:
lowercase, accents removed, everything else turned into -. This section has
no anchor_id:
---
title: Team
add_to_page: /index.html
add_to_page_order: 4
---
# Über uns
Zwei Leute, ein Studio.
It is merged as:
<section>
<a id="uber-uns"></a>
<h1>Über uns</h1>
<p>Zwei Leute, ein Studio.</p>
</section>
Lower headings are ignored, so a section with only ## headings gets no anchor
unless you set anchor_id. And an <h1> per section is not what you want on
a one-page site: the home page already has its <h1>, and with team.md in
place nera build --check warns page has more than one `<h1>`
(a11y-h1). So give the sections ## headings and an explicit anchor_id, as
in step 3, and delete team.md again. Explicit ids also stay the same when you
rename a heading or translate the page (see step 10).
5. Sections without a page of their own
A section is still a page of its own. Nera writes a page only if it has a
layout, and the sections above have none, so they exist only inside
/index.html. Add layout: pages/default.pug to about.md and the build also
writes /sections/about.html, while the section still appears on the home page.
Leave layout out unless you want that second URL.
nera validate does not know that the missing layout is on purpose:
pages/sections/about.md
:1 warning page has no `layout` — the build skips it silently; add `layout: pages/default.pug` to its frontmatter (a template in theme/views), or, if it has none on purpose (a fragment or draft), add `ignore: { layout-missing: [pages/sections/about.md] }` to config/validate.yaml layout-missing
…
0 error(s), 3 warning(s)
Because all sections live in one folder, one entry silences them all:
ignore:
layout-missing:
- pages/sections
A folder covers everything below it. nera validate now prints
✓ No problems found.
6. In-page navigation
plugin-page-navigation builds a navigation of sibling pages in the same
folder, and skips pages at the site root, so on its own it gives index.md
nothing. Its other mode fits: a page_navigation list in the page's
frontmatter, whose entries go to the template exactly as written. Point them
at the anchors:
---
layout: pages/one-page.pug
title: Studio Lena
description: Web design for small businesses in Berlin
page_navigation:
- href: '#services'
name: Services
- href: '#about'
name: About
- href: '#contact'
name: Contact
---
# Websites for small businesses
I design and build fast, simple websites for shops, studios and practices.
Quote the hrefs: in YAML an unquoted # after a space starts a comment.
Include the published template above the content:
extends ../layouts/layout
block content
main#main
include /vendor/plugin-page-navigation/page-navigation
!= content
The <nav> is named by the translation key Page navigation, so it stays
distinct from a main menu for screen readers. Give it a label in your
language in config/app.yaml:
translations:
en:
Page navigation: On this page
The built page starts with:
<nav class="page-nav" aria-label="On this page"><a class="page-nav__link" href="#services">Services</a><a class="page-nav__link" href="#about">About</a><a class="page-nav__link" href="#contact">Contact</a></nav>
You could write these three links into the template by hand just as well. The plugin earns its place once the links differ per page or per language: they live in each page's frontmatter, next to the content they point to.
7. The contact form
plugin-contact-form renders a form from config/contact-form.yaml. On submit
a small script in the browser builds a mailto: link from the fields and opens
the visitor's own email program with subject and message filled in. There is no
backend: your site stores and sends nothing, and the message leaves only through
the visitor's email. (Handling the form on a server is not covered here.)
recipient: hello@example.com
subject: contact_subject
submit_label: contact_submit
error_required: contact_required
error_email: contact_email_invalid
fields:
- { name: name, label: contact_name, type: text, required: true }
- { name: email, label: contact_email, type: email, required: true }
- name: topic
label: contact_topic
type: select
required: true
options:
- { value: new-site, label: contact_topic_new }
- { value: redesign, label: contact_topic_redesign }
- { value: care, label: contact_topic_care }
- { name: message, label: contact_message, type: textarea, required: true }
Every user-facing value here is a translation key, resolved for each
page's language from translations: in config/app.yaml. A key without a
translation renders as written, so a site in one language could write the text
directly; keys pay off in step 10. name and the option values are data and
are never translated. Add the English texts:
translations:
en:
Page navigation: On this page
contact_subject: Enquiry from the website
contact_submit: Open in my email program
contact_required: Please fill in all required fields.
contact_email_invalid: Please enter a valid email address.
contact_name: Your name
contact_email: Your email
contact_topic: What can I help with?
contact_topic_new: A new website
contact_topic_redesign: A redesign
contact_topic_care: A care plan
contact_message: Your message
Keep the keys nera new put under en: (app_description, skip_to_content
and the others) next to these.
The form is Pug, so it goes in the template, not in a Markdown section. The
contact section has the highest add_to_page_order, so the form placed after
the content follows its text directly:
extends ../layouts/layout
block content
main#main
include /vendor/plugin-page-navigation/page-navigation
!= content
include /vendor/plugin-contact-form/contact-form
The built form (shortened):
<form class="contact-form" novalidate="novalidate" data-contact-form="data-contact-form">
<script type="application/json" data-contact-config="data-contact-config">
{
"subject": "Enquiry from the website",
…
}
</script><span class="contact-form__recipient" hidden="hidden" data-contact-recipient="hello@example.com"></span>
<div class="contact-form__field contact-form__field--text"><label class="contact-form__label" for="contact-name">Your name</label><input class="contact-form__input" type="text" id="contact-name" name="name" data-contact-field="data-contact-field" data-contact-label="Your name" required="required" /></div>
…
<div class="contact-form__decoy" aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden"><label for="contact-website">website</label><input type="text" id="contact-website" name="website" tabindex="-1" autocomplete="off" /></div>
<div class="contact-form__status" role="alert" aria-live="polite" data-contact-status="data-contact-status"></div><button class="contact-form__submit" type="submit">Open in my email program</button>
</form>
<script src="/js/contact-form.js"></script>
Submitted with every field filled in, the form opened this link:
mailto:hello@example.com?subject=Enquiry%20from%20the%20website&body=Your%20name%3A%20Jane%20Doe%0D%0AYour%20email%3A%20jane%40example.org%0D%0AWhat%20can%20I%20help%20with%3F%3A%20redesign%0D%0AYour%20message%3A%20Hello%20Lena
Submitted empty, it opened nothing and showed Please fill in all required fields. in the status line. Three details are worth knowing:
-
The recipient is obfuscated, not hidden. It never appears as readable text, but it is in the page:
obfuscationWhat lands in the HTML Strength entities(default)each character as an HTML entity, as above deters naïve scrapers; a determined one can still decode it jsassemblyonly an array of character codes, rebuilt in JavaScript on submit stronger, still not a guarantee Use an address you are willing to publish.
-
A honeypot is built in. The hidden
websitefield is a decoy for bots; a submission that fills it is dropped silently. Never give a real field the honeypot's name. If you want a real "website" field, rename the decoy withhoneypot:in the config. -
The plugin ships no CSS apart from the inline rule that hides the decoy. Style the BEM classes
.contact-form,.contact-form__field,.contact-form__inputand the others from your own stylesheet.
8. Social links in the footer
plugin-social-media-links reads a list of links from
config/social-media-links.yaml:
aria_label: Social media
open_in_new_tab: false
social_media_links:
- name: GitHub
href: https://github.com/yourname
icon: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 …"/></svg>'
icon_raw: true
- name: LinkedIn
href: https://www.linkedin.com/in/yourname
The d attribute is shortened here; paste the full path of the icon you use.
Simple Icons (simpleicons.org) has brand logos as SVG. icon is escaped unless
the entry sets icon_raw: true, so set it only for markup you wrote or checked
yourself. The LinkedIn entry has no icon and shows its name only. The plugin's
sample config uses Font Awesome <i> tags instead, which need the Font Awesome
CSS. Loading it from a CDN sends every visitor's IP address to a third party,
and nera build --check then warns privacy-third-party. Inline SVG or plain
text needs no request at all.
open_in_new_tab: false lets the visitor decide where a link opens; without
it, every link gets target="_blank". rel="noopener noreferrer" is set
either way.
Include the published template in the footer of the base layout that
nera new created. Remove its first line, the //- nera:scaffold-default
marker, now that the file is your own:
<!DOCTYPE html>
html(lang=app.lang)
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title #{ meta.title } | #{ app.name }
meta(name="description", content=`${ meta.description || t('app_description') }`)
meta(name="keywords", content=`${ meta.keywords || t('app_keywords') }`)
meta(name="generator" content="nera")
meta(name="robots" content=`${ meta.robots || app.robots }`)
style.
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { position: static; }
body
a.skip-link(href="#main") #{ t('skip_to_content') }
block content
footer
include /vendor/plugin-social-media-links/social-media-links
a(href="/imprint.html") #{ t('imprint') }
= ' · '
a(href="/privacy.html") #{ t('privacy') }
The footer now starts with (SVG path shortened):
<footer>
<nav class="social-media-links" aria-label="Social media">
<ul class="social-media-links__list">
<li class="social-media-links__item"><a class="social-media-links__link" href="https://github.com/yourname" title="GitHub" rel="noopener noreferrer"><span class="social-media-links__icon"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 …"/></svg></span><span class="social-media-links__label">GitHub</span></a></li>
<li class="social-media-links__item"><a class="social-media-links__link" href="https://www.linkedin.com/in/yourname" title="LinkedIn" rel="noopener noreferrer"><span class="social-media-links__icon"></span><span class="social-media-links__label">LinkedIn</span></a></li>
</ul>
</nav><a href="/imprint.html">Imprint</a> · <a href="/privacy.html">Privacy</a>
</footer>
9. Scrolling under a sticky navigation
A little CSS makes the page feel like one: the navigation stays at the top, the
links scroll smoothly, and a section does not end up hidden under the
navigation. Create theme/assets/css/site.css:
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
.page-nav {
position: sticky;
top: 0;
display: flex;
gap: 1rem;
padding: 1rem 0;
background: #fff;
}
.section > a[id] {
scroll-margin-top: 4rem;
}
The scroll target is the anchor, not the <section>, so scroll-margin-top
goes on a[id]. Load the file in the layout's head, after the meta lines:
link(rel="stylesheet", href="/css/site.css")
Files in theme/assets/ are served from the site root, so the file is
/css/site.css. In a headless browser, on a page long enough to scroll that
far, a click on each navigation link put its heading 64 px (4rem) from the top,
just below the 50 px high sticky navigation.
10. A second language
A German copy of the page is a second target with its own sections. Set
lang: de on each file and target /de/index.html:
---
layout: pages/one-page.pug
lang: de
title: Studio Lena
description: Webdesign für kleine Unternehmen in Berlin
page_navigation:
- href: '#services'
name: Leistungen
- href: '#about'
name: Über mich
- href: '#contact'
name: Kontakt
---
# Websites für kleine Unternehmen
Ich gestalte und baue schnelle, einfache Websites für Läden, Studios und Praxen.
---
lang: de
title: Leistungen
add_to_page: /de/index.html
add_to_page_order: 1
anchor_id: services
content_wrapper_attributes:
class: section section--services
---
## Leistungen
- **Neue Websites**: von der ersten Skizze bis zur fertigen Seite.
- **Redesigns**: ein frischer Auftritt für die Website, die Sie schon haben.
- **Pflege**: Updates und kleine Änderungen jeden Monat.
about.md and contact.md in pages/de/sections/ follow the same pattern
with anchor_id: about and anchor_id: contact. The anchors stay the same in
both languages, so the CSS works for both and a link to #contact works on
either page.
Extend the ignore entry to every language folder; * stands for one folder
name:
ignore:
layout-missing:
- pages/sections
- pages/*/sections
Translate the keys from steps 6 and 7 under de: in config/app.yaml:
de:
app_description: Eine statische Website, gebaut mit Nera
app_keywords: nera, static site generator
skip_to_content: Zum Inhalt springen
imprint: Impressum
privacy: Datenschutz
Page navigation: Auf dieser Seite
contact_subject: Anfrage über die Website
contact_submit: In meinem E-Mail-Programm öffnen
contact_required: Bitte füllen Sie alle Pflichtfelder aus.
contact_email_invalid: Bitte geben Sie eine gültige E-Mail-Adresse ein.
contact_name: Ihr Name
contact_email: Ihre E-Mail-Adresse
contact_topic: Wobei kann ich helfen?
contact_topic_new: Eine neue Website
contact_topic_redesign: Ein Redesign
contact_topic_care: Ein Pflegepaket
contact_message: Ihre Nachricht
The scaffolded layout sets html(lang=app.lang), which is en on every page.
Use the page's language instead:
html(lang=meta.lang || app.lang)
/de/index.html then has <html lang="de">, the German navigation and the
German form, with the same field names:
<nav class="page-nav" aria-label="Auf dieser Seite"><a class="page-nav__link" href="#services">Leistungen</a><a class="page-nav__link" href="#about">Über mich</a><a class="page-nav__link" href="#contact">Kontakt</a></nav>
…
<div class="contact-form__field contact-form__field--text"><label class="contact-form__label" for="contact-name">Ihr Name</label><input class="contact-form__input" type="text" id="contact-name" name="name" data-contact-field="data-contact-field" data-contact-label="Ihr Name" required="required" /></div>
The social links' aria_label is not passed through t() by the shipped
template. To translate it, change the nav line in your published
theme/views/vendor/plugin-social-media-links/social-media-links.pug:
nav.social-media-links(aria-label=t(app.socialMediaLinksLabel))
and add Social media: Soziale Medien under de:. With all of this in place,
nera validate and nera build --check both print ✓ No problems found.
One page or stacks?
plugin-stacks also turns Markdown
files into pieces of other pages, but the other way round. A stack is a block
that a view places, anywhere and as often as you like, through
app.stacks; it marks itself with type: stack and needs no validate.yaml
entry. A one-page section names its target page in add_to_page, and the
plugin appends it to that page's content, in order, with an anchor and a
wrapper, without touching a template. Use one-page when a page is a sequence
of sections. Use stacks when the same block belongs in several places or in a
particular spot of a layout.
Next
- Build a navigation with active states adds a main menu, once the one page grows into a few.
- How this website is built shows a complete Nera site with its plugins working together.