npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@nordbeam/nb-inertia

v0.10.0

Published

Modal system, route-bound forms, and SSR-safe components for Phoenix + Inertia.js

Readme

NbInertia

Advanced Inertia.js integration for Phoenix with declarative page DSL, type-safe props, shared props, SSR support, and optional NbSerializer integration.

Features

  • Declarative Page DSL: Define pages and their props with compile-time validation
  • Component Name Inference: Automatic conversion from :users_index to "Users/Index"
  • Shared Props: Define props shared across all pages (inline or as dedicated modules)
  • Type Safety: Compile-time prop validation in dev/test environments
  • Server-Side Rendering: Built-in SSR support with DenoRider (Deno-based)
  • NbSerializer Integration: Optional automatic serialization for high-performance JSON
  • Flexible Rendering: Support for both all-in-one and pipe-friendly patterns
  • Test Helpers: Comprehensive test utilities for Inertia pages
  • Optional Dependency: Works standalone or with NbSerializer for advanced features

Installation

Quick Start (Recommended)

Run the installer directly from GitHub:

mix igniter.install nb_inertia@github:nordbeam/nb_inertia

For TypeScript support, add the --typescript flag:

mix igniter.install nb_inertia@github:nordbeam/nb_inertia --typescript

This installs and configures:

  • NbInertia controller helpers
  • Inertia configuration
  • Optional TypeScript type generation (with --typescript)
  • Mix aliases
  • Example files

Manual Installation

Add nb_inertia to your mix.exs dependencies:

def deps do
  [
    {:nb_inertia, "~> 0.1"},
    {:nb_serializer, "~> 0.1", optional: true},  # Optional
    {:nb_ts, "~> 0.1", optional: true}           # Optional for TypeScript
  ]
end

Run:

mix deps.get

Quick Start Guide

1. Define an Inertia Page

In your controller, use NbInertia.Controller:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use NbInertia.Controller

  # Define the page and its props
  inertia_page :users_index do
    prop :users, :list
    prop :total_count, :integer
    prop :filters, :map, optional: true
  end

  def index(conn, params) do
    users = MyApp.Accounts.list_users(params)

    render_inertia(conn, :users_index,
      users: users,
      total_count: length(users),
      filters: params["filters"]
    )
  end
end

2. Component Name Inference

NbInertia automatically converts page atoms to React component paths:

  • :users_index"Users/Index"
  • :users_show"Users/Show"
  • :users_new"Users/New"
  • :admin_users_index"Admin/Users/Index"
  • :admin_dashboard"Admin/Dashboard"
  • :dashboard"Dashboard"
  • :settings"Settings"

Override manually if needed:

inertia_page :custom_name, component: "CustomPath/Component" do
  prop :data, :map
end

3. Unified Prop Syntax

NbInertia supports a unified, consistent syntax for defining props (matching the NbSerializer field syntax):

inertia_page :products_index do
  # Primitives
  prop :id, :integer
  prop :name, :string
  prop :active, :boolean

  # Lists of primitives
  prop :tags, list: :string           # TypeScript: tags: string[]
  prop :scores, list: :number         # TypeScript: scores: number[]

  # Enums (restricted values)
  prop :status, enum: ["active", "inactive", "pending"]
  # TypeScript: status: "active" | "inactive" | "pending"

  # List of enums
  prop :roles, list: [enum: ["admin", "user", "guest"]]
  # TypeScript: roles: ("admin" | "user" | "guest")[]

  # Single serializer (when nb_serializer is installed)
  prop :user, UserSerializer          # TypeScript: user: User

  # List of serializers
  prop :users, list: UserSerializer   # TypeScript: users: User[]

  # Modifiers
  prop :priority, enum: ["low", "high"], optional: true
  prop :notes, list: :string, optional: true
  prop :metadata, :map, nullable: true
end

Benefits of unified syntax:

  • Same syntax as field in NbSerializer
  • Automatic TypeScript generation (with nb_ts)
  • Type-safe props with compile-time validation
  • Clear, consistent API across the codebase

Advanced Usage

With NbSerializer (Optional)

If you have nb_serializer installed, you get automatic high-performance JSON serialization:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use NbInertia.Controller

  inertia_page :users_index do
    prop :users, MyApp.UserSerializer  # Uses serializer for type
    prop :total_count, :integer
  end

  def index(conn, _params) do
    users = MyApp.Accounts.list_users()

    render_inertia_serialized(conn, :users_index,
      users: {MyApp.UserSerializer, users},
      total_count: length(users)
    )
  end
end

NbSerializer Functions

When nb_serializer is available, you get additional functions:

  • assign_serialized/5 - Assign single prop with automatic serialization
  • assign_serialized_props/2 - Assign multiple serialized props at once
  • assign_serialized_errors/2 - Serialize Ecto changeset validation errors
  • render_inertia_serialized/3 - Render with serialized props

Advanced Prop Options

# Lazy evaluation - only serialize on partial reloads
assign_serialized(conn, :posts, PostSerializer, posts, lazy: true)

# Lazy function - automatically optional, only executes when requested
assign_serialized(conn, :expensive_data, DataSerializer, fn ->
  fetch_expensive_data()
end)

# Lazy function - automatically optional
assign_serialized(conn, :themes, ThemeSerializer, fn ->
  Themes.list_all_with_status()
end)

# Deferred loading - async load after initial render
assign_serialized(conn, :stats, StatsSerializer, stats, defer: true)

# Merge props - for infinite scroll/pagination
assign_serialized(conn, :items, ItemSerializer, items, merge: true)

Shared Props

Define props that are available to all pages:

Inline Shared Props

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use NbInertia.Controller

  # Shared across all pages in this controller
  inertia_shared do
    prop :current_user, from: :assigns
    prop :flash, from: :assigns
  end

  inertia_page :dashboard do
    prop :stats, :map
  end
