diff --git a/app/helpers/better_together/application_helper.rb b/app/helpers/better_together/application_helper.rb
index 7470d9d88..5f8e2942b 100644
--- a/app/helpers/better_together/application_helper.rb
+++ b/app/helpers/better_together/application_helper.rb
@@ -160,6 +160,25 @@ def open_graph_meta_tags # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, M
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity
+ # Generates a canonical link tag for the current request.
+ # Defaults to request.original_url but can be overridden by setting
+ # `content_for(:canonical_url)` in views. When provided a relative path,
+ # the host and locale are ensured by prefixing with `base_url_with_locale`.
+ def canonical_link_tag
+ canonical_url = if content_for?(:canonical_url)
+ content_for(:canonical_url)
+ else
+ request.original_url
+ end
+
+ unless canonical_url.starts_with?('http://', 'https://')
+ path = canonical_url.sub(%r{^/#{I18n.locale}}, '')
+ canonical_url = "#{base_url_with_locale}#{path}"
+ end
+
+ tag.link(rel: 'canonical', href: canonical_url)
+ end
+
# Retrieves the setup wizard for hosts or raises an error if not found.
# This is crucial for initial setup processes and should be pre-configured.
def host_setup_wizard
diff --git a/app/views/layouts/better_together/application.html.erb b/app/views/layouts/better_together/application.html.erb
index 5a40356a7..91be40036 100644
--- a/app/views/layouts/better_together/application.html.erb
+++ b/app/views/layouts/better_together/application.html.erb
@@ -12,6 +12,7 @@
<%= (yield(:page_title) + ' | ') if content_for?(:page_title) %><%= host_platform.name %>
<%= open_graph_meta_tags %>
<%= seo_meta_tags %>
+ <%= canonical_link_tag %>
<%= robots_meta_tag %>
diff --git a/app/views/layouts/better_together/turbo_native.html.erb b/app/views/layouts/better_together/turbo_native.html.erb
index 68f193a01..d1f9c5ab7 100644
--- a/app/views/layouts/better_together/turbo_native.html.erb
+++ b/app/views/layouts/better_together/turbo_native.html.erb
@@ -12,6 +12,7 @@
<%= (yield(:page_title) + ' | ') if content_for?(:page_title) %><%= host_platform.name %>
<%= open_graph_meta_tags %>
<%= seo_meta_tags %>
+ <%= canonical_link_tag %>
<%= robots_meta_tag %>
diff --git a/spec/helpers/better_together/application_helper_spec.rb b/spec/helpers/better_together/application_helper_spec.rb
index 735112991..3abc666e8 100644
--- a/spec/helpers/better_together/application_helper_spec.rb
+++ b/spec/helpers/better_together/application_helper_spec.rb
@@ -3,7 +3,37 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ApplicationHelper do
+ RSpec.describe ApplicationHelper, type: :helper do
+ describe '#canonical_link_tag' do
+ before do
+ allow(helper).to receive(:base_url_with_locale).and_return('https://example.com/en')
+ end
+
+ context 'when no canonical_url is provided' do
+ it 'defaults to request.original_url' do
+ allow(helper.request).to receive(:original_url).and_return('https://example.com/en/posts')
+ result = helper.canonical_link_tag
+ expect(result).to include('href="https://example.com/en/posts"')
+ end
+ end
+
+ context 'when canonical_url is a relative path with locale' do
+ it 'prefixes base_url_with_locale and removes duplicate locale' do
+ helper.content_for(:canonical_url, '/en/custom')
+ result = helper.canonical_link_tag
+ expect(result).to include('href="https://example.com/en/custom"')
+ end
+ end
+
+ context 'when canonical_url is a full URL' do
+ it 'uses the provided URL' do
+ helper.content_for(:canonical_url, 'https://external.test/path')
+ result = helper.canonical_link_tag
+ expect(result).to include('href="https://external.test/path"')
+ end
+ end
+ end
+
describe '#robots_meta_tag' do
it 'renders default robots meta tag' do # rubocop:todo RSpec/MultipleExpectations
tag = helper.robots_meta_tag