Accent · Tutorials

Build a one-page site

A café site — chrome, three bands, one template — built from the theme's vocabulary in fifty-odd lines of EJS. If you just want to paste and go, the complete file is at the bottom.


What you're building

Juniper Coffee: a navbar with two anchor links, a hero, a features band for the menu, a call-to-action band for visiting, and a footer. One file, one route.

Every piece is a partial with declared fields, and the props you pass by hand are the same fields a CMS editor would fill in. A page written this way is already CMS-shaped — that is the point of the whole system, and this tutorial stays on that path.

Run the dev server

From the theme root:

npm install
npm run dev     # → http://localhost:4180

Files under pages/ are the routes: pages/about.ejs serves /about, and pages/my-site/index.ejs serves /my-site. Save any file and the browser reloads itself.

The smallest page

Create pages/my-site/index.ejs:

<%
  const hero = require('/site/partials/wraps/container.ejs')({
    title: 'Coffee, roasted on Fig Street.',
    lead: 'Small-batch beans, an eight-seat espresso bar, and pastry that sells out by ten.',
  })
%>
<%- require('/templates/default.ejs')({
  meta_title: 'Juniper Coffee',
  content: hero,
}) %>

A page is two moves. The <% %> prologue is plain JavaScript that composes HTML strings — requiring an .ejs partial gives you a function from props to markup. The single <%- %> line writes one string out: the page handed to a template. Paths starting with /site/partials/ or /templates/ resolve inside the theme package.

Open localhost:4180/my-site — an accent-colored hero band with your headline. The banner surface is the hero's default; you stated only what differs from the defaults, which is how every band in this tutorial will work.

Add the chrome

Chrome is the furniture that frames a site — here, the navbar and footer. Add these to the prologue:

const navbar      = require('/site/partials/chrome/navbar.ejs')
const themeToggle = require('/site/partials/components/theme-toggle.ejs')
const footer      = require('/site/partials/chrome/footer.ejs')

const nav = navbar({
  brand: 'Juniper Coffee',
  brand_href: '#',
  actions: themeToggle(),
  links: [
    { label: 'Menu', href: '#menu' },
    { label: 'Visit', href: '#visit' },
  ],
  cta_label: 'Order beans',
  cta_href: '#visit',
})

const foot = footer({
  brand: 'Juniper Coffee',
  tagline: '412 Fig Street · Open daily 7–3',
  copyright: '© 2026 Juniper Coffee.',
})

The navbar's links point at #menu and #visit — anchors the bands will declare in the next step. actions is a slot rather than a field: it takes rendered HTML, and here it gets the theme toggle so your site picks up light and dark mode for free.

Add the bands

Patterns are full-width bands, and a page is a stack of them. Replace the hero from step one with the fuller version, then add the menu and the closing call to action:

const hero = require('/site/partials/wraps/container.ejs')({
  eyebrow: 'Fig Street · Est. 2019',
  title: 'Coffee, roasted here.',
  lead: 'Small-batch beans, an eight-seat espresso bar, and pastry that sells out by ten.',
  cta_label: 'See the menu',
  cta_href: '#menu',
})

const menu = require('/site/partials/patterns/features.ejs')({
  id: 'menu',
  title: 'The short menu',
  intro: 'Three things, done properly.',
  items: [
    { icon: 'bi-cup-hot',  title: 'Espresso',    body: 'Two roasts on the grinders — one comfort, one experiment.' },
    { icon: 'bi-cookie',   title: 'Pastry',      body: 'From the oven at seven. When the case is empty, that is that.' },
    { icon: 'bi-box-seam', title: 'Beans to go', body: 'Whole-bean bags, roasted every Tuesday.' },
  ],
})

const visit = require('/site/partials/patterns/cta-band.ejs')({
  fill: 'primary',
  id: 'visit',
  title: 'Come by the shop.',
  body: '412 Fig Street, open daily 7 to 3. Beans ship anywhere.',
  cta_label: 'Order beans',
  cta_href: 'mailto:hello@juniper.example',
})