end

Shared Props Modules

For more complex or application-wide shared props, create dedicated modules:

defmodule MyAppWeb.InertiaShared.Auth do
  use NbInertia.SharedProps

  inertia_shared do
    prop :locale, :string
    prop :current_user, :map
    prop :flash, :map
  end

  def build_props(conn, _opts) do
    %{
      locale: conn.assigns[:locale] || "en",
      current_user: conn.assigns[:current_user],
      flash: Phoenix.Controller.get_flash(conn)
    }
  end
end

Register the shared props module in your controller:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use NbInertia.Controller

  # Use the shared props module
  inertia_shared(MyAppWeb.InertiaShared.Auth)

  inertia_page :dashboard do
    prop :stats, :map
  end
end

Auto-Registering Shared Props via web.ex

Best Practice: For shared props needed across ALL controllers, use Phoenix's web.ex pattern:

# lib/my_app_web.ex
defmodule MyAppWeb do
  def controller do
    quote do
      use Phoenix.Controller
      use NbInertia.Controller

      # Auto-register base shared props for ALL controllers
      inertia_shared(MyAppWeb.InertiaShared.Base)

      import Plug.Conn
      import MyAppWeb.Gettext

      unquote(verified_routes())
    end
  end
end

Now every controller automatically includes base shared props without manual registration:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller  # Automatically includes Base shared props!

  # Additional controller-specific shared props (optional)
  inertia_shared(MyAppWeb.InertiaShared.Auth)

  inertia_page :dashboard do
    prop :stats, :map
  end
end

This pattern is ideal for app-wide data like:

  • App name, version, environment
  • Flash messages
  • Current user (from conn.assigns)
  • Feature flags

See COOKBOOK.md for more patterns and examples.

Conditional Shared Props

Control when shared props are included using Phoenix-style options:

defmodule MyAppWeb.AdminController do
  use MyAppWeb, :controller

  # Only include for specific actions
  inertia_shared(MyAppWeb.InertiaShared.Admin, only: [:index, :show])

  # Exclude from specific actions
  inertia_shared(MyAppWeb.InertiaShared.Public, except: [:admin])

  # Conditional based on guard function
  inertia_shared(MyAppWeb.InertiaShared.BetaFeatures, when: :beta_enabled?)

  # Multiple conditions
  inertia_shared(MyAppWeb.InertiaShared.Analytics,
    only: [:index],
    when: :analytics_enabled?
  )

  defp beta_enabled?(conn) do
    conn.assigns[:current_user]?.beta_tester?
  end

  defp analytics_enabled?(conn) do
    Application.get_env(:my_app, :enable_analytics, false)
  end
end

Options:

  • :only - List of actions where props should be included
  • :except - List of actions where props should be excluded
  • :when - Atom referencing a guard function (receives conn, returns boolean)

Deep Merging Shared Props

By default, page props override shared props (shallow merge). Enable deep merge to recursively merge nested maps:

Global Configuration:

# config/config.exs
config :nb_inertia,
  endpoint: MyAppWeb.Endpoint,
  deep_merge_shared_props: true  # Default: false

Per-Action Override:

def index(conn, _params) do
  # Shared: %{settings: %{theme: "dark", notifications: true}}
  # Page:   %{settings: %{theme: "light"}}
  # Result: %{settings: %{theme: "light", notifications: true}}

  render_inertia(conn, :index,
    [settings: %{theme: "light"}],
    deep_merge: true
  )
end

Use cases:

  • Feature flags with per-page overrides
  • User preferences with page-specific defaults
  • Partial updates to configuration objects

With NbSerializer Integration

When using NbSerializer, you can specify serializers for shared props:

defmodule MyAppWeb.InertiaShared.Auth do
  use NbInertia.SharedProps

  inertia_shared do
    prop :locale, :string
    prop :current_user, MyApp.UserSerializer  # Automatically serialized
    prop :flash, :map
    prop :permissions, :list
  end

  def build_props(conn, _opts) do
    %{
      locale: conn.assigns[:locale] || "en",
      current_user: conn.assigns[:current_user],
      flash: Phoenix.Controller.get_flash(conn),
      permissions: conn.assigns[:permissions] || []
    }
  end
end

Rendering Patterns

NbInertia supports multiple rendering patterns to fit your style:

All-in-One Pattern (Recommended)

Provides compile-time validation in dev/test:

def index(conn, _params) do
  render_inertia(conn, :users_index,
    users: list_users(),
    total_count: count_users(),
    filters: %{status: "active"}
  )
end

Pipe-Friendly Pattern

More flexible, no compile-time validation:

def index(conn, _params) do
  conn
  |> assign_prop(:users, list_users())
  |> assign_prop(:total_count, count_users())
  |> assign_prop(:filters, %{status: "active"})
  |> render_inertia(:users_index)
end

With NbSerializer

Automatic serialization for performance:

def index(conn, _params) do
  render_inertia(conn, :users_index,
    users: {UserSerializer, list_users()},
    pagination: {PaginationSerializer, pagination_data()},
    # Lazy function - automatically optional, only executes when requested
    analytics: {AnalyticsSerializer, fn -> fetch_analytics() end},
    total_count: count_users()
  )
end

Pipe-Friendly with NbSerializer

def index(conn, _params) do
  conn
  |> assign_serialized(:users, UserSerializer, list_users())
  |> assign_serialized(:pagination, PaginationSerializer, pagination_data())
  |> assign_prop(:total_count, count_users())
  |> render_inertia(:users_index)
end

Prop Types and Validation

Supported Primitive Types

  • :string - String values
  • :integer - Integer numbers
  • :float - Floating point numbers
  • :boolean - Boolean values
  • :map - Map/object structures
  • :list - List/array structures
  • :any - Any type (no validation)

