Docs menu

Getting started

IntroductionInstallationProject structure

Building pages

RoutingPagesLayoutsStylingMarkdownContent and dataIslands

Going live

SEO and headDeployingForms

Guides

Build a brochure site

Advanced

Running as a server

Reference

Reference

Guides

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 .imba file per page; the file path is the URL.
  • Scoped css blocks 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 own src/ and public/, 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 with node lib/cli.mjs dev <folder>. A site in its own folder or repo installs the framework instead (see Installation) and runs imba-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 except noindex ones.
  • robots.txt: allows everything and points to the sitemap. A robots.txt in public/ 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.imba with export const business = {phone: …} is just a module you import. Only folders are collections.
  • load can be async, 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: prose wraps the page in src/layouts/prose.imba. Without it, the default layout is used; layout: false means no layout at all. Every file in src/layouts/ can be picked by name.
  • Other front-matter fields (heading, updated, …) reach the layout as meta, so a layout can show them.
  • The rendered Markdown always gets the class prose. Imba's scoped css blocks don't apply to HTML that came from Markdown, so style it in global 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 css block inside a tag only applies to that tag. This is the default and the way to style most things.
  • Global CSS: global css in src/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 in public/fonts/ and declare @font-face in a plain CSS file linked from head.
  • Shared classes like .button or .container used across many tags belong in global css, since scoped blocks don't reach into other tags.

Imba CSS gotchas:

  • Colour shades run 0–9: warm0, emerald1, emerald8. Tailwind-style emerald50 or emerald100 doesn'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:24px hides the element on phones, but d:flex g:24px @lt-md:none sets the gap to none.
  • Breakpoints: @lt-sm, @lt-md, @lt-lg (below), @sm, @md, @lg (from).
  • When two properties clash, the later one wins: m:0 mt:4 keeps the top margin, mt:4 m:0 doesn't.
  • s: (width and height together) needs a unit, e.g. s:40px; s:2 gives nothing. For the 0.25rem steps use w: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:

  • post gets the fields as plain strings. Repeated fields (checkboxes with the same name) arrive as an array.
  • Keep required='' and type='email' in the HTML too: the browser checks those before sending, and the server checks again in post.
  • 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, but hreflang links 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.