inertia-rails-cookbook

Installation
SKILL.md

Inertia Rails Cookbook

Practical recipes for common patterns and integrations in Inertia Rails applications.

Working with the Official Starter Kits

The official starter kits provide a complete foundation. Here's how to customize them for your needs.

Starter Kit Structure (React)

app/
├── controllers/
│   ├── application_controller.rb    # Shared data setup
│   ├── dashboard_controller.rb      # Example authenticated page
│   ├── home_controller.rb           # Public landing page
│   ├── sessions_controller.rb       # Login/logout
│   ├── users_controller.rb          # Registration
│   ├── identity/                    # Password reset
│   └── settings/                    # User settings
├── frontend/
│   ├── components/
│   │   ├── ui/                      # shadcn/ui components
│   │   ├── nav-main.tsx             # Main navigation
│   │   ├── app-sidebar.tsx          # Sidebar component
│   │   └── user-menu-content.tsx    # User dropdown
│   ├── hooks/
│   │   ├── use-flash.tsx            # Flash message hook
│   │   └── use-appearance.tsx       # Dark mode hook
│   ├── layouts/
│   │   ├── app-layout.tsx           # Main app layout
│   │   ├── auth-layout.tsx          # Auth pages layout
│   │   └── app/
│   │       ├── app-sidebar-layout.tsx
│   │       └── app-header-layout.tsx
│   ├── pages/
│   │   ├── dashboard/index.tsx      # Dashboard page
│   │   ├── home/index.tsx           # Landing page
│   │   ├── sessions/new.tsx         # Login page
│   │   ├── users/new.tsx            # Registration page
│   │   └── settings/                # Settings pages
│   └── types/
│       └── index.ts                 # Shared TypeScript types

Adding a New Resource

1. Generate the controller:

bin/rails generate controller Products index show new create edit update destroy

2. Create the page components:

// app/frontend/pages/products/index.tsx
import { Head, Link } from '@inertiajs/react'
import AppLayout from '@/layouts/app-layout'
import { Button } from '@/components/ui/button'
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'

interface Product {
  id: number
  name: string
  price: number
}

interface Props {
  products: Product[]
}

