diff --git a/app/helpers/better_together/application_helper.rb b/app/helpers/better_together/application_helper.rb
index 7470d9d88..d7d563334 100644
--- a/app/helpers/better_together/application_helper.rb
+++ b/app/helpers/better_together/application_helper.rb
@@ -160,6 +160,17 @@ def open_graph_meta_tags # rubocop:todo Metrics/AbcSize, Metrics/MethodLength, M
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity
+ # Generates `` tags for
+ # each locale supported by the application. These tags help search engines
+ # understand language-specific versions of a page.
+ def hreflang_links
+ tags = I18n.available_locales.map do |locale|
+ tag.link(rel: 'alternate', hreflang: locale, href: url_for(locale:, only_path: false))
+ end
+
+ safe_join(tags, "\n")
+ 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..caab338ba 100644
--- a/app/views/layouts/better_together/application.html.erb
+++ b/app/views/layouts/better_together/application.html.erb
@@ -17,6 +17,8 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+ <%= tag.link rel: 'canonical', href: url_for(only_path: false) %>
+ <%= content_for?(:hreflang_links) ? yield(:hreflang_links) : hreflang_links %>
diff --git a/app/views/layouts/better_together/turbo_native.html.erb b/app/views/layouts/better_together/turbo_native.html.erb
index 68f193a01..8edaee98b 100644
--- a/app/views/layouts/better_together/turbo_native.html.erb
+++ b/app/views/layouts/better_together/turbo_native.html.erb
@@ -15,8 +15,10 @@
<%= robots_meta_tag %>
- <%= csrf_meta_tags %>
- <%= csp_meta_tag %>
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= tag.link rel: 'canonical', href: url_for(only_path: false) %>
+ <%= content_for?(:hreflang_links) ? yield(:hreflang_links) : hreflang_links %>
diff --git a/spec/helpers/better_together/application_helper_spec.rb b/spec/helpers/better_together/application_helper_spec.rb
index 735112991..30d0056e3 100644
--- a/spec/helpers/better_together/application_helper_spec.rb
+++ b/spec/helpers/better_together/application_helper_spec.rb
@@ -3,7 +3,22 @@
require 'rails_helper'
module BetterTogether
- RSpec.describe ApplicationHelper do
+ RSpec.describe ApplicationHelper, type: :helper do
+
+ describe '#hreflang_links' do
+ it 'returns alternate link tags for all locales' do
+ allow(I18n).to receive(:available_locales).and_return(%i[en fr])
+ allow(helper).to receive(:url_for) do |options|
+ "http://example.com/#{options[:locale]}"
+ end
+
+ html = helper.hreflang_links
+
+ expect(html).to include('rel="alternate" hreflang="en" href="http://example.com/en"')
+ expect(html).to include('rel="alternate" hreflang="fr" href="http://example.com/fr"')
+ end
+ end
+
describe '#robots_meta_tag' do
it 'renders default robots meta tag' do # rubocop:todo RSpec/MultipleExpectations
tag = helper.robots_meta_tag
diff --git a/spec/views/layouts/better_together/application.html.erb_spec.rb b/spec/views/layouts/better_together/application.html.erb_spec.rb
new file mode 100644
index 000000000..86e0303aa
--- /dev/null
+++ b/spec/views/layouts/better_together/application.html.erb_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'layouts/better_together/application', type: :view do
+ before do
+ view.extend BetterTogether::ApplicationHelper
+
+ allow(view).to receive(:host_platform).and_return(double(name: 'Platform', cache_key_with_version: '1', css_block: nil))
+ allow(view).to receive(:open_graph_meta_tags).and_return('')
+ allow(view).to receive(:seo_meta_tags).and_return('')
+ allow(view).to receive(:metrics_body_tag).and_yield
+ allow(view).to receive(:javascript_importmap_tags).and_return('')
+ allow(view).to receive(:stylesheet_link_tag).and_return('')
+ allow(view).to receive(:csrf_meta_tags).and_return('')
+ allow(view).to receive(:csp_meta_tag).and_return('')
+ allow(view).to receive(:javascript_i18n).and_return({ translations: {} })
+ allow(view).to receive(:render).and_call_original
+ allow(view).to receive(:render).with('layouts/better_together/custom_head_javascript').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/custom_stylesheets').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/header').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/flash_messages').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/extra_page_content_bottom').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/footer').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/mobile_bar').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/custom_body_javascript').and_return('')
+ allow(view).to receive(:hreflang_links).and_return('')
+ allow(view).to receive(:url_for).with(only_path: false).and_return('http://test.host/current')
+ end
+
+ it 'renders canonical and hreflang links by default' do
+ render template: 'layouts/better_together/application'
+
+ expect(rendered).to match(%r{]+rel="canonical"[^>]+href="http://test.host/current"[^>]*>})
+ expect(rendered).to include('')
+ end
+
+ it 'renders custom hreflang links when content_for provided' do
+ view.content_for :hreflang_links, ''
+
+ render template: 'layouts/better_together/application'
+
+ expect(rendered).to include('')
+ expect(rendered).not_to include('')
+ end
+end
+
diff --git a/spec/views/layouts/better_together/turbo_native.html.erb_spec.rb b/spec/views/layouts/better_together/turbo_native.html.erb_spec.rb
new file mode 100644
index 000000000..f36d7f6bb
--- /dev/null
+++ b/spec/views/layouts/better_together/turbo_native.html.erb_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'layouts/better_together/turbo_native', type: :view do
+ before do
+ view.extend BetterTogether::ApplicationHelper
+
+ allow(view).to receive(:host_platform).and_return(double(name: 'Platform', cache_key_with_version: '1', css_block: nil))
+ allow(view).to receive(:open_graph_meta_tags).and_return('')
+ allow(view).to receive(:seo_meta_tags).and_return('')
+ allow(view).to receive(:metrics_body_tag).and_yield
+ allow(view).to receive(:javascript_importmap_tags).and_return('')
+ allow(view).to receive(:stylesheet_link_tag).and_return('')
+ allow(view).to receive(:csrf_meta_tags).and_return('')
+ allow(view).to receive(:csp_meta_tag).and_return('')
+ allow(view).to receive(:javascript_i18n).and_return({ translations: {} })
+ allow(view).to receive(:render).and_call_original
+ allow(view).to receive(:render).with('layouts/better_together/custom_head_javascript').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/custom_stylesheets').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/header').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/flash_messages').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/extra_page_content_bottom').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/footer').and_return('')
+ allow(view).to receive(:render).with('layouts/better_together/custom_body_javascript').and_return('')
+ allow(view).to receive(:hreflang_links).and_return('')
+ allow(view).to receive(:url_for).with(only_path: false).and_return('http://test.host/current')
+ end
+
+ it 'renders canonical and hreflang links by default' do
+ render template: 'layouts/better_together/turbo_native'
+
+ expect(rendered).to match(%r{]+rel="canonical"[^>]+href="http://test.host/current"[^>]*>})
+ expect(rendered).to include('')
+ end
+
+ it 'renders custom hreflang links when content_for provided' do
+ view.content_for :hreflang_links, ''
+
+ render template: 'layouts/better_together/turbo_native'
+
+ expect(rendered).to include('')
+ expect(rendered).not_to include('')
+ end
+end
+