Inertia Wisp

Package Version Hex Docs

An Inertia.js adapter for Gleam and the Wisp web framework.

Installation

gleam add inertia_wisp

Quick Start

1. Define Your HTML Layout

First, create a layout function that generates your HTML document structure. You can use Lustre’s HTML builder or the built-in default layout:

// src/layout.gleam
import gleam/json
import lustre/attribute
import lustre/element
import lustre/element/html

/// Custom HTML layout built with Lustre
pub fn html_layout(component_name: String, app_data: json.Json) -> String {
  html.html([attribute.attribute("lang", "en")], [
    html.head([], [
      html.meta([attribute.attribute("charset", "utf-8")]),
      html.meta([
        attribute.name("viewport"),
        attribute.attribute("content", "width=device-width, initial-scale=1"),
      ]),
      html.title([], component_name),
      html.link([
        attribute.rel("stylesheet"),
        attribute.href("/static/css/styles.css"),
      ]),
    ]),
    html.body([], [
      html.div(
        [
          attribute.id("app"),
          attribute.attribute("data-page", json.to_string(app_data)),
        ],
        [],
      ),
      html.script(
        [attribute.type_("module"), attribute.src("/static/js/main.js")],
        "",
      ),
    ]),
  ])
  |> element.to_document_string()
}

Alternatively, use the built-in default layout:

import inertia_wisp/html

// Use html.default_layout for a simple starting point

2. Basic Server Setup

import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
import inertia_wisp/inertia
import layout  // Your custom layout module

pub fn main() {
  wisp.configure_logger()

  let assert Ok(_) =
    fn(req) { handle_request(req) }
    |> wisp_mist.handler("your_secret_key")
    |> mist.new
    |> mist.port(8000)
    |> mist.start_http

  process.sleep_forever()
}

fn handle_request(req: wisp.Request) -> wisp.Response {
  use <- wisp.serve_static(req, from: "./static", under: "/static")

  case wisp.path_segments(req) {
    [] -> home_page(req)
    ["about"] -> about_page(req)
    _ -> wisp.not_found()
  }
}

3. Create Your Pages

Define your page props type and create pages:

import gleam/dict
import gleam/json
import inertia_wisp/inertia

// Define your props type
pub type HomePageProps {
  HomePageProps(
    message: String,
    user: String,
    count: Int,
  )
}

// Create encoder for your props
fn encode_home_page_props(props: HomePageProps) -> dict.Dict(String, json.Json) {
  dict.from_list([
    #("message", json.string(props.message)),
    #("user", json.string(props.user)),
    #("count", json.int(props.count)),
  ])
}

fn home_page(req: wisp.Request) -> wisp.Response {
  let props = HomePageProps(
    message: "Hello from Gleam!",
    user: "Alice",
    count: 42,
  )

  req
  |> inertia.response_builder("Home")
  |> inertia.props(props, encode_home_page_props)
  |> inertia.response(200, layout.html_layout)
}

fn about_page(req: wisp.Request) -> wisp.Response {
  let props = AboutPageProps(title: "About Us")

  req
  |> inertia.response_builder("About")
  |> inertia.props(props, encode_about_page_props)
  |> inertia.response(200, layout.html_layout)
}

4. Frontend Setup

Install Dependencies

Create a frontend/ directory and add a package.json. You can use your preferred JavaScript build tools; this example uses ESBuild with code splitting enabled:

{
  "name": "my-app-frontend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "esbuild src/main.tsx --bundle --outdir=../priv/static/js --format=esm --splitting --jsx=automatic --minify",
    "watch": "esbuild src/main.tsx --bundle --outdir=../priv/static/js --format=esm --splitting --jsx=automatic --watch"
  },
  "dependencies": {
    "@inertiajs/react": "^2.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "esbuild": "^0.19.0",
    "typescript": "^5.3.0"
  }
}

Install dependencies:

npm install

Create Entry Point

Create src/main.tsx:

import React from "react";
import { createRoot } from "react-dom/client";
import { createInertiaApp } from "@inertiajs/react";

