Lato CMS
How to use Lato CMS
lato_cms adds content management to a Lato admin panel.
Use it when admins need to create pages and edit structured content without changing application code.
Lato CMS works with Lato Spaces, so pages can be scoped to the selected group.
Installation
Add Lato CMS to the application Gemfile:
gem "lato"
gem "lato_spaces"
gem "lato_cms"
Install the engine and run migrations:
bundle
rails lato_cms:install:application
rails lato_cms:install:migrations
rails db:migrate
Mount the engine in config/routes.rb:
Rails.application.routes.draw do
mount LatoCms::Engine => "/lato-cms"
# ...
end
Import styles in app/assets/stylesheets/application.scss:
@import "lato_cms/application";
Import JavaScript in app/javascript/application.js:
import "lato_cms/application"
Permissions
Users must be logged into Lato, must have a selected Lato Spaces group, and must have Lato CMS admin permission enabled.
user = Lato::User.find_by(email: "admin@example.com")
user.update!(lato_cms_admin_role: 1)
Configuration
Configure available locales and template directory:
LatoCms.configure do |config|
config.locales = [:en, :it]
config.templates_path = "config/lato_cms"
end
Templates and components
Templates define which content blocks are available on a page. Components define the fields inside those blocks. Both are YAML files stored in the application. Template components are enabled by default, and each page can turn optional components on or off from the admin panel.
config/lato_cms/
templates/
homepage.yml
blog_post.yml
components/
hero.yml
content.yml
Generate new files with Rails tasks:
rails "lato_cms:generate:template[blog_post]"
rails "lato_cms:generate:component[hero,title:string,subtitle:textarea,image:file]"
Template example
# config/lato_cms/templates/homepage.yml
id: homepage
name: Homepage
components:
hero_section:
component_id: hero
name: Hero Section
required: true
content_section:
component_id: content
Optional and required components
Components listed in a template are optional unless required: true is set.
Optional components show an enabled switch in the page editor. Admins can disable them for a specific page
without removing the component from the template.
# config/lato_cms/templates/homepage.yml
id: homepage
name: Homepage
components:
hero_section:
component_id: hero
name: Hero Section
required: true
banner_section:
component_id: banner
name: Promotional Banner
content_section:
component_id: content
name: Content
In this example hero_section is always active and cannot be disabled.
banner_section and content_section can be enabled or disabled per page.
Disabled components are hidden in the editor, cannot be saved, and are not exposed when page fields are returned by the API.
Repeater components
Add repeater: true to a template component when admins need to create multiple items with the same field set.
The API returns repeater fields as an ordered array of items.
# config/lato_cms/templates/homepage.yml
components:
feature_cards:
component_id: feature_card
name: Feature Cards
repeater: true
settings:
min: 1
# config/lato_cms/components/feature_card.yml
id: feature_card
name: Feature Card
fields:
title:
name: Title
type: string
required: true
description:
name: Description
type: textarea
Component example
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
title:
name: Title
type: string
required: true
subtitle:
name: Subtitle
type: textarea
image:
name: Image
type: file
settings:
accept: "image/*"
attachments:
name: Attachments
type: file
settings:
multiple: true
Supported field types
| Type | Use for |
|---|---|
string |
Short text. |
textarea |
Multi-line plain text. |
text |
Long rich text or HTML content. |
number |
Numeric values. |
date / datetime
|
Date and time values. |
boolean |
True/false values. |
select / multiselect
|
One or more values from predefined options. |
color |
Color values. |
json |
Structured data. |
file |
One or more files picked from the media library. Set settings.multiple: true to allow more than one. |
image |
Single image picked from the media library. Optionally generates resized variants (see Image sizes). |
video |
Single video picked from the media library. Its poster image is generated once per media item (see Video poster). |
gallery |
Multiple sortable images picked from the media library. Optionally generates resized variants (see Image sizes). |
custom |
Application-defined field rendered with a custom partial. |
Any field can declare required: true. For attachment fields
(file, image, video, gallery) this means the
field must keep at least one file: the editor blocks saving when the field would end up empty,
and the server rejects the save with an error otherwise.
Media library
file, image, video, and gallery fields don't upload
straight into the field: admins pick from a shared Media library (Media in the sidebar),
scoped to the current Lato Spaces group like pages. Each field opens a picker overlay to search the
existing library or drag & drop a new file; the same media item can be reused across any number of
fields and pages.
A media item's name and alt text are edited from the library's own edit form, not from the
upload dialog — on upload, name defaults to the filename. Replacing the underlying file
of an existing media item isn't supported: uploading a different file always creates a new media item, so
that changing what a shared media item points to never silently changes it everywhere it's already in use.
Deleting a media item still referenced by a field is blocked until it's removed from every field using it.
Alt text is translatable: it's only shown for image media, and the edit form has one tab per locale
configured in LatoCms.config.locales. Each locale's text is independent — leaving one
blank doesn't affect the others.
image = LatoCms::Media.find(12)
image.alt_text(:en) # => "A dog running on the beach"
image.alt_text(:it) # => nil, if the Italian tab was left blank
image.alt_text_translations # => { "en" => "A dog running on the beach" }
image.alt_text without an explicit locale resolves against I18n.locale. When a
media item is read off a page's field (page_field.attachments in the JSON API), its alt text
resolves against that page's own locale instead, so a multi-locale API response always
gets the right language regardless of the server's current I18n.locale.
AI-generated alt text
Configure an OpenAI-compatible endpoint to have alt text generated automatically:
LatoCms.configure do |config|
config.llm_api_url = "https://api.openai.com/v1"
config.llm_model = "gpt-4o-mini"
config.llm_api_key = Rails.application.credentials.dig(:openai, :api_key)
end
All three must be set for the feature to activate (it's off by default). Once configured, uploading a new
image queues a background job that sends it to the LLM and writes back an alt text translation for every
locale in LatoCms.config.locales, in one call. It only runs once, right after upload —
it never overwrites text an admin later edits by hand. Generation is best effort: any failure (LLM
unreachable, malformed response, ...) is logged and simply leaves the alt text as it was.
A Regenerate with AI button on the media edit form (shown only for images, and only when an LLM
is configured) re-runs generation on demand, overwriting the current text in every locale. Unlike the
automatic post-upload run, this one is triggered by an admin waiting on it, so it runs as a
Lato::Operation instead: the admin is taken to a live progress screen rather than the request
blocking (and potentially timing out) on the LLM call.
Image sizes
image and gallery fields can declare settings.sizes to generate
resized variants via image_processing. Each size produces an Active Storage variant that is
processed lazily on first request and exposed in the API under the attachment's sizes map.
Each size accepts width, height, and a resize mode:
-
limit(default): scale down to fit within the bounds, never upscale, keep aspect ratio. -
fit: scale to fit within the bounds, keep aspect ratio. -
fill: crop to exactlywidth×height.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
cover:
name: Cover
type: image
settings:
sizes:
thumb:
width: 150
height: 150
resize: fill
medium:
width: 800
The API response includes the generated variant URLs alongside the original:
{
"id": 12,
"filename": "cover.jpg",
"url": "/rails/active_storage/blobs/.../cover.jpg",
"sizes": {
"thumb": "/rails/active_storage/representations/.../cover.jpg",
"medium": "/rails/active_storage/representations/.../cover.jpg"
}
}
Video poster
After a video is uploaded to the media library, a background job generates a poster image from it
via Active Storage previews (requires ffmpeg on the server) and attaches it to that media
item — once per media item, regardless of how many fields or pages reuse it. Poster generation is
best effort: when ffmpeg is unavailable or preview fails, the upload still succeeds and the
video is exposed without a poster.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
trailer:
name: Trailer
type: video
settings:
accept: "video/mp4"
The API response exposes the video attachment with its poster URL (null until the job runs):
{
"id": 12,
"filename": "trailer.mp4",
"content_type": "video/mp4",
"byte_size": 1048576,
"url": "/rails/active_storage/blobs/.../trailer.mp4",
"poster_url": "/rails/active_storage/blobs/.../trailer_poster.jpg"
}
Custom fields
Use type: custom when a component needs an editor that is not covered by built-in field types.
Set render to an application partial path. The partial receives field_id,
field_config, and page_field locals.
# config/lato_cms/components/hero.yml
id: hero
name: Hero
fields:
icon_note:
name: Icon note
type: custom
render: lato_cms/custom_fields/icon_note
required: false
settings:
placeholder: "Example: rocket|Hero launch section"
Create the partial in the host application:
<%# app/views/lato_cms/custom_fields/_icon_note.html.erb %>
<% settings = field_config["settings"] || {} %>
<% current_value = page_field&.value.to_s %>
<label class="form-label" for="fields_<%= field_id %>_value">
<%= field_config["name"] || field_id.humanize %>
</label>
<input type="text"
class="form-control"
name="fields[<%= field_id %>][value]"
id="fields_<%= field_id %>_value"
value="<%= current_value %>"
placeholder="<%= settings["placeholder"] %>">
Custom field values are saved like standard scalar fields when the input name is
fields[FIELD_ID][value]. Use settings for custom options consumed by the partial.
Using CMS content in the application
Use Lato CMS pages as structured content records for public or private pages. Field values are available in their parsed form, so numbers, booleans, dates, JSON values, multi-select values, and files can be consumed directly by application views or serializers.
page = LatoCms::Page.find_by(permalink: "/homepage")
title = page.fields.find_by(field_id: "title")&.parsed_value
image = page.fields.find_by(field_id: "image")&.media&.first
image_url = image&.url
Page translations
Each page belongs to a single locale. From a page, the Actions → Translations menu lets you link the corresponding pages in the other configured locales. Linked pages form a translation group with at most one page per locale, and the link is symmetric: linking A to B makes both aware of each other. On the page view, the locale badge becomes a dropdown to jump to the same page in another language.
When a page has linked translations, each component header shows a Clone dropdown. Picking a language copies that component's field values from the corresponding translation, replacing the current ones after confirmation; attachment fields keep referencing the same Media (not duplicated).
When an LLM is configured, the dropdown also offers
Clone & translate: same copy, but every string/textarea/text
field is additionally translated into the target page's locale with one batched LLM call
(HTML in text fields is preserved, only the visible text is translated).
Everything else — select/multiselect/boolean/number/date/color/json/custom values, and
media (including its alt text) — is left exactly as a plain clone would leave it: media
alt text is translated independently by its own per-locale generation, not as part of
cloning a page.
The clone itself is instant, as always. Only the translation step — the actual LLM call — runs as
a Lato::Operation (see the alt text section above), landing the
admin on a live progress screen instead of blocking the request. If the LLM call fails, the operation
reports the failure, but the fields are still cloned (just untranslated) since that part already
committed synchronously before the translation step ever started.
Read a page's translations in the application or from the JSON API:
page = LatoCms::Page.find_by(permalink: "/homepage")
# Sibling pages in the other locales
page.translations # => ActiveRecord::Relation of pages
page.translation_for(:it) # => the linked Italian page, or nil
# In as_json / API output, translations are exposed as a locale => page map:
# { "it" => { id:, permalink:, frontend_url: }, ... }