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
templatesmap, no webpack needed for plain HTML/CSS. dcarries value + label + help together —d.metadata.sample_idis aFieldDataobject bundling the raw API value, the UI-serialized value, and the schema metadata (label,help,hint) from your model YAML. Useui_value(d.metadata.sample_id)to render the formatted value andd.metadata.sample_id.label(...)for its localized label — see Pulling labels, help, and values from the model. For a plain value dump there’s alsometadata(shorthand forrecord_ui["metadata"]).- Blocks hide or replace —
main.htmlextends 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 withoutsuper()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/.
| Partial | What it renders / how to use it | Common task |
|---|---|---|
main.html | The 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.html | Top-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.html | Inline <style> inside the page <head>. Auto-included. | Tweak colors, font sizes |
javascript.html | Inline/content JS at the end of <body>. Register a webpack entry instead of inline <script> (CSP). | Interactive widgets |
head_meta.html | OpenGraph / citation meta tags. Auto-included. | Embed Dublin Core / JSON-LD |
record_sidebar.html | Right-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:
{% 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:
{% 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):
| Call | Returns |
|---|---|
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_value | UI-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:
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",
]
}<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.
| Block | What it contains by default |
|---|---|
record_body | Entire <article> wrapper (header → title → content → files → media files → details → footer) |
record_header | Wraps 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_management | Mobile-only “Manage record” popup |
record_header_info | Publication date, version, resource-type label, access-status label |
record_title | <h1> title + creators/contributors list |
record_content | The description / abstract section (includes RDM description.html) |
record_files | File preview box, file list, or the “Request access” accordion when files are restricted |
record_files_access_request | Access-request form rendered inside record_files for restricted files |
record_media_files | System/media files listing |
additional_record_details | RDM “Additional details” inclusion (details.html) |
record_footer | Empty 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:
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 layerRegister it in the resource config:
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:
<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:
{{ webpack["mymodel_landing_custom.js"] }}Register the entry in your webpack.py (see Webpack configuration):
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:
<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:
{% 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:
class MymodelUIResourceConfig(RecordsUIResourceConfig):
templates = {
"record_detail": "mymodel.RecordDetail", # resolves to 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:
{% 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:
{% 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
| Variable | Pages | Contents |
|---|---|---|
pid | both | The requested PID |
model_name | both | Your model name |
tombstone | tombstone only | Dict 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 bynrp-model-copier; pass-through for RDM presets. If your base model isempty, the copier generates a full template extendingconfig.BASE_TEMPLATEdirectly, keeping the same 6 partial auto-includes.oarepo_ui/record_detail.html— oarepo-ui middle layer. Responsible for: including the 6 partials (withignore missingor 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 thejump(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.