createInertiaApp({
  title: (title) => `${title} - My App`,
  resolve: async (name) => {
    const module = await import(`./Pages/${name}.tsx`);
    return module.default;
  },
  setup({ el, App, props }) {
    const root = createRoot(el);
    root.render(<App {...props} />);
  },
  progress: {
    color: "#9333ea",
  },
});

Create React Components

Create your React components in src/Pages/ that correspond to your Gleam page components:

// src/Pages/Home.tsx
import { Link } from '@inertiajs/react'

export default function Home({ message, user, count }: {
  message: string;
  user: string;
  count: number;
}) {
  return (
    <div>
      <h1>Welcome {user}!</h1>
      <p>{message}</p>
      <p>Count: {count}</p>
      <Link href="/about">Go to About</Link>
    </div>
  );
}

// src/Pages/About.tsx
import { Link } from '@inertiajs/react'

export default function About({ title }: { title: string }) {
  return (
    <div>
      <h1>{title}</h1>
      <Link href="/">Back to Home</Link>
    </div>
  );
}

Build Your Frontend

From the frontend/ directory, build for production:

npm run build

Or watch for changes during development:

npm run watch

This will output your bundled JavaScript to ../priv/static/js/, which your Gleam server will serve.

Advanced Usage

Lazy, Optional, and Always Props

import gleam/option.{type Option}

pub type DashboardProps {
  DashboardProps(
    auth: User,              // Always included
    stats: Stats,            // Default prop
    notifications: Option(List(Notification)),  // Lazy-loaded
  )
}

fn dashboard_page(req: wisp.Request) -> wisp.Response {
  let props = DashboardProps(
    auth: get_current_user(req),
    stats: get_user_stats(),
    notifications: option.None,  // Not loaded initially
  )

  req
  |> inertia.response_builder("Dashboard")
  |> inertia.props(props, encode_dashboard_props)
  |> inertia.always("auth")  // Include in all requests, even partial reloads
  |> inertia.lazy("notifications", fn(props) {
      // Lazy evaluation - only computed when needed
      Ok(DashboardProps(
        ..props,
        notifications: option.Some(get_notifications()),
      ))
    })
  |> inertia.response(200, layout.html_layout)
}

Deferred Props

Deferred props are loaded after the initial page render, perfect for heavy background operations:

import gleam/option.{type Option, Some, None}
import gleam/erlang/process

pub type AnalyticsPageProps {
  AnalyticsPageProps(
    basic_stats: Stats,
    heavy_report: Option(Report),
  )
}

fn analytics_page(req: wisp.Request) -> wisp.Response {
  let props = AnalyticsPageProps(
    basic_stats: get_basic_stats(),
    heavy_report: None,
  )

  req
  |> inertia.response_builder("Analytics")
  |> inertia.props(props, encode_analytics_props)
  |> inertia.defer("heavy_report", fn(props) {
      // This runs in a separate request after page loads
      process.sleep(2000)  // Simulate expensive operation
      Ok(AnalyticsPageProps(
        ..props,
        heavy_report: Some(generate_heavy_report()),
      ))
    })
  |> inertia.response(200, layout.html_layout)
}

Form Handling and Validation

import gleam/dict

pub type ContactFormProps {
  ContactFormProps(
    name: String,
    email: String,
    message: String,
  )
}

pub fn show_contact_form(req: wisp.Request) -> wisp.Response {
  let props = ContactFormProps(name: "", email: "", message: "")

  req
  |> inertia.response_builder("ContactForm")
  |> inertia.props(props, encode_contact_form_props)
  |> inertia.response(200, layout.html_layout)
}

pub fn submit_contact_form(req: wisp.Request) -> wisp.Response {
  use json_data <- wisp.require_json(req)

  let form_data = decode_contact_form(json_data)

  case validate_contact_form(form_data) {
    Ok(_) -> {
      // Success - redirect
      wisp.redirect("/thank-you")
    }
    Error(validation_errors) -> {
      // Re-render with errors
      req
      |> inertia.response_builder("ContactForm")
      |> inertia.errors(validation_errors)
      |> inertia.redirect("/contact")
    }
  }
}

fn validate_contact_form(form) -> Result(_, dict.Dict(String, String)) {
  let errors = dict.new()

  // Add validation logic
  let errors = case form.email {
    "" -> dict.insert(errors, "email", "Email is required")
    _ -> errors
  }

  case dict.is_empty(errors) {
    True -> Ok(form)
    False -> Error(errors)
  }
}

