# 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)
}
```
## 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