TypeScript Types (with NbTs)

When using NbTs, you can specify exact TypeScript types:

import NbTs.Sigil

inertia_page :dashboard do
  prop :stats, type: ~TS"{ total: number; active: number }"
  prop :status, type: ~TS"'active' | 'inactive' | 'pending'"
  prop :config, type: ~TS"Record<string, unknown>"
end

Real-Time Type Regeneration: When NbTs is installed, NbInertia automatically registers a compile hook that regenerates TypeScript types whenever your controllers are recompiled. This means your frontend types stay in sync with your backend prop definitions during development without any manual intervention.

Serializer Types (with NbSerializer)

When NbSerializer is installed, use serializer modules as types:

inertia_page :users_index do
  prop :users, MyApp.UserSerializer     # Single or list of users
  prop :current_user, MyApp.UserSerializer
end

Compile-Time Validation

In development and test environments, NbInertia validates at compile time:

Validates:

  • All required props are provided
  • No undeclared props are passed
  • No collisions between shared and page props
  • Prop types match declarations (when using serializers)

⚠️ Note: Validation is disabled in production for performance.

Optional Props

Props can be marked as optional:

inertia_page :users_show do
  prop :user, :map
  prop :posts, :list, optional: true      # Can be omitted
  prop :comments, :list, lazy: true       # Can be omitted
  prop :analytics, :map, defer: true      # Can be omitted
end

# Valid - optional/lazy/defer props can be omitted
render_inertia(conn, :users_show, user: user)

Configuration

Configure NbInertia using the :nb_inertia namespace. All configuration is automatically forwarded to the underlying :inertia library on application startup.

# config/config.exs
config :nb_inertia,
  endpoint: MyAppWeb.Endpoint,           # Required for SSR and versioning
  camelize_props: true,                  # Convert snake_case to camelCase (default: true)
  history: [],                           # Scroll position preservation
  static_paths: ["/css", "/js"],         # Static paths for asset versioning
  default_version: "1",                  # Asset version
  ssr: [
    enabled: false,                      # Enable SSR (default: false)
    raise_on_failure: true              # Raise on SSR errors (default: true)
  ]

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | :endpoint | module | required | Phoenix endpoint for SSR and versioning | | :camelize_props | boolean | true | Auto-convert snake_case to camelCase | | :history | keyword | [] | History config for scroll preservation | | :static_paths | list | [] | Paths for asset versioning | | :default_version | string | "1" | Default asset version | | :ssr | keyword/boolean | false | SSR configuration (see below) |

SSR Configuration

config :nb_inertia,
  endpoint: MyAppWeb.Endpoint,
  ssr: [
    enabled: true,                                    # Enable SSR
    raise_on_failure: config_env() != :prod,         # Raise on errors (not in prod)
    script_path: nil,                                 # Auto-detected from endpoint
    dev_server_url: "http://localhost:5173"          # Dev server URL (optional)
  ]

See RELEASES.md for detailed SSR setup and deployment guide.

Important: Always configure :nb_inertia, not :inertia directly. NbInertia automatically forwards configuration to the underlying Inertia library on application startup.

Testing

NbInertia provides comprehensive test helpers for testing Inertia pages.

Setup Test Helpers

Import NbInertia.TestHelpers in your test support:

# test/support/conn_case.ex
defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      import NbInertia.TestHelpers

      @endpoint MyAppWeb.Endpoint
    end
  end
end

Test Helper Functions

# Make Inertia requests
conn = inertia_get(conn, ~p"/users")
conn = inertia_post(conn, ~p"/users", user: %{name: "John"})
conn = inertia_put(conn, ~p"/users/1", user: %{name: "Jane"})
conn = inertia_patch(conn, ~p"/users/1", user: %{name: "Jane"})
conn = inertia_delete(conn, ~p"/users/1")

# Assert page and props
assert_inertia_page(conn, "Users/Index")
assert_inertia_props(conn, [:users, :total_count])
assert_inertia_prop(conn, :total_count, 10)
refute_inertia_prop(conn, :secret_data)

# Assert shared props
assert_shared_props(conn, [:app_name, :version])
assert_shared_prop(conn, :current_user, %{id: 1, name: "Alice"})
refute_shared_prop(conn, :admin_settings)  # For non-admin users
assert_shared_module_props(conn, MyAppWeb.InertiaShared.Auth)

Example Tests

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase

  test "renders users index", %{conn: conn} do
    user = insert(:user, name: "John Doe")
    conn = inertia_get(conn, ~p"/users")

    assert_inertia_page(conn, "Users/Index")
    assert_inertia_props(conn, [:users, :total_count])
    assert_inertia_prop(conn, :total_count, 1)
  end

  test "creates user with valid data", %{conn: conn} do
    conn = inertia_post(conn, ~p"/users", user: %{name: "Jane", email: "[email protected]"})

    assert redirected_to(conn) =~ ~p"/users"
  end
end

Performance

Compile-Time Validation Overhead

  • Development: Adds ~50-100ms per controller during compilation
  • Production: Zero overhead - validation is disabled in production
  • Benefit: Catches errors before deployment, saves debugging time

Serialization Performance

NbSerializer is optimized for performance while providing type safety:

Benchmark (serializing 100 users):
  Manual maps:          0.5ms
  NbSerializer:         0.6ms  (overhead ~20%)
  Jason.encode:         0.8ms

Benefit: Type safety + TypeScript generation + compile-time validation

When to use NbSerializer:

  • ✅ Complex nested data structures
  • ✅ Need TypeScript types generated
  • ✅ Want compile-time type validation
  • ✅ Serializing the same data multiple times

When to use manual maps:

  • ✅ Simple, flat data
  • ✅ One-off serialization
  • ✅ Data already in the right format