Merge Props

Merge props allow efficient client-side merging of data, useful for infinite scroll or pagination:

import gleam/option

pub type UsersListProps {
  UsersListProps(
    users: List(User),
    page: Int,
  )
}

fn users_list(req: wisp.Request, page: Int) -> wisp.Response {
  let props = UsersListProps(
    users: get_users(page),
    page: page,
  )

  req
  |> inertia.response_builder("UsersList")
  |> inertia.props(props, encode_users_list_props)
  |> inertia.merge("users", match_on: option.Some(["id"]), deep: False)
  |> inertia.response(200, layout.html_layout)
}

Sharing Types Between Frontend and Backend

For larger applications, you can share types, encoders, decoders, and validation logic between your Gleam backend and TypeScript frontend by creating a separate shared package.

Repository Structure

Organize your project with three packages:

my-app/
├── backend/           # Gleam backend (target: erlang)
│   ├── src/
│   ├── gleam.toml
│   └── priv/static/   # Compiled frontend assets
├── frontend/          # TypeScript/React frontend
│   ├── src/
│   ├── package.json
│   └── tsconfig.json
└── shared/            # Shared Gleam code (target: javascript)
    ├── src/shared/
    ├── gleam.toml
    └── manifest.toml

Setting Up the Shared Package

Create shared/gleam.toml:

name = "shared"
version = "1.0.0"
target = "javascript"

[dependencies]
gleam_stdlib = ">= 0.60.0"
gleam_json = ">= 3.0.0"

[javascript]
typescript_declarations = true

Define Shared Types and Codecs

Create shared/src/shared/todo_item.gleam:

import gleam/dict
import gleam/dynamic/decode
import gleam/json.{type Json}

// Types
pub type Todo {
  Todo(id: Int, text: String, completed: Bool)
}

pub type TodoProps {
  TodoProps(todos: List(Todo))
}

pub type AddTodoRequest {
  AddTodoRequest(text: String)
}

// Encoders (Gleam → JSON)
pub fn encode_todo(item: Todo) -> Json {
  json.object([
    #("id", json.int(item.id)),
    #("text", json.string(item.text)),
    #("completed", json.bool(item.completed)),
  ])
}

pub fn encode_todo_props(props: TodoProps) -> dict.Dict(String, json.Json) {
  dict.from_list([
    #("todos", json.array(props.todos, encode_todo)),
  ])
}

