Dev server

Blogatto includes a built-in development server that watches your source files for changes, automatically rebuilds the site, and live-reloads the browser. This eliminates the need for Docker or external servers during development.

Overview

The dev server combines three capabilities into a single dev.start() call:

  1. File watching β€” monitors src/, markdown paths, and static assets for changes using filespy
  2. Auto-rebuild β€” shells out to a configurable build command with debouncing (~300ms) to batch rapid saves
  3. Live reload β€” injects a small script into HTML responses that reloads the browser via Server-Sent Events (SSE) after each successful rebuild

Setup

The dev server needs its own entrypoint module, separate from your build script. This is because the dev server runs your build command as a subprocess β€” it shells out to gleam run (or whatever you configure) to rebuild the site.

1. Extract your config into a shared module

If your build configuration lives directly in main(), move it to a separate module so both the build script and dev server can use it:

// src/my_blog/blog.gleam

import blogatto/config
import blogatto/config/markdown

pub fn config() -> config.Config(Nil) {
  let md =
    markdown.default()
    |> markdown.markdown_path("./blog")
    |> markdown.route_prefix("blog")

  config.new("https://example.com")
  |> config.output_dir("./dist")
  |> config.static_dir("./static")
  |> config.markdown(md)
  |> config.route("/", home_view)
}

// ... view functions ...

2. Update your build script

// src/my_blog.gleam

import blogatto
import blogatto/error
import gleam/io
import my_blog/blog

pub fn main() {
  case blogatto.build(blog.config()) {
    Ok(Nil) -> io.println("Site built successfully!")
    Error(err) -> io.println("Build failed: " <> error.describe_error(err))
  }
}

3. Create a dev entrypoint

// src/my_blog/dev.gleam

import blogatto/dev
import blogatto/error
import gleam/io
import my_blog/blog

pub fn main() {
  case
    blog.config()
    |> dev.new()
    |> dev.build_command("gleam run -m my_blog")
    |> dev.port(3000)
    |> dev.start()
  {
    Ok(Nil) -> io.println("Dev server stopped.")
    Error(err) -> io.println("Dev server error: " <> error.describe_error(err))
  }
}

4. Run the dev server

gleam run -m my_blog/dev

The server starts, performs an initial build, then watches for changes:

πŸ”¨ blogatto dev server starting...
πŸ‘€ Watching for file changes...
πŸ‘€ Watching: ./static
πŸ‘€ Watching: ./src/
πŸ‘€ Watching: ./blog

⟳ Rebuilding...
βœ“ Rebuild complete
  β†’  http://127.0.0.1:3000
  Output: ./dist

Open http://127.0.0.1:3000 in your browser. When you save a file, the site rebuilds and the browser reloads automatically.

Configuration

The DevServer type uses the same builder pattern as the rest of Blogatto:

blog.config()
|> dev.new()
|> dev.build_command("gleam run -m my_blog")
|> dev.port(8080)
|> dev.host("0.0.0.0")
|> dev.live_reload(False)
|> dev.before_build(fn() {
  io.println("Starting build...")
  Ok(Nil)
})
|> dev.after_build(fn() {
  io.println("Build finished!")
  Ok(Nil)
})
|> dev.start()

dev.new(config)

Creates a new DevServer from a Blogatto Config. The config is used to derive:

dev.build_command(server, command)

Set the shell command executed on each rebuild. Default: "gleam run".

The build command can be anything: gleam run -m my_blog, make build, a shell script, etc. Each invocation recompiles the project and runs your build logic, so source changes are picked up naturally without BEAM hot-reloading.

dev.new(config)
|> dev.build_command("gleam run -m my_blog")

dev.port(server, port)

Set the HTTP server port. Default: 3000.

dev.new(config)
|> dev.port(8080)

dev.host(server, host)

Set the bind address. Default: "127.0.0.1".

To make the server accessible from other devices on your network:

dev.new(config)
|> dev.host("0.0.0.0")

dev.live_reload(server, enabled)

Enable or disable live-reload script injection. Default: True.

When enabled, the dev server:

  1. Injects a small <script> tag before </body> in all served HTML responses
  2. Exposes an SSE endpoint at /__blogatto_dev/reload
  3. After each successful rebuild, sends a reload event to all connected browsers

When disabled, the server still watches and rebuilds, but you must manually refresh the browser.

dev.new(config)
|> dev.live_reload(False)

dev.before_build(server, hook)

Set a function to run before each rebuild. The hook runs before the build command is executed, on every rebuild (including the initial build on startup). This is useful for setup or cleanup tasks.

The hook must return Result(Nil, String). If it returns Error(reason), the build is aborted and the error reason is logged. The hook runs regardless of whether the subsequent build would succeed or fail.

dev.new(config)
|> dev.before_build(fn() {
  // Clean generated assets, fetch data, etc.
  io.println("Preparing build...")
  Ok(Nil)
})

dev.after_build(server, hook)

Set a function to run after each successful rebuild. The hook runs only when the build command exits with code 0. This is useful for post-processing tasks like running Tailwind CSS, copying additional assets, or sending notifications.

The hook must return Result(Nil, String). If it returns Error(reason), the error is logged and browsers are not reloaded. The hook is not called when the build fails.

dev.new(config)
|> dev.after_build(fn() {
  case shellout.command("npx", ["tailwindcss", "-o", "./dist/style.css"], ".", []) {
    Ok(_) -> Ok(Nil)
    Error(_) -> Error("Tailwind CSS compilation failed")
  }
})

Reference

Option Default Description
build_command "gleam run" Shell command to rebuild the site
port 3000 HTTP server port
host "127.0.0.1" Bind address
live_reload True Inject live-reload script into HTML responses
before_build None fn() -> Result(Nil, String) to run before each rebuild
after_build None fn() -> Result(Nil, String) to run after each successful rebuild

How it works

Architecture

The dev server is built on OTP actors:

Rebuild flow

  1. A file changes on disk
  2. The file watcher sends a FileChanged message to the rebuild actor
  3. The rebuild actor cancels any pending debounce timer and starts a new 300ms timer
  4. When the timer fires, the before_build hook runs (if configured)
  5. The actor shells out to the build command
  6. On success (exit code 0), the after_build hook runs (if configured), then a Reload event is sent to all connected SSE clients
  7. On failure, the error output is logged and the server keeps running with the last successful build (the after_build hook is not called)

Watched directories

The dev server watches these directories based on your config:

Source Derived from
Gleam source code src/ (always watched)
Blog post directories config.markdown_config.paths
Static assets directory config.static_dir

The output directory itself is not watched β€” it is rebuilt by the build command.

HTTP serving

The dev server serves files from config.output_dir:

Platform notes

Linux

The file watcher requires inotify-tools to be installed:

# Debian/Ubuntu
sudo apt-get install inotify-tools

# Fedora
sudo dnf install inotify-tools

# Arch Linux
sudo pacman -S inotify-tools

macOS

File watching uses FSEvents natively β€” no additional setup required.

Troubleshooting

Build command hangs

If the build command prompts for input or enters an infinite loop, it will be killed after 120 seconds and the dev server will report a timeout error. Fix the underlying issue in your build command.

Port already in use

If the port is already bound by another process, dev.start() returns an error. Either stop the other process or use a different port:

dev.new(config) |> dev.port(8080)

No file change events on Linux

Make sure inotify-tools is installed. You may also need to increase the inotify watch limit:

echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p