Docs menu
Getting started
IntroductionInstallationProject structureBuilding pages
RoutingPagesLayoutsStylingMarkdownContent and dataIslandsGoing live
SEO and headDeployingFormsGuides
Build a brochure siteAdvanced
Running as a serverReference
ReferenceGuides
Build a brochure site
A walkthrough of a small business site, from site settings to deploying, with every snippet tested.
A walkthrough for the main use case: a small marketing site (home, about, services, contact) written in Imba, built to plain HTML/CSS files you can host anywhere.
What you get:
- One
.imbafile per page; the file path is the URL. - Scoped
cssblocks in each component, collected into one stylesheet. - Zero JavaScript in the output, except for the components you make interactive (islands).
- The build writes a static
dist/folder: no Node server needed in production.
The framework lives in
lib/in this repo. Each site is a folder with its ownsrc/andpublic/, e.g.examples/brochure/, which is a complete working version of this guide's kind of site (with real content, two islands and a pricing page). Run any site folder withnode lib/cli.mjs dev <folder>. A site in its own folder or repo installs the framework instead (see Installation) and runsimba-pages dev.
1. Project layout
src/
site.imba site-wide title/description, extra <head> tags, global CSS
layouts/default.imba wraps every page (header, nav, footer)
components/ plain server-rendered tags (no JS shipped)
islands/ interactive tags (their JS is shipped to the pages that use them)
pages/ one file per URL
content/ collections: each folder (services/, team/) is a set of entries;
loose files (business.imba) are ordinary data modules
public/ copied as-is: images, favicon, fonts, robots.txt
Pages for a typical brochure site:
src/pages/index.imba → /
src/pages/about.imba → /about
src/pages/services/index.imba → /services
src/pages/services/[slug].imba → /services/web-design, /services/branding, …
src/pages/contact.imba → /contact
src/pages/privacy.md → /privacy (a Markdown page)
src/pages/404.imba → not-found page (dist/404.html)
Files and folders starting with _ are ignored, so src/pages/_drafts/ is a safe place for
work in progress.
2. Site settings: src/site.imba
# The live address. Turns on canonical links, full URLs for share images,
# and a generated /sitemap.xml and /robots.txt.
export const url = 'https://acme.example'
export const meta =
title: 'Acme Studio'
siteName: 'Acme Studio'
description: 'Design and development for small businesses.'
lang: 'en-GB'
locale: 'en_GB'
image: '/images/share.png'
imageAlt: 'The Acme Studio logo on a green background'
# Extra tags added to every page's <head>
export def head ctx
[
<meta name='theme-color' content='#ffffff'>
<link rel='icon' href='/favicon.svg'>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='stylesheet' href='https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap'>
]
# Site-wide styles
global css
* box-sizing:border-box
body m:0 ff:'Inter', system-ui c:gray8 bg:white
h1 fs:3xl lh:1.2
a c:indigo6
meta sets the defaults. A page's own meta is merged on top, so any page can override any
of these:
| key | becomes |
|---|---|
title |
<title> and og:title |
description |
<meta name="description"> and og:description |
image |
og:image, the preview when a link is shared (1200 × 630 works best), plus a large Twitter/X card |
imageAlt |
og:image:alt |
siteName |
og:site_name |
type |
og:type (default website; article for a blog post) |
locale |
og:locale, e.g. en_GB |
lang |
<html lang> |
noindex |
yes keeps the page out of search results and the sitemap |
canonical |
a different canonical address, or no for none |
sitemap |
no leaves the page out of the sitemap (it can still be indexed) |
jsonld |
structured data: an object (or a list) written as <script type="application/ld+json"> |
With url set, every page gets a canonical link and og:url with its full address,
relative image paths become full URLs (social networks need those), and the build writes:
sitemap.xml: every page exceptnoindexones.robots.txt: allows everything and points to the sitemap. Arobots.txtinpublic/replaces it.
The 404 page is noindex automatically. Use noindex: yes for anything else that shouldn't be
found, like a form's thank-you page.
Structured data helps search engines show a business's details. Put site-wide data in the
site meta; a page that sets its own jsonld replaces it (use a list for several):
export const meta =
jsonld:
'@context': 'https://schema.org'
'@type': 'LocalBusiness'
name: 'Acme Studio'
telephone: '0117 496 0000'
head receives ctx with meta, path, url (the page's canonical address, or null)
and absolute(path), which turns a path into a full URL. Use it for anything the defaults
don't cover: icons, fonts, hreflang links between languages, analytics.
Writing all of <head> yourself: export const defaultHead = no turns off every tag in the
table above. Pages then get only the charset, viewport, <title>, stylesheet and whatever
head returns.
Trailing slashes: canonical links and the sitemap use /about by default. Some hosts
redirect /about to /about/ (Cloudflare Pages does for folders), in which case set
export const trailingSlash = yes so the links point at the final address.
3. Layout and navigation: src/layouts/default.imba
The layout wraps every page. The page goes where <slot> is. It receives meta and path
(the current URL path), so the nav can highlight the current page.
import {SiteNav} from '../components/site-nav'
import {MobileMenu} from '../islands/mobile-menu'
export default tag Layout
meta = {}
path = '/'
<self>
<header>
<a.logo href='/'> "Acme Studio"
<SiteNav path=path>
<MobileMenu path=path>
<main> <slot>
<footer>
<p> "© {new Date!.getFullYear!} Acme Studio"
css d:flex fld:column min-height:100vh
header d:flex ai:center jc:space-between px:24px py:16px bdb:1px solid gray2
.logo fw:600 td:none c:gray9
main flg:1 w:100% max-width:960px m:0 auto px:24px py:48px
footer px:24px py:32px bg:gray1 c:gray6 fs:sm
# src/components/site-nav.imba
export const links = [
['/', 'Home']
['/about', 'About']
['/services', 'Services']
['/contact', 'Contact']
]
export tag SiteNav
path = '/'
def isActive href
href == '/' ? path == '/' : path.startsWith(href)
<self> for [href, label] in links
<a href=href .active=isActive(href)> label
css d:flex @lt-md:none g:24px
a c:gray7 @hover:indigo6 td:none
a.active c:indigo6 fw:600
Imba method names can only end in ? for getters, so use isActive, not active?.
A page can opt out of the layout with export const layout = null, or use a different one
with export const layout = OtherLayout.
4. Pages
A page is a file with a default-exported tag. Everything else is optional.
# src/pages/index.imba
import {Hero} from '../components/hero'
export const meta = {title: 'Acme Studio: design for small businesses', image: '/images/og.jpg'}
export default tag HomePage
<self>
<Hero title="Websites that work as hard as you do" cta='/contact'>
<section.features>
<div>
<h2> "Design"
<p> "Clear, fast, accessible sites."
<div>
<h2> "Build"
<p> "Hand-written, no page builders."
css .features d:grid gtc:1fr 1fr @lt-md:1fr g:32px mt:48px
Split repeated sections into src/components/. They're plain tags and cost no JavaScript:
# src/components/hero.imba
export tag Hero
title = ''
cta = '/contact'
<self>
<h1> title
<a.button href=cta> "Get in touch"
css py:64px ta:center
.button d:inline-block mt:24px px:20px py:10px rd:md bg:indigo6 @hover:indigo7 c:white td:none
5. Content: services as Markdown
For a set of similar things (services, team members, case studies) use a collection:
a folder in src/content/ with one file per entry. Markdown files keep fields like the price
at the top (front matter) and the description below, so they're easy for anyone to edit.
<!-- src/content/services/web-design.md -->
---
title: Web design
order: 1
summary: Sites that look good everywhere.
priceFrom: 1200
---
We design **and** build, so nothing gets lost in the hand-over.
## What you get
- A design for every page
- A site you can update yourself
.json and .yaml files work too, for entries with no body text. Each entry looks like:
{id: 'web-design', collection: 'services', file: 'services/web-design.md',
data: {title: …, order: 1, summary: …, priceFrom: 1200}, # the front matter
html: '<p>We design <strong>and</strong> build…'} # the rendered body (Markdown only)
Read collections anywhere on the server, including at the top of a file:
# src/pages/services/index.imba
import {getCollection} from '@milktop/imba-pages/content'
export const meta = {title: 'Services · Acme Studio'}
export default tag ServicesPage
<self>
<h1> "Services"
for s in getCollection('services')
<a.card href="/services/{s.id}">
<h2> s.data.title
<p> s.data.summary
css .card d:block p:24px mb:16px bd:1px solid gray2 bc:gray2 @hover:indigo3 rd:lg td:none c:inherit
One dynamic page renders every entry. <Markdown> places the rendered body:
# src/pages/services/[slug].imba
import {getCollection, getEntry} from '@milktop/imba-pages/content'
import {Markdown} from '@milktop/imba-pages/markdown'
# Which pages exist. In the static build these are the pages generated;
# in dev, any other /services/… URL gets the 404 page.
export def paths
getCollection('services').map do({id}) {slug: id}
# Runs before render; the result is passed in as `data`
export def load {params}
getEntry('services', params.slug)
export def meta {data}
{title: "{data.data.title} · Acme Studio", description: data.data.summary}
export default tag ServicePage
data
<self>
<a href='/services'> "← All services"
<h1> data.data.title
<Markdown.prose html=data.html>
Checking entries. src/content/config.imba can list required fields and a sort order. A
missing field stops the build with the file name (services/web-design.md is missing priceFrom) instead of quietly rendering a blank:
# src/content/config.imba
export const collections =
services:
required: ['title', 'summary', 'priceFrom']
sort: 'order'
Without a sort, entries are in file-name order.
Things to know:
- Collections are server-only. An island can't call
getCollection; pass it the entries (or just the fields it needs) as props. - Loose data files still work:
src/content/business.imbawithexport const business = {phone: …}is just a module you import. Only folders are collections. loadcan beasync, so data can also come from a CMS or an API at build time.- In dev, editing or adding a content file updates the site without a restart.
6. Markdown pages
Any .md file in src/pages/ is a page. The front matter becomes the page's meta. Long text
pages like privacy notices, terms or an FAQ are easiest this way.
<!-- src/pages/privacy.md -->
---
title: Privacy · Acme Studio
description: How we use the details you send us.
layout: prose
---
## What we collect
When you use the [contact form](/contact) we receive your name and email address.
layout: prosewraps the page insrc/layouts/prose.imba. Without it, the default layout is used;layout: falsemeans no layout at all. Every file insrc/layouts/can be picked by name.- Other front-matter fields (
heading,updated, …) reach the layout asmeta, so a layout can show them. - The rendered Markdown always gets the class
prose. Imba's scopedcssblocks don't apply to HTML that came from Markdown, so style it inglobal css:
# in src/site.imba
global css
.prose > * + * mt:1em
.prose h2 fs:2xl mt:2em
.prose a c:indigo6 td:underline
A layout for these pages can wrap the default one:
# src/layouts/prose.imba
import Layout from './default'
export default tag ProseLayout
meta = {}
path = '/'
<self>
<Layout meta=meta path=path>
<article.container>
<h1> meta.heading or meta.title
<slot>
Markdown is turned into HTML when the site is built, so it adds nothing to the page's JavaScript. It's treated as trusted: HTML written inside a Markdown file is kept as-is.
7. Images and static files
Anything in public/ is copied to the site root unchanged:
public/images/team.jpg → /images/team.jpg
public/favicon.svg → /favicon.svg
public/robots.txt → /robots.txt
<img src='/images/team.jpg' alt='The Acme team' width=800 height=533 loading='lazy'>
Images are not resized or optimised. Export them at the right size before adding them.
8. Styling
- Scoped CSS: a
cssblock inside a tag only applies to that tag. This is the default and the way to style most things. - Global CSS:
global cssinsrc/site.imba(or any imported file) for element defaults, as in section 2. - Fonts: link a hosted font in
head(section 2), or self-host the files inpublic/fonts/and declare@font-facein a plain CSS file linked fromhead. - Shared classes like
.buttonor.containerused across many tags belong inglobal css, since scoped blocks don't reach into other tags.
Imba CSS gotchas:
- Colour shades run 0–9:
warm0,emerald1,emerald8. Tailwind-styleemerald50oremerald100doesn't produce a usable colour, and there's no error. - A modifier applies to the property right before it:
d:flex @lt-md:none g:24pxhides the element on phones, butd:flex g:24px @lt-md:nonesets the gap tonone. - Breakpoints:
@lt-sm,@lt-md,@lt-lg(below),@sm,@md,@lg(from). - When two properties clash, the later one wins:
m:0 mt:4keeps the top margin,mt:4 m:0doesn't. s:(width and height together) needs a unit, e.g.s:40px;s:2gives nothing. For the 0.25rem steps usew:2 h:2.
Styles can also go straight on a tag in square brackets, much like Tailwind classes in the
markup: <div[d:flex g:4 p:6 @md:10]>. This docs site (site/ in the repo) is styled entirely that way.
All CSS ends up in one hashed stylesheet (/assets/index.XXXX.css), linked from every page.
9. A bit of interactivity: islands
Most brochure sites need one or two interactive pieces, like a mobile menu, an image carousel
or an FAQ. Put those tags in src/islands/. They render on the server like everything else,
then come alive in the browser. Only pages that use an island load its script.
# src/islands/mobile-menu.imba
import {links} from '../components/site-nav'
export tag MobileMenu
path = '/'
open = no
<self>
<button @click=(open = !open) aria-expanded=open> open ? "Close" : "Menu"
if open
<nav> for [href, label] in links
<a href=href .active=(href == path)> label
css d:none @lt-md:block
nav pos:absolute l:0 r:0 t:64px d:flex fld:column bg:white p:24px g:16px shadow:lg
a.active fw:600
Island fields set on the server (path above) are saved into the HTML and restored in the
browser, so the island starts with the same values. Keep island fields plain data: strings,
numbers, booleans, arrays and plain objects.
For simple show/hide content, plain HTML may be enough with no island at all:
<details> + <summary> makes a working FAQ accordion with zero JavaScript.
10. Contact form
A static site has no server to receive a form post, so send the form to a form service
(Netlify Forms, Formspree, Basin, …). This is the recommended setup: no code to run or maintain.
Or skip the form and use a mailto: link.
On Netlify, Netlify Forms needs no account elsewhere: mark the form and Netlify finds it when you deploy, stores the submissions and emails them to you.
<form$form name='contact' method='post' action='/contact/thanks' data-netlify='true'>
<input type='hidden' name='form-name' value='contact'>
…
Imba templates drop custom hyphenated attributes other than data- and aria-, so Netlify's
optional netlify-honeypot attribute has to be set from code: call
$form.setAttribute('netlify-honeypot', 'website') in the tag's rendered method.
Any other form service works the same way: It's a
plain HTML form whose action is the service's URL. No JavaScript needed:
<form method='post' action='https://formspree.io/f/your-form-id'>
<label> "Email"
<input type='email' name='email' required=''>
<label> "Message"
<textarea name='message' rows=5 required=''>
<button type='submit'> "Send"
Advanced: your own form handler
Most sites won't need this. If you want your own email wording, an auto-reply, or to send
enquiries somewhere else, the page can handle the post itself with export def post. This only
works when the site runs as a Node server (imba-pages start, see the end of section 11),
not as static files. Return {redirect} when it worked, or values and errors to show the form
again. The page receives what post returned as its form prop:
# src/pages/contact.imba
import {sendEnquiry} from '../email'
export def post {form}
return {redirect: '/contact/thanks'} if form.website # hidden field only bots fill in
let errors = {}
errors.email = 'Please enter your email.' unless form.email
errors.message = 'Please write a message.' unless form.message
if Object.keys(errors).length
return {status: 422, values: form, errors}
await sendEnquiry(form)
{redirect: '/contact/thanks'}
export default tag ContactPage
form = null
get values
form..values or {}
get errors
form..errors or {}
<self>
<form method='post' action='/contact'>
<label>
"Email"
<input type='email' name='email' required='' value=(values.email or '')>
<small> errors.email if errors.email
<label>
"Message"
<textarea name='message' rows=5 required=''> values.message or ''
<small> errors.message if errors.message
<input.trap name='website' tabindex='-1' autocomplete='off' aria-hidden='true'>
<button type='submit'> "Send"
css .trap pos:absolute l:-9999px
Sending the email is your own code, so any provider works. With
Resend it's one fetch:
# src/email.imba
export def sendEnquiry form
let res = await fetch('https://api.resend.com/emails',
method: 'POST'
headers: {'Authorization': "Bearer {process.env.RESEND_API_KEY}", 'Content-Type': 'application/json'}
body: JSON.stringify(
from: 'Website <website@your-domain.com>'
to: 'you@your-domain.com'
reply_to: form.email
subject: 'New enquiry'
text: form.message
)
)
throw new Error("Email failed: {res.status}") unless res.ok
Good to know:
postgets the fields as plain strings. Repeated fields (checkboxes with the same name) arrive as an array.- Keep
required=''andtype='email'in the HTML too: the browser checks those before sending, and the server checks again inpost. - File uploads aren't supported yet.
- In a static build the page still renders, but its form has nowhere to post to; the build prints a note naming those pages.
11. Develop, build, deploy
node lib/cli.mjs dev examples/brochure
(or npm run dev:brochure). Opens a live server on http://localhost:3000 with hot reload. New pages and islands are picked
up without restarting.
node lib/cli.mjs build examples/brochure
Writes the finished site to dist/ inside the site folder:
dist/index.html
dist/about/index.html
dist/services/index.html
dist/services/web-design/index.html
dist/404.html
dist/assets/… CSS, island scripts
dist/images/… from public/
Option A: static files. Upload dist/ to any static host: Netlify, Cloudflare Pages,
Vercel, an S3 bucket, or plain shared hosting. On Netlify the settings are: build command
npm run build, publish directory dist. Each page is a folder with an index.html, so URLs are clean (/about, not
/about.html). Netlify and Cloudflare Pages use 404.html automatically.
Advanced: a Node server. Only for sites whose pages handle their own form posts:
node lib/cli.mjs start examples/brochure
This compiles the site into examples/brochure/.pages/build/ (entry.js plus public/) and
runs it. To deploy, copy that folder to any host that runs Node (Render, Fly.io, Railway, a
VPS) and run node entry.js, with PORT and any keys like RESEND_API_KEY as environment
variables. Pages are rendered on each request, so content changes need a rebuild and redeploy
just like the static option.
Either way, the site must be served from the root of its domain. Asset links start with /assets/, so
hosting under a sub-path (like a GitHub Pages project site at user.github.io/repo/)
won't work yet.
Current limits
- No image optimisation or RSS feed.
- No built-in support for several languages yet (
/fr/...pages work, buthreflanglinks and per-language 404 pages are up to you). - Islands load as soon as the page loads (no "when visible" option yet), and content passed
into an island's
<slot>isn't carried over to the browser. - Island JavaScript is not minified yet.