Shared Props Performance

Shared props are included in every Inertia response. Keep them minimal:

Good (minimal):

%{
  current_user: %{id: 1, name: "Alice", role: "admin"},
  unread_count: 5,
  app_version: "1.0.0"
}

Bad (too much data):

%{
  current_user: %{... 50 fields ...},
  all_users: [...],  # Entire users table!
  settings: %{... massive config ...}
}

Optimization strategies:

  1. Use conditional shared props (only:, when:) to limit inclusion
  2. Use lazy props for expensive data
  3. Paginate large lists
  4. Reduce serializer fields to only what's needed

Response Size Optimization

Lazy props prevent loading expensive data unless requested:

inertia_page :dashboard do
  prop :summary, :map
  prop :detailed_analytics, :map, lazy: true  # Only loaded when requested
  prop :audit_log, :list, lazy: true          # Only loaded when requested
end

Pagination reduces payload size:

def index(conn, params) do
  page = Accounts.paginate_users(params, page_size: 25)

  render_inertia(conn, :index,
    users: {UserSerializer, page.entries},
    meta: %{
      current_page: page.page_number,
      total_pages: page.total_pages,
      total_count: page.total_entries
    }
  )
end

See DEBUGGING.md for more performance troubleshooting.

Error Handling

With NbSerializer

Validation errors from Ecto changesets are automatically formatted:

def create(conn, %{"user" => user_params}) do
  case MyApp.Accounts.create_user(user_params) do
    {:ok, user} ->
      conn
      |> put_flash(:info, "User created successfully")
      |> redirect(to: ~p"/users/#{user.id}")

    {:error, changeset} ->
      conn
      |> assign_serialized_errors(changeset)
      |> put_flash(:error, "Could not create user")
      |> redirect(to: ~p"/users/new")
  end
end

Integration with nb_routes

nb_routes generates type-safe route helpers from Phoenix routes. When used with nb_inertia, it provides enhanced form helpers for seamless HTML form integration.

Route Helpers with Inertia

Use route helpers to navigate in your Inertia apps:

import { router } from '@inertiajs/react';
import { users_path, user_path, edit_user_path } from './routes';

function UserCard({ user }) {
  return (
    <div>
      <a href={user_path(user.id)}>View</a>
      <button onClick={() => router.visit(edit_user_path(user.id))}>
        Edit
      </button>
    </div>
  );
}

Form Helpers for Inertia

When nb_routes is configured with rich mode and form helpers, you get automatic method spoofing for HTML forms:

Backend Configuration:

# config/config.exs
config :nb_routes,
  variant: :rich,
  with_methods: true,
  with_forms: true  # Enable form helpers

Frontend Usage with Inertia:

import { router } from '@inertiajs/react';
import { update_user_path, delete_user_path } from './routes';

