Create Custom Web Form in Odoo 18
By Braincuber Team
Published on January 28, 2026
Website forms in Odoo work differently from backend forms. There's no automatic ORM binding, no field widgets, no save button that magically persists data. You're building raw HTML forms, handling POST requests in controllers, and manually creating records. But this gives you complete control over the look, feel, and behavior—something backend views can't offer.
This tutorial walks through building a complete Job Application form for your company's career page. Visitors fill out the form on your website, upload their resume, and the data creates a record in your custom model—ready for HR to review in the backend. We'll cover models, menus, controllers, QWeb templates, file uploads, and success pages.
What You'll Build: A Job Application web form with text inputs, select dropdowns, file upload for resume/CV, form validation, CSRF protection, and a thank-you redirect. Data saves to a custom model viewable in the Odoo backend.
How Web Forms Work in Odoo
Controller Handles Requests
One route renders the form page (GET). Another handles form submission (POST). Both use website=True for website layout.
CSRF Protection Required
Include csrf_token hidden field in forms. Odoo validates this token to prevent cross-site request forgery attacks.
sudo() for Public Access
Public users have no access rights. Use sudo() to bypass permissions when creating records from website forms.
Module Structure
Implementation Steps
Create the Data Model
Define the model that stores job application data submitted through the web form.
from odoo import models, fields, api
class JobApplication(models.Model):
_name = 'hr.job.application.web'
_description = 'Web Job Application'
_order = 'create_date desc'
name = fields.Char(string='Applicant Name', required=True)
email = fields.Char(string='Email Address', required=True)
phone = fields.Char(string='Phone Number')
position_id = fields.Many2one('hr.job', string='Applied Position')
experience_years = fields.Selection([
('0-1', '0-1 Years'),
('1-3', '1-3 Years'),
('3-5', '3-5 Years'),
('5-10', '5-10 Years'),
('10+', '10+ Years'),
], string='Experience')
expected_salary = fields.Float(string='Expected Salary')
resume = fields.Binary(string='Resume/CV', attachment=True)
resume_filename = fields.Char(string='Resume Filename')
cover_letter = fields.Text(string='Cover Letter')
linkedin_url = fields.Char(string='LinkedIn Profile')
availability_date = fields.Date(string='Available From')
state = fields.Selection([
('new', 'New'),
('reviewing', 'Under Review'),
('interview', 'Interview Scheduled'),
('offer', 'Offer Made'),
('hired', 'Hired'),
('rejected', 'Rejected'),
], string='Status', default='new')
@api.model_create_multi
def create(self, vals_list):
"""Send notification on new application."""
records = super().create(vals_list)
for record in records:
# Optionally send email notification to HR
pass
return records
Add Website Menu Item
Create navigation link under the main website menu.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Website Menu Item -->
<record id="website_careers_menu" model="website.menu">
<field name="name">Careers</field>
<field name="url">/careers/apply</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">80</field>
</record>
</odoo>
Dependency: Your module must include 'website' in the depends list of __manifest__.py for website.menu records to work.
Build the Controller
Create routes for displaying the form and handling submissions.
import base64
from odoo import http
from odoo.http import request
class JobApplicationController(http.Controller):
@http.route('/careers/apply', type='http', auth='public', website=True)
def career_application_form(self):
"""Render the job application form."""
# Fetch open job positions for dropdown
positions = request.env['hr.job'].sudo().search([
('website_published', '=', True)
])
return request.render(
'job_application_form.application_form_template',
{'positions': positions}
)
@http.route('/careers/apply/submit', type='http', auth='public',
website=True, methods=['POST'], csrf=True)
def submit_application(self, **post):
"""Handle form submission and create record."""
# Process file upload
resume_data = False
resume_filename = False
resume_file = post.get('resume')
if resume_file:
resume_data = base64.b64encode(resume_file.read())
resume_filename = resume_file.filename
# Prepare values for record creation
vals = {
'name': post.get('name'),
'email': post.get('email'),
'phone': post.get('phone') or False,
'position_id': int(post.get('position_id')) if post.get('position_id') else False,
'experience_years': post.get('experience_years') or False,
'expected_salary': float(post.get('expected_salary') or 0),
'resume': resume_data,
'resume_filename': resume_filename,
'cover_letter': post.get('cover_letter') or False,
'linkedin_url': post.get('linkedin_url') or False,
'availability_date': post.get('availability_date') or False,
}
# Create the application record
request.env['hr.job.application.web'].sudo().create(vals)
# Redirect to thank you page
return request.redirect('/careers/thank-you')
@http.route('/careers/thank-you', type='http', auth='public', website=True)
def thank_you_page(self):
"""Display confirmation after successful submission."""
return request.render('job_application_form.thank_you_template')
File Upload: For binary fields, read the file with file.read() and encode with base64.b64encode(). Store the filename separately for download functionality.
Design the Form Template
Create the QWeb template with Bootstrap styling and form fields.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Application Form Template -->
<template id="application_form_template">
<t t-call="website.layout">
<div id="wrap" class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-lg border-0">
<div class="card-header bg-primary text-white py-4">
<h2 class="mb-0">
<i class="fa fa-briefcase me-2"/>
Join Our Team
</h2>
<p class="mb-0 mt-2 opacity-75">
Submit your application and take the next step in your career
</p>
</div>
<div class="card-body p-4">
<form action="/careers/apply/submit" method="POST"
enctype="multipart/form-data">
<!-- CSRF Token -->
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<!-- Personal Information -->
<h5 class="border-bottom pb-2 mb-4">
<i class="fa fa-user me-2 text-primary"/>
Personal Information
</h5>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">
Full Name <span class="text-danger">*</span>
</label>
<input type="text" name="name" class="form-control"
required="required" placeholder="John Smith"/>
</div>
<div class="col-md-6">
<label class="form-label">
Email Address <span class="text-danger">*</span>
</label>
<input type="email" name="email" class="form-control"
required="required" placeholder="john@example.com"/>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" class="form-control"
placeholder="+1 555-123-4567"/>
</div>
<div class="col-md-6">
<label class="form-label">LinkedIn Profile</label>
<input type="url" name="linkedin_url" class="form-control"
placeholder="https://linkedin.com/in/..."/>
</div>
</div>
<!-- Position Details -->
<h5 class="border-bottom pb-2 mb-4">
<i class="fa fa-briefcase me-2 text-primary"/>
Position Details
</h5>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Position Applying For</label>
<select name="position_id" class="form-select">
<option value="">Select position...</option>
<t t-foreach="positions" t-as="pos">
<option t-att-value="pos.id">
<t t-esc="pos.name"/>
</option>
</t>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Years of Experience</label>
<select name="experience_years" class="form-select">
<option value="">Select experience...</option>
<option value="0-1">0-1 Years</option>
<option value="1-3">1-3 Years</option>
<option value="3-5">3-5 Years</option>
<option value="5-10">5-10 Years</option>
<option value="10+">10+ Years</option>
</select>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label">Expected Salary</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="expected_salary"
class="form-control" placeholder="75000"/>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Available From</label>
<input type="date" name="availability_date"
class="form-control"/>
</div>
</div>
<!-- Documents -->
<h5 class="border-bottom pb-2 mb-4">
<i class="fa fa-file-text me-2 text-primary"/>
Documents
</h5>
<div class="mb-3">
<label class="form-label">
Resume/CV <span class="text-danger">*</span>
</label>
<input type="file" name="resume" class="form-control"
accept=".pdf,.doc,.docx" required="required"/>
<small class="text-muted">
Accepted formats: PDF, DOC, DOCX (Max 5MB)
</small>
</div>
<div class="mb-4">
<label class="form-label">Cover Letter</label>
<textarea name="cover_letter" class="form-control"
rows="5"
placeholder="Tell us why you're the perfect fit..."></textarea>
</div>
<!-- Submit -->
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa fa-paper-plane me-2"/>
Submit Application
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- Thank You Page Template -->
<template id="thank_you_template">
<t t-call="website.layout">
<div id="wrap" class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center">
<div class="card shadow border-0 p-5">
<div class="mb-4">
<i class="fa fa-check-circle text-success"
style="font-size: 80px;"/>
</div>
<h2 class="mb-3">Application Received!</h2>
<p class="text-muted mb-4">
Thank you for your interest in joining our team.
We've received your application and will review it shortly.
Our HR team will contact you within 5-7 business days.
</p>
<a href="/" class="btn btn-primary">
<i class="fa fa-home me-2"/>Return to Homepage
</a>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>
Add Backend View for HR
Create tree and form views so HR can review applications in the backend.
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Tree View -->
<record id="job_application_web_tree" model="ir.ui.view">
<field name="name">hr.job.application.web.tree</field>
<field name="model">hr.job.application.web</field>
<field name="arch" type="xml">
<tree>
<field name="create_date"/>
<field name="name"/>
<field name="email"/>
<field name="position_id"/>
<field name="experience_years"/>
<field name="state" widget="badge"
decoration-info="state == 'new'"
decoration-warning="state == 'reviewing'"
decoration-success="state == 'hired'"
decoration-danger="state == 'rejected'"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="job_application_web_form" model="ir.ui.view">
<field name="name">hr.job.application.web.form</field>
<field name="model">hr.job.application.web</field>
<field name="arch" type="xml">
<form>
<header>
<field name="state" widget="statusbar"
statusbar_visible="new,reviewing,interview,offer,hired"/>
</header>
<sheet>
<group>
<group string="Personal Information">
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<field name="linkedin_url" widget="url"/>
</group>
<group string="Position Details">
<field name="position_id"/>
<field name="experience_years"/>
<field name="expected_salary"/>
<field name="availability_date"/>
</group>
</group>
<notebook>
<page string="Cover Letter">
<field name="cover_letter"/>
</page>
<page string="Resume">
<field name="resume" filename="resume_filename"/>
<field name="resume_filename" invisible="1"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_job_applications_web" model="ir.actions.act_window">
<field name="name">Web Applications</field>
<field name="res_model">hr.job.application.web</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menu -->
<menuitem id="menu_job_applications_web"
name="Web Applications"
parent="hr_recruitment.menu_hr_recruitment_root"
action="action_job_applications_web"
sequence="30"/>
</odoo>
Configure Access Rights
Set up security for the new model.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_job_application_web_hr,hr.job.application.web.hr,model_hr_job_application_web,hr_recruitment.group_hr_recruitment_user,1,1,1,0 access_job_application_web_manager,hr.job.application.web.manager,model_hr_job_application_web,hr_recruitment.group_hr_recruitment_manager,1,1,1,1
Conclusion
Custom web forms give you full control over the user experience—design, validation, workflow—while still storing data in Odoo models for backend processing. The pattern is straightforward: define a model, create a controller with GET and POST routes, design QWeb templates, and handle file uploads with base64 encoding. Use this approach for contact forms, surveys, registrations, quote requests, or any data collection that needs a public-facing interface.
Key Takeaways: Include CSRF token in every form. Use website=True for website layout inheritance. Process files with base64.b64encode(). Use sudo() for public user record creation. Always redirect after POST to prevent resubmission.
