A web framework for Gleam on the BEAM
One obvious way to build Gleam web apps.
Refrakt is a CLI that generates plain Gleam code you own, built on Wisp, Lustre and Mist. Productive like Phoenix, explicit like Gleam, composable with Wisp and Lustre.
gleam add refraktGenerators
One command in, a whole resource out
gen resource writes the handler, views, form, domain type, repo, migration and test for a resource, wires seven routes into your router, and runs gleam format on all of it.
refrakt gen resource posts \
title:string body:text \
published:bool- web
router.gleamupdated - web
web/post_handler.gleam - web
web/post_views.gleam - web
web/forms/post_form.gleam - domain
domain/post.gleam - data
data/post_repo.gleam - data
data/migrations/001_create_posts.sql - test
test/…/post_handler_test.gleam
Add --api for a JSON handler under /api/, or --belongs-to posts for a foreign key. Prefer SQLite? Create the project with --db sqlite.
The generated code
Code you'd write by hand
Excerpts from a fresh refrakt new blog with a posts resource, unedited.
All routes live in one file. Each route is a case branch on the path segments and the HTTP method. Generators add their routes above the catch-all, with /new before /:id.
src/blog/router.gleam
pub fn handle_request(req: Request, ctx: Context) -> Response {
use req <- middleware(req)
case wisp.path_segments(req), req.method {
[], http.Get -> home_handler.index(req, ctx)
["posts"], http.Get -> post_handler.index(req, ctx)
["posts", "new"], http.Get -> post_handler.new(req, ctx)
["posts"], http.Post -> post_handler.create(req, ctx)
["posts", id], http.Get -> post_handler.show(req, ctx, id)
["posts", id, "edit"], http.Get -> post_handler.edit(req, ctx, id)
["posts", id], http.Put -> post_handler.update(req, ctx, id)
["posts", id], http.Delete -> post_handler.delete(req, ctx, id)
_, _ -> error_handler.not_found(req)
}
}Request first, context second, route parameters after. A handler parses input, calls the repo, and renders a view inside the layout. Invalid forms re-render with a 422; success redirects with a flash message.
src/blog/web/post_handler.gleam
pub fn create(req: Request, ctx: Context) -> Response {
use form_data <- wisp.require_form(req)
case post_form.decode(form_data) {
Error(errors) ->
post_views.form_view(post_form.from_form_data(form_data), errors)
|> root_layout.wrap("New Post")
|> wisp.html_response(422)
Ok(params) ->
case post_repo.create(ctx.db, params) {
Ok(item) ->
wisp.redirect("/posts/" <> int.to_string(item.id))
|> flash.set_flash(req, "info", "Post created")
Error(_) -> error_handler.internal_error(req)
}
}
}Views take data and return Element(Nil). No template language, no Wisp imports: the compiler checks your HTML like any other Gleam code.
src/blog/web/post_views.gleam
pub fn index_view(items: List(Post)) -> Element(Nil) {
section([class("posts")], [
div([class("header")], [
h1([], [text("Posts")]),
a([href("/posts/new"), class("btn")], [text("New Post")]),
]),
ul(
[class("post-list")],
list.map(items, fn(item) {
li([], [
a([href("/posts/" <> int.to_string(item.id))], [
text(item.title),
]),
])
}),
),
])
}Each form has a PostForm for re-rendering and a PostParams for clean input. Validation is a pipeline of refrakt/validate functions, and errors are a list of field and message pairs.
src/blog/web/forms/post_form.gleam
pub fn decode(
data: wisp.FormData,
) -> Result(PostParams, List(#(String, String))) {
let title = get_value(data, "title")
let body = get_value(data, "body")
let errors =
[]
|> validate.required(title, "title", "Title is required")
|> validate.required(body, "body", "Body is required")
case errors {
[] ->
Ok(PostParams(
title: title,
body: body,
published: list.any(data.values, fn(v) { v.0 == "published" }),
))
_ -> Error(errors)
}
}The domain module has no framework or database imports. The repo is raw SQL through Pog (or Sqlight) with a typed decoder, and returns domain types.
src/blog/domain/post.gleam · src/blog/data/post_repo.gleam
pub type Post {
Post(id: Int, title: String, body: String, published: Bool)
}
pub fn list(db: pog.Connection) -> List(Post) {
pog.query("SELECT id, title, body, published FROM posts ORDER BY id DESC")
|> pog.returning(post_decoder())
|> pog.execute(db)
|> result.map(fn(r) { r.rows })
|> result.unwrap([])
}Structure
Three layers, one direction
Every Refrakt app has the same shape. The web layer handles HTTP and HTML, the domain holds your types and logic, the data layer talks to the database.
Domain modules import no Wisp, Lustre, Pog or Sqlight: just your types and functions.
✓ web → domain, data ✓ data → domain ✗ domain → anything
Principles
Five rules Refrakt holds to
Before anything is added to Refrakt, it has to make a new Gleam developer more productive in their first hour.
- Conventions over assembly. One project layout, one routing shape, one way to structure handlers, views, forms and repos.
- Generated code is your code. Every file the CLI writes is plain Gleam you can read, change and keep. There is no hidden framework layer.
- No magic, no macros. Routing is pattern matching, views are functions, validation is functions.
- Built on the ecosystem. Wisp handles HTTP, Lustre renders HTML, Mist serves it, Pog or Sqlight talk to the database.
- Boring where boring is good. CRUD, forms, validation, sessions and layouts come first, and they should just work.
CLI
The commands
A code generator and a few dev helpers. Your app never calls the CLI at runtime.
new <name>- A project, with
--db postgresor--db sqlite gen resource- Full CRUD: handler, views, form, domain, repo, migration, test
gen auth- Register, login, logout, sessions and
require_auth gen page- A handler and a GET route
gen island- A Lustre client-side island
gen live- A Lustre server component over WebSocket
gen migration- An empty, numbered SQL migration
migrate·seed- Run pending migrations, run seeds
routes- Print the route table
dev·build- Rebuild on change, compile islands to JS
Built on
The Gleam ecosystem, not a replacement for it
If Wisp or Lustre already does it, Refrakt doesn't rebuild it.
Build a blog in ten minutes
The tutorial goes from refrakt new to posts, authentication and an about page. Free and open source under the MIT license.