Rails + Inertia.js + Svelte
Read this first
This page is a companion to the Rails 8 guide. All server-side logic — controller, PKCE, token exchange, JWT verification — is the same as in that guide. This page covers only the three differences for an Inertia.js + Svelte frontend.
📋 Register a Confidential client
The Rails server sends client_secret to /oauth/token, so register the app as Client type: Confidential. Never expose the secret to the Svelte frontend.
In an Inertia app, page transitions are intercepted by a client-side router (@inertiajs/svelte's router, use:inertia, <Link>). 1pass login starts with a cross-origin 302 redirect — and a client-side router tries to follow that 302 over XHR and fails silently (the same failure mode as Turbo Drive). So three things change.
① Starting login — bypass the client-side router (most important)
The "Log in with 1pass" button must be a full-page navigation. Do not use use:inertia / <Link> / router.visit.
<script lang="ts">
import { inertia } from "@inertiajs/svelte"
</script>
<!-- ✅ Start login: plain <a>. No use:inertia.
logi's authorize is a cross-origin 302 that a client router would eat. -->
<a href="/auth/logi/start" data-no-inertia>Log in with 1pass</a>
<!-- ❌ Never: nothing happens — no error in the console or the network tab -->
<!-- <a href="/auth/logi/start" use:inertia>Log in with 1pass</a> -->Choose the /auth/logi/start route in your app (see Login Button). The controller's redirect_to(..., allow_other_host: true) is the same as in the Rails guide.
The callback does not need special handling
/auth/logi/callback is handled by the server controller and finishes with redirect_to root_path, so Inertia renders the next page as usual. Only the start (①) needs the bypass, not the callback.
② Login state — share current_user via inertia_share
To expose the logged-in user on every Inertia page without an extra round-trip, use ApplicationController#inertia_share.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
inertia_share do
{
auth: {
user: current_user && {
id: current_user.id,
email: current_user.email,
displayName: current_user.display_name
}
},
flash: { notice: flash[:notice], alert: flash[:alert] }
}
end
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
endDon't create users from front-channel data
current_user must come only from the server session (session[:user_id]). Create users only from the server-side userinfo response — see Rails guide: first login.
③ In Svelte — page is a rune, not a store (page.props)
@inertiajs/svelte v3 trap
The v2 $page store pattern breaks in v3. In v3, page is a $state rune object, so subscribing with $page fails hydration with store_invalid_shape. Read page.props directly.
<script lang="ts">
import { inertia, page, router } from "@inertiajs/svelte"
let { children } = $props()
// ✅ v3: page is a rune. Not $page (store subscription).
let user = $derived(
(page.props as any)?.auth?.user as
| { displayName: string; email?: string }
| null
| undefined
)
function logout() {
router.delete("/auth/logi/logout") // end local session → full reload to root
}
</script>
<header>
{#if user}
<span title={user.email}>👤 {user.displayName}</span>
<button type="button" onclick={logout}>Log out</button>
{:else}
<!-- ① again: start login with a plain <a> -->
<a href="/auth/logi/start" data-no-inertia>Log in with 1pass</a>
{/if}
</header>Logout is safe via router.delete (a same-origin DELETE — not a cross-origin 302, so the client router can handle it).
Checklist
- [ ] Login start button is a plain
<a href>(notuse:inertia/<Link>) - [ ]
inertia_shareexposesauth.user;current_useris session-based - [ ] Svelte reads
page.propsdirectly, not$page - [ ] RP registered as Confidential; secret lives only in server env
See also
- Rails 8 — controller, PKCE, token exchange, JWT verification (all server-side)
- SPA + Serverless — pure SPA with no backend (Public client)
- Login Button — button design and start-route conventions