Building Pages
In the previous chapter we learned ErlyDTL template syntax. Now let's build complete pages — layouts with navigation, forms with error handling, and reusable partials.
A proper base layout
Expand the base layout with navigation, flash messages, and a footer:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Blog{% endblock %} — Nova Blog</title>
<link rel="stylesheet" href="/assets/css/style.css">
{% block head %}{% endblock %}
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
{% if auth_data %}
<a href="/posts/new">New Post</a>
<span>{{ auth_data.username }}</span>
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>
<a href="/register">Register</a>
{% endif %}
</nav>
</header>
<main>
{% if flash_info %}
<div class="flash flash-info">{{ flash_info }}</div>
{% endif %}
{% if flash_error %}
<div class="flash flash-error">{{ flash_error }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer>
<p>Built with Nova</p>
</footer>
</body>
</html>
Forms with validation errors
A post creation form that displays changeset errors:
{% extends "base.dtl" %}
{% block title %}New Post{% endblock %}
{% block content %}
<h1>New Post</h1>
<form action="/posts" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}" />
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ form_title|default:"" }}">
{% if errors.title %}
<span class="error">{{ errors.title }}</span>
{% endif %}
</div>
<div class="field">
<label for="body">Body</label>
<textarea id="body" name="body">{{ form_body|default:"" }}</textarea>
{% if errors.body %}
<span class="error">{{ errors.body }}</span>
{% endif %}
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="draft" {% if form_status == "draft" %}selected{% endif %}>Draft</option>
<option value="published" {% if form_status == "published" %}selected{% endif %}>Published</option>
</select>
</div>
<button type="submit">Create Post</button>
</form>
{% endblock %}
The controller re-renders the form with errors and the submitted values:
create(#{params := Params, auth_data := #{id := UserId}} = _Req) ->
Params1 = Params#{<<"user_id">> => UserId},
CS = post:changeset(#{}, Params1),
case blog_repo:insert(CS) of
{ok, Post} ->
{redirect, "/posts/" ++ integer_to_list(maps:get(id, Post))};
{error, #kura_changeset{} = CS1} ->
Errors = changeset_errors_to_json(CS1),
{ok, [{errors, Errors},
{form_title, maps:get(<<"title">>, Params, <<>>)},
{form_body, maps:get(<<"body">>, Params, <<>>)},
{form_status, maps:get(<<"status">>, Params, <<"draft">>)}],
#{view => new_post, status_code => 422}}
end.
Template includes (partials)
Extract reusable fragments with {% include %}:
src/views/_post_card.dtl:
<article class="post-card">
<h2><a href="/posts/{{ post.id }}">{{ post.title }}</a></h2>
<p>by {{ post.author.username }} — {{ post.inserted_at }}</p>
{% if post.tags %}
<div class="tags">
{% for tag in post.tags %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</article>
Use it in a listing page:
{% extends "base.dtl" %}
{% block content %}
<h1>Posts</h1>
{% for post in posts %}
{% include "_post_card.dtl" %}
{% endfor %}
{% endblock %}
Flash messages
Flash messages show one-time notifications (e.g. "Post created successfully"). Store them in the session and clear after display.
A controller sets a flash before redirecting:
create(#{params := Params} = Req) ->
case blog_repo:insert(post:changeset(#{}, Params)) of
{ok, Post} ->
set_flash(Req, flash_info, <<"Post created!">>),
{redirect, "/posts/" ++ integer_to_list(maps:get(id, Post))};
{error, CS} ->
%% re-render with errors (no flash needed)
...
end.
The helpers that store and retrieve flash values from the session:
%% Setting a flash message
set_flash(Req, Key, Message) ->
nova_session:set(Req, Key, Message).
%% Reading and clearing flash messages
get_flash(Req, Key) ->
case nova_session:get(Req, Key) of
{ok, Message} ->
nova_session:delete(Req, Key),
Message;
{error, _} ->
undefined
end.
The destination controller reads the flash and passes it to the template. The base layout (shown at the top of this chapter) renders flash_info and flash_error if present.
We now have a complete HTML frontend. Next, let's build JSON APIs with code generators.