Skip to main content

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)

  1. Request enters the Shelf Pipeline.
  2. requestIdMiddleware() stamps trace context.
  3. HTTP logging middleware captures timing and status.
  4. Router dispatches to web (/, /movies/...) or API (/api/...) handlers.
  5. 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.

      ..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)

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:

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();
}
}

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.

    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},
);

Genre handlers

Genres are simpler as they are mostly read-only in this example.

    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);

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.

We configure a logger that outputs structured JSON.

Logger buildLogger() {
final logger = Logger()
..environment('development')
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
);

return logger;
}

Verify Before Continuing

Run the app and confirm both surfaces respond:

dart run bin/server.dart
  • GET / should render HTML.
  • GET /api/movies should return JSON.