// Update form with PATCH
function EditUserForm({ user }) {
  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const route = update_user_path.patch(user.id);

    router.visit(route.url, {
      method: route.method,
      data: Object.fromEntries(formData)
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" defaultValue={user.name} />
      <input type="email" name="email" defaultValue={user.email} />
      <button type="submit">Update User</button>
    </form>
  );
}

// Delete with confirmation
function DeleteUserButton({ user }) {
  const handleDelete = () => {
    if (confirm('Are you sure?')) {
      const route = delete_user_path.delete(user.id);
      router.visit(route.url, {
        method: route.method
      });
    }
  };

  return <button onClick={handleDelete}>Delete</button>;
}

HTML Form Integration

For standard HTML forms (without JavaScript), form helpers automatically handle method spoofing:

import { update_user_path, delete_user_path } from './routes';

function EditUserForm({ user }) {
  const formAttrs = update_user_path.form.patch(user.id);
  // formAttrs = { action: "/users/1?_method=PATCH", method: "post" }

  return (
    <form {...formAttrs}>
      <input type="text" name="user[name]" defaultValue={user.name} />
      <button type="submit">Update</button>
    </form>
  );
}

function DeleteUserForm({ user }) {
  const formAttrs = delete_user_path.form.delete(user.id);
  // formAttrs = { action: "/users/1?_method=DELETE", method: "post" }

  return (
    <form {...formAttrs}>
      <button type="submit">Delete User</button>
    </form>
  );
}

Available Method Variants

When form helpers are enabled, you get these variants:

// Standard route (returns { url, method })
update_user_path(1)              // => { url: "/users/1", method: "patch" }
update_user_path.patch(1)        // => { url: "/users/1", method: "patch" }
update_user_path.put(1)          // => { url: "/users/1", method: "put" }

// Form variants (returns { action, method })
update_user_path.form(1)         // => { action: "/users/1?_method=PATCH", method: "post" }
update_user_path.form.patch(1)   // => { action: "/users/1?_method=PATCH", method: "post" }
update_user_path.form.put(1)     // => { action: "/users/1?_method=PUT", method: "post" }

delete_user_path.form.delete(1)  // => { action: "/users/1?_method=DELETE", method: "post" }

Query Parameters with Forms

Form helpers support query parameters:

// Add query parameters
const route = update_user_path.patch(user.id, {
  query: { redirect_to: '/dashboard' }
});
// => { url: "/users/1?redirect_to=/dashboard", method: "patch" }

// Form with query parameters
const formAttrs = update_user_path.form.patch(user.id, {
  query: { step: '2' }
});
// => { action: "/users/1?_method=PATCH&step=2", method: "post" }

TypeScript Types

Form helpers include full TypeScript support:

import type { RouteResult, FormAttributes } from './routes';
import { update_user_path } from './routes';

// Route result
const route: RouteResult = update_user_path.patch(123);
route.url;     // string
route.method;  // 'get' | 'post' | 'patch' | 'put' | 'delete' | 'head' | 'options'

// Form attributes
const formAttrs: FormAttributes = update_user_path.form.patch(123);
formAttrs.action;  // string (URL with _method param)
formAttrs.method;  // 'get' | 'post'

Setup

  1. Install nb_routes:
mix deps.get
# Add {:nb_routes, "~> 0.1.0"} to mix.exs
  1. Configure rich mode with form helpers:
# config/config.exs
config :nb_routes,
  variant: :rich,
  with_methods: true,
  with_forms: true
  1. Generate route helpers:
mix nb_routes.gen
  1. Optional: Auto-regeneration with nb_vite:
// assets/vite.config.ts
import { defineConfig } from 'vite';
import phoenix from '@nordbeam/nb-vite';
import { nbRoutes } from '@nordbeam/nb-vite/nb-routes';

export default defineConfig({
  plugins: [
    phoenix({ input: ['js/app.ts'] }),
    nbRoutes({ enabled: true })  // Auto-regenerate on router changes
  ]
});

See nb_routes documentation for more details.

Integration with nb_routes Rich Mode (React)

When using nb_routes in rich mode, route helpers return { url, method } objects (called RouteResult). The official @inertiajs/react already supports these objects natively in router.visit() and Link components via the UrlMethodPair type.

What Official Inertia Already Supports

The official @inertiajs/react package natively accepts { url, method } objects:

import { router, Link } from '@inertiajs/react';
import { user_path, update_user_path, delete_user_path } from './routes';

// Router accepts RouteResult directly
router.visit(user_path(1));                    // GET /users/1
router.visit(update_user_path.patch(1));       // PATCH /users/1
router.visit(delete_user_path.delete(1));      // DELETE /users/1

// Link accepts RouteResult in href
<Link href={user_path(1)}>View User</Link>
<Link href={update_user_path.patch(1)}>Edit User</Link>

No wrapper needed - use @inertiajs/react directly for routing!

What nb_inertia Provides

nb_inertia adds features not available in official Inertia:

  1. useForm with Route Binding - Simplify form submission
  2. Modal System - Open pages as modals/slideovers
  3. SSR-Safe Components - Head and usePage with modal context support

useForm with Route Binding

The enhanced useForm hook supports optional route binding. When bound to a RouteResult, the submit() method automatically uses the route's URL and method without needing to pass them explicitly.

Import:

import { useForm } from '@nordbeam/nb-inertia/react/useForm';
import { update_user_path, create_user_path } from './routes';

Basic Usage (Bound):

// Bound to a route - submit() is simplified
const form = useForm(
  { name: 'John', email: '[email protected]' },
  update_user_path.patch(1)  // Route binding
);

// Submit automatically uses PATCH /users/1
const handleSubmit = (e) => {
  e.preventDefault();
  form.submit({
    preserveScroll: true,
    onSuccess: () => console.log('Saved!')
  });
};

Basic Usage (Unbound):

// Not bound - works like standard Inertia useForm
const form = useForm({ name: 'John', email: '[email protected]' });

// Must specify method and URL
const handleSubmit = (e) => {
  e.preventDefault();
  form.submit('patch', `/users/${userId}`, {
    preserveScroll: true,
    onSuccess: () => console.log('Saved!')
  });
};

Form State and Methods:

const form = useForm({ name: '', email: '' }, update_user_path.patch(1));

// All standard useForm features work
form.setData('name', 'John');
form.setData({ name: 'Jane', email: '[email protected]' });
form.transform((data) => ({ ...data, timestamp: Date.now() }));
form.reset();
form.reset('name');
form.clearErrors();
form.clearErrors('name');

// Check form state
console.log(form.data);           // Current form data
console.log(form.errors);         // Validation errors
console.log(form.processing);     // Is form submitting?
console.log(form.progress);       // Upload progress
console.log(form.wasSuccessful);  // Was last submit successful?
console.log(form.recentlySuccessful); // Recently successful?
console.log(form.isDirty);        // Has form been modified?

Real-World Example:

import { useForm } from '@nordbeam/nb-inertia/react/useForm';
import { update_user_path } from './routes';
import type { User } from './types';

interface EditUserFormProps {
  user: User;
}

export default function EditUserForm({ user }: EditUserFormProps) {
  // Bound to update route
  const form = useForm(
    {
      name: user.name,
      email: user.email,
      bio: user.bio || ''
    },
    update_user_path.patch(user.id)
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Transform data before sending
    form.transform((data) => ({
      ...data,
      updated_at: new Date().toISOString()
    }));

    // Submit with options
    form.submit({
      preserveScroll: true,
      onSuccess: () => {
        // Show success message
        form.reset();
      },
      onError: (errors) => {
        // Handle errors
        console.error('Validation errors:', errors);
      }
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          type="text"
          value={form.data.name}
          onChange={(e) => form.setData('name', e.target.value)}
        />
        {form.errors.name && <span className="error">{form.errors.name}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={form.data.email}
          onChange={(e) => form.setData('email', e.target.value)}
        />
        {form.errors.email && <span className="error">{form.errors.email}</span>}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea
          id="bio"
          value={form.data.bio}
          onChange={(e) => form.setData('bio', e.target.value)}
        />
        {form.errors.bio && <span className="error">{form.errors.bio}</span>}
      </div>

      <div className="actions">
        <button type="submit" disabled={form.processing || !form.isDirty}>
          {form.processing ? 'Saving...' : 'Save Changes'}
        </button>

        <button type="button" onClick={() => form.reset()} disabled={!form.isDirty}>
          Reset
        </button>
      </div>

      {form.recentlySuccessful && (
        <div className="success">Saved successfully!</div>
      )}
    </form>
  );
}

TypeScript Support

Full TypeScript support is available:

RouteResult Type:

import type { RouteResult } from '@nordbeam/nb-inertia/shared/types';

const route: RouteResult = user_path(1);
route.url;      // string
route.method;   // 'get' | 'post' | 'patch' | 'put' | 'delete' | 'head' | 'options'

Link Props (Official Inertia):

import { Link } from '@inertiajs/react';
import { user_path } from './routes';

// Official Inertia Link accepts RouteResult via UrlMethodPair type
<Link href={user_path(1)}>View User</Link>

Form Types:

import { useForm } from '@nordbeam/nb-inertia/react/useForm';

// Bound form (simplified submit signature)
type UserFormData = { name: string; email: string };
const boundForm = useForm(
  { name: '', email: '' },
  update_user_path.patch(1)
);
boundForm.submit({ preserveScroll: true });  // No method/URL needed

// Unbound form (standard Inertia signature)
const unboundForm = useForm({ name: '', email: '' });
unboundForm.submit('patch', '/users/1', { preserveScroll: true });

Comparison with Standard Inertia.js

Before (Manual URL Construction):

import { router, Link, useForm } from '@inertiajs/react';

// Manual URL construction
router.visit(`/users/${userId}`);
router.visit(`/users/${userId}`, { method: 'patch' });

// Link with manual URL
<Link href={`/users/${userId}`}>View</Link>
<Link href={`/users/${userId}`} method="patch">Edit</Link>

// Form with manual method/URL
const form = useForm({ name: '', email: '' });
form.submit('patch', `/users/${userId}`, options);

After (nb_routes Rich Mode + nb_inertia useForm):

import { router, Link } from '@inertiajs/react';  // Official Inertia
import { useForm } from '@nordbeam/nb-inertia/react/useForm';  // nb_inertia
import { user_path, update_user_path } from './routes';  // nb_routes

// Type-safe route helpers - official Inertia supports RouteResult natively
router.visit(user_path(userId));
router.visit(update_user_path.patch(userId));

// Link with RouteResult - official Inertia supports this!
<Link href={user_path(userId)}>View</Link>
<Link href={update_user_path.patch(userId)}>Edit</Link>

// Form with route binding - nb_inertia's enhanced useForm
const form = useForm({ name: '', email: '' }, update_user_path.patch(userId));
form.submit(options);  // Method and URL from route

Benefits:

  • Type Safety - Routes are validated at compile time
  • Refactor Safety - Changing routes in router.ex updates all usages
  • Auto-completion - IDE suggests available routes and parameters
  • Method Binding - No need to manually specify HTTP methods (useForm)
  • Reduced Boilerplate - Less code to write and maintain
  • Official Inertia Compatibility - router and Link work out of the box

Vue Support

Vue 3 has full support for both official Inertia routing and nb_inertia's enhanced useForm:

<script setup lang="ts">
import { router, Link } from '@inertiajs/vue3';  // Official Inertia - supports RouteResult
import { useForm } from '@nordbeam/nb-inertia/vue/useForm';  // nb_inertia
import { update_user_path, user_path } from './routes';

const props = defineProps<{ user: User }>();

// Official Inertia router and Link support RouteResult natively
const visitUser = () => router.visit(user_path(props.user.id));

// nb_inertia's useForm with route binding
const form = useForm(
  { name: props.user.name, email: props.user.email },
  update_user_path.patch(props.user.id)
);
</script>

<template>
  <!-- Official Inertia Link supports RouteResult -->
  <Link :href="user_path(user.id)">View User</Link>

  <!-- Form with route binding -->
  <form @submit.prevent="form.submit({ preserveScroll: true })">
    <input v-model="form.data.name" />
    <span v-if="form.errors.name">{{ form.errors.name }}</span>
    <button type="submit" :disabled="form.processing">Save</button>
  </form>
</template>

Official @inertiajs/vue3 router and Link components already support RouteResult objects natively, so you only need nb_inertia's useForm for route binding.

Best Practices

1. Use Route Binding for Forms

When the form is always submitting to the same route, use route binding:

// ✅ Good - Bound to route
const form = useForm(initialData, update_user_path.patch(user.id));
form.submit(options);

// ❌ Less ideal - Repeating route info
const form = useForm(initialData);
const route = update_user_path.patch(user.id);
form.submit(route.method, route.url, options);

2. Extract Routes to Constants for Complex Logic

For complex navigation logic, extract the route to a constant:

// ✅ Good - Clear and reusable
const editRoute = edit_user_path(user.id);
const deleteRoute = delete_user_path.delete(user.id);

const handleEdit = () => router.visit(editRoute);
const handleDelete = () => {
  if (confirm('Are you sure?')) {
    router.visit(deleteRoute);
  }
};

3. Use Method Variants for Clarity

Use method variants to make the HTTP method explicit:

// ✅ Good - Method is clear
<Link href={update_user_path.patch(user.id)}>Edit</Link>
<Link href={delete_user_path.delete(user.id)}>Delete</Link>

// ⚠️ Works but less clear
<Link href={update_user_path(user.id)}>Edit</Link>
<Link href={delete_user_path(user.id)}>Delete</Link>

4. Leverage TypeScript for Route Parameters

Let TypeScript catch parameter errors:

// ✅ TypeScript validates parameters
user_path(123);        // OK
user_path();           // Error: Missing required parameter

// ✅ Optional parameters are type-safe
users_path({ query: { filter: 'active' } });

5. Mix with Standard Inertia for Flexibility

Don't feel locked in - use plain strings when appropriate:

// RouteResult for most cases
<Link href={user_path(user.id)}>View</Link>

// Plain string for dynamic or external URLs
<Link href={dynamicUrl}>View</Link>
<Link href="/external-page">External</Link>

Modals and Slideovers

NbInertia provides built-in support for rendering pages as modals and slideovers without full page navigation, creating a smoother user experience. This feature integrates seamlessly with both the backend (Elixir) and frontend (React/Vue).

Features

  • Backend Modal DSL: Build modals using a fluent API in your controllers
  • Frontend Components: Pre-built Modal and ModalLink components for React and Vue
  • Stacked Modals: Support for nested modals with proper z-indexing
  • Configurable Appearance: Control size, position, styling, and behavior
  • nb_routes Integration: Works seamlessly with RouteResult objects
  • Custom Headers: Communicates modal state via HTTP headers
  • Redirect Support: Special redirect handling for modal workflows

Backend Usage

Rendering a Modal

Use render_inertia_modal/4 in your controller to render an Inertia page as a modal:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  use NbInertia.Controller

  def show(conn, %{"id" => id}) do
    user = Accounts.get_user!(id)

    render_inertia_modal(conn, :users_show,
      [user: user],
      base_url: "/users",
      size: :lg,
      position: :center
    )
  end
end

Modal Configuration Options

render_inertia_modal(conn, :page_name,
  [props],
  # Required: Base URL for modal backdrop
  base_url: "/users",

  # Optional: Modal size
  # Options: :sm, :md, :lg, :xl, :full, or custom CSS class
  size: :lg,

  # Optional: Modal position
  # Options: :center, :top, :bottom, :left, :right
  position: :center,

  # Optional: Render as slideover instead of modal
  slideover: false,

  # Optional: Show close button (default: true)
  close_button: true,

  # Optional: Require explicit close (disable ESC/backdrop click)
  close_explicitly: false,

  # Optional: Custom max-width (e.g., "800px", "50rem")
  max_width: nil,

  # Optional: Custom CSS classes
  padding_classes: "p-6",
  panel_classes: "bg-white rounded-lg shadow-xl",
  backdrop_classes: "bg-black/50"
)

Rendering a Slideover

def edit(conn, %{"id" => id}) do
  user = Accounts.get_user!(id)

  render_inertia_modal(conn, :users_edit,
    [user: user, changeset: Accounts.change_user(user)],
    base_url: "/users/#{id}",
    slideover: true,
    position: :right,
    size: :lg
  )
end

Using the Modal DSL

For more complex modal configuration, use the Modal DSL:

alias NbInertia.Modal

def show(conn, %{"id" => id}) do
  user = Accounts.get_user!(id)

  modal =
    Modal.new("Users/Show", %{user: user})
    |> Modal.base_url("/users")
    |> Modal.size(:lg)
    |> Modal.position(:center)
    |> Modal.close_button(true)

  render(conn, modal)
end

Redirecting from Modals

Use redirect_modal/2 to redirect after modal operations:

def create(conn, %{"user" => user_params}) do
  case Accounts.create_user(user_params) do
    {:ok, user} ->
      conn
      |> put_flash(:info, "User created successfully")
      |> redirect_modal(to: "/users")

    {:error, changeset} ->
      render_inertia_modal(conn, :users_new,
        [form: changeset],
        base_url: "/users"
      )
  end
end

Frontend Usage (React)

Using the Modal Component

Import and use the Modal component with nb_routes integration:

import { Modal } from '@/modals/Modal';
import { user_path } from '@/routes';
import type { User } from '@/types';

interface UserShowProps {
  user: User;
}

export default function UserShow({ user }: UserShowProps) {
  return (
    <Modal
      baseUrl={user_path(user.id).url}
      config={{
        size: 'lg',
        position: 'center',
        closeButton: true
      }}
    >
      {(close) => (
        <div>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
          <button onClick={close}>Close</button>
        </div>
      )}
    </Modal>
  );
}

Using ModalLink

ModalLink opens pages as modals when clicked:

import { ModalLink } from '@/modals/ModalLink';
import { user_path, edit_user_path } from '@/routes';

function UserList({ users }) {
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>
          <h3>{user.name}</h3>

          {/* Basic modal link */}
          <ModalLink href={user_path(user.id)}>
            View Details
          </ModalLink>

          {/* With custom modal config */}
          <ModalLink
            href={edit_user_path(user.id)}
            modalConfig={{
              slideover: true,
              position: 'right',
              size: 'lg'
            }}
          >
            Edit User
          </ModalLink>
        </div>
      ))}
    </div>
  );
}

