Build a Notes API with Dart Frog
Build a REST API in Dart connected to a local Supabase Postgres database.
Introduction
30 Second Summary
Every app you use stores data somewhere. Whether it's a notes app, a to-do list, or a social feed, there's always a backend server talking to a database behind the scenes.
In this project, you will build a Notes API from scratch using Dart Frog (a lightweight Dart backend framework) and a local Supabase database running in Docker on your machine.
What You'll Build
You'll open a terminal, fire a POST request, and watch your API save a note to a real Postgres database. Then you'll list, update, and delete notes, confirming each change in both the terminal response and the Supabase Studio dashboard.
By the end of this project, you'll have:
- A running Dart Frog backend that serves a full REST API for notes. You can create, list, update, and delete notes with curl commands and get back clean JSON responses.
- A local Supabase stack running in Docker with a properly designed PostgreSQL database schema. You can browse your data in the Studio dashboard at any time.
- A complete backend system with input validation and error handling that returns clear, client-friendly error messages instead of raw crashes.
- Secret Mission: Add search and date filtering to your API so clients can find specific notes by title keyword or creation date.
Are there any prerequisites?
This project assumes you have the Flutter SDK (3.44.7) installed on macOS and basic familiarity with Dart syntax. You do not need any prior backend or database experience.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Install Your Tools
Your Notes API will run on a local Supabase stack. Supabase spins up PostgreSQL, Auth, and Storage as Docker containers on your Mac. That means you need Docker to run the containers, and the Supabase CLI to orchestrate them.
In this step, you'll install three tools: Docker Desktop (the container runtime), the Supabase CLI (to manage your local Supabase stack), and the Dart Frog CLI (to scaffold and run your backend server).
In this step, get ready to:
- Install Docker Desktop and verify it's running.
- Install the Supabase CLI via Homebrew.
- Install the Dart Frog CLI via Dart's package manager.
Install Docker Desktop
Docker Desktop provides the container engine that Supabase needs to run its services locally.
- Check if Docker is already installed by running this command in your terminal:
docker --version
✔️ I see a version number
Docker is already installed on your machine.
- Make sure Docker Desktop is running. Look for the whale icon in your menu bar at the top of your screen.
- If the whale icon is not there, open Docker Desktop by pressing Cmd+Space to open Spotlight, typing Docker, and pressing Enter.
- Wait until the whale icon in your menu bar stops animating. That means Docker is ready.
ⓧ Command not found
You need to install Docker Desktop. Follow these steps:
- Open the Docker Desktop download page in your browser:
https://docs.docker.com/desktop/setup/install/mac-install/
- Click the download button for your chip type (Apple Silicon or Intel).
- Double-click the downloaded Docker.dmg file to open the installer.
- Drag the Docker icon to the Applications folder.
- Open Docker Desktop by pressing Cmd+Space to open Spotlight, typing Docker, and pressing Enter.
- Docker Desktop will ask you to accept the Docker Subscription Service Agreement. Accept it to continue.
- You may be prompted to enter your Mac password to grant Docker privileged access. Enter it to proceed.
- Wait for the whale icon in your menu bar to stop animating. This means Docker has finished starting.
Now verify the installation by running:
docker --version
You should see a version number printed, confirming Docker is ready.
Docker command still not found?
Make sure Docker Desktop has fully started (whale icon is visible and not animating). The docker CLI only becomes available after Docker Desktop creates symlinks in /usr/local/bin.
Try closing and reopening your terminal, then run docker --version again.
Help me troubleshoot Docker Desktop not being recognized in my terminal.
Install the Supabase CLI
The Supabase CLI manages your local Supabase stack. It uses Homebrew for installation on macOS.
- Install the Supabase CLI by running this command:
brew install supabase/tap/supabase
What does this command do?
This taps the official Supabase Homebrew repository and installs the supabase CLI binary. Homebrew handles downloading the correct version for your Mac's architecture.
- Verify the installation by running:
supabase --version
✔️ I see a version number
The Supabase CLI is ready. Move on to the next tool.
ⓧ Command not found
Homebrew may not be installed, or the install command had an error.
- Check that Homebrew itself works by running brew --version. If that fails, install Homebrew first from https://brew.sh.
- Once Homebrew is working, re-run the install command above.
Help me fix Supabase CLI installation issues on my Mac.
Install the Dart Frog CLI
Dart Frog is the backend framework you'll use to build your API. Its CLI scaffolds projects and runs the development server.
- Install the Dart Frog CLI by running this command:
dart pub global activate dart_frog_cli
What does this command do?
This downloads the dart_frog_cli package from pub.dev and places its executable in your Dart cache's bin directory. You can then run dart_frog commands from anywhere in your terminal.
- Verify the installation by running:
dart_frog --version
✔️ I see version 1.2.14
Dart Frog is installed and ready to go.
ⓧ I see an older version
You have an older version of Dart Frog installed. Upgrade it by running the same activate command:
dart pub global activate dart_frog_cli
This replaces the old version with the latest. Run dart_frog --version again to confirm it now shows 1.2.14.
ⓧ Command not found
The dart_frog executable is not on your PATH. Dart's global packages live in $HOME/.pub-cache/bin on macOS.
- Check if that directory is in your PATH by running:
echo $PATH
- If you don't see .pub-cache/bin in the output, add it to your shell profile. Run:
echo 'export PATH="$HOME/.pub-cache/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
- Now run dart_frog --version again. You should see 1.2.14.
Help me add .pub-cache/bin to my PATH so dart_frog is found.
Before you move on, think about what you expect to see if all three tools are working.
- Confirm all three tools are ready by running these commands one after the other:
docker --version
supabase --version
dart_frog --version
Each command prints a version number without errors. dart_frog --version shows 1.2.14.
Your toolbox is ready. Next up, you'll spin up a local Supabase stack and design the database schema for your notes.
Spin Up Local Supabase and Design Your Database
Your tools are installed and ready to go. Now it's time to use them to spin up a real database.
Before writing any backend code, you need a running database to talk to. Starting Supabase locally gives you a full PostgreSQL instance and a Studio dashboard where you can see your data. This means you can verify your schema works before writing a single line of API code.
In this step, get ready to:
- Create a project directory and initialize a local Supabase stack.
- Design and apply a database migration for the notes table.
- Verify the table exists in Supabase Studio.
Initialize and start local Supabase
You need a project directory to hold your Supabase configuration and your Dart Frog API (which you'll create in the next step).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir notes_api
cd notes_api
- Initialize Supabase in this directory by running:
supabase init
What does this command do?
This creates a supabase/ folder inside your project with a config.toml file. The config.toml holds settings for your local Supabase stack (ports, database name, auth config).
- Confirm the folder was created by running:
ls
You should see a supabase folder in the output.
Now you'll start the local Supabase stack. This pulls several Docker images and spins up containers for Postgres, Auth, Storage, and Studio.
First run takes a few minutes
The first time you run supabase start, it downloads Docker images (around 2-3 GB). Subsequent starts are much faster because the images are cached locally.
Your machine needs at least 7 GB of RAM available for the local Supabase stack to run smoothly.
- Start the local Supabase stack by running:
supabase start
When it finishes, you'll see a table of local credentials and URLs printed to your terminal.
- Find the DB URL line in the output. It should read postgresql://postgres:postgres@localhost:54322/postgres.
- Find the Studio URL line. It should read http://localhost:54323.
Why these ports?
The Supabase CLI uses port 54322 for Postgres (not the standard 5432) so it won't conflict with any Postgres you already have installed. Studio runs on 54323 to give you a web UI for browsing your database.
Supabase start hanging or failing?
Make sure Docker Desktop is running (the whale icon in your menu bar should be steady, not animating).
If you see a port conflict error, another process may already be using port 54322. Close any other Postgres instances or Docker containers that might be running.
help me fix supabase start failing on my Mac
Create the notes table via SQL migration
Your database is running but it has no tables yet. You'll define the schema using a migration file, which is version-controlled SQL that can recreate your database from scratch at any time.
- Create a new migration file by running:
supabase migration new create_notes_table
What is a migration?
A migration is a SQL file that describes a change to your database schema. The Supabase CLI creates these files in supabase/migrations/ with a timestamp prefix so they run in order.
This makes your database reproducible. Anyone (or any CI pipeline) can run your migrations and get the exact same schema.
- Confirm the migration file was created by running:
ls supabase/migrations/
You should see a file ending in _create_notes_table.sql. The long number prefix is a timestamp.
- Open the migration file in Neovim by running:
nvim supabase/migrations/*_create_notes_table.sql
- The file is empty. Add the following SQL to define the notes table:
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
What does this schema do?
- id is a UUID that Postgres generates automatically for each new note.
- title is required (NOT NULL) so every note must have a title.
- body defaults to an empty string, so you can create a note with just a title.
- created_at and updated_at are timestamps that Postgres sets automatically using NOW().
- Save and exit Neovim by pressing Esc, then typing :wq and pressing Enter.
The migration file exists on disk, but the database hasn't changed yet. You need to apply it.
- Apply your migration by running:
supabase db reset
What does db reset do?
This command destroys and recreates the local database, then runs all migration files in order. It's the cleanest way to apply migrations during local development.
You should see output confirming the migration was applied successfully.
Seeing a syntax error?
Double-check your SQL matches exactly. Common mistakes include missing commas between column definitions or a missing semicolon at the end.
help me fix a SQL syntax error in my Supabase migration
✔️ Awesome, I've got everything!
Great. Make sure you've saved the migration file before moving on.
ⓧ I'd like to double check the full code
Your migration file should contain exactly this SQL:
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Verify in Supabase Studio
Your migration has been applied. Before you move on, you'll confirm the table actually exists by looking at it in a visual interface.
Before you open the dashboard, what do you expect to see in there?
- Open your browser and go to http://localhost:54323.
- In the left sidebar, click Table Editor.
- Confirm you see a table called notes listed with all five columns: id, title, body, created_at, and updated_at.
You should see the Supabase Studio dashboard with your notes table visible under Table Editor. The table is empty for now, but all five columns are there and ready for data.
Don't see the notes table?
Make sure you ran supabase db reset after saving the migration file. If the file was empty when the reset ran, the table won't exist.
Try running supabase db reset again and refresh the Studio page.
help me troubleshoot my missing notes table in Supabase Studio
Your database is running and your schema is in place. Next up, you'll create a Dart Frog API server that connects to this Postgres database and starts serving requests.
Create Your Dart Frog API and Connect to Postgres
Your local Supabase database is running, and you've verified the notes table exists in PostgreSQL.
The problem is there's no way to interact with that database from the outside world yet. Dart Frog solves this by giving you a backend server with file-system routing (your folder structure IS your API structure), and the postgres package lets you talk directly to your local Supabase Postgres.
In this step, get ready to:
- Scaffold a Dart Frog project and install the postgres dependency.
- Create a database connection class and inject it via middleware.
- Run the dev server and confirm it responds to requests.
Scaffold the Dart Frog project
The dart_frog create command generates a complete project skeleton with a routes/ folder, a pubspec.yaml, and a default route handler.
- Make sure you're in the notes_api directory (the one containing your supabase/ folder), then scaffold the Dart Frog project by running:
dart_frog create api
What does this command do?
This creates a new api/ folder inside notes_api/ with a ready-to-run Dart Frog project. It includes a routes/ directory for your API endpoints, a pubspec.yaml for dependencies, and a default routes/index.dart handler.
- Move into the new project directory by running:
cd api
- Confirm the scaffold was created by running:
ls
You should see pubspec.yaml, routes/, and other project files in the output.
Don't see the api folder?
Make sure you ran the command from inside notes_api/, not from inside supabase/ or your home directory. Run pwd to check your current location.
Help me troubleshoot the dart_frog create command not generating my project correctly.
Now you need to add the postgres package so your API can talk to the database.
- Open pubspec.yaml in your editor and replace its entire contents with the following:
name: api
description: A Notes API built with Dart Frog
version: 1.0.0
publish_to: none
environment:
sdk: ^3.0.0
dependencies:
dart_frog: ^1.2.6
postgres: ^3.5.12
dev_dependencies:
test: ^1.24.0
very_good_analysis: ^6.0.0
What changed here?
The key addition is postgres: ^3.5.12 under dependencies. This is the Dart package that gives you a direct connection to PostgreSQL, using the modern Connection.open API.
The dart_frog: ^1.2.6 dependency is the framework itself, providing routing, middleware, and the dev server.
- Install the dependencies by running:
dart pub get
You should see output showing the packages being resolved, ending with a message about the number of dependencies available.
Seeing dependency resolution errors?
Make sure your Flutter SDK is version 3.44.7 or later (which ships with Dart SDK 3.0+). Run dart --version to confirm.
Help me fix dart pub get dependency errors in my Dart Frog project.
Create a database connection provider
Your API needs a reliable way to connect to the local Postgres database. A singleton class ensures only one connection is open at a time, preventing resource leaks.
- Create the directory for your library code by running:
mkdir -p lib
- Create a new file at lib/db.dart and add the following code:
import 'package:postgres/postgres.dart';
class Database {
Database._();
static final Database _instance = Database._();
factory Database() => _instance;
Connection? _connection;
Future<Connection> get connection async {
if (_connection == null || !_connection!.isOpen) {
_connection = await Connection.open(
Endpoint(
host: 'localhost',
port: 54322,
database: 'postgres',
username: 'postgres',
password: 'postgres',
),
settings: ConnectionSettings(sslMode: SslMode.disable),
);
}
return _connection!;
}
Future<void> close() async {
await _connection?.close();
}
}
What does this code do?
- Database._() is a private constructor. Combined with the factory Database() pattern, it creates a singleton: every call to Database() returns the same instance.
- The connection getter opens a connection to your local Supabase Postgres on port 54322 (the port Supabase CLI exposes).
- SslMode.disable is required for localhost connections. Your local Supabase Postgres doesn't use SSL.
- If the connection drops, the getter opens a fresh one automatically.
- Save lib/db.dart.
- Confirm it exists by running:
ls lib/
You should see db.dart in the output.
✔️ Awesome, I've got everything!
Great. Make sure you've saved lib/db.dart before moving on.
ⓧ I'd like to double check the full code
import 'package:postgres/postgres.dart';
class Database {
Database._();
static final Database _instance = Database._();
factory Database() => _instance;
Connection? _connection;
Future<Connection> get connection async {
if (_connection == null || !_connection!.isOpen) {
_connection = await Connection.open(
Endpoint(
host: 'localhost',
port: 54322,
database: 'postgres',
username: 'postgres',
password: 'postgres',
),
settings: ConnectionSettings(sslMode: SslMode.disable),
);
}
return _connection!;
}
Future<void> close() async {
await _connection?.close();
}
}
Next, you need middleware to inject this Database instance into every route handler. Dart Frog uses dependency injection through a provider function in a special _middleware.dart file.
- Create a new file at routes/_middleware.dart and add the following code:
import 'package:dart_frog/dart_frog.dart';
import 'package:api/db.dart';
Handler middleware(Handler handler) {
return handler.use(provider<Database>((_) => Database()));
}
What does this code do?
The middleware function wraps every request handler in this directory (and subdirectories). The provider<Database> call makes a Database instance available to any route via context.read<Database>().
Because Database() is a singleton, every route shares the same connection. No new connections are opened per request.
- Save routes/_middleware.dart.
- Confirm it exists by running:
ls routes/
You should see _middleware.dart and index.dart in the output.
✔️ Awesome, I've got everything!
Great. Make sure you've saved routes/_middleware.dart before moving on.
ⓧ I'd like to double check the full code
import 'package:dart_frog/dart_frog.dart';
import 'package:api/db.dart';
Handler middleware(Handler handler) {
return handler.use(provider<Database>((_) => Database()));
}
Run and verify the server starts
Time to see if everything connects. Dart Frog includes a dev server with hot reload, so you can start it and test with curl.
- Start the Dart Frog dev server by running:
dart_frog dev
What does this command do?
This starts a local HTTP server on port 8080 and watches for file changes. When you edit a route file, the server hot-reloads automatically. This command takes over your current terminal session.
- Open a second terminal window (the dev server is running in the first one).
- Navigate back into your project by running:
cd ~/Desktop/notes_api/api
- Test the server by running:
curl http://localhost:8080
You should see the default Dart Frog response (plain text saying the route was requested).
The server works, but you want a JSON response that identifies your API. Time to update the default route.
- Open routes/index.dart and replace its entire contents with the following:
import 'package:dart_frog/dart_frog.dart';
Response onRequest(RequestContext context) {
return Response.json(
body: {'message': 'Notes API is running'},
);
}
What does this code do?
The onRequest function handles any request to the root path (/). Response.json automatically serializes the Dart map to JSON and sets the Content-Type header.
- Save routes/index.dart. The dev server hot-reloads automatically.
Before you run the next curl, what JSON do you expect to see back from the server?
- Test the updated route by running:
curl http://localhost:8080
You should see:
{"message":"Notes API is running"}
Still seeing the old response?
Make sure you saved routes/index.dart after editing. The hot reload only triggers on save.
If the server crashed, check the terminal running dart_frog dev for error messages. A common issue is a typo in the import or a missing comma in the response map.
Help me debug why my Dart Frog server isn't returning the updated JSON response after I edited routes/index.dart.
✔️ Awesome, I've got everything!
Great. Make sure routes/index.dart is saved and the server is running.
ⓧ I'd like to double check the full code
import 'package:dart_frog/dart_frog.dart';
Response onRequest(RequestContext context) {
return Response.json(
body: {'message': 'Notes API is running'},
);
}
Your Dart Frog API is alive and connected to the database layer. Next up, you'll build the actual CRUD endpoints that let you create, read, update, and delete notes.
Build the CRUD Endpoints
Your server is running and your database is connected. But right now, the API does nothing useful.
Without endpoints, there is no way for a client (like the Flutter app you will build in a future project) to create, read, update, or delete notes. This step is where the backend earns its keep. You will build out the full set of REST endpoints that let any HTTP client interact with your notes table.
In this step, get ready to:
- Create GET and POST handlers for the /notes route.
- Create GET, PUT, and DELETE handlers for the /notes/:id route.
- Test all endpoints with curl and verify data in Supabase Studio.
Create the GET /notes and POST /notes endpoints
Dart Frog uses file-system routing. A file at routes/notes/index.dart automatically handles requests to /notes. No router configuration needed.
- Create the notes directory and the index.dart file inside it by running these commands from the api directory:
mkdir -p routes/notes
touch routes/notes/index.dart
- Confirm the file exists by running:
ls routes/notes/
You should see index.dart in the output.
- Open routes/notes/index.dart in Neovim and add the imports and method router by pasting this code:
import 'dart:convert';
import 'package:dart_frog/dart_frog.dart';
import 'package:api/db.dart';
import 'package:postgres/postgres.dart';
// Route incoming requests to the correct handler
Future<Response> onRequest(RequestContext context) async {
switch (context.request.method) {
case HttpMethod.get:
return _getAll(context);
case HttpMethod.post:
return _create(context);
default:
return Response(statusCode: 405);
}
}
What does this code do?
The onRequest function is the entry point for any request hitting /notes. It inspects the HTTP method and routes to the appropriate private handler.
Returning 405 for any other method tells the client that method is not allowed on this endpoint.
- Below the router, add the _getAll function that queries all notes from Postgres:
// Fetch all notes from the database
Future<Response> _getAll(RequestContext context) async {
final db = context.read<Database>();
try {
final conn = await db.connection;
final result = await conn.execute(
'SELECT id, title, body, created_at, updated_at FROM notes ORDER BY created_at DESC',
);
final notes = result.map((row) {
final columns = row.toColumnMap();
return {
'id': columns['id'],
'title': columns['title'],
'body': columns['body'],
'created_at': columns['created_at'].toString(),
'updated_at': columns['updated_at'].toString(),
};
}).toList();
return Response.json(body: notes);
} catch (e) {
print('Error fetching notes: $e');
return Response.json(
statusCode: 500,
body: {'error': 'Failed to fetch notes'},
);
}
}
What does this code do?
The context.read<Database>() call retrieves the Database instance that your middleware provides to every route.
The query selects all columns from the notes table, ordered newest-first. Each row is converted to a Dart map using toColumnMap(), then the whole list is returned as JSON.
- Save the file, then test the GET endpoint. In your second terminal, run:
curl http://localhost:8080/notes
You should see an empty JSON array [] since there are no notes yet. This proves your route and database query are working.
Seeing a 404 or 500 error?
- Make sure the file is saved at routes/notes/index.dart (not routes/note/index.dart). The folder name must be notes with an s.
- If you see a 500 error, confirm your local Supabase stack is still running. Run supabase status to check.
- Dart Frog hot-reloads on save, but if the route does not appear, try stopping and re-running dart_frog dev.
help me debug my Dart Frog /notes route returning an error
- Now add the _create function below _getAll in the same file. Start with the function signature, body parsing, and title validation:
// Insert a new note into the database
Future<Response> _create(RequestContext context) async {
final db = context.read<Database>();
try {
final body = await context.request.body();
final data = json.decode(body) as Map<String, dynamic>;
final title = data['title'] as String?;
final noteBody = data['body'] as String?;
if (title == null || title.trim().isEmpty) {
return Response.json(
statusCode: 400,
body: {'error': 'Title is required'},
);
}
What does this validation do?
The function reads the raw request body and decodes it from JSON into a Dart map. It then checks whether title is present and non-empty, returning a 400 error immediately if the check fails.
- Continue the _create function directly below the validation block. Add the database insert and error handling:
final conn = await db.connection;
final result = await conn.execute(
Sql.named(
'INSERT INTO notes (title, body) VALUES (@title, @body) '
'RETURNING id, title, body, created_at, updated_at',
),
parameters: {
'title': title.trim(),
'body': noteBody?.trim() ?? '',
},
);
final row = result.first.toColumnMap();
return Response.json(
statusCode: 201,
body: {
'id': row['id'],
'title': row['title'],
'body': row['body'],
'created_at': row['created_at'].toString(),
'updated_at': row['updated_at'].toString(),
},
);
} on FormatException {
return Response.json(
statusCode: 400,
body: {'error': 'Invalid JSON body'},
);
} catch (e) {
print('Error creating note: $e');
return Response.json(
statusCode: 500,
body: {'error': 'Failed to create note'},
);
}
}
What does this code do?
- The RETURNING clause in the SQL tells Postgres to send back the full row after insert, including the auto-generated id and timestamps.
- It uses Sql.named with @title and @body placeholders. Named parameters protect against SQL injection.
- The on FormatException catch handles cases where the client sends invalid JSON. The generic catch handles unexpected database errors.
- Save the file, then test creating a note by running:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d '{"title":"My first note","body":"Hello from the API!"}'
You should see a JSON response containing your note with a generated id, title, body, created_at, and updated_at fields.
Getting a 400 or 500 error on POST?
- A 400 with Title is required means your JSON is malformed or missing the title field. Check that you included the -H "Content-Type: application/json" header.
- A 500 error usually means the database connection failed. Confirm Supabase is running with supabase status.
help me debug my POST /notes endpoint
- Verify the note landed in your database by listing all notes:
curl http://localhost:8080/notes
You should now see an array containing the note you just created.
✔️ Awesome, I've got everything!
Great. Make sure routes/notes/index.dart is saved.
ⓧ I'd like to double check the full code
Scroll up and confirm your routes/notes/index.dart contains these sections in order:
- Imports (dart:convert, dart_frog, db.dart, postgres) and the onRequest method router with the switch on HttpMethod.get and HttpMethod.post.
- The _getAll function that selects all notes ordered by created_at DESC and returns them as JSON.
- The _create function that validates the title, inserts a row with Sql.named, and returns the new note with status 201.
Create the GET, PUT, and DELETE handlers for a single note
Dart Frog maps bracketed filenames to dynamic route parameters. A file named [id].dart inside routes/notes/ handles requests to /notes/some-uuid. The UUID is passed directly as an argument to your handler function.
- Create the dynamic route file by running:
touch routes/notes/'[id]'.dart
Why the quotes?
Square brackets are special characters in bash used for pattern matching. Wrapping the filename in single quotes tells the shell to treat them literally and create a file named [id].dart on disk.
- Open routes/notes/[id].dart in Neovim and add the imports and method router:
import 'dart:convert';
import 'package:dart_frog/dart_frog.dart';
import 'package:api/db.dart';
import 'package:postgres/postgres.dart';
// Route requests for a single note by HTTP method
Future<Response> onRequest(RequestContext context, String id) async {
switch (context.request.method) {
case HttpMethod.get:
return _getById(context, id);
case HttpMethod.put:
return _update(context, id);
case HttpMethod.delete:
return _delete(context, id);
default:
return Response(statusCode: 405);
}
}
How does Dart Frog pass the ID?
Notice the second parameter String id in onRequest. Dart Frog automatically extracts the dynamic segment from the URL and passes it as a positional argument. No manual parsing required.
- Below the router, add the _getById function that fetches a single note:
// Fetch a single note by its UUID
Future<Response> _getById(RequestContext context, String id) async {
final db = context.read<Database>();
try {
final conn = await db.connection;
final result = await conn.execute(
Sql.named('SELECT id, title, body, created_at, updated_at FROM notes WHERE id = @id'),
parameters: {'id': id},
);
if (result.isEmpty) {
return Response.json(statusCode: 404, body: {'error': 'Note not found'});
}
final row = result.first.toColumnMap();
return Response.json(body: {'id': row['id'], 'title': row['title'], 'body': row['body'], 'created_at': row['created_at'].toString(), 'updated_at': row['updated_at'].toString()});
} catch (e) {
print('Error fetching note: $e');
return Response.json(statusCode: 500, body: {'error': 'Failed to fetch note'});
}
}
What does this code do?
It queries by id using a named parameter. If the result set is empty, it returns a 404 with a clear error message. Otherwise it returns the note as JSON.
- Save the file and test fetching a note that does not exist to confirm the 404 handling works:
curl http://localhost:8080/notes/00000000-0000-0000-0000-000000000000
You should see {"error":"Note not found"} with a 404 status. This proves the route is live and the not-found check works.
- Next, add the _update function that handles PUT requests:
// Update a note's title and/or body
Future<Response> _update(RequestContext context, String id) async {
final db = context.read<Database>();
try {
final body = await context.request.body();
final data = json.decode(body) as Map<String, dynamic>;
final title = data['title'] as String?;
final noteBody = data['body'] as String?;
if (title != null && title.trim().isEmpty) {
return Response.json(statusCode: 400, body: {'error': 'Title cannot be empty'});
}
final conn = await db.connection;
final setClauses = <String>[];
final parameters = <String, dynamic>{'id': id};
if (title != null) { setClauses.add('title = @title'); parameters['title'] = title.trim(); }
if (noteBody != null) { setClauses.add('body = @body'); parameters['body'] = noteBody.trim(); }
if (setClauses.isEmpty) {
return Response.json(statusCode: 400, body: {'error': 'No fields to update'});
}
setClauses.add('updated_at = NOW()');
final result = await conn.execute(
Sql.named('UPDATE notes SET ${setClauses.join(', ')} WHERE id = @id RETURNING id, title, body, created_at, updated_at'),
parameters: parameters,
);
if (result.isEmpty) {
return Response.json(statusCode: 404, body: {'error': 'Note not found'});
}
final row = result.first.toColumnMap();
return Response.json(body: {'id': row['id'], 'title': row['title'], 'body': row['body'], 'created_at': row['created_at'].toString(), 'updated_at': row['updated_at'].toString()});
} on FormatException {
return Response.json(statusCode: 400, body: {'error': 'Invalid JSON body'});
} catch (e) {
print('Error updating note: $e');
return Response.json(statusCode: 500, body: {'error': 'Failed to update note'});
}
}
Why build SET clauses dynamically?
A PUT request might send just title, just body, or both. Building the SET clause dynamically means only the provided fields get updated.
The updated_at = NOW() clause is always appended so the timestamp reflects the latest change.
- Finally, add the _delete function:
// Delete a note by its UUID
Future<Response> _delete(RequestContext context, String id) async {
final db = context.read<Database>();
try {
final conn = await db.connection;
final result = await conn.execute(
Sql.named('DELETE FROM notes WHERE id = @id'),
parameters: {'id': id},
);
if (result.affectedRows == 0) {
return Response.json(statusCode: 404, body: {'error': 'Note not found'});
}
return Response(statusCode: 204);
} catch (e) {
print('Error deleting note: $e');
return Response.json(statusCode: 500, body: {'error': 'Failed to delete note'});
}
}
What does 204 mean?
HTTP 204 No Content means the request succeeded but there is nothing to send back. It is the standard response for a successful DELETE.
The affectedRows check ensures you return 404 if the note did not exist.
- Save routes/notes/[id].dart.
✔️ Awesome, I've got everything!
Great. Make sure routes/notes/[id].dart is saved.
ⓧ I'd like to double check the full code
Scroll up and confirm your routes/notes/[id].dart contains these sections in order:
- Imports and the onRequest router switching on HttpMethod.get, HttpMethod.put, and HttpMethod.delete.
- The _getById function querying by id and returning 404 if not found.
- The _update function building dynamic SET clauses and appending updated_at = NOW().
- The _delete function returning 204 on success or 404 if the note does not exist.
Test all endpoints with curl
Time to exercise the full CRUD lifecycle. You will create a note, read it back, update it, and delete it.
- Before you run the following commands, what do you think the POST response will contain that the GET response did not?
- Create a note by running:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d '{"title":"My first note","body":"Hello from the API!"}'
You should see a JSON response with status 201 containing your note's id, title, body, and timestamps.
- Copy the id value from the response. You will need it for the next commands.
- Fetch that specific note by running (replace the UUID with your actual ID):
curl http://localhost:8080/notes/[[NOTE_ID="your-note-uuid-from-above"]]
You should see the same note returned as a single JSON object.
- Update the note's title by running:
curl -X PUT http://localhost:8080/notes/[[NOTE_ID="your-note-uuid-from-above"]] -H "Content-Type: application/json" -d '{"title":"Updated title"}'
The response shows the note with the new title. Notice updated_at is now later than created_at.
- Delete the note by running:
curl -X DELETE http://localhost:8080/notes/[[NOTE_ID="your-note-uuid-from-above"]] -w "\nHTTP Status: %{http_code}\n"
You should see HTTP Status: 204 with no response body. The note is gone.
- Confirm deletion by listing all notes:
curl http://localhost:8080/notes
The array should be empty again (or missing the deleted note if you created others earlier).
- Open http://localhost:54323 in your browser to view Supabase Studio.
- Navigate to Table Editor and select the notes table to confirm the data matches what your API returned.
Seeing unexpected results in Studio?
- If the table shows old rows, you may have created notes during earlier testing. Create a fresh note to confirm the round-trip works:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d '{"title":"Fresh note","body":"Testing again"}'
Then refresh Studio to see the new entry.
help me debug my CRUD endpoints not reflecting in Supabase Studio
Your API now handles the full create-read-update-delete lifecycle. Any HTTP client can talk to your notes table through clean REST endpoints. Next up, you will add proper error handling and input validation so the API fails gracefully instead of crashing on bad input.
Add Error Handling and Input Validation
Your CRUD endpoints handle the happy path. Creating, reading, updating, and deleting notes all work when given perfect input.
But what happens when a future Flutter app sends garbage data? An empty title, malformed JSON, or a request for a note that doesn't exist. A real API needs to respond with clear, consistent error messages so the client knows exactly what went wrong.
In this step, get ready to:
- Verify your input validation catches bad requests and returns proper status codes.
- Confirm your error handling wraps database failures gracefully.
- Test every error path with curl to prove the API responds correctly.
Test input validation on your endpoints
Your route handlers already include validation logic that checks incoming data before touching the database. The pattern is consistent: check the input, return a 400 status with a clear error message if something is wrong, and only proceed to the database if validation passes.
Let's prove this works by intentionally sending bad data.
- Before you run this, what do you think will happen if you send a POST request with an empty title? Will it crash, return a 500, or something else?
- Test creating a note with an empty title by running:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d '{"title":"","body":""}'
You get back a 400 response with a clear error message:
{"error": "Title is required"}
Why 400 and not 500?
A 400 status means the client sent something wrong. A 500 means the server broke internally. Returning the right code tells your Flutter frontend exactly where the problem is.
The validation in your _create function checks if (title == null || title.trim().isEmpty) before ever hitting the database.
- Test sending malformed JSON by running:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d 'this is not json'
You see a 400 response with:
{"error": "Invalid JSON body"}
How does the invalid JSON get caught?
The on FormatException clause in your handler catches the error thrown by json.decode() when it receives unparseable input. This is a Dart-specific pattern for typed exception handling.
- Test sending an unsupported HTTP method by running:
curl -X PATCH http://localhost:8080/notes -w "\n%{http_code}"
The last line of output shows 405. Your switch statement's default case returns a Method Not Allowed status for any HTTP method your endpoint doesn't handle.
Getting a different status code?
If you see 200 or 404 instead of 405, check that your onRequest function in routes/notes/index.dart has a default: return Response(statusCode: 405); case in the switch statement.
Also confirm dart_frog dev is still running in your other terminal. Help me debug my 405 response
Test error handling for database operations
Input validation catches bad data before it reaches the database. But what about errors that happen during the database call itself? Your handlers wrap every database operation in a try/catch that returns a generic 500 message to the client while logging the real error to your terminal.
- Test requesting a note that doesn't exist by running:
curl http://localhost:8080/notes/00000000-0000-0000-0000-000000000000
You get back:
{"error": "Note not found"}
What does this code do?
The _getById function runs the SELECT query and checks if (result.isEmpty). If no rows come back, it returns a 404 before trying to read any data from the result set. This prevents a crash from accessing result.first on an empty list.
- Test updating with no fields provided by running:
curl -X PUT http://localhost:8080/notes/00000000-0000-0000-0000-000000000000 -H "Content-Type: application/json" -d '{}'
You get back a 400 response:
{"error": "No fields to update"}
Why check for empty updates?
Without this check, the handler would build an SQL UPDATE statement with no SET clauses, which is invalid SQL. Catching this early avoids a database error and gives the client a clear message about what's wrong.
Your _update function checks if (setClauses.isEmpty) before ever constructing the query.
- Test updating with an explicitly empty title by running:
curl -X PUT http://localhost:8080/notes/00000000-0000-0000-0000-000000000000 -H "Content-Type: application/json" -d '{"title":""}'
You get back:
{"error": "Title cannot be empty"}
Why two different title checks?
POST uses Title is required because a new note must always have a title. PUT uses Title cannot be empty because the title field is optional in an update. You only reject it if the client explicitly passes an empty string.
- Switch to the terminal where dart_frog dev is running and check the output.
You should see no error logs from these tests. That's because all the failures were client errors (400/404) caught by validation before hitting the database. The print() statements in your catch blocks only fire when an actual database exception occurs.
Why log server-side but hide from clients?
Exposing raw database errors (stack traces, SQL syntax details, connection strings) to clients is a security risk. Your handlers return a generic {"error": "Failed to fetch notes"} to the client while printing the actual exception to your server console for debugging.
Seeing unexpected 500 errors?
If your validation tests return 500 instead of 400, the request might be reaching the database before validation runs. Check that your validation checks (if (title == null || title.trim().isEmpty)) appear BEFORE the await db.connection line in each handler.
If dart_frog dev stopped running, restart it from the api directory. Help me debug my validation order
Now let's run the verification from the outline to confirm everything works end to end.
- What do you expect to see when you send an empty title to POST /notes? Predict the status code and the response body before running the command.
- Verify your full validation by running:
curl -X POST http://localhost:8080/notes -H "Content-Type: application/json" -d '{"title":"","body":""}' -w "\n%{http_code}"
The output shows {"error":"Title is required"} followed by 400 on the last line. Your API rejects bad input with a clear message and the correct HTTP status code.
✔️ Awesome, I've got everything!
Great. Your API handles every error case with the right status code and a consistent JSON error structure.
ⓧ I'd like to double check the full code
Your routes/notes/index.dart and routes/notes/[id].dart files should contain the validation and error handling patterns you tested above. Scroll up through this step and confirm each test returned the expected response. The key patterns to verify in your code:
- A switch on context.request.method with a default: return Response(statusCode: 405) case.
- Title validation before any database call (if (title == null || title.trim().isEmpty) in POST, if (title != null && title.trim().isEmpty) in PUT).
- An on FormatException clause catching malformed JSON.
- A generic catch (e) block that calls print() and returns a 500 response.
- An if (result.isEmpty) check returning 404 for missing notes.
Your API now has a proper safety net. Every request gets validated before touching the database, and every database error is caught and reported cleanly. Next up, you'll extend this API with search and filtering in the Secret Mission.
Secret mission
Add Search and Filtering
Your API returns every note in the database every time. A real-world app needs to let clients find specific notes without downloading the whole list. Can you add search and date filtering to your GET /notes endpoint?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing financial costs. The Docker containers do consume RAM and CPU while active.
Resources you used:
- Docker containers running the local Supabase stack (Postgres, Auth, Storage, Studio, and supporting services).
- Project files in the ~/Desktop/notes_api directory (contains supabase/ and api/ folders).
- Dart Frog CLI (installed globally via dart pub global activate).
- Supabase CLI (installed via Homebrew).
- Docker Desktop (installed application).
Keep everything running
No action needed. Choose this if you plan to continue to Project 2 soon. Docker and Supabase will stay running in the background, ready for your next session.
- Your Dart Frog dev server stops when you close the terminal or press Ctrl+C. Restart it anytime with dart_frog dev from the api/ directory.
- Docker Desktop and the Supabase containers continue to consume RAM while active.
- The Dart Frog CLI, Supabase CLI, and Docker Desktop remain installed on your machine for future projects.
Pause - I'll come back to this later
Shut down the running containers to free up memory. Your data and project files stay intact so you can resume later.
- Stop the Supabase containers by running this command from your notes_api directory:
- When you want to resume, run supabase start from the same directory. Your data persists between stops.
cd ~/Desktop/notes_api
supabase stop
- You can also quit Docker Desktop from the whale icon in your menu bar. Supabase containers will stop automatically.
Delete - I don't want to use this again
Remove all project resources and reclaim disk space. This deletes your containers, data, project files, and installed tools.
- Stop the Supabase containers and reclaim Docker disk space:
cd ~/Desktop/notes_api
supabase stop
docker system prune
- When prompted, type y to confirm the prune.
- Remove the project directory:
rm -rf ~/Desktop/notes_api
- Deactivate the Dart Frog CLI:
dart pub global deactivate dart_frog_cli
- Uninstall the Supabase CLI:
brew uninstall supabase/tap/supabase
- Uninstall Docker Desktop: open Docker Desktop, click the Troubleshoot icon (gear/bug icon in the top-right), select Uninstall, and confirm. Then drag Docker.app from Applications to the Trash.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional Notes REST API from scratch, powered by a local Supabase Postgres database and served through Dart Frog.
You've learned how to:
- Build a Dart Frog backend with file-system routing connected to a local Supabase PostgreSQL database running in Docker.
- Implement full CRUD endpoints (Create, Read, Update, Delete) with proper HTTP semantics, status codes, and dependency injection via Dart Frog's middleware/provider pattern.
- Add input validation and error handling that returns consistent, client-friendly JSON error responses instead of raw server crashes.
- Secret Mission: Build dynamic query filtering with search and date range parameters that construct SQL WHERE clauses on the fly.
Ready to quiz yourself?