Functional HTML generation for Ruby.
HTMG treats HTML as data structures, not text files. Build stateless, testable, reusable UI components through functional composition—no templating language tax.
Templates (ERB, Slim) separate logic from structure, often leading to "magic" context and implicit dependencies. HTMG takes a different approach: Your view is just a function.
- Code is Data: HTML structure is defined by nesting Ruby function calls.
- Unidirectional Data Flow: Data is passed explicitly. Views never "pull" data.
- Explicit over Implicit: No magical
scopeorinstance_variables. If a view needs data, it must be passed as an argument. - Boring Technology: Just Ruby. No parsers, no distinct compilation step.
gem'htmg'HTMG uses ARGS style for all HTML generation. Content and child elements are passed as positional arguments, attributes as keyword arguments.
# <div class="card"><h1>Title</h1></div>div(h1("Title"),class: "card")# Nested structurehtml(head(meta(charset: "utf-8"),title("My App")),body(header("Welcome"),main(p("Hello world"),id: "content"),footer("© 2024")))Why ARGS style?
- Cleaner and more readable
- Faster performance (no
instance_evaloverhead) - Natural functional composition
- Children are auto-joined—no need for
+operator
When is + needed? Only when you have multiple top-level siblings that cannot be nested:
# Rare: multiple top-level siblingshtmgdodiv("a") + div("b")# Must use + hereend# Preferred: nest them insteadhtmgdodiv(div("a"),div("b"))# Auto-joined, no + neededend# Attributes as keyword argumentsa("Home",href: "/",class: "nav-link")# Data attributesdiv("data-id": "123","data-type": "user")# Boolean attributesinput(type: "checkbox",checked: true)Use standard Ruby .map and .join:
ul(items.map{ |i| li(h(i.name))}.join,class: "list")Ruby's ternary or if expressions:
div(active ? "Active" : "Inactive",class: active ? "badge-success" : "badge-secondary")HTMG is designed for unidirectional data flow. Data is fetched once in the route, then flows down through function arguments.
Route (fetch data) → Layout (document shell) → Page (compose) → Component (render)
Every layer follows the same pattern: a module with extend self and include HTMG. Pure functions — data in, HTML string out.
Components are the smallest unit. They receive data as keyword arguments and return an HTML string. Never fetch data inside components.
# views/components/post_list.rbmoduleViewsmoduleComponentsmodulePostListextendselfincludeHTMGdefrender(posts:)htmgdoul(posts.map{ |post| li(h(post.title),class: "post")}.join,class: "posts")endendendendendComponents can be nested in subdirectories for grouping (e.g. views/components/headers/simple.rb → Components::Headers::Simple).
Pages compose components into a full page body. They receive explicit keyword arguments — no framework context needed.
# views/pages/home.rbmoduleViewsmodulePagesmoduleHomeextendselfdefrender(user:,posts:)Components::Headers::Simple.render(heading: "Welcome, #{CGI.escapeHTML(user.name)}") + Components::PostList.render(posts: posts)endendendendPages don't need include HTMG themselves — they just call component .render methods and concatenate results with +.
Layouts wrap page content in the HTML document structure (<html>, <head>, <body>, header, footer). They receive ctx (the app context) because they need request-level information like the current path.
# views/layouts/application.rbmoduleViewsmoduleLayoutsmoduleApplicationextendselfincludeHTMGdefrender(ctx,title: "",flash_notices: [],flash_errors: [])current_path=ctx.request.path_infocontent=yield"<!DOCTYPE html>" + ctx.htmgdohtml(head(meta(charset: "utf-8"),meta(name: "viewport",content: "width=device-width, initial-scale=1"),'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>',title(title.empty? ? "MyApp" : "MyApp - #{title}")),body(Components::Header.render(current_path: current_path),main(Components::FlashMessages.render(notices: flash_notices,errors: flash_errors),content,class: "pt-24"),Components::Footer.render),lang: "en")endendendendendMultiple layouts are natural — e.g. an Error layout without header/footer for error pages.
The route is the only place data is fetched. A thin render_page helper wires flash messages into the layout.
# helpers/render.rbmoduleAppHelpersdefrender_page(title: "", &block)content=block.callViews::Layouts::Application.render(self,title: title,flash_notices: flash["notice"] ? [flash["notice"]] : [],flash_errors: flash["error"] ? [flash["error"]] : []){content}endend# app.rb (Roda)classApp < RodaincludeHTMGincludeAppHelpersroutedo |r|
r.rootdouser=current_userposts=Repo::Posts.recentrender_page(title: "Home")doViews::Pages::Home.render(user: user,posts: posts)endendendend| Layer | Receives | Returns | Needs ctx? |
|---|---|---|---|
| Component | Data as keyword args | HTML string | No |
| Page | Data as keyword args | HTML string (composed components) | No |
| Layout | ctx, title, flash, &block | Full HTML document | Yes |
| Route | Request | Response (via render_page) | Is ctx |
# Components: data onlyComponents::PostList.render(posts: posts)# Pages: compose components, data onlyPages::Home.render(user: user,posts: posts)# Layout: wraps page content in document shellrender_page(title: "Home"){Pages::Home.render(user: user,posts: posts)}- Attributes: Auto-escaped (except
class/idfor Tailwind compatibility). - Content: Raw by default. You must escape user input with
h().
div(user_content)# Renders as-is (potentially unsafe)div(h(user_content))# HTML escaped (safe)HTMG shines with "HTML over the Wire" (HTMX, Hotwire). Because components are pure functions, you can reuse them for both full-page renders and partial updates.
# Component — same function serves both contextsmoduleViewsmoduleComponentsmoduleAlertFeedextendselfincludeHTMGdefrender(alerts:)htmgdodiv(alerts.map{ |a| div(h(a.msg),class: "p-4 bg-yellow-50 rounded")}.join,id: "feed")endendendendend# Full page render — component wrapped in layoutr.rootdoalerts=Repo::Alerts.recentrender_page(title: "Alert Feed")doComponents::AlertFeed.render(alerts: alerts)endend# HTMX partial — call the component directlyr.get"partials/feed"doComponents::AlertFeed.render(alerts: Repo::Alerts.recent)endThis works because:
- Same component renders for both initial page load and HTMX updates
- No duplicated logic between full-page and partial responses
- Components are pure functions — call them anywhere with the same data
| Category | Do | Don't |
|---|---|---|
| Style | Use ARGS style: div(h1("Title"), class: "card") | Use + when you could nest instead |
| State | Pass data as explicit keyword arguments | Rely on scope or instance variables |
| Layers | Route → Layout → Page → Component | Skip layers or fetch data in pages/components |
| Context | Only layouts receive ctx | Pass ctx through pages and components |
| Safety | Use h() for dynamic content | Interpolate strings blindly |
| HTMX | Call components directly for partials | Wrap in unnecessary helpers |
Main entry point. Returns HTML string.
ctx.htmgdodiv("Hello",class: "greeting")end# => '<div class="greeting">Hello</div>'All HTML5 tags are available as methods:
tag_name(*content, **attributes)*content- Child elements or text (concatenated)**attributes- HTML attributes (keyword arguments)
div("text"," more",class: "box",id: "main")# => '<div class="box" id="main">text more</div>'div(h1("Title"),p("Body"))# => '<div><h1>Title</h1><p>Body</p></div>'HTML escape helper. Always use for user input.
h("<script>")# => "<script>"HTMG bypasses template parsing, running at Ruby method call speed. The ARGS style is faster than block style (no instance_eval overhead). Generally comparable to cached ERB.
MIT