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 refrakt

Generators

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.

blog
refrakt gen resource posts \
  title:string body:text \
  published:bool
  • webrouter.gleamupdated
  • webweb/post_handler.gleam
  • webweb/post_views.gleam
  • webweb/forms/post_form.gleam
  • domaindomain/post.gleam
  • datadata/post_repo.gleam
  • datadata/migrations/001_create_posts.sql
  • testtest/…/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/handlers, views, forms, layouts, middleware
domain/pure Gleam types and functions
data/repos with raw SQL, migrations

✓ 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.

  1. Conventions over assembly. One project layout, one routing shape, one way to structure handlers, views, forms and repos.
  2. 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.
  3. No magic, no macros. Routing is pattern matching, views are functions, validation is functions.
  4. Built on the ecosystem. Wisp handles HTTP, Lustre renders HTML, Mist serves it, Pog or Sqlight talk to the database.
  5. 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 postgres or --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

Full CLI reference →

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.