Server & Web Routes
The server is where everything comes together. We use Shelf to handle HTTP requests and Shelf Router to map URLs to specific handler functions.
Prerequisites
What You’ll Learn
- How to bootstrap the app server and middleware stack
- How web and API routes are organized
- How to keep route modules maintainable as features grow
Step Outcome
By the end of this page, you should have:
- A bootable Shelf server with request ID + HTTP logging middleware
- Router groups for web routes and API routes
- Handler patterns for list/show/create/update/delete flows
Request Lifecycle (What Happens Per Request)
- Request enters the Shelf
Pipeline. requestIdMiddleware()stamps trace context.- HTTP logging middleware captures timing and status.
- Router dispatches to web (
/,/movies/...) or API (/api/...) handlers. - Handler reads/writes through
AppDatabase, storage, and templates.
Bootstrap the Shelf server
The entry point of our application initializes the database, storage, and template engine before starting the server. We also use Pipeline to add middleware, such as logRequests and our custom contextual_shelf middleware for structured logging.
final logger = buildLogger();
final httpLogger = buildHttpLogger(logger);
final storage = StorageService();
await storage.init();
final database = AppDatabase();
await database.init();
final templates = TemplateRenderer(
FileSystemRoot(AppPaths.templatesDir, throwOnMissing: true),
sharedData: {'app_name': 'Movie Catalog', 'year': DateTime.now().year},
);
final app = MovieCatalogApp(
database: database,
storage: storage,
templates: templates,
logger: logger,
);
final handler = Pipeline()
.addMiddleware(requestIdMiddleware())
.addMiddleware(httpLogger.middleware)
.addHandler(app.buildHandler());
final server = await serve(handler, host, port);
logger.info('Server running', Context({'host': host, 'port': port}));
return server;
The dependency setup block builds services once at startup. The pipeline block defines per-request behavior.
Router map
We divide our routes into two main sections: Web (for HTML pages) and API (for JSON data). This keeps the application organized and easy to maintain.
- Web routes
- API routes
..get('/', _index)
..get('/genres', _genresIndex)
..get('/genres/<id|[0-9]+>', _showGenre)
..get('/movies/new', _newMovie)
..post('/movies', _createMovie)
..get('/movies/<id|[0-9]+>', _showMovie)
..get('/movies/<id|[0-9]+>/edit', _editMovie)
..post('/movies/<id|[0-9]+>/edit', _updateMovie)
..get('/movies/<id|[0-9]+>/delete', _confirmDeleteMovie)
..post('/movies/<id|[0-9]+>/delete', _deleteMovie)
..get('/api/genres', _apiListGenres)
..get('/api/genres/<id|[0-9]+>', _apiShowGenre)
..get('/api/movies', _apiListMovies)
..get('/api/movies/<id|[0-9]+>', _apiShowMovie)
..post('/api/movies', _apiCreateMovie)
..patch('/api/movies/<id|[0-9]+>', _apiUpdateMovie)
..delete('/api/movies/<id|[0-9]+>', _apiDeleteMovie)
Keep web and API routes in separate groups even for the same entity. It keeps response concerns explicit: HTML rendering vs JSON contracts.
Movie handlers
Handlers are responsible for processing requests, interacting with the database, and returning a response.
Listing Movies
The index handler fetches movies from the database, including their associated genres, and renders them using a template.
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.orderBy('createdAt', descending: true)
.get();
final viewModels = movies.map(_movieViewModel).toList();
final html = await templates.render('movies/index.liquid', {
'movies': viewModels,
});
return _html(html);
Creating a Movie
Creating a movie is a multi-step process. We've broken it down to show how each part works:
- 1. Form Parsing
- 2. Validation
- 3. File Storage
- 4. Database Insert
- 5. Logging
We use shelf_multipart to handle file uploads (the movie poster) alongside regular form fields.
final form = request.formData();
if (form == null) {
return Response(HttpStatus.badRequest, body: 'Expected form data');
}
final fields = <String, String>{};
List<int>? posterBytes;
String? posterName;
await for (final formData in form.formData) {
if (formData.name == 'poster' && formData.filename != null) {
posterName = formData.filename;
posterBytes = await formData.part.readBytes();
} else {
fields[formData.name] = await formData.part.readString();
}
}
Before saving, we ensure the data is valid. If not, we re-render the form with error messages.
final errors = _validateMovieForm(fields);
if (errors.isNotEmpty) {
final genres = await database.dataSource
.query<$Genre>()
.orderBy('name')
.get();
final html = await templates.render('movies/new.liquid', {
'genres': genres.map(_genreViewModel).toList(),
'errors': errors,
'values': fields,
});
return _html(html, status: HttpStatus.badRequest);
}
The poster is saved to our storage service.
final posterPath = await storage.savePoster(
originalName: posterName,
bytes: posterBytes,
);
We use the MovieInsertDto to safely insert the new movie into the database.
final repo = database.dataSource.repo<$Movie>();
final movie = await repo.insert(
MovieInsertDto(
title: fields['title']!.trim(),
releaseYear: int.parse(fields['releaseYear']!),
summary: _nullIfEmpty(fields['summary']),
posterPath: posterPath,
genreId: _parseOptionalInt(fields['genreId']),
),
);
Finally, we log the event with context for better observability.
logger.info(
'Movie created',
Context({
'movie_id': movie.id,
'title': movie.title,
'request_id': request.context['requestId'],
}),
);
This split is intentional: parsing, validation, storage, persistence, and logging are independent concerns and are easier to test when separated.
Updating a Movie
Updating follows a similar pattern, using MovieUpdateDto to apply changes.
- Database Update
- Logging
final repo = database.dataSource.repo<$Movie>();
final movie = await repo.update(
MovieUpdateDto(
title: fields['title']!.trim(),
releaseYear: int.parse(fields['releaseYear']!),
summary: _nullIfEmpty(fields['summary']),
genreId: _parseOptionalInt(fields['genreId']),
),
where: {'id': movieId},
);
logger.info(
'Movie updated',
Context({
'movie_id': movie.id,
'title': movie.title,
'request_id': request.context['requestId'],
}),
);
Genre handlers
Genres are simpler as they are mostly read-only in this example.
- Index
- Show
final genres = await database.dataSource
.query<$Genre>()
.orderBy('name')
.get();
final html = await templates.render('genres/index.liquid', {
'genres': genres.map(_genreViewModel).toList(),
});
return _html(html);
final genreId = int.tryParse(id);
if (genreId == null) {
return Response(HttpStatus.badRequest, body: 'Invalid id');
}
final genre = await database.dataSource.query<$Genre>().find(genreId);
if (genre == null) {
return Response.notFound('Genre not found');
}
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.where('genreId', genreId)
.orderBy('createdAt', descending: true)
.get();
final html = await templates.render('genres/show.liquid', {
'genre': _genreViewModel(genre),
'movies': movies.map(_movieViewModel).toList(),
});
return _html(html);
Structured logging
Structured logging (via Contextual) is essential for debugging and observability. Instead of plain text logs, we emit JSON logs with attached context like request IDs and user information.
- Logger
- HTTP middleware
- Request ID
We configure a logger that outputs structured JSON.
Logger buildLogger() {
final logger = Logger()
..environment('development')
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
);
return logger;
}
This middleware logs every incoming request and its response time.
HttpLogger buildHttpLogger(Logger logger) {
final writer = DefaultLogWriter(
logger,
sanitizer: Sanitizer(mask: '[REDACTED]'),
);
return HttpLogger(LogAllRequests(), writer);
}
Every request gets a unique ID, which is attached to all logs for that request.
Middleware requestIdMiddleware() {
return (inner) {
return (request) async {
final requestId = _randomId();
final updated = request.change(context: {'requestId': requestId});
return inner(updated);
};
};
}
Verify Before Continuing
Run the app and confirm both surfaces respond:
dart run bin/server.dart
GET /should render HTML.GET /api/moviesshould return JSON.