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:

terminal
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

terminal
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:

terminal
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:

pages/index.md · markdown
---
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:

theme/views/pages/one-page.pug · pug
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/:

pages/sections/services.md · markdown
---
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.
pages/sections/about.md · markdown
---
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.
pages/sections/contact.md · markdown
---
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):

html
    <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 : :

yaml
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:

text
⚠️ 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:

pages/sections/team.md · markdown
---
title: Team
add_to_page: /index.html
add_to_page_order: 4
---
# Über uns

Zwei Leute, ein Studio.

It is merged as:

html
      <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:

text
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:

config/validate.yaml · yaml
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:

pages/index.md · markdown
---
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:

theme/views/pages/one-page.pug · pug
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:

config/app.yaml · yaml
translations:
  en:
    Page navigation: On this page

The built page starts with:

html
      <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.)

config/contact-form.yaml · yaml
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:

config/app.yaml · yaml
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:

theme/views/pages/one-page.pug · pug
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):

html
      <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="&#104;&#101;&#108;&#108;&#111;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;"></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:

text
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:

    obfuscation What 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
    jsassembly only 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 website field 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 with honeypot: 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__input and the others from your own stylesheet.

plugin-social-media-links reads a list of links from config/social-media-links.yaml:

config/social-media-links.yaml · 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:

theme/views/layouts/layout.pug · pug
<!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):

html
    <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:

theme/assets/css/site.css · 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:

pug
    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:

pages/de/index.md · markdown
---
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.
pages/de/sections/services.md · markdown
---
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:

config/validate.yaml · yaml
ignore:
    layout-missing:
        - pages/sections
        - pages/*/sections

Translate the keys from steps 6 and 7 under de: in config/app.yaml:

config/app.yaml · 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:

pug
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:

html
      <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:

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