Icons are Bootstrap Icons names, and an id on a band is the anchor the navbar links to. Notice how much is defaulted: the hero picks the banner surface, the features band lays out three columns, the CTA band centers itself. Fields you do not set fall back to what the partial declares.

Hand it to the template

The template owns the document — doctype, head, stylesheets, scripts. The page owns the order of the bands. Concatenate and hand it over:

<%- require('/templates/default.ejs')({
  meta_title: 'Juniper Coffee — Fig Street roastery',
  meta_description: 'Small-batch coffee, an eight-seat espresso bar, and beans that ship anywhere.',
  content: nav + '<main>' + hero + menu + visit + '</main>' + foot,
}) %>

The whole file

Everything above in one place — pages/my-site/index.ejs, complete:

<%
  // Juniper Coffee — a one-page site.
  const navbar      = require('/site/partials/chrome/navbar.ejs')
  const themeToggle = require('/site/partials/components/theme-toggle.ejs')
  const footer      = require('/site/partials/chrome/footer.ejs')

  const nav = navbar({
    brand: 'Juniper Coffee',
    brand_href: '#',
    actions: themeToggle(),
    links: [
      { label: 'Menu', href: '#menu' },
      { label: 'Visit', href: '#visit' },
    ],
    cta_label: 'Order beans',
    cta_href: '#visit',
  })

  const hero = require('/site/partials/wraps/container.ejs')({
    eyebrow: 'Fig Street · Est. 2019',
    title: 'Coffee, roasted here.',
    lead: 'Small-batch beans, an eight-seat espresso bar, and pastry that sells out by ten.',
    cta_label: 'See the menu',
    cta_href: '#menu',
  })

  const menu = require('/site/partials/patterns/features.ejs')({
    id: 'menu',
    title: 'The short menu',
    intro: 'Three things, done properly.',
    items: [
      { icon: 'bi-cup-hot',  title: 'Espresso',    body: 'Two roasts on the grinders — one comfort, one experiment.' },
      { icon: 'bi-cookie',   title: 'Pastry',      body: 'From the oven at seven. When the case is empty, that is that.' },
      { icon: 'bi-box-seam', title: 'Beans to go', body: 'Whole-bean bags, roasted every Tuesday.' },
    ],
  })

  const visit = require('/site/partials/patterns/cta-band.ejs')({
    fill: 'primary',
    id: 'visit',
    title: 'Come by the shop.',
    body: '412 Fig Street, open daily 7 to 3. Beans ship anywhere.',
    cta_label: 'Order beans',
    cta_href: 'mailto:hello@juniper.example',
  })

  const foot = footer({
    brand: 'Juniper Coffee',
    tagline: '412 Fig Street · Open daily 7–3',
    copyright: '© 2026 Juniper Coffee.',
  })
%>
<%- require('/templates/default.ejs')({
  meta_title: 'Juniper Coffee — Fig Street roastery',
  meta_description: 'Small-batch coffee, an eight-seat espresso bar, and beans that ship anywhere.',
  content: nav + '<main>' + hero + menu + visit + '</main>' + foot,
}) %>

Reload /my-site: a finished single-page site, light and dark, responsive, with working anchor navigation.

Where next

  • Need a page with no setup at all? The one-page starter is this same shape as a single copy-paste HTML file — plain Bootstrap from the CDN, no theme required.
  • More vocabulary. Every component and its live variations: Components and Chrome.
  • The same shape, grown up. The small-business example (pages/pages/example-sites/small-business.ejs) is this page with more bands and hand-composed content; the portfolio example shows the multi-page pattern — shared nav and footer props in a site.js.
  • Slots, when fields are not enough. Most patterns also accept a composed content slot — bring your own headings, text, and arrangement wraps. The example sites are written that way.
  • Make it yours. Change the accent in scss/_variables.scss and run npm run build:css.