export default function ProductsIndex({ products }: Props) {
  return (
    <AppLayout>
      <Head title="Products" />

      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Products</h1>
        <Button asChild>
          <Link href="/products/new">Add Product</Link>
        </Button>
      </div>

      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Name</TableHead>
            <TableHead>Price</TableHead>
            <TableHead>Actions</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {products.map((product) => (
            <TableRow key={product.id}>
              <TableCell>{product.name}</TableCell>
              <TableCell>${product.price}</TableCell>
              <TableCell>
                <Link href={`/products/${product.id}/edit`}>Edit</Link>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </AppLayout>
  )
}

3. Update navigation:

// app/frontend/components/nav-main.tsx
const navItems = [
  { title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
  { title: 'Products', href: '/products', icon: Package },  // Add this
  // ...
]

4. Add route:

# config/routes.rb
resources :products

Adding New shadcn/ui Components

The starter kit includes many components, but you can add more:

# Add a specific component
npx shadcn@latest add toast
npx shadcn@latest add calendar
npx shadcn@latest add data-table

# See all available components
npx shadcn@latest add

Customizing the Layout

Switch between sidebar and header layouts:

// app/frontend/layouts/app-layout.tsx
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'
import AppHeaderLayout from '@/layouts/app/app-header-layout'

// Use sidebar (default)
export default function AppLayout({ children }: Props) {
  return <AppSidebarLayout>{children}</AppSidebarLayout>
}

// Or use header layout
export default function AppLayout({ children }: Props) {
  return <AppHeaderLayout>{children}</AppHeaderLayout>
}

Extending Types

// app/frontend/types/index.ts
export interface User {
  id: number
  name: string
  email: string
  avatar_url: string | null
}

// Add your own types
export interface Product {
  id: number
  name: string
  description: string
  price: number
  created_at: string
}

export interface PageProps {
  auth: {
    user: User | null
  }
  flash: {
    success?: string
    error?: string
  }
}

Using the Flash Hook

The starter kit includes a flash message system with Sonner toasts:

// Already set up in the layout, just use flash in your controller
class ProductsController < ApplicationController
  def create
    @product = Product.create(product_params)
    redirect_to products_path, notice: 'Product created!'
  end
end

The use-flash hook automatically displays flash messages as toasts.

Removing Features You Don't Need

Remove settings pages:

rm -rf app/frontend/pages/settings
rm -rf app/controllers/settings
# Remove routes in config/routes.rb

Remove authentication (for internal tools):

rm -rf app/frontend/pages/sessions
rm -rf app/frontend/pages/users
rm -rf app/frontend/pages/identity
rm app/controllers/sessions_controller.rb
rm app/controllers/users_controller.rb
rm -rf app/controllers/identity
# Update routes and ApplicationController

Layout Props (v3)

Share data between pages and their persistent layouts using useLayoutProps:

Static Layout Props

// app/frontend/pages/dashboard/index.tsx
import AppLayout from '@/layouts/app-layout'

export default function Dashboard({ stats }) {
  return <div>Dashboard content</div>
}

// Pass static props to the layout
Dashboard.layout = [AppLayout, { title: 'Dashboard', breadcrumbs: ['Home', 'Dashboard'] }]

Dynamic Layout Props

import { useLayoutProps } from '@inertiajs/react'
import AppLayout from '@/layouts/app-layout'

export default function UserProfile({ user }) {
  // Set layout props dynamically
  useLayoutProps({ title: user.name, breadcrumbs: ['Users', user.name] })

  return <div>{user.name}'s profile</div>
}

UserProfile.layout = AppLayout

In the Layout

// app/frontend/layouts/app-layout.tsx
import { useLayoutProps } from '@inertiajs/react'

export default function AppLayout({ children }) {
  const { title, breadcrumbs } = useLayoutProps()

  return (
    <div>
      <header>
        <h1>{title}</h1>
        <nav>{breadcrumbs?.join(' > ')}</nav>
      </header>
      <main>{children}</main>
    </div>
  )
}

Inertia Modal - Render Pages as Dialogs

The inertia_rails-contrib gem and @inertiaui/modal package let you render any Inertia page as a modal dialog.

Installation

# Ruby gem (optional, for base_url helper)
bundle add inertia_rails-contrib

# NPM package (Vue)
npm install @inertiaui/modal-vue

# NPM package (React)
npm install @inertiaui/modal-react

Setup (React)

// app/frontend/entrypoints/application.js
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { renderApp } from '@inertiaui/modal-react'

createInertiaApp({
  resolve: (name) => {
    const pages = import.meta.glob('../pages/**/*.tsx', { eager: true })
    return pages[`../pages/${name}.tsx`]
  },
  setup({ el, App, props }) {
    createRoot(el).render(renderApp(App, props))
  },
})

Tailwind Configuration

// tailwind.config.js (v3)
module.exports = {
  content: [
    // ... your content paths
    './node_modules/@inertiaui/modal-react/src/**/*.{js,jsx,ts,tsx}',
  ],
}
/* For Tailwind v4 */
@import "tailwindcss";
@source '../../../node_modules/@inertiaui/modal-react';

Basic Usage

Open a page as modal:

import { ModalLink } from '@inertiaui/modal-react'

export default function UsersList() {
  return (
    <ModalLink href="/users/create">
      Create User
    </ModalLink>
  )
}

Wrap page content in Modal:

// pages/users/create.tsx
import { Modal } from '@inertiaui/modal-react'

export default function CreateUser({ roles }) {
  return (
    <Modal>
      <h2>Create User</h2>
      <UserForm roles={roles} />
    </Modal>
  )
}

Modal with Base URL

Enable URL updates and browser history:

Controller:

class UsersController < ApplicationController
  def create
    render inertia_modal: {
      roles: Role.all.as_json
    }, base_url: users_path
  end
end

Link with navigation:

<ModalLink href="/users/create" navigate>
  Create User
</ModalLink>

Now the URL changes to /users/create when opened, supports browser back button, and can be bookmarked.

Slideover Variant

<Modal slideover>
  <h2>User Details</h2>
  {/* Content slides in from the side */}
</Modal>

Nested Modals

<Modal>
  <h2>Edit User</h2>
  <UserForm />

  {/* Open another modal from within */}
  <ModalLink href="/roles/create">
    Add New Role
  </ModalLink>
</Modal>

Closing Modals

import { Modal } from '@inertiaui/modal-react'

export default function EditUser({ onClose }) {
  return (
    <Modal onClose={onClose}>
      <button onClick={onClose}>Cancel</button>
    </Modal>
  )
}

Integrating shadcn/ui

Use shadcn/ui components with Inertia Rails for a polished UI.

Setup (React)

# Initialize shadcn/ui
npx shadcn@latest init

# Add components
npx shadcn@latest add button input card form

Form with shadcn/ui

import { useForm } from '@inertiajs/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'

export default function LoginForm() {
  const { data, setData, post, processing, errors } = useForm({
    email: '',
    password: '',
  })

  function submit(e) {
    e.preventDefault()
    post('/login')
  }

  return (
    <Card className="w-[400px]">
      <CardHeader>
        <CardTitle>Login</CardTitle>
      </CardHeader>
      <CardContent>
        <form onSubmit={submit} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              value={data.email}
              onChange={(e) => setData('email', e.target.value)}
              placeholder="you@example.com"
            />
            {errors.email && (
              <p className="text-sm text-red-500">{errors.email}</p>
            )}
          </div>

          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              type="password"
              value={data.password}
              onChange={(e) => setData('password', e.target.value)}
            />
          </div>

          <Button type="submit" disabled={processing} className="w-full">
            {processing ? 'Signing in...' : 'Sign in'}
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}

Data Table with Sorting and Filtering

import { router, usePage } from '@inertiajs/react'
import { useState, useCallback } from 'react'
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Link } from '@inertiajs/react'
import { useDebouncedCallback } from 'use-debounce'

export default function UsersTable({ users, filters }) {
  const [search, setSearch] = useState(filters.search || '')
  const [sort, setSort] = useState(filters.sort || 'name')
  const [direction, setDirection] = useState(filters.direction || 'asc')

  const debouncedSearch = useDebouncedCallback((value) => {
    router.get('/users', { search: value, sort, direction }, {
      preserveState: true,
      replace: true,
    })
  }, 300)

  function toggleSort(column) {
    const newDirection = sort === column && direction === 'asc' ? 'desc' : 'asc'
    setSort(column)
    setDirection(newDirection)
    router.get('/users', { search, sort: column, direction: newDirection }, {
      preserveState: true,
      replace: true,
    })
  }

  return (
    <div className="space-y-4">
      <Input
        value={search}
        onChange={(e) => {
          setSearch(e.target.value)
          debouncedSearch(e.target.value)
        }}
        placeholder="Search users..."
        className="max-w-sm"
      />

      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>
              <Button variant="ghost" onClick={() => toggleSort('name')}>
                Name {sort === 'name' && (direction === 'asc' ? '↑' : '↓')}
              </Button>
            </TableHead>
            <TableHead>
              <Button variant="ghost" onClick={() => toggleSort('email')}>
                Email {sort === 'email' && (direction === 'asc' ? '↑' : '↓')}
              </Button>
            </TableHead>
            <TableHead>Actions</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {users.map((user) => (
            <TableRow key={user.id}>
              <TableCell>{user.name}</TableCell>
              <TableCell>{user.email}</TableCell>
              <TableCell>
                <Link href={`/users/${user.id}/edit`}>Edit</Link>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

Search with Filters

Controller

class UsersController < ApplicationController
  def index
    users = User.all

    # Apply search
    if params[:search].present?
      users = users.where('name ILIKE ? OR email ILIKE ?',
        "%#{params[:search]}%", "%#{params[:search]}%")
    end

    # Apply filters
    users = users.where(role: params[:role]) if params[:role].present?
    users = users.where(active: params[:active]) if params[:active].present?

    # Apply sorting
    sort_column = %w[name email created_at].include?(params[:sort]) ? params[:sort] : 'name'
    sort_direction = params[:direction] == 'desc' ? 'desc' : 'asc'
    users = users.order("#{sort_column} #{sort_direction}")

    # Paginate
    users = users.page(params[:page]).per(20)

    render inertia: {
      users: users.as_json(only: [:id, :name, :email, :role, :active]),
      filters: {
        search: params[:search],
        role: params[:role],
        active: params[:active],
        sort: sort_column,
        direction: sort_direction,
      },
      pagination: {
        current_page: users.current_page,
        total_pages: users.total_pages,
        total_count: users.total_count,
      }
    }
  end
end

Frontend with URL Sync

import { router } from '@inertiajs/react'
import { useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'

export default function UsersIndex({ users, filters, pagination }) {
  const [search, setSearch] = useState(filters.search || '')
  const [role, setRole] = useState(filters.role || '')
  const [active, setActive] = useState(filters.active || '')

  function applyFilters(overrides = {}) {
    router.get('/users', {
      search: search || undefined,
      role: role || undefined,
      active: active || undefined,
      ...overrides,
    }, {
      preserveState: true,
      replace: true,
    })
  }

  const debouncedSearch = useDebouncedCallback(() => applyFilters(), 300)

  function clearFilters() {
    setSearch('')
    setRole('')
    setActive('')
    router.get('/users', {}, { preserveState: true, replace: true })
  }

  return (
    <div className="space-y-4">
      <div className="flex gap-4">
        <input
          value={search}
          onChange={(e) => { setSearch(e.target.value); debouncedSearch() }}
          placeholder="Search..."
          className="input"
        />

        <select value={role} onChange={(e) => { setRole(e.target.value); applyFilters({ role: e.target.value }) }} className="select">
          <option value="">All Roles</option>
          <option value="admin">Admin</option>
          <option value="user">User</option>
        </select>

        <select value={active} onChange={(e) => { setActive(e.target.value); applyFilters({ active: e.target.value }) }} className="select">
          <option value="">All Status</option>
          <option value="true">Active</option>
          <option value="false">Inactive</option>
        </select>

        <button onClick={clearFilters}>Clear</button>
      </div>

      <UserTable users={users} />
      <Pagination pagination={pagination} />
    </div>
  )
}

Multi-Step Wizard

Controller

class OnboardingController < ApplicationController
  def show
    step = params[:step]&.to_i || 1

    render inertia: "onboarding/step#{step}", props: {
      step: step,
      total_steps: 4,
      data: session[:onboarding] || {}
    }
  end

  def update
    step = params[:step].to_i

    # Merge step data into session
    session[:onboarding] ||= {}
    session[:onboarding].merge!(step_params.to_h)

    if step < 4
      redirect_to onboarding_path(step: step + 1)
    else
      # Complete onboarding
      User.create!(session[:onboarding])
      session.delete(:onboarding)
      redirect_to dashboard_path, notice: 'Welcome!'
    end
  end

  private

  def step_params
    case params[:step].to_i
    when 1 then params.permit(:name, :email)
    when 2 then params.permit(:company, :role)
    when 3 then params.permit(:preferences)
    when 4 then params.permit(:terms_accepted)
    end
  end
end

Wizard Component

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

export default function WizardStep({ step, total_steps, data, children }) {
  const form = useForm(data)

  function next(e) {
    e.preventDefault()
    form.post(`/onboarding?step=${step}`)
  }

  function back() {
    router.get(`/onboarding?step=${step - 1}`)
  }

  return (
    <div>
      {/* Progress indicator */}
      <div className="flex gap-2 mb-8">
        {Array.from({ length: total_steps }, (_, i) => i + 1).map((i) => (
          <div
            key={i}
            className={`w-8 h-8 rounded-full flex items-center justify-center ${
              i <= step ? 'bg-blue-500 text-white' : 'bg-gray-200'
            }`}
          >
            {i}
          </div>
        ))}
      </div>

      <form onSubmit={next}>
        {typeof children === 'function' ? children({ form }) : children}

        <div className="flex gap-4 mt-8">
          {step > 1 && (
            <button type="button" onClick={back}>Back</button>
          )}
          <button type="submit" disabled={form.processing}>
            {step === total_steps ? 'Complete' : 'Next'}
          </button>
        </div>
      </form>
    </div>
  )
}

Flash Messages with Toast

Shared Data Setup

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  inertia_share flash: -> {
    {
      success: flash.notice,
      error: flash.alert,
      info: flash[:info],
      warning: flash[:warning]
    }.compact
  }
end

Toast Component (React)

// components/FlashMessages.tsx
import { usePage } from '@inertiajs/react'
import { useEffect, useState } from 'react'

interface Toast {
  id: number
  type: string
  message: string
}

export default function FlashMessages() {
  const { flash } = usePage().props
  const [toasts, setToasts] = useState<Toast[]>([])

  useEffect(() => {
    if (!flash) return

    Object.entries(flash).forEach(([type, message]) => {
      if (message) {
        const id = Date.now() + Math.random()
        setToasts((prev) => [...prev, { id, type, message: message as string }])

        setTimeout(() => {
          setToasts((prev) => prev.filter((t) => t.id !== id))
        }, 5000)
      }
    })
  }, [flash])

  const colorMap: Record<string, string> = {
    success: 'bg-green-500 text-white',
    error: 'bg-red-500 text-white',
    info: 'bg-blue-500 text-white',
    warning: 'bg-yellow-500 text-black',
  }

  return (
    <div className="fixed top-4 right-4 space-y-2 z-50">
      {toasts.map((toast) => (
        <div
          key={toast.id}
          className={`px-4 py-3 rounded-lg shadow-lg transition-all ${colorMap[toast.type] || 'bg-gray-500 text-white'}`}
        >
          {toast.message}
        </div>
      ))}
    </div>
  )
}

Usage in Layout

// layouts/AppLayout.tsx
import FlashMessages from '@/components/FlashMessages'

export default function AppLayout({ children }) {
  return (
    <div>
      <FlashMessages />
      <nav>{/* ... */}</nav>
      <main>{children}</main>
    </div>
  )
}

Flash API (v3)

Inertia.js v3 introduces a dedicated Flash API for richer flash data beyond simple key-value strings.

Server-Side

class PostsController < ApplicationController
  def create
    post = Post.create!(post_params)

    # Standard Rails flash (exposed via flash_keys config)
    redirect_to posts_path, notice: 'Post created!'
  end

  def update
    post = Post.find(params[:id])

    # Rich flash data via flash.inertia
    flash.inertia[:undo] = { url: undo_post_path(post), expires_in: 30 }

    # Current-request-only flash
    flash.now.inertia[:notification] = { type: 'info', message: 'Saving...' }

    redirect_to post_path(post)
  end
end

Frontend

import { usePage, router } from '@inertiajs/react'

function FlashHandler() {
  const { flash } = usePage().props

  // Access standard flash
  if (flash.notice) showToast(flash.notice)

  // Access rich flash data
  if (flash.undo) {
    showUndoToast(flash.undo.message, () => {
      router.post(flash.undo.url)
    })
  }
}

// Client-side flash (no server roundtrip)
router.flash({ notice: 'Saved locally' })

Configure Flash Keys

InertiaRails.configure do |config|
  config.flash_keys = %i[notice alert success error warning info]
end

Confirmation Dialogs

Reusable Confirm Component

// components/ConfirmDialog.tsx
import { useState, useCallback, useRef } from 'react'

interface ConfirmOptions {
  title?: string
  message?: string
  confirmText?: string
  cancelText?: string
  destructive?: boolean
}

export function useConfirm() {
  const [isOpen, setIsOpen] = useState(false)
  const [options, setOptions] = useState<ConfirmOptions>({})
  const resolveRef = useRef<((value: boolean) => void) | null>(null)

  const confirm = useCallback((opts: ConfirmOptions = {}) => {
    setOptions({
      title: 'Are you sure?',
      message: 'This action cannot be undone.',
      confirmText: 'Confirm',
      cancelText: 'Cancel',
      destructive: false,
      ...opts,
    })
    setIsOpen(true)

    return new Promise<boolean>((resolve) => {
      resolveRef.current = resolve
    })
  }, [])

  function handleConfirm() {
    setIsOpen(false)
    resolveRef.current?.(true)
  }

  function handleCancel() {
    setIsOpen(false)
    resolveRef.current?.(false)
  }

  const ConfirmDialog = isOpen ? (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="absolute inset-0 bg-black/50" onClick={handleCancel} />
      <div className="relative bg-white rounded-lg p-6 max-w-md w-full mx-4">
        <h3 className="text-lg font-semibold">{options.title}</h3>
        <p className="mt-2 text-gray-600">{options.message}</p>
        <div className="mt-6 flex gap-3 justify-end">
          <button onClick={handleCancel} className="btn-secondary">
            {options.cancelText}
          </button>
          <button
            onClick={handleConfirm}
            className={options.destructive ? 'btn-danger' : 'btn-primary'}
          >
            {options.confirmText}
          </button>
        </div>
      </div>
    </div>
  ) : null

  return { confirm, ConfirmDialog }
}

Usage

import { router } from '@inertiajs/react'
import { useConfirm } from '@/components/ConfirmDialog'

export default function UserRow({ user }) {
  const { confirm, ConfirmDialog } = useConfirm()

  async function deleteUser() {
    const confirmed = await confirm({
      title: 'Delete User',
      message: `Are you sure you want to delete ${user.name}?`,
      confirmText: 'Delete',
      destructive: true,
    })

    if (confirmed) {
      router.delete(`/users/${user.id}`)
    }
  }

  return (
    <div>
      <button onClick={deleteUser}>Delete</button>
      {ConfirmDialog}
    </div>
  )
}

Handling Rails Validation Error Types

Rails returns different error formats. Handle them consistently:

# Controller helper
def format_errors(model)
  model.errors.to_hash.transform_values { |messages| messages.first }
end

# Usage
redirect_to edit_user_url(user), inertia: { errors: format_errors(user) }
// Frontend - errors are now { field: 'message' } format
form.errors.email  // "can't be blank"

Nested Model Errors

# For nested attributes
def format_nested_errors(model)
  errors = {}

  model.errors.each do |error|
    key = error.attribute.to_s.gsub('.', '_')
    errors[key] = error.message
  end

  errors
end

Real-Time Features with ActionCable

Setup Turbo Streams Alternative

// channels/notifications_channel.js
import { router } from '@inertiajs/react'
import consumer from './consumer'

consumer.subscriptions.create('NotificationsChannel', {
  received(data) {
    if (data.reload) {
      router.reload({ only: ['notifications'] })
    }
  }
})

Controller Broadcast

class NotificationsController < ApplicationController
  def create
    notification = current_user.notifications.create!(notification_params)

    ActionCable.server.broadcast(
      "notifications_#{current_user.id}",
      { reload: true }
    )

    redirect_to notifications_path
  end
end

File Downloads

Triggering Downloads

def download
  report = Report.find(params[:id])

  # Return download URL as prop
  render inertia: {
    download_url: rails_blob_path(report.file, disposition: 'attachment')
  }
end
// Trigger download without navigation
function downloadFile(url) {
  window.location.href = url
}

// Or use inertia_location for non-Inertia responses
router.visit(url, { method: 'get' })

External Redirect for Downloads

def export
  # Generate file...
  inertia_location export_download_path(token: token)
end
Related skills
Installs
52
GitHub Stars
31
First Seen
Jan 28, 2026