Customizing Page Views
The install generator creates shared page stubs, navigation props, and app-level barrels under app/views/admin/. Default React pages, components, fields, and UI primitives come from the terrazzo package until you eject the files you want to own.
File Structure
app/views/admin/
├── application/
│ ├── index.jsx # list view
│ ├── show.jsx # detail view
│ ├── new.jsx # new form
│ ├── edit.jsx # edit form
│ ├── _collection.jsx # ejected shared collection table partial
│ ├── _form.jsx # ejected shared form partial
│ └── _navigation.json.props # sidebar navigation partial
├── components/
│ ├── ui/ # UI barrel and ejected shadcn-style primitives
│ ├── Layout.jsx # ejected main layout wrapper
│ ├── SearchBar.jsx # ejected search component
│ └── ...
└── fields/
├── string/ # ejected field variants
├── boolean/
└── ...Overriding Views Per Resource
Extending the JSON props
To add custom props to a specific resource's JSON response, generate a resource-specific view:
rails g terrazzo:views:show Product
rails g terrazzo:views:index Product
rails g terrazzo:views:edit ProductEach generator ejects a page component and a .json.props that calls the gem's base partial. Index also copies _collection.jsx when it is missing; edit and new also copy _form.jsx when it is missing. Existing app-owned shared partials are preserved. When the matching form page is still Terrazzo's generated package stub, form-page ejection also replaces that counterpart so both pages use the same app-owned form:
# app/views/admin/products/show.json.props
json.partial! partial: "terrazzo/application/show_base"
json.inventoryCount @resource.inventory_count
json.onSale @resource.on_sale?The base partials (show_base, index_base, edit_base, new_base) provide the standard Terrazzo serialization. Your file just adds to it.
The ejected page and JSX partials let you customize the layout, collection table, or form for that resource — for example, adding custom columns, changing the row layout, or adding extra form fields.
Overriding the React component
To customize the page component for a specific resource, create a view at that resource's path:
app/views/admin/products/index.jsx → overrides the products index page
app/views/admin/products/show.jsx → overrides the products show pageYour custom component receives the same props as the default (plus any custom props from your .json.props):
// app/views/admin/products/index.jsx
import { useContent } from "@thoughtbot/superglue"
import { getLayout } from "terrazzo"
import { CollectionToolbarActions } from "../components"
export default function ProductsIndex() {
const Layout = getLayout()
const { table, searchBar, filters, pagination, layoutActions, toolbarActions, bulkActions, emptyState, navigation } = useContent()
return (
<Layout
navigation={navigation}
title="Products"
actions={
<div>
<CollectionToolbarActions actions={layoutActions} />
<CollectionToolbarActions actions={toolbarActions} />
</div>
}>
{/* your custom layout */}
</Layout>
)
}Index props carry normalized pagination state. The default searchBar.perPage, table sort URLs, filter URLs, and pagination links all use the controller-clamped page size so custom index pages can preserve row-count selection without reading raw query params. The bulkActions prop contains dashboard-provided bulk action definitions; pass it through to AdminCollection or ResourceTable when a custom index still wants default selected-row behavior.
Re-generating Views
To reset the shared page stubs, app barrels, and navigation props to their defaults:
rails g terrazzo:viewsThis will overwrite those generated files. Use rails g terrazzo:eject ... for individual pages, components, fields, or UI primitives that you want to own locally. Ejection replaces Terrazzo's generated package stubs directly; customized app-owned files still use Rails' normal overwrite prompt. Files copied only as dependencies of the requested ejection are skipped when they already exist, so later ejections do not clobber app-owned dependency files or assume their default exports. Eject that dependency directly when you want to replace and register it.
Custom Pages
You can add pages beyond the standard CRUD views — for example, a dashboard overview or a reports page.
1. Add a controller action
# app/controllers/admin/reports_controller.rb
class Admin::ReportsController < Admin::ApplicationController
superglue_template "admin/reports"
def index
@data = # ...
end
endSetting superglue_template "admin/reports" tells Superglue to look for components under admin/reports/ instead of the shared admin/application/ templates.
2. Create a .json.props template
# app/views/admin/reports/index.json.props
json.pageTitle "Reports"
json.data @data
# ... any props your component needs
# Navigation is automatically provided by the layout partial3. Create a React component
// app/views/admin/reports/index.jsx
import { useContent } from "@thoughtbot/superglue"
import { getLayout } from "terrazzo"
export default function ReportsIndex() {
const Layout = getLayout()
const { pageTitle, data, navigation } = useContent()
return (
<Layout navigation={navigation} title={pageTitle}>
{/* your custom page */}
</Layout>
)
}4. Add a route
# config/routes.rb
namespace :admin do
resources :reports, only: [:index]
end5. Register the component
Add an entry to app/javascript/admin/custom_page_mapping.js. Resource-specific Terrazzo generators use generated_page_mapping.js, so this manual manifest is safe to edit directly.
import ReportsIndex from "../../views/admin/reports/index"
export const customPageMapping = {
'admin/reports/index': ReportsIndex,
}page_to_page_mapping.js imports both generated_page_mapping.js and custom_page_mapping.js, then merges them with the built-in application pages. Manual custom mappings win over generated mappings when keys overlap.
Custom Layout
The gem's page components use a Layout component that wraps every page with a sidebar, header, and flash messages. To replace it globally without ejecting every page, register a custom Layout in your entry point:
// app/javascript/admin/application.jsx
import { setLayout } from "terrazzo";
import { CustomLayout } from "../../views/admin/components/CustomLayout";
setLayout(CustomLayout);Your custom Layout must accept the same props as the default:
function CustomLayout({ navigation, title, actions, children }) {
return (
// your custom shell — sidebar, header, etc.
);
}All page components — both the gem's built-in pages and ejected pages — will automatically use your custom Layout. If no custom Layout is registered, the gem's default Layout is used.
Ejected pages use getLayout() (also exported from terrazzo) to resolve the active Layout at render time, so they respect setLayout without any extra wiring.
Running rails g terrazzo:eject components/Layout copies the default layout and any missing local dependencies, then updates app/views/admin/components/index.js to call setLayout(Layout) for you. The generated admin entrypoint imports that barrel before rendering.
The default Layout is also available as DefaultLayout from terrazzo/components, so you can extend it:
import { DefaultLayout } from "terrazzo/components";
function CustomLayout(props) {
return (
<DefaultLayout {...props}>
<MyDrawerPanel />
{props.children}
</DefaultLayout>
);
}Custom Components
Default Terrazzo pages resolve overridable components through a component registry. Running a component ejection command copies the component source into your app and updates app/views/admin/components/index.js to register it:
rails g terrazzo:eject components/ResourceTable
rails g terrazzo:eject components/SearchBar
rails g terrazzo:eject components/CollectionToolbarActionsThe generated admin entrypoint imports the components barrel before rendering, so package pages will use registered local overrides for components such as ResourceTable, SearchBar, CollectionFilters, Pagination, SortableHeader, CollectionItemActions, CollectionToolbarActions, HasManyPagination, AppSidebar, SiteHeader, and FlashMessages. CollectionToolbarActions renders dashboard-provided header actions and the default show-page Back/Edit/Delete actions.
Customizing the Sidebar Navigation
The sidebar navigation is rendered by a shared partial at app/views/admin/application/_navigation.json.props. This partial is included automatically by the layout, so you don't need to add it to each page template.
For resource labels, grouping, ordering, and hiding, prefer dashboard constants:
class ProductDashboard < Terrazzo::BaseDashboard
NAVIGATION_LABEL = "Catalog"
NAVIGATION_GROUP = "Commerce"
NAVIGATION_GROUP_ORDER = 10
NAVIGATION_ORDER = 20
endSet SHOW_IN_NAVIGATION = false to hide a resource from the generated sidebar.
Adding Custom Links
Eject the navigation partial when you need non-resource links or fully custom sidebar data:
rails g terrazzo:eject navigation# app/views/admin/application/_navigation.json.props
resources = Terrazzo::Namespace.new(namespace).navigation_resources
groups = [
{
label: "Admin",
items: [
{ label: "Dashboard", path: admin_root_path, active: controller_path == "admin/dashboard" },
],
},
{
label: "Resources",
items: resources.map do |r|
{
label: r.navigation_label,
path: url_for(controller: "/#{r.controller_path}", action: :index, only_path: true),
active: r.controller_path == controller_path,
}
end,
},
]
json.array! groups do |group|
json.label group[:label]
json.items do
json.array! group[:items] do |item|
json.label item[:label]
json.path item[:path]
json.active item[:active]
end
end
endSPA Navigation
Terrazzo uses Superglue for client-side navigation:
- Full navigation — links with
data-sg-visittrigger a full page transition (e.g., sidebar links, show/edit links) - Partial updates — links with
data-sg-remoteupdate only part of the page. Terrazzo uses this for nestedhas_manypagination on show pages.
Nested has_many pagination links include props_at parameters so the server only renders the relevant subtree, keeping updates fast.