All pages
MDX components
View as MarkdownUse your own Rails partials inside Markdown.
An .mdx file is Markdown that can also call components. In Andromeda a
component is a plain Rails partial — there is no component framework to adopt.
Calling a component
import Callout from 'content_components/callout';
<Callout type="warning" title="Careful">
The body of a component is **Markdown** too.
</Callout>Writing one
<%# app/views/content_components/_callout.html.erb %>
<aside class="callout callout--<%= local_assigns.fetch(:type, "note") %>">
<% if local_assigns[:title] %>
<p class="callout__title"><%= title %></p>
<% end %>
<%= content %>
</aside>- Props become locals.
camelCaseprops arrive assnake_case. - The children of the tag arrive as
content, already rendered. <Fragment slot="header">…</Fragment>arrives as aheaderlocal.- Optional props are read through
local_assigns.
content is passed even to a self-closing tag (<Callout />), so a partial
that uses strict locals must declare it:
<%# locals: (type: "note", title: nil, content: nil) %>bin/rails generate andromeda:component Callout type title writes the stub,
with content: nil already in its locals.
Querying other entries
A component can look up other entries, such as a card that links to another post:
<%# app/views/content_components/_post_card.html.erb %>
<%# locals: (slug:, content: nil) %>
<% post = Content::Blog.find(slug) %>
<a href="<%= blog_path(post.id) %>">
<img src="<%= andromeda_image_url(post.cover_image) %>" alt="">
<%= post.title %>
</a>During andromeda:build these queries read the source files, so an entry that
has not been converted yet is still found, and andromeda_content(post)
converts it on the spot. Two entries whose components render each other fail
with a “circular conversion” error.
The component’s HTML is stored at build time, before the asset pipeline has
stamped any digests, so andromeda_image_url leaves a placeholder there.
andromeda_content turns it into the digest-stamped URL when the page is
served.
Passing images
Import an image and pass it as a prop, as in Astro:
import photo from './photo.png';
<Figure src={photo} caption="The view from the top" />The image is published like an :image attribute, and the partial receives an
Andromeda::Image:
<%# app/views/content_components/_figure.html.erb %>
<%# locals: (src:, caption: nil, content: nil) %>
<figure>
<img src="<%= andromeda_image_url(src) %>" class="w-full" alt="">
<figcaption><%= caption %></figcaption>
</figure>How a tag is resolved
- An explicit registration, if you made one.
- The path in the
importstatement, when it points at a partial. - The naming convention:
Callout→app/views/content_components/_callout.html.erb.
A component with no partial is an error that names the file, the line and the path to create — never a silently dropped tag.
Expressions
Literals, {frontmatter.title} and imported images ({photo}) are evaluated;
{/* comments */} are dropped. Anything that would need a JavaScript runtime raises an error instead
of being ignored, so a page never renders half of what its author wrote.
When components render
Components are expanded during the build, not per request. That makes a page
cheap to serve, and means a component cannot depend on the current request —
no current_user inside the body. Put per-user markup in the layout, or load
it after the fact with a lazy Turbo Frame.
Last updated September 29, 2026