Skip to Content
CustomizeModel UIRecord landing page

Record landing page

The record landing page (detail page) displays everything about one record: its title, creators, files, and metadata. Unlike the deposit form, it is not a React application — it is server-rendered Jinja templates. You customize it by writing HTML/Jinja in your model’s template partials.

Key Concepts

  • Six auto-included partials — the page is assembled from six model-specific partial files (body, banners, CSS, JS, meta tags, sidebar; full list). The base template picks each one up automatically and falls back to its default when the file doesn’t exist.
  • Creating the file is enough — no registration, no templates map, no webpack needed for plain HTML/CSS.
  • d carries value + label + help together — d.metadata.sample_id is a FieldData object bundling the raw API value, the UI-serialized value, and the schema metadata (label, help, hint) from your model YAML. Use ui_value(d.metadata.sample_id) to render the formatted value and d.metadata.sample_id.label(...) for its localized label — see Pulling labels, help, and values from the model. For a plain value dump there’s also metadata (shorthand for record_ui["metadata"]).
  • Blocks hide or replace — main.html extends the oarepo-ui base, which is one long chain of blocks (record_header_info, record_title, record_files, …). Override a block with {{ super() }} inside to augment it; override it without super() to hide or replace that section entirely.

Partials Cheat Sheet

Start here. For a model named mymodel, these files live at ui/mymodel/templates/semantic-ui/mymodel/record_detail/.

PartialWhat it renders / how to use itCommon task
main.htmlThe whole record body. Extends oarepo_ui/record_detail/main.html. The only file generated by default — edit it directly.Hide a default block, add a metadata section, change the title
banners.htmlTop-of-page banners. Extends the oarepo-ui default (community / preview / “newer version” banners). Wrap it with {% extends "oarepo_ui/record_detail/banners.html" %} and {{ super() }} to keep the defaults.Add a model banner below the preview banner
css.htmlInline <style> inside the page <head>. Auto-included.Tweak colors, font sizes
javascript.htmlInline/content JS at the end of <body>. Register a webpack entry instead of inline <script> (CSP).Interactive widgets
head_meta.htmlOpenGraph / citation meta tags. Auto-included.Embed Dublin Core / JSON-LD
record_sidebar.htmlRight-hand sidebar content (replaces the whole default). To add a widget instead of replacing the sidebar, use OAREPO_UI_SIDEBAR_TEMPLATES config — see Add a sidebar widget.Custom sidebar; rarely needed

The generated main.html already extends the oarepo-ui base and carries a commented map of every overridable block — read its comment header first. It is the single most important file on this page.

The Three Most Common Customizations

These three cover the bulk of real-world landing-page work. Each is a small edit, no source-code forking needed.

1. Hide a default section

Drop a block body but leave the main.html {% extends %} untouched. To remove the “Published 2026-09-23 | Version 3” info line:

ui/mymodel/templates/semantic-ui/mymodel/record_detail/main.html
{% extends "oarepo_ui/record_detail/main.html" %} {%- block record_header_info -%} {# publication date / version / resource type / access status hidden #} {%- endblock record_header_info -%}

To hide a different part, use its block name from the block reference below.

2. Add a custom metadata field

Your custom fields live under metadata. This block puts a new section after the standard “Additional details” — using {{ super() }} keeps the built-in section in place:

ui/mymodel/templates/semantic-ui/mymodel/record_detail/main.html
{% extends "oarepo_ui/record_detail/main.html" %} {%- block additional_record_details -%} {{ super() }} {% if d.metadata.sample_id %} <section id="sample-metadata" class="rel-mt-2"> <h2>{{ _("Sample metadata") }}</h2> <dl class="ui list"> {# label + help come from your model YAML, already localized #} <dt title="{{ d.help(d.metadata.sample_id) }}">{{ d.label(d.metadata.sample_id) }}</dt> <dd>{{ d.metadata.sample_id | ui_value }}</dd> {% if d.metadata.concentration %} <dt title="{{ d.help(d.metadata.concentration) }}">{{ d.label(d.metadata.concentration) }}</dt> <dd>{{ d.metadata.concentration.value | ui_value }} {{ d.metadata.concentration.unit | ui_value }}</dd> {% endif %} </dl> </section> {% endif %} {%- endblock additional_record_details -%}

Pulling labels, help, and values from the model

d is a FieldData object — a per-field wrapper carrying three things: the raw API value, the UI-serialized value, and the UI definitions (label / help / hint) extracted from your model’s YAML schema (set per-field under label: / help: / hint: keys — see Model schema customization). label, help, and hint are methods available on every FieldData node, taking the node as their argument. Index into d by field path and call them on d, e.g. d.label(d.metadata.sample_id):

CallReturns
d.label(d.metadata.sample_id)Field label from the model schema, localized
d.help(d.metadata.sample_id)Help text from the model schema, localized
d.hint(d.metadata.sample_id)Hint text from the model schema, localized
d.metadata.sample_id | ui_valueUI-serialized value (localized dates, vocabulary titles, …); falls back to the raw value
value(d.metadata.sample_id)Raw API value
d.metadata.sample_id in an {% if %}Truthy when the field has a non-empty value

These are the exact calls oarepo-ui’s own field components use (see oarepo_ui/templates/components/datafields/IField.jinja) — all available in templates without any import. ui_value also has a function form (ui_value(x), same thing) while value is function-form only, and label / help / hint are node methods, not filters. Labels/help/hint return "Item does not exist" when the field isn’t defined in the schema — gate the whole section with {% if d.metadata.field %} as in the example above.

Simpler alternative when you don’t need schema metadata: metadata is the plain UI-serialized dict (record_ui["metadata"] — already localized), so {{ metadata.sample_id }} renders the value directly. Reach for d when you want the model’s labels/help to render with the field.

3. Add a sidebar widget

To extend the right sidebar for one model, set OAREPO_UI_SIDEBAR_TEMPLATES in invenio.cfg. You write a template that renders one widget, and oarepo-ui slots it into the sidebar list. This appends your widget after the built-in list (versions, keywords, licenses, citations, export, …) while leaving the rest alone:

invenio.cfg
OAREPO_UI_SIDEBAR_TEMPLATES = { "mymodel": [ # start from the full default list (invenio-app-rdm), then append your own "invenio_app_rdm/records/details/side_bar/manage_menu.html", "invenio_app_rdm/records/details/side_bar/metrics.html", "invenio_app_rdm/records/details/side_bar/versions.html", "invenio_app_rdm/records/details/side_bar/external_resources.html", "invenio_app_rdm/records/details/side_bar/communities.html", "invenio_app_rdm/records/details/side_bar/keywords_subjects.html", "invenio_app_rdm/records/details/side_bar/details.html", "invenio_app_rdm/records/details/side_bar/locations.html", "invenio_app_rdm/records/details/side_bar/licenses.html", "invenio_app_rdm/records/details/side_bar/citations.html", "invenio_app_rdm/records/details/side_bar/export.html", "invenio_app_rdm/records/details/side_bar/technical_metadata.html", # -- your widget, rendered after the above -- "mymodel/record_detail/side_bar/sample_history.html", ] }
ui/mymodel/templates/semantic-ui/mymodel/record_detail/side_bar/sample_history.html
<section id="sample-history" class="ui segment"> <h2 class="ui small header">{{ _("Sample history") }}</h2> <p>Replaced by your resource component: {{ extra_context.sample_history | length }} events.</p> </section>

OAREPO_UI_SIDEBAR_TEMPLATES is read by the oarepo-ui fallback sidebar template (oarepo_ui/record_detail/side_bar/record_sidebar.html). When the config key exists for your model_name, the whole built-in Invenio sidebar list is replaced by the list you supply — so include the defaults you still want.

Changing it for all models

APP_RDM_DETAIL_SIDE_BAR_TEMPLATES sets the same widget list globally, and is used when OAREPO_UI_SIDEBAR_TEMPLATES has no entry for your model. Its default, from invenio-app-rdm, is the 12-item list shown above. Override it for a change across all models; prefer OAREPO_UI_SIDEBAR_TEMPLATES for a single model.

Don’t confuse the per-widget template list with the record_sidebar.html partial block — creating a record_sidebar.html partial (cheat sheet) replaces the entire sidebar HTML, which is rarely what you want. Use the config approach first.

Block Reference: main.html

These blocks exist in oarepo_ui/record_detail/main.html and can be overridden in your model’s main.html. They are the same blocks the generated main.html comment pre-lists for you.

BlockWhat it contains by default
record_bodyEntire <article> wrapper (header → title → content → files → media files → details → footer)
record_headerWraps record_header_button + record_mobile_management + record_header_info
record_header_button”Back to edit” nav shown in preview mode for users who can curate the draft
record_mobile_managementMobile-only “Manage record” popup
record_header_infoPublication date, version, resource-type label, access-status label
record_title<h1> title + creators/contributors list
record_contentThe description / abstract section (includes RDM description.html)
record_filesFile preview box, file list, or the “Request access” accordion when files are restricted
record_files_access_requestAccess-request form rendered inside record_files for restricted files
record_media_filesSystem/media files listing
additional_record_detailsRDM “Additional details” inclusion (details.html)
record_footerEmpty by default — a hook for anything below the details section

Blocks available in banners.html: banner_community_header, banner_preview_header, banner_version_header.

Blocks at the outer page_body level (only available if your model’s top template extends the RDM detail page — i.e. most models): record_sidebar, jump, page_body itself.

Use {{ super() }} inside an override to keep the parent block’s output, then add your markup before/after it. Omit super() to hide the block. More on template inheritance: Templating: Jinja.

Using Resource Components

A resource component’s before_ui_detail hook lets you inject arbitrary data into the template context — for the sidebar widget’s extra_context.sample_history above, and for anything you’d otherwise have to fetch in the template. The correct hook signature takes the record as both the raw api_record: RecordItem and its serialized record dict:

ui/mymodel/components.py
from oarepo_ui.resources.components import UIResourceComponent class MymodelDetailComponent(UIResourceComponent): def before_ui_detail( self, *, api_record, record, identity, ui_links, extra_context, render_kwargs, **kwargs, ): # `record` is the serialized UI dict; `api_record` is the RecordItem extra_context["sample_history"] = self._load_history(api_record.id) # You can also add brand-new top-level template variables: render_kwargs["hero_image_url"] = "/static/images/sample-hero.png" def _load_history(self, record_id): return [] # fetch from your service layer

Register it in the resource config:

ui/mymodel/__init__.py
from oarepo_ui.resources.records.config import RecordsUIResourceConfig from .components import MymodelDetailComponent class MymodelUIResourceConfig(RecordsUIResourceConfig): blueprint_name = "mymodel" url_prefix = "/mymodel" components = [MymodelDetailComponent]

In the template you can read either extra_context.sample_history (note: extra_context keys are not unpacked into the top-level context — they stay in the extra_context dict variable) or — if you put it in render_kwargs — as a top-level variable like {{ hero_image_url }}.

The historical before_detail(self, resource, request, extra_context, **kwargs) hook does not exist in current oarepo-ui. record is also not pre-populated into extra_context — grab it from the record / api_record hook arguments.

CSS, JavaScript, and Meta Partials

Three of the six partials exist purely for static / <head> customization.

css.html

Auto-included inside {% block css %}. Drop raw <style> rules here — simpler than a webpack entry for one-off styling:

ui/mymodel/templates/semantic-ui/mymodel/record_detail/css.html
<style> #record-title { color: #2185d0; } #sample-metadata dt { font-weight: bold; } </style>

javascript.html

Auto-included at the end of the <body> block. Because of Content-Security-Policy, inline <script> blocks are blocked by default — register a webpack entry and pull it in by name:

ui/mymodel/templates/semantic-ui/mymodel/record_detail/javascript.html
{{ webpack["mymodel_landing_custom.js"] }}

Register the entry in your webpack.py (see Webpack configuration):

ui/mymodel/webpack.py
from invenio_assets.webpack import WebpackThemeBundle theme = WebpackThemeBundle( __name__, "assets", default="semantic-ui", themes={ "semantic-ui": { "entry": { "mymodel_landing_custom": "./js/mymodel/landing_custom.js", }, }, }, )

head_meta.html

Auto-included within {% block head_meta %} after the default <meta> tags. Use it for SEO / citation metadata:

ui/mymodel/templates/semantic-ui/mymodel/record_detail/head_meta.html
<meta property="og:type" content="article"> <meta name="citation_sample_id" content="{{ metadata.sample_id }}">

Replacing the Whole Page

Reserve this for cases where none of the block overrides are enough — when you need a totally different page layout. This mirrors the deposit form’s “Optional: Custom Page Templates”.

Create your top-level model template

Start from the generated record_detail.html (preserving its variable flow) and add the layout you need. The generated file for non-empty base models is essentially a no-op:

ui/mymodel/templates/semantic-ui/mymodel/record_detail.html
{% extends "oarepo_ui/record_detail.html" %} {%- block record_body -%} <div class="my-split-layout"> <aside class="my-left-panel"><!-- custom panel --></aside> <div class="my-right-panel">{{ super() }}</div> </div> {%- endblock record_body -%}

Notice the block being overridden is record_body — the oarepo-ui middle-template’s {% block record_body %} is what pulls in main.html. Overriding it here bypasses main.html entirely.

Optionally point the route at a custom JinjaX component

If you build your own page component (a JinjaX .jinja file with a {# def ... #} block declaring the context variables it accepts), register it in the resource config:

ui/mymodel/__init__.py
class MymodelUIResourceConfig(RecordsUIResourceConfig): templates = { "record_detail": "mymodel.RecordDetail", # resolves to mymodel/RecordDetail.jinja }
ui/mymodel/templates/semantic-ui/mymodel/RecordDetail.jinja
{# def record, record_ui, files, media_files, permissions, is_preview, is_draft, model_name, ui_links, extra_context, d #} {% extends model_name ~ "/record_detail.html" %}

Keep the default variable contract

Whatever component or template you register must accept the same context variables the default oarepo_ui.pages.RecordDetail declares — otherwise the view raises a missing-kwarg error. The authoritative default is at oarepo_ui/templates/oarepo_ui/pages/RecordDetail.jinja.

You almost never need this. The six auto-included partials plus before_ui_detail cover banner/title/files/sidebar/custom-section / arbitrary-data needs. Reach for a custom page component only when you need to re-shape <html>-level layout or introduce new top-level CSS grids.

Error Pages

The 404 (not_found) and 410 (tombstone, deleted record) pages work the same way as the record detail page: the default page components {% extends model_name ~ "/not_found.html" %} (and .../tombstone.html), so your model’s own template is already in the chain — just edit it, no templates = {} registration needed.

nrp-model-copier generates both files at ui/mymodel/templates/semantic-ui/mymodel/, each extending its oarepo-ui base (oarepo_ui/not_found.html / oarepo_ui/tombstone.html).

Not-Found Page (404)

Override blocks of the base template in the generated not_found.html:

ui/mymodel/templates/semantic-ui/mymodel/not_found.html
{% extends "oarepo_ui/not_found.html" %} {%- block page_body -%} <div class="ui container centered"> <h1><i class="search icon"></i> {{ _("Sample not found") }}</h1> {% if pid %} <p>{{ _("No record with identifier") }} <code>{{ pid }}</code>.</p> {% endif %} <p><a href="{{ url_for('mymodel.search') }}">{{ _("Back to search") }}</a></p> </div> {%- endblock page_body -%}

Available blocks (see oarepo_ui/not_found.html): page_body, pid_info.

Tombstone Page (410)

A tombstone page shows for deleted records whose metadata is kept for archival. Override in the generated tombstone.html:

ui/mymodel/templates/semantic-ui/mymodel/tombstone.html
{% extends "oarepo_ui/tombstone.html" %} {%- block tombstone_content -%} {{ super() }} <div class="ui warning message"> {{ _("If you have cited this record, please update your references.") }} </div> {%- endblock tombstone_content -%}

Available blocks (see oarepo_ui/tombstone.html): page_body, tombstone_content.

Template Context

VariablePagesContents
pidbothThe requested PID
model_namebothYour model name
tombstonetombstone onlyDict with Removal reason, Note, Citation text, URL

The tombstone dict is assembled by the error handler from the record’s tombstone metadata — its keys are display labels, not identifiers.

How It Works Under the Hood

Request flow

Template stack

  • mymodel/record_detail.html — your top model template. Generated by nrp-model-copier; pass-through for RDM presets. If your base model is empty, the copier generates a full template extending config.BASE_TEMPLATE directly, keeping the same 6 partial auto-includes.
  • oarepo_ui/record_detail.html — oarepo-ui middle layer. Responsible for: including the 6 partials (with ignore missing or list-fallback), adding the JSON-LD schema.org <script>, and pulling in the landing-page webpack chunks.
  • invenio_app_rdm/records/detail.html — Invenio RDM base. Provides the outer {% block page_body %}, the top-level <article> layout grid, record-access status flash, record JSON-LD block, and the jump (scroll-to-top) block.

Where the context comes from

The RecordsUIResource._detail() method builds a render_kwargs dict with: record, record_ui, files, media_files, user_communities_memberships, is_preview, include_deleted, embedded, is_draft, community, community_ui, user_avatar, model, model_name, record_owner_id, context, ui_links, extra_context, and d — the FieldData wrapper for easy template-side metadata access. It then calls run_components("before_ui_detail", …), which is where your component hooks mutate extra_context and render_kwargs. Finally it renders the JinjaX macro from get_jinjax_macro("record_detail"), which resolves to either the default oarepo_ui.pages.RecordDetail or whatever you put into templates["record_detail"].

permissions is injected by the built-in PermissionsComponent (registered by default): it looks at the record and current identity, fills extra_context["permissions"] with can_edit/can_update/can_manage/can_read_files/… and copies the dict into render_kwargs["permissions"], so it’s a top-level variable inside your templates.

Last updated on