# Inertia Wisp
[](https://hex.pm/packages/inertia_wisp)
[](https://hexdocs.pm/inertia_wisp/)
An [Inertia.js](https://inertiajs.com/) adapter for Gleam and the [Wisp](https://github.com/gleam-wisp/wisp) web framework.
## Installation
```sh
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:
```gleam
// 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:
```gleam
import inertia_wisp/html
// Use html.default_layout for a simple starting point
```
### 2. Basic Server Setup
```gleam
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:
```gleam
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:
```json
{
"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:
```bash
npm install
```
#### Create Entry Point
Create `src/main.tsx`:
```typescript
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:
```tsx
// 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:
```bash
npm run build
```
Or watch for changes during development:
```bash
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
```gleam
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:
```gleam
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
```gleam
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:
```gleam
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)
}
```
### Asset Versioning
Inertia.js supports asset versioning to track when your frontend assets change. When the client's asset version doesn't match the server's version, Inertia forces a full page reload to fetch fresh assets.
The recommended approach is to create an application context that holds shared configuration like the asset version and layout function, then pass this context through to your handlers.
#### Context module
Define a context type that holds shared configuration:
```gleam
// src/app_context.gleam
import gleam/erlang/atom.{type Atom}
import gleam/erlang/charlist.{type Charlist}
import gleam/json
import layout
/// Application context containing shared configuration and functions
pub type Context {
Context(
/// The application version from gleam.toml
version: String,
/// HTML layout function for rendering Inertia responses
layout: fn(String, json.Json) -> String,
)
}
/// Create a new application context, initializing all shared values
pub fn new() -> Context {
Context(version: app_version(), layout: layout.html_layout)
}
@external(erlang, "application", "get_key")
fn get_application_key(app: Atom, key: Atom) -> Result(Charlist, Atom)
/// Get the application version from the compiled .app file.
/// Returns the version string from gleam.toml, or "1.0.0" as a fallback.
fn app_version() -> String {
let app_name = atom.create("my_app") // Replace with your app name
let vsn_key = atom.create("vsn")
case get_application_key(app_name, vsn_key) {
Ok(version_charlist) -> charlist.to_string(version_charlist)
Error(_) -> "1.0.0"
}
}
```
Initialize the context in your applications main function and use from handlers:
```gleam
fn home_page(req: wisp.Request, ctx: Context) -> wisp.Response {
let props = HomePageProps(
message: "Hello from Gleam!",
user: "Alice",
)
req
|> inertia.response_builder("Home")
|> inertia.props(props, encode_home_page_props)
|> inertia.version(ctx.version) // Use version from context
|> inertia.response(200, ctx.layout) // Use layout from context
}
```
## 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`:
```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`:
```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`:
```toml
[dependencies]
shared = { path = "../shared" }
```
Use the shared types in your handlers:
```gleam
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`:
```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:
```typescript
// 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:
```tsx
// 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:
```typescript
// 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`:
```tsx
// 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:
```bash
# Build shared package
cd shared && gleam build --target javascript
# Build frontend
cd frontend && npm run build
```
## Documentation
- **[API Documentation](https://hexdocs.pm/inertia_wisp)** - Complete API reference
- **[Inertia.js Documentation](https://inertiajs.com/)** - Official Inertia.js docs
## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests.
## License
MIT