Slideover Example

export default function UserEdit({ user, changeset }: UserEditProps) {
  const form = useForm(
    { name: user.name, email: user.email },
    update_user_path.patch(user.id)
  );

  return (
    <Modal
      baseUrl={user_path(user.id).url}
      config={{
        slideover: true,
        position: 'right',
        size: 'lg',
        closeButton: true
      }}
    >
      {(close) => (
        <div>
          <h2>Edit User</h2>
          <form onSubmit={(e) => {
            e.preventDefault();
            form.submit({
              onSuccess: () => close()
            });
          }}>
            <input
              type="text"
              value={form.data.name}
              onChange={e => form.setData('name', e.target.value)}
            />
            {form.errors.name && <div className="error">{form.errors.name}</div>}

            <button type="submit" disabled={form.processing}>
              Save
            </button>
            <button type="button" onClick={close}>
              Cancel
            </button>
          </form>
        </div>
      )}
    </Modal>
  );
}

Frontend Usage (Vue)

Using the Modal Component

<script setup lang="ts">
import { Modal } from '@/modals/Modal.vue';
import { user_path } from '@/routes';
import type { User } from '@/types';

interface Props {
  user: User;
}

const props = defineProps<Props>();
</script>

<template>
  <Modal
    :base-url="user_path(user.id).url"
    :config="{
      size: 'lg',
      position: 'center',
      closeButton: true
    }"
    v-slot="{ close }"
  >
    <div>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
      <button @click="close">Close</button>
    </div>
  </Modal>
