diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 3040bccf..61febd2c 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --exclude-limit 10000`
-# on 2025-04-10 07:56:29 UTC using RuboCop version 1.75.2.
+# on 2025-07-25 08:41:37 UTC using RuboCop version 1.79.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@@ -44,7 +44,7 @@ Layout/ExtraSpacing:
- 'spec/inertia/request_spec.rb'
- 'spec/inertia/response_spec.rb'
-# Offense count: 2
+# Offense count: 16
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: IndentationWidth.
# SupportedStyles: special_inside_parentheses, consistent, align_braces
@@ -184,8 +184,8 @@ Style/BlockDelimiters:
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules.
# SupportedStyles: nested, compact
-# SupportedStylesForClasses: , nested, compact
-# SupportedStylesForModules: , nested, compact
+# SupportedStylesForClasses: ~, nested, compact
+# SupportedStylesForModules: ~, nested, compact
Style/ClassAndModuleChildren:
Exclude:
- 'lib/inertia_rails/helper.rb'
@@ -404,7 +404,7 @@ Style/SoleNestedConditional:
Exclude:
- 'lib/inertia_rails/controller.rb'
-# Offense count: 78
+# Offense count: 81
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
@@ -434,7 +434,7 @@ Style/StringLiterals:
- 'spec/inertia/rspec_helper_spec.rb'
- 'spec/rails_helper.rb'
-# Offense count: 2
+# Offense count: 3
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: .
# SupportedStyles: percent, brackets
@@ -466,9 +466,9 @@ Style/TrailingCommaInHashLiteral:
- 'spec/inertia/response_spec.rb'
- 'spec/inertia/rspec_helper_spec.rb'
-# Offense count: 19
+# Offense count: 32
# This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
+# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Max: 276
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 891579d5..ab5d373f 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -167,6 +167,15 @@ export default defineConfig({
{ text: 'Inertia Modal', link: '/cookbook/inertia-modal' },
],
},
+ {
+ text: 'Inertia-Rails-Only Features',
+ items: [
+ {
+ text: 'Server Managed Meta Tags',
+ link: '/cookbook/server-managed-meta-tags',
+ },
+ ],
+ },
{
text: 'Troubleshooting',
items: [
diff --git a/docs/cookbook/server-managed-meta-tags.md b/docs/cookbook/server-managed-meta-tags.md
new file mode 100644
index 00000000..9c01eda8
--- /dev/null
+++ b/docs/cookbook/server-managed-meta-tags.md
@@ -0,0 +1,361 @@
+# Server Managed Meta Tags
+
+Inertia Rails can manage a page's meta tags on the server instead of on the frontend. This means that link previews (such as on Facebook, LinkedIn, etc.) will include correct meta _without server-side rendering_.
+
+Inertia Rails renders server defined meta tags into both the server rendered HTML and the client-side Inertia page props. Because the tags share unique `head-key` attributes, the client will "take over" the meta tags after the initial page load.
+
+@available_since rails=3.10.0
+
+## Setup
+
+### Server Side
+
+Simply add the `inertia_meta_tags` helper to your layout. This will render the meta tags in the `
` section of your HTML.
+
+```erb
+
+
+
+
+
+ ...
+ <%= inertia_meta_tags %> // [!code ++]
+ My Inertia App // [!code --]
+
+
+```
+
+> [!NOTE]
+> Make sure to remove the `` tag in your Rails layout if you plan to manage it with Inertia Rails. Otherwise you will end up with duplicate `` tags.
+
+### Client Side
+
+Copy the following code into your application. It should be rendered **once** in your application, such as in a [layout component
+](/guide/pages#creating-layouts).
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+```
+
+== React
+
+```jsx
+import React from 'react'
+import { Head, usePage } from '@inertiajs/react'
+
+const MetaTags = () => {
+ const { _inertia_meta: meta } = usePage().props
+ return (
+
+ {meta.map((meta) => {
+ const { tagName, innerContent, headKey, httpEquiv, ...attrs } = meta
+
+ let stringifiedInnerContent
+ if (innerContent != null) {
+ stringifiedInnerContent =
+ typeof innerContent === 'string'
+ ? innerContent
+ : JSON.stringify(innerContent)
+ }
+
+ return React.createElement(tagName, {
+ key: headKey,
+ 'head-key': headKey,
+ ...(httpEquiv ? { 'http-equiv': httpEquiv } : {}),
+ ...attrs,
+ ...(stringifiedInnerContent
+ ? { dangerouslySetInnerHTML: { __html: stringifiedInnerContent } }
+ : {}),
+ })
+ })}
+
+ )
+}
+
+export default MetaTags
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+
+ {#if ready}
+
+ {#each voidTags as tag (tag.headKey)}
+
+ {/each}
+
+
+ {#each contentTags as tag (tag.headKey)}
+
+ {@html typeof tag.innerContent === 'string'
+ ? tag.innerContent
+ : JSON.stringify(tag.innerContent)}
+
+ {/each}
+ {/if}
+
+```
+
+:::
+
+## Rendering Meta Tags
+
+Tags are defined as plain hashes and conform to the following structure:
+
+```ruby
+# All fields are optional.
+{
+ # Defaults to "meta" if not provided
+ tag_name: "meta",
+
+ # Used for
+ http_equiv: "Content-Security-Policy",
+
+ # Used to deduplicate tags. InertiaRails will auto-generate one if not provided
+ head_key: "csp-header",
+
+ # Used with ')
end
end
end
diff --git a/spec/inertia/meta_tag_rendering_spec.rb b/spec/inertia/meta_tag_rendering_spec.rb
new file mode 100644
index 00000000..af03cbfc
--- /dev/null
+++ b/spec/inertia/meta_tag_rendering_spec.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+RSpec.describe 'rendering inertia meta tags', type: :request do
+ let(:headers) do
+ {
+ 'X-Inertia' => true,
+ 'X-Inertia-Partial-Component' => 'TestComponent',
+ }
+ end
+
+ it 'returns meta tag data' do
+ get basic_meta_path, headers: headers
+
+ expect(response.parsed_body['props']['_inertia_meta']).to match_array(
+ [
+ {
+ 'tagName' => 'meta',
+ 'name' => 'description',
+ 'content' => 'Inertia rules',
+ 'headKey' => 'first_head_key',
+ },
+ {
+ 'tagName' => 'title',
+ 'innerContent' => 'The Inertia title',
+ 'headKey' => 'title',
+ },
+ {
+ 'tagName' => 'meta',
+ 'httpEquiv' => 'content-security-policy',
+ 'content' => "default-src 'self';",
+ 'headKey' => 'third_head_key',
+ }
+ ]
+ )
+ end
+
+ context 'with multiple title tags' do
+ it 'only renders the last title tag' do
+ get multiple_title_tags_meta_path, headers: headers
+
+ expect(response.parsed_body['props']['_inertia_meta']).to eq(
+ [
+ {
+ 'tagName' => 'title',
+ 'innerContent' => 'The second Inertia title',
+ 'headKey' => 'title',
+ }
+ ]
+ )
+ end
+ end
+
+ context 'with a before filter setting meta tags' do
+ it 'returns the meta tag set from the before filter' do
+ get from_before_filter_meta_path, headers: headers
+
+ expect(response.parsed_body['props']['_inertia_meta']).to eq(
+ [
+ {
+ 'tagName' => 'meta',
+ 'name' => 'description',
+ 'content' => 'This is a description set from a before filter',
+ 'headKey' => 'before_filter_tag',
+ }
+ ]
+ )
+ end
+ end
+
+ context 'with duplicate head keys' do
+ it 'returns the last meta tag with the same head key' do
+ get with_duplicate_head_keys_meta_path, headers: headers
+
+ expect(response.parsed_body['props']['_inertia_meta']).to eq(
+ [
+ {
+ 'tagName' => 'meta',
+ 'name' => 'description2', # Contrived mismatch between meta tag names to ensure head_key deduplication works
+ 'content' => 'This is another description',
+ 'headKey' => 'duplicate_key',
+ }
+ ]
+ )
+ end
+ end
+
+ context 'with meta tags set from a module' do
+ it 'overrides the meta tag set from the module' do
+ get override_tags_from_module_meta_path, headers: headers
+
+ expect(response.parsed_body['props']['_inertia_meta']).to eq([
+ {
+ 'tagName' => 'meta',
+ 'name' => 'meta_tag_from_concern',
+ 'content' => 'This is overriden by the controller',
+ 'headKey' => 'meta_tag_from_concern',
+ }
+ ])
+ end
+ end
+
+ describe 'automatic deduplication without head_keys' do
+ # Don't care what the auto generated head keys are, just check the content
+ let(:meta_without_head_keys) do
+ response.parsed_body['props']['_inertia_meta'].map do |tag|
+ tag.reject { |properties| properties['headKey'] }
+ end
+ end
+
+ it 'dedups on :name, :property, :http_equiv, :charset, and :itemprop keys' do
+ get auto_dedup_meta_path, headers: headers
+
+ expect(meta_without_head_keys).to match_array([
+ {
+ 'tagName' => 'meta',
+ 'name' => 'description',
+ 'content' => 'Overridden description',
+ },
+ {
+ 'tagName' => 'meta',
+ 'property' => 'og:description',
+ 'content' => 'Overridden Open Graph description',
+ },
+ {
+ 'tagName' => 'meta',
+ 'httpEquiv' => 'content-security-policy',
+ 'content' => 'Overridden CSP',
+ },
+ {
+ 'tagName' => 'meta',
+ 'charset' => 'Overridden charset',
+ }
+ ])
+ end
+
+ it 'allows duplicates for specified meta tags' do
+ get allowed_duplicates_meta_path, headers: headers
+
+ expect(meta_without_head_keys).to match_array([
+ {
+ 'tagName' => 'meta',
+ 'property' => 'article:author',
+ 'content' => 'Cassian Andor',
+ },
+ {
+ 'tagName' => 'meta',
+ 'property' => 'article:author',
+ 'content' => 'Tony Gilroy',
+ }
+ ])
+ end
+ end
+
+ it 'can clear meta tags' do
+ get cleared_meta_path, headers: headers
+ expect(response.parsed_body['props']['_inertia_meta']).not_to be
+ end
+
+ context 'with default rendering' do
+ it 'returns meta tags with default rendering' do
+ get meta_with_default_render_path, headers: headers
+
+ expect(response.parsed_body['props']['some']).to eq('prop')
+ expect(response.parsed_body['props']['_inertia_meta']).to eq(
+ [
+ {
+ 'tagName' => 'meta',
+ 'name' => 'description',
+ 'content' => 'default rendering still works',
+ 'headKey' => 'meta-name-description',
+ }
+ ]
+ )
+ end
+ end
+end
diff --git a/spec/inertia/meta_tag_spec.rb b/spec/inertia/meta_tag_spec.rb
new file mode 100644
index 00000000..96b97eda
--- /dev/null
+++ b/spec/inertia/meta_tag_spec.rb
@@ -0,0 +1,201 @@
+# frozen_string_literal: true
+
+RSpec.describe InertiaRails::MetaTag do
+ let(:meta_tag) { described_class.new(head_key: dummy_head_key, name: 'description', content: 'Inertia rules') }
+ let(:dummy_head_key) { 'meta-12345678' }
+ let(:tag_helper) { ActionController::Base.helpers.tag }
+
+ describe '#to_json' do
+ it 'returns the meta tag as JSON' do
+ expected_json = {
+ tagName: :meta,
+ headKey: dummy_head_key,
+ name: 'description',
+ content: 'Inertia rules',
+ }.to_json
+
+ expect(meta_tag.to_json).to eq(expected_json)
+ end
+
+ it 'transforms snake_case keys to camelCase' do
+ meta_tag = described_class.new(head_key: dummy_head_key, http_equiv: 'content-security-policy', content: "default-src 'self'")
+
+ expected_json = {
+ tagName: :meta,
+ headKey: dummy_head_key,
+ httpEquiv: 'content-security-policy',
+ content: "default-src 'self'",
+ }.to_json
+
+ expect(meta_tag.to_json).to eq(expected_json)
+ end
+
+ it 'handles JSON LD content' do
+ meta_tag = described_class.new(tag_name: 'script', head_key: dummy_head_key, type: 'application/ld+json', inner_content: { '@context': 'https://schema.org' })
+
+ expected_json = {
+ tagName: :script,
+ headKey: dummy_head_key,
+ type: 'application/ld+json',
+ innerContent: { '@context': 'https://schema.org' },
+ }.to_json
+
+ expect(meta_tag.to_json).to eq(expected_json)
+ end
+
+ it 'marks executable script tags with text/plain' do
+ meta_tag = described_class.new(tag_name: 'script', head_key: dummy_head_key, inner_content: '', type: 'application/javascript')
+
+ expected_json = {
+ tagName: :script,
+ headKey: dummy_head_key,
+ type: 'text/plain',
+ innerContent: '',
+ }.to_json
+
+ expect(meta_tag.to_json).to eq(expected_json)
+ end
+ end
+
+ describe 'generated head keys' do
+ it 'generates a headKey of the format {tag name}-{hexdigest of tag content}' do
+ meta_tag = described_class.new(some_name: 'description', content: 'Inertia rules')
+ expected_head_key = "meta-#{Digest::MD5.hexdigest('content=Inertia rules&some_name=description')[0, 8]}"
+
+ expect(meta_tag.as_json[:headKey]).to eq(expected_head_key)
+ end
+
+ it 'generates the same headKey regardless of hash data order' do
+ first_tags = described_class.new(some_name: 'description', content: 'Inertia rules').as_json
+ first_head_key = first_tags[:headKey]
+
+ second_tags = described_class.new(content: 'Inertia rules', some_name: 'description').as_json
+ second_head_key = second_tags[:headKey]
+
+ expect(first_head_key).to eq(second_head_key)
+ end
+
+ it 'generates a different headKey for different content' do
+ first_tags = described_class.new(some_name: 'thing', content: 'Inertia rules').as_json
+ first_head_key = first_tags[:headKey]
+
+ second_tags = described_class.new(some_name: 'thing', content: 'Inertia rocks').as_json
+ second_head_key = second_tags[:headKey]
+
+ expect(first_head_key).not_to eq(second_head_key)
+ end
+
+ it 'respects a user specified head_key' do
+ custom_head_key = 'blah'
+ meta_tag = described_class.new(head_key: custom_head_key, name: 'description', content: 'Inertia rules')
+
+ expect(meta_tag.as_json[:headKey]).to eq(custom_head_key)
+ end
+
+ it 'generates a head key by the name attribute if no head_key is provided' do
+ meta_tag = described_class.new(name: 'description', content: 'Inertia rules')
+
+ expect(meta_tag.as_json[:headKey]).to eq('meta-name-description')
+ end
+
+ it 'generates a head key by the http_equiv attribute if no head_key is provided' do
+ meta_tag = described_class.new(http_equiv: 'content-security-policy', content: "default-src 'self'")
+
+ expect(meta_tag.as_json[:headKey]).to eq('meta-http_equiv-content-security-policy')
+ end
+
+ it 'generates a head key by the property attribute if no head_key is provided' do
+ meta_tag = described_class.new(property: 'og:title', content: 'Inertia Rocks')
+
+ expect(meta_tag.as_json[:headKey]).to eq('meta-property-og-title')
+ end
+
+ context 'with allow_duplicates set to true' do
+ it 'generates a head key with a unique suffix' do
+ meta_tag = described_class.new(name: 'description', content: 'Inertia rules', allow_duplicates: true)
+
+ expect(meta_tag.as_json[:headKey]).to eq("meta-name-description-#{Digest::MD5.hexdigest('content=Inertia rules&name=description')[0, 8]}")
+ end
+ end
+ end
+
+ describe '#to_tag' do
+ it 'returns a string meta tag' do
+ tag = meta_tag.to_tag(tag_helper)
+ expect(tag).to be_a(String)
+ expect(tag).to eq('')
+ end
+
+ it 'renders kebab case' do
+ meta_tag = described_class.new(tag_name: :meta, head_key: dummy_head_key, http_equiv: 'X-UA-Compatible', content: 'IE=edge')
+
+ tag = meta_tag.to_tag(tag_helper)
+
+ expect(tag).to eq('')
+ end
+
+ describe 'script tag rendering' do
+ it 'renders JSON LD content correctly' do
+ meta_tag = described_class.new(tag_name: :script, head_key: dummy_head_key, type: 'application/ld+json', inner_content: { '@context': 'https://schema.org' })
+
+ tag = meta_tag.to_tag(tag_helper)
+
+ expect(tag).to eq('')
+ end
+
+ it 'adds text/plain and escapes all other script tags' do
+ meta_tag = described_class.new(tag_name: :script, head_key: dummy_head_key, type: 'application/javascript', inner_content: 'alert("XSS")')
+
+ tag = meta_tag.to_tag(tag_helper)
+
+ expect(tag).to eq('')
+ end
+ end
+
+ describe 'rendering unary tags' do
+ described_class::UNARY_TAGS.each do |tag_name|
+ it "renders a content attribute for a #{tag_name} tag" do
+ meta_tag = described_class.new(tag_name: tag_name, head_key: dummy_head_key, content: 'Inertia rules')
+
+ tag = meta_tag.to_tag(tag_helper)
+
+ expect(tag).to include("<#{tag_name} content=\"Inertia rules\" inertia=\"meta-12345678\">")
+ end
+ end
+ end
+
+ it 'escapes inner content for non-script tags' do
+ meta_tag = described_class.new(tag_name: :div, head_key: dummy_head_key, inner_content: '')
+
+ tag = meta_tag.to_tag(tag_helper)
+
+ expect(tag).to eq('<script>alert("XSS")</script>
')
+ end
+ end
+
+ describe 'title tag rendering' do
+ it 'renders a title tag if only a title key is provided' do
+ meta_tag = described_class.new(tag_name: :title, head_key: dummy_head_key, inner_content: 'Inertia Page Title')
+
+ tag = meta_tag.to_tag(ActionController::Base.helpers.tag)
+
+ expect(tag).to eq('Inertia Page Title')
+ end
+
+ context 'when only a title key is provided' do
+ let(:title_tag) { described_class.new(title: 'Inertia Is Great', head_key: 'title') }
+
+ it 'renders JSON correctly' do
+ expect(title_tag.to_json).to eq({
+ tagName: :title,
+ headKey: 'title',
+ innerContent: 'Inertia Is Great',
+ }.to_json)
+ end
+
+ it 'renders a title tag' do
+ expect(title_tag.to_tag(tag_helper)).to eq('Inertia Is Great')
+ end
+ end
+ end
+end