Skip to content

Odoo Website Pages with Custom Controllers: Complete Guide

DeployMonkey Team · March 23, 2026 11 min read

Website Controller Basics

Odoo website pages combine HTTP controllers with QWeb templates to render server-side HTML. Unlike backend views, website controllers produce public-facing pages with SEO metadata, responsive design, and optional authentication.

from odoo import http
from odoo.http import request

class WebsiteController(http.Controller):

    @http.route('/my-page', type='http', auth='public', website=True)
    def my_page(self, **kwargs):
        values = {
            'products': request.env['product.template'].sudo().search(
                [('website_published', '=', True)], limit=12
            ),
        }
        return request.render('my_module.my_page_template', values)

Key attributes:

  • type='http': Returns HTML (not JSON)
  • auth='public': Accessible without login
  • website=True: Enables website layout, SEO, and multi-website support

Route Patterns

# Static route
@http.route('/about', type='http', auth='public', website=True)

# Dynamic route with parameter
@http.route('/product/<int:product_id>', type='http', auth='public', website=True)
def product_page(self, product_id, **kwargs):
    product = request.env['product.template'].sudo().browse(product_id)
    if not product.exists():
        raise request.not_found()
    return request.render('my_module.product_page', {'product': product})

# Slug-based route (SEO-friendly)
@http.route('/blog/<string:slug>', type='http', auth='public', website=True, sitemap=True)
def blog_post(self, slug, **kwargs):
    post = request.env['blog.post'].sudo().search([('slug', '=', slug)], limit=1)
    if not post:
        raise request.not_found()
    return request.render('my_module.blog_post_page', {'post': post})

# Multiple routes on one method
@http.route(['/pricing', '/plans'], type='http', auth='public', website=True)
def pricing(self, **kwargs):
    ...

QWeb Template for Website Pages

<template id="my_page_template" name="My Page">
    <t t-call="website.layout">
        <t t-set="pageName" t-value="'my-page'"/>
        <t t-set="additional_title">My Page Title</t>

        <div id="wrap" class="oe_structure">
            <div class="container py-5">
                <h1>My Custom Page</h1>

                <div class="row">
                    <t t-foreach="products" t-as="product">
                        <div class="col-md-4 mb-4">
                            <div class="card">
                                <div class="card-body">
                                    <h5 t-field="product.name"/>
                                    <p t-field="product.description_sale"/>
                                    <span class="h4" t-field="product.list_price"
                                          t-options="{'widget': 'monetary', 'display_currency': product.currency_id}"/>
                                </div>
                            </div>
                        </div>
                    </t>
                </div>
            </div>
        </div>
    </t>
</template>

SEO Metadata

Set page title, description, and OpenGraph tags:

<template id="product_page" name="Product Page">
    <t t-call="website.layout">
        <t t-set="additional_title" t-value="product.name"/>
        <t t-set="meta_description" t-value="product.description_sale"/>
        <t t-set="meta_og_title" t-value="product.name"/>
        <t t-set="meta_og_description" t-value="product.description_sale"/>
        <t t-set="meta_og_image" t-value="product.image_url"/>

        <div id="wrap">
            <!-- page content -->
        </div>
    </t>
</template>

Form Handling

Handle form submissions with a POST controller:

@http.route('/contact-submit', type='http', auth='public',
            website=True, methods=['POST'], csrf=True)
def contact_submit(self, **kwargs):
    name = kwargs.get('name', '').strip()
    email = kwargs.get('email', '').strip()
    message = kwargs.get('message', '').strip()

    if not all([name, email, message]):
        return request.redirect('/contact?error=missing_fields')

    request.env['mail.mail'].sudo().create({
        'subject': f'Contact form: {name}',
        'email_from': email,
        'email_to': '[email protected]',
        'body_html': f'<p>From: {name} ({email})</p><p>{message}</p>',
    })

    return request.redirect('/contact?success=1')

The template form:

<form action="/contact-submit" method="POST" class="s_website_form">
    <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
    <input type="text" name="name" class="form-control" placeholder="Name" required=""/>
    <input type="email" name="email" class="form-control" placeholder="Email" required=""/>
    <textarea name="message" class="form-control" placeholder="Message" required=""></textarea>
    <button type="submit" class="btn btn-primary">Send</button>
</form>

Portal Pages (Authenticated)

For logged-in user pages, use auth='user' and extend the portal layout:

@http.route('/my/documents', type='http', auth='user', website=True)
def my_documents(self, **kwargs):
    partner = request.env.user.partner_id
    documents = request.env['ir.attachment'].sudo().search([
        ('res_partner_id', '=', partner.id)
    ])
    return request.render('my_module.portal_documents', {
        'documents': documents,
        'page_name': 'documents',
    })

Sitemap Integration

@http.route('/services', type='http', auth='public', website=True, sitemap=True)
def services_page(self, **kwargs):
    return request.render('my_module.services_page')

# Dynamic sitemap entries
@http.route('/blog/<string:slug>', type='http', auth='public', website=True)
def blog_post(self, slug, **kwargs):
    ...

# Override sitemap generation for dynamic routes
def sitemap_blog(env, rule, qs):
    posts = env['blog.post'].search([('website_published', '=', True)])
    for post in posts:
        yield {'loc': f'/blog/{post.slug}'}

Caching Website Pages

@http.route('/pricing', type='http', auth='public', website=True, cache=300)
def pricing(self, **kwargs):
    # Response cached for 300 seconds
    return request.render('my_module.pricing_page', {...})

Best Practices

  • Always use website=True for public pages — it enables the website layout, editor, and multi-website support
  • Use sudo() for reading public data in auth='public' controllers — the public user has minimal permissions
  • Include CSRF tokens in all POST forms
  • Use request.not_found() for missing records — returns proper 404
  • Set SEO metadata on every page template
  • Use sitemap=True for pages that should appear in the XML sitemap
  • Cache static or semi-static pages for performance