</template>

Using ModalLink (Vue)

<script setup lang="ts">
import { ModalLink } from '@/modals/ModalLink.vue';
import { user_path, edit_user_path } from '@/routes';
import type { User } from '@/types';

interface Props {
  users: User[];
}

const props = defineProps<Props>();
</script>

<template>
  <div>
    <div v-for="user in users" :key="user.id">
      <h3>{{ user.name }}</h3>

      <!-- Basic modal link -->
      <ModalLink :href="user_path(user.id)">
        View Details
      </ModalLink>

      <!-- With custom modal config -->
      <ModalLink
        :href="edit_user_path(user.id)"
        :modal-config="{
          slideover: true,
          position: 'right',
          size: 'lg'
        }"
      >
        Edit User
      </ModalLink>
    </div>
  </div>
</template>

Configuration Reference

Size Options

  • :sm - Small modal (max-width: 400px)
  • :md - Medium modal (max-width: 600px) - default
  • :lg - Large modal (max-width: 800px)
  • :xl - Extra large modal (max-width: 1024px)
  • :full - Full screen modal
  • Custom CSS class string (e.g., "max-w-4xl")

Position Options

  • :center - Centered modal - default
  • :top - Top-aligned modal
  • :bottom - Bottom-aligned modal
  • :left - Left-aligned (for slideovers)
  • :right - Right-aligned (for slideovers)
  • Custom CSS class string

