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:
- File watching β monitors
src/, markdown paths, and static assets for changes using filespy - Auto-rebuild β shells out to a configurable build command with debouncing (~300ms) to batch rapid saves
- 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:
- Output directory β served over HTTP
- Markdown paths β watched for blog post changes
- Static directory β watched for asset changes
src/β always watched for Gleam source changes
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:
- Injects a small
<script>tag before</body>in all served HTML responses - Exposes an SSE endpoint at
/__blogatto_dev/reload - 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 actor β receives file change notifications, debounces them (300ms), shells out to the build command, and broadcasts reload events to connected browsers
- File watcher β uses filespy (which wraps Erlangβs
fslibrary) to monitor directories and send change events to the rebuild actor - HTTP server β mist serves static files from the output directory and manages SSE connections for live reload
Rebuild flow
- A file changes on disk
- The file watcher sends a
FileChangedmessage to the rebuild actor - The rebuild actor cancels any pending debounce timer and starts a new 300ms timer
- When the timer fires, the
before_buildhook runs (if configured) - The actor shells out to the build command
- On success (exit code 0), the
after_buildhook runs (if configured), then aReloadevent is sent to all connected SSE clients - On failure, the error output is logged and the server keeps running with the last successful build (the
after_buildhook 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:
- Directory requests (paths ending in
/) resolve toindex.htmlwithin that directory .htmlpaths are served withContent-Type: text/html- Other files are served with the appropriate MIME type based on file extension
- All responses include
Cache-Control: no-cache, no-storeto prevent browser caching - Path traversal attempts (paths containing
..) are rejected with 404 - Missing files return 404
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