Ad – 728×90
🌐 Portal Development

Odoo Portal QWeb Templates – Building Portal Pages

Portal pages are QWeb templates that wrap their content inside portal.portal_layout. This layout adds the portal header, breadcrumb navigation, and sidebar. You define listing templates (showing many records) and detail templates (showing one record) in your module's views/ XML files.

⏱️ 20 min 🎯 Intermediate 📅 Updated 2026
What you'll learn:
  • How to use portal.portal_layout as a page wrapper
  • Listing page template with pager and sort controls
  • Detail page template with breadcrumbs and status badges
  • t-field for formatted output and share links with access tokens

Portal Template Structure

Portal templates live in your module under views/portal_templates.xml and are registered as ir.ui.view records with type="qweb". They are loaded by listing them in __manifest__.py under data.

Every portal page calls portal.portal_layout as its outer wrapper:

XML
<template id="portal_my_loans" name="My Loans">
  <t t-call="portal.portal_layout">
    <t t-set="breadcrumbs_searchbar" t-value="True"/>
    <!-- page content goes here -->
  </t>
</template>

Adding to the Portal Home

Inherit portal.portal_my_home to add a link and counter to the /my/ home page:

XML
<template id="portal_my_home_loan" name="Portal My Home: Loans"
          inherit_id="portal.portal_my_home" priority="40">
  <xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
    <t t-if="loan_count" t-call="portal.portal_docs_entry">
      <t t-set="title">Loans</t>
      <t t-set="url">/my/loans</t>
      <t t-set="count" t-value="loan_count"/>
    </t>
  </xpath>
</template>

The loan_count variable is set by your controller's _prepare_home_portal_values method.

Listing Page Template

A full listing template with sort controls and pager:

XML
<template id="portal_my_loans" name="My Loans">
  <t t-call="portal.portal_layout">
    <t t-set="breadcrumbs_searchbar" t-value="True"/>

    <t t-call="portal.portal_searchbar">
      <t t-set="title">My Loans</t>
      <t t-set="searchbar_sortings" t-value="searchbar_sortings"/>
      <t t-set="sortby" t-value="sortby"/>
    </t>

    <t t-if="not loans">
      <div class="alert alert-info">You have no active loans.</div>
    </t>

    <t t-if="loans">
      <div class="card">
        <div class="table-responsive">
          <table class="table table-sm o_portal_my_doc_table">
            <thead>
              <tr>
                <th>Book</th>
                <th>Loan Date</th>
                <th>Return Date</th>
                <th>Status</th>
              </tr>
            </thead>
            <tbody>
              <t t-foreach="loans" t-as="loan">
                <tr>
                  <td>
                    <a t-attf-href="/my/loans/#{loan.id}">
                      <t t-out="loan.book_id.name"/>
                    </a>
                  </td>
                  <td><t t-field="loan.loan_date"/></td>
                  <td><t t-field="loan.return_date"/></td>
                  <td>
                    <span t-attf-class="badge badge-#{
                      'success' if loan.state == 'returned' else
                      'danger' if loan.state == 'overdue' else 'info'
                    }">
                      <t t-out="loan.state"/>
                    </span>
                  </td>
                </tr>
              </t>
            </tbody>
          </table>
        </div>
      </div>
      <div t-if="pager" class="o_portal_pager text-center">
        <t t-call="portal.pager"/>
      </div>
    </t>
  </t>
</template>

Detail Page Template

The detail template shows one record and includes a share link using the access token:

XML
<template id="portal_loan_detail" name="Loan Detail">
  <t t-call="portal.portal_layout">
    <t t-set="o_portal_fullwidth_alert">
      <t t-call="portal.portal_back_in_edit_mode">
        <t t-set="backend_url"
           t-value="'/web#model=library.loan&id=%s&view_type=form' % loan.id"/>
      </t>
    </t>

    <div class="row">
      <div class="col-12 col-lg-8">
        <div class="card">
          <div class="card-header">
            <h4>Loan: <t t-field="loan.book_id.name"/></h4>
          </div>
          <div class="card-body">
            <dl class="row">
              <dt class="col-4">Member</dt>
              <dd class="col-8"><t t-field="loan.member_id.name"/></dd>
              <dt class="col-4">Loan Date</dt>
              <dd class="col-8"><t t-field="loan.loan_date"/></dd>
              <dt class="col-4">Return Date</dt>
              <dd class="col-8"><t t-field="loan.return_date"/></dd>
            </dl>
          </div>
        </div>
      </div>
    </div>
  </t>
</template>
Ad – 728×90

t-field vs t-out vs t-esc

DirectiveUse caseEscaping
t-fieldOdoo field — renders with proper formatting (dates, currencies, Many2one names)Yes, via widget
t-outAny Python expression — renders HTML as-isNo (raw HTML)
t-escAny Python expression — renders as escaped textYes

Prefer t-field for model fields — it handles date formatting, currency symbols, and related record names automatically.

Key takeaways:
  • All portal templates wrap content in t-call="portal.portal_layout"
  • Add your section to /my/ by inheriting portal.portal_my_home
  • Use t-call="portal.pager" for pagination
  • Use t-field for model fields — it formats dates, currencies, and relations correctly

Frequently Asked Questions

Where do portal templates go in the module?

In views/portal_templates.xml (or any XML file listed in __manifest__.py under data). They are ir.ui.view records with type="qweb" and no model attribute.

How do I add custom CSS/JS to portal pages?

Add your assets to the web.assets_frontend bundle in __manifest__.py under assets. Frontend assets are loaded on all public-facing pages including the portal.

Can I use Bootstrap classes in portal templates?

Yes. Bootstrap is included in Odoo's frontend assets, so all standard Bootstrap utility and component classes work in portal templates.