Global Configuration

Create config/nb_inertia_modal.exs to set application-wide defaults:

import Config

config :nb_inertia, :modal,
  default_size: :md,
  default_position: :center,
  default_close_button: true,
  default_close_explicitly: false,
  default_padding_classes: "p-6",
  default_panel_classes: "bg-white rounded-lg shadow-xl",
  default_backdrop_classes: "bg-black/50"

config :nb_inertia, :slideover,
  default_position: :right,
  default_size: :md

Advanced Usage

Nested Modals

Modals automatically support nesting with proper z-index management:

<Modal baseUrl="/users" config={{ size: 'lg' }}>
  {(closeOuter) => (
    <div>
      <h2>User Details</h2>
      <ModalLink
        href={edit_user_path(user.id)}
        modalConfig={{ size: 'md' }}
      >
        Edit (opens nested modal)
      </ModalLink>
      <button onClick={closeOuter}>Close</button>
    </div>
  )}
</Modal>

Form in Modal

import { useForm } from '@nordbeam/nb-inertia/react/useForm';
import { create_user_path } from '@/routes';

export default function CreateUser() {
  const form = useForm(
    { name: '', email: '' },
    create_user_path.post()
  );

  return (
    <Modal
      baseUrl="/users"
      config={{
        size: 'lg',
        closeExplicitly: true
      }}
    >
      {(close) => (
        <form onSubmit={(e) => {
          e.preventDefault();
          form.submit({
            onSuccess: () => close()
          });
        }}>
          <h2>Create User</h2>

          <div>
            <label>Name</label>
            <input
              type="text"
              value={form.data.name}
              onChange={e => form.setData('name', e.target.value)}
            />
            {form.errors.name && <span className="error">{form.errors.name}</span>}
          </div>

          <div>
            <label>Email</label>
            <input
              type="email"
              value={form.data.email}
              onChange={e => form.setData('email', e.target.value)}
            />
            {form.errors.email && <span className="error">{form.errors.email}</span>}
          </div>

          <div>
            <button type="submit" disabled={form.processing}>
              {form.processing ? 'Creating...' : 'Create User'}
            </button>
            <button type="button" onClick={close}>
              Cancel
            </button>
          </div>
        </form>
      )}
    </Modal>
  );
}

Setup

1. Add Modal Plug

Add the modal headers plug to your router pipeline:

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  import NbInertia.Plugs.ModalHeaders

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug Inertia.Plug
    plug :modal_headers  # Add this line
  end
end

2. Install Dependencies

The modal components use Radix UI for React:

cd assets
npm install @radix-ui/react-dialog

For Vue, the components use Headless UI:

cd assets
npm install @headlessui/vue@latest

3. Import Modal Components

In your Inertia pages, import modal components from the nb_inertia package:

// React
import { Modal } from '@/modals/Modal';
import { ModalLink } from '@/modals/ModalLink';

// Vue
import { Modal } from '@/modals/Modal.vue';
import { ModalLink } from '@/modals/ModalLink.vue';

Related Projects

  • NbRoutes - Type-safe route helpers with form integration
  • NbSerializer - High-performance JSON serialization
  • NbTs - TypeScript type generation and validation
  • NbVite - Vite integration for Phoenix

Documentation

Full documentation is available on HexDocs.

Guides

  • COOKBOOK.md - Patterns and recipes

    • Shared props strategies (web.ex, conditional, organizing modules)
    • Deep merging nested data
    • Testing patterns with examples
    • Common use cases (auth, flash, feature flags, notifications)
    • Best practices
  • MIGRATION.md - Migrating from plain Inertia

    • Step-by-step migration guide
    • Three migration options (minimal, recommended, full type safety)
    • Common migration issues and solutions
    • Rollback plan if needed
    • Timeline estimates by team size
  • DEBUGGING.md - Troubleshooting guide

    • Compile-time errors (missing props, collisions, etc.)
    • Runtime errors (guard functions, camelization, etc.)
    • Props issues (nil handling, performance)
    • Shared props issues (not appearing, overriding)
    • TypeScript issues (generation, stale types)
    • SSR issues (bundle not found, rendering failures)
    • Testing issues
  • RELEASES.md - Deployment and SSR

    • Production deployment guide
    • SSR configuration for Docker, Fly.io, etc.
    • Zero-config default setup
    • Troubleshooting production issues

Quick Links

License

MIT License. See LICENSE for details.

Credits

Built by Nordbeam as part of the NbSerializer ecosystem.