pub fn encode_add_todo_request(request: AddTodoRequest) -> Json {
  json.object([#("text", json.string(request.text))])
}

// Decoders (JSON → Gleam)
pub fn decode_todo() -> decode.Decoder(Todo) {
  use id <- decode.field("id", decode.int)
  use text <- decode.field("text", decode.string)
  use completed <- decode.field("completed", decode.bool)
  decode.success(Todo(id:, text:, completed:))
}

pub fn decode_todo_props() -> decode.Decoder(TodoProps) {
  use todos <- decode.field("todos", decode.list(decode_todo()))
  decode.success(TodoProps(todos:))
}

pub fn decode_add_todo_request() -> decode.Decoder(AddTodoRequest) {
  use text <- decode.field("text", decode.string)
  decode.success(AddTodoRequest(text:))
}

Using Shared Types in Backend

Add the shared package to backend/gleam.toml:

[dependencies]
shared = { path = "../shared" }

Use the shared types in your handlers:

import shared/todo_item.{Todo, TodoProps, AddTodoRequest}
import gleam/dynamic/decode

pub fn show_todos(req: Request) -> Response {
  let props = TodoProps(todos: [])

  req
  |> inertia.response_builder("TodoList")
  |> inertia.props(props, todo_item.encode_todo_props)
  |> inertia.response(200, layout.html_layout)
}

pub fn add_todo_item(req: Request) -> Response {
  use json_body <- wisp.require_json(req)
  let assert Ok(add_request) = decode.run(json_body, todo_item.decode_add_todo_request())

  let new_item = Todo(id: generate_id(), text: add_request.text, completed: False)
  let props = TodoProps(todos: [new_item])

  req
  |> inertia.response_builder("TodoList")
  |> inertia.props(props, todo_item.encode_todo_props)
  |> inertia.merge("todos", match_on: option.Some(["id"]), deep: False)
  |> inertia.response(200, layout.html_layout)
}

Using Shared Types in Frontend

Configure TypeScript to find the compiled Gleam code. Update frontend/tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@gleam/*": ["../shared/build/dev/javascript/*"]
      "@shared/*": ["../shared/build/dev/javascript/shared/shared/*"]
    }
  }
}

Gleam 1.13 and later compiles to JavaScript with verbose accessor names. Create a wrapper file to re-export with cleaner names:

// frontend/src/lib/todo_item.ts
export {
  // Types
  type Todo$,
  type TodoProps$,
  type AddTodoRequest$,
  // Constructors
  Todo$Todo as createTodo,
  AddTodoRequest$AddTodoRequest as createAddTodoRequest,
  // Accessors
  TodoProps$TodoProps$todos as getTodos,
  Todo$Todo$id as getId,
  Todo$Todo$text as getText,
  Todo$Todo$completed as getCompleted,
  // Codecs
  decode_todo_props,
  encode_add_todo_request,
} from "@shared/todo_item.mjs";

Use the shared types in your React components:

// frontend/src/Pages/TodoList.tsx
import { router } from "@inertiajs/react";
import * as TodoItem from "../lib/todo_item";

function TodoList(props: TodoItem.TodoProps$) {
  const todos = Array.from(TodoItem.getTodos(props));

  const handleAddTodo = (text: string) => {
    const request = TodoItem.createAddTodoRequest(text);
    router.post("/todo/add", TodoItem.encode_add_todo_request(request), {
      preserveScroll: true,
      only: ["todos"],
    });
  };

  return (
    <div>
      {todos.map((item) => (
        <div key={TodoItem.getId(item)}>
          <span>{TodoItem.getText(item)}</span>
          <input
            type="checkbox"
            checked={TodoItem.getCompleted(item)}
            onChange={() => handleToggle(item)}
          />
        </div>
      ))}
    </div>
  );
}

export default TodoList;

Runtime Props Validation with decodeProps

To ensure type safety at runtime, you can create a higher-order component that validates props using Gleam decoders before rendering:

// frontend/src/lib/decodeProps.tsx
import { ComponentType } from "react";
import * as Decode from "@gleam/gleam_stdlib/gleam/dynamic/decode.mjs";
import {
  Result$Error$0 as unwrapError,
  Result$isOk as isOk,
  Result$Ok$0 as unwrapOk,
} from "@gleam/prelude.mjs";
import { Dynamic$ } from "@gleam/gleam_stdlib/gleam/dynamic.mjs";

export function decodeProps<P extends object>(
  Component: ComponentType<P>,
  decoder: Decode.Decoder$<P>,
) {
  return function ValidatedComponent(props: Dynamic$) {
    const result = Decode.run(props, decoder);

    if (isOk(result)) {
      const decodedProps = unwrapOk(result)!;
      return <Component {...decodedProps} />;
    } else {
      // Decoding failed - show error UI
      const errorsList = unwrapError(result)!;
      const errors = Array.from(errorsList);

      console.error("Props validation failed:", errors);

      return (
        <div className="error-container">
          <h1>Props Validation Failed</h1>
          <pre>{JSON.stringify(errors, null, 2)}</pre>
        </div>
      );
    }
  };
}

Wrap your component exports with decodeProps:

// frontend/src/Pages/TodoList.tsx
import { router } from "@inertiajs/react";
import * as TodoItem from "../lib/todo_item";
import { decodeProps } from "../lib/decodeProps";

function TodoList(props: TodoItem.TodoProps$) {
  const todos = Array.from(TodoItem.getTodos(props));
  // ... component implementation
}

// Validate props at runtime using the same Gleam decoder
export default decodeProps(TodoList, TodoItem.decode_todo_props());

This approach provides ensures your frontend component only ever renders with valid props explicitly decoded for type safety.

Build Process

Build the shared package with the javascript target before building frontend:

# Build shared package
cd shared && gleam build --target javascript

# Build frontend
cd frontend && npm run build

Documentation

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests.

License

MIT

Search Document