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 loginwebsite=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=Truefor public pages — it enables the website layout, editor, and multi-website support - Use
sudo()for reading public data inauth='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=Truefor pages that should appear in the XML sitemap - Cache static or semi-static pages for performance