Building a Full CRUD Application#
In this article we will tie together everything we have learned in the series so far. We will build a complete CRUD (Create, Read, Update, Delete) application with both an HTML frontend using ErlyDTL templates and a JSON API backend. The application will manage a list of notes using Kura for the database layer.
The plan#
We will build a note-taking application with:
- HTML pages for listing, creating and editing notes
- JSON API endpoints for the same operations
- Database persistence with PostgreSQL via Kura
- Authentication for the HTML pages
Note schema#
Create src/schemas/note.erl:
-module(note).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, primary_key/0]).
table() -> <<"notes">>.
primary_key() -> id.
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = title, type = string, nullable = false},
#kura_field{name = body, type = text},
#kura_field{name = author, type = string},
#kura_field{name = inserted_at, type = utc_datetime},
#kura_field{name = updated_at, type = utc_datetime}
].Compile to auto-generate the migration:
$ rebar3 compile
===> kura: generated migration m20260214_create_notesThe generated migration creates the notes table for you — no SQL needed.
JSON API controller#
We can use the rebar3_nova code generator to scaffold the API controller and a JSON schema in one step:
$ rebar3 nova gen_resource --name notes
===> Writing src/controllers/my_first_nova_notes_api_controller.erl
===> Writing priv/schemas/note.json
Add these routes to your router:
{<<"/notes">>, {my_first_nova_notes_api_controller, list}, #{methods => [get]}}
{<<"/notes/:id">>, {my_first_nova_notes_api_controller, show}, #{methods => [get]}}
{<<"/notes">>, {my_first_nova_notes_api_controller, create}, #{methods => [post]}}
{<<"/notes/:id">>, {my_first_nova_notes_api_controller, update}, #{methods => [put]}}
{<<"/notes/:id">>, {my_first_nova_notes_api_controller, delete}, #{methods => [delete]}}This gives us a controller with stub functions and a JSON schema that the OpenAPI generator can pick up later. Now let’s replace the stubs with our actual implementation.
Create (or replace) src/controllers/my_first_nova_notes_api_controller.erl:
-module(my_first_nova_notes_api_controller).
-include_lib("kura/include/kura.hrl").
-export([
index/1,
show/1,
create/1,
update/1,
delete/1
]).
index(_Req) ->
{ok, Notes} = my_first_nova_repo:all(kura_query:from(note)),
{json, #{notes => Notes}}.
show(#{bindings := #{<<"id">> := Id}}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Note} ->
{json, Note};
{error, not_found} ->
{status, 404, #{}, #{error => <<"note not found">>}}
end.
create(#{json := Params}) ->
CS = kura_changeset:cast(note, #{}, Params, [title, body, author]),
CS1 = kura_changeset:validate_required(CS, [title, author]),
case my_first_nova_repo:insert(CS1) of
{ok, Note} ->
{json, 201, #{}, Note};
{error, Changeset} ->
{status, 422, #{}, #{errors => Changeset#kura_changeset.errors}}
end.
update(#{bindings := #{<<"id">> := Id}, json := Params}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Existing} ->
CS = kura_changeset:cast(note, Existing, Params, [title, body]),
case my_first_nova_repo:update(CS) of
{ok, Updated} ->
{json, Updated};
{error, Changeset} ->
{status, 422, #{}, #{errors => Changeset#kura_changeset.errors}}
end;
{error, not_found} ->
{status, 404, #{}, #{error => <<"note not found">>}}
end.
delete(#{bindings := #{<<"id">> := Id}}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Record} ->
CS = kura_changeset:cast(note, Record, #{}, []),
{ok, _} = my_first_nova_repo:delete(CS),
{status, 204};
{error, not_found} ->
{status, 404, #{}, #{error => <<"note not found">>}}
end.Compare this to the raw pgo approach — no SQL strings, no positional parameters, no manual row conversion. Changesets handle validation before anything hits the database, and invalid requests get proper error responses automatically.
HTML controller#
Create src/controllers/my_first_nova_notes_controller.erl for the HTML views:
-module(my_first_nova_notes_controller).
-include_lib("kura/include/kura.hrl").
-export([
index/1,
new/1,
create/1,
edit/1,
update/1,
delete/1
]).
index(#{auth_data := #{username := Username}}) ->
Q = kura_query:order_by(kura_query:from(note), {inserted_at, desc}),
{ok, Notes} = my_first_nova_repo:all(Q),
{ok, [{notes, Notes}, {username, Username}], #{view => notes_index}};
index(_Req) ->
{redirect, "/login"}.
new(#{auth_data := #{authed := true}}) ->
{ok, [], #{view => notes_new}};
new(_Req) ->
{redirect, "/login"}.
create(#{auth_data := #{username := Username},
params := Params}) ->
Params1 = Params#{<<"author">> => Username},
CS = kura_changeset:cast(note, #{}, Params1, [title, body, author]),
CS1 = kura_changeset:validate_required(CS, [title, author]),
my_first_nova_repo:insert(CS1),
{redirect, "/notes"};
create(_Req) ->
{redirect, "/login"}.
edit(#{auth_data := #{authed := true},
bindings := #{<<"id">> := Id}}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Note} ->
{ok, [{note, Note}], #{view => notes_edit}};
{error, not_found} ->
{status, 404}
end;
edit(_Req) ->
{redirect, "/login"}.
update(#{auth_data := #{authed := true},
bindings := #{<<"id">> := Id},
params := Params}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Existing} ->
CS = kura_changeset:cast(note, Existing, Params, [title, body]),
my_first_nova_repo:update(CS);
_ ->
ok
end,
{redirect, "/notes"};
update(_Req) ->
{redirect, "/login"}.
delete(#{auth_data := #{authed := true},
bindings := #{<<"id">> := Id}}) ->
case my_first_nova_repo:get(note, binary_to_integer(Id)) of
{ok, Record} ->
CS = kura_changeset:cast(note, Record, #{}, []),
my_first_nova_repo:delete(CS);
_ ->
ok
end,
{redirect, "/notes"};
delete(_Req) ->
{redirect, "/login"}.Views#
Create the templates in src/views/.
src/views/notes_index.dtl - List all notes:
<html>
<head><title>Notes</title></head>
<body>
<h1>Notes</h1>
<p>Welcome, {{ username }}</p>
<a href="/notes/new">New Note</a>
<ul>
{% for note in notes %}
<li>
<strong>{{ note.title }}</strong> by {{ note.author }}
<br>{{ note.body }}
<br>
<a href="/notes/{{ note.id }}/edit">Edit</a>
<form action="/notes/{{ note.id }}/delete" method="post" style="display:inline">
<input type="submit" value="Delete">
</form>
</li>
{% empty %}
<li>No notes yet.</li>
{% endfor %}
</ul>
</body>
</html>src/views/notes_new.dtl - Create a new note:
<html>
<head><title>New Note</title></head>
<body>
<h1>New Note</h1>
<form action="/notes" method="post">
<label for="title">Title:</label><br>
<input type="text" id="title" name="title"><br>
<label for="body">Body:</label><br>
<textarea id="body" name="body" rows="10" cols="50"></textarea><br>
<input type="submit" value="Create">
</form>
<a href="/notes">Back</a>
</body>
</html>src/views/notes_edit.dtl - Edit a note:
<html>
<head><title>Edit Note</title></head>
<body>
<h1>Edit Note</h1>
<form action="/notes/{{ note.id }}" method="post">
<label for="title">Title:</label><br>
<input type="text" id="title" name="title" value="{{ note.title }}"><br>
<label for="body">Body:</label><br>
<textarea id="body" name="body" rows="10" cols="50">{{ note.body }}</textarea><br>
<input type="submit" value="Update">
</form>
<a href="/notes">Back</a>
</body>
</html>Routing#
Now we put it all together in the router:
-module(my_first_nova_router).
-behaviour(nova_router).
-export([
routes/1
]).
routes(_Environment) ->
[
%% Public routes
#{prefix => "",
security => false,
routes => [
{"/login", fun my_first_nova_main_controller:login/1, #{methods => [get]}},
{"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}},
{"/ws", my_first_nova_ws_handler, #{protocol => ws}}
]
},
%% Auth endpoint
#{prefix => "",
security => fun my_first_nova_auth:username_password/1,
routes => [
{"/", fun my_first_nova_main_controller:index/1, #{methods => [post]}}
]
},
%% HTML notes (with auth)
#{prefix => "/notes",
security => fun my_first_nova_auth:username_password/1,
routes => [
{"/", fun my_first_nova_notes_controller:index/1, #{methods => [get]}},
{"/new", fun my_first_nova_notes_controller:new/1, #{methods => [get]}},
{"/", fun my_first_nova_notes_controller:create/1, #{methods => [post]}},
{"/:id/edit", fun my_first_nova_notes_controller:edit/1, #{methods => [get]}},
{"/:id", fun my_first_nova_notes_controller:update/1, #{methods => [post]}},
{"/:id/delete", fun my_first_nova_notes_controller:delete/1, #{methods => [post]}}
]
},
%% JSON API (no auth for simplicity)
#{prefix => "/api",
security => false,
routes => [
{"/notes", fun my_first_nova_notes_api_controller:index/1, #{methods => [get]}},
{"/notes/:id", fun my_first_nova_notes_api_controller:show/1, #{methods => [get]}},
{"/notes", fun my_first_nova_notes_api_controller:create/1, #{methods => [post]}},
{"/notes/:id", fun my_first_nova_notes_api_controller:update/1, #{methods => [put]}},
{"/notes/:id", fun my_first_nova_notes_api_controller:delete/1, #{methods => [delete]}}
]
}
].Testing it#
Start the application:
$ rebar3 nova serveTest the JSON API:
# Create a note
$ curl -s -X POST localhost:8080/api/notes \
-H "Content-Type: application/json" \
-d '{"title": "My first note", "body": "Hello from Nova!", "author": "Alice"}'
# List all notes
$ curl -s localhost:8080/api/notes
# Get a specific note
$ curl -s localhost:8080/api/notes/1
# Update a note
$ curl -s -X PUT localhost:8080/api/notes/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated title", "body": "Updated body"}'
# Delete a note
$ curl -s -X DELETE localhost:8080/api/notes/1For the HTML interface, go to localhost:8080/login, log in and then navigate to localhost:8080/notes.
What we built#
Let’s recap what we have in our application now:
- Router with four route groups: public, auth, HTML notes with security, and a JSON API
- Controllers for both HTML and JSON responses using Kura changesets for validation
- Views using ErlyDTL templates with loops and variable interpolation
- Security module for authentication
- Schema defining the shape of our data with automatic migration generation
- Database persistence with PostgreSQL via Kura’s repo pattern
No raw SQL, no manual row conversions, no hand-written migrations. The schema is the single source of truth and everything else flows from it. As your application grows you add more schemas, controllers, views, and route groups. Nova stays out of your way and lets you organize things with standard Erlang/OTP patterns.
In the next article we will look at how to deploy a Nova application to production.