diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbc3aae..176957c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+### Added
+- Automatically encode DateTime objects as an ISO-8601 string
+- The "conflict" server response
+
+### Changed
+- URL Design matching now respects the base URL
+- Allow null to be returned by error interceptors
+
+### Fixed
+- StandardUriDesign working inconsistently, depending on the trailing slash in the path
+
 ## [8.1.0] - 2024-08-29
 ### Added
 - The rawResponse property to the responses of the RoutingClient
@@ -13,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [8.0.0] - 2024-07-01
 ### Added
-- CORS middware
+- CORS middleware
 
 ### Changed
 - Bump http\_interop to v2.0
@@ -264,6 +276,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[Unreleased]: https://github.com/f3ath/json-api-dart/compare/8.1.0...HEAD
 [8.1.0]: https://github.com/f3ath/json-api-dart/compare/8.0.0...8.1.0
 [8.0.0]: https://github.com/f3ath/json-api-dart/compare/7.0.1...8.0.0
 [7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1
diff --git a/example/server.dart b/example/server.dart
index a8173b4..e673434 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -1,14 +1,13 @@
 import 'dart:io';
 
-import 'package:http_interop/http_interop.dart' as interop;
-import 'package:json_api/document.dart';
-import 'package:json_api/http.dart';
+import 'package:http_interop_middleware/http_interop_middleware.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/server.dart';
 import 'package:uuid/uuid.dart';
 
 import 'server/in_memory_repo.dart';
 import 'server/json_api_server.dart';
+import 'server/logger_middleware.dart';
 import 'server/repository.dart';
 import 'server/repository_controller.dart';
 
@@ -18,21 +17,11 @@ Future<void> main() async {
   final repo = InMemoryRepo(['colors']);
   await initRepo(repo);
   final controller = RepositoryController(repo, Uuid().v4);
-  interop.Handler handler =
-      ControllerRouter(controller, StandardUriDesign.matchTarget).handle;
-
-  handler = tryCatchMiddleware(handler,
-      onError: ErrorConverter(onError: (e, stack) async {
-        stderr.writeln(e);
-        stderr.writeln(stack);
-        return response(500,
-            document: OutboundErrorDocument(
-                [ErrorObject(title: 'Internal Server Error')]));
-      }).call);
-
-  handler = loggingMiddleware(handler,
-      onRequest: (r) => print('${r.method} ${r.uri}'),
-      onResponse: (r) => print('${r.statusCode}'));
+
+  final handler = errorConverter()
+      .add(loggerMiddleware)
+      .call(router(controller, StandardUriDesign.pathOnly.matchTarget));
+
   final server = JsonApiServer(handler, host: host, port: port);
 
   ProcessSignal.sigint.watch().listen((event) async {
diff --git a/example/server/logger_middleware.dart b/example/server/logger_middleware.dart
new file mode 100644
index 0000000..86e0827
--- /dev/null
+++ b/example/server/logger_middleware.dart
@@ -0,0 +1,20 @@
+import 'dart:io';
+
+import 'package:http_interop_middleware/http_interop_middleware.dart';
+
+/// Middleware that logs all requests and responses to stderr.
+final Middleware loggerMiddleware = middleware(
+  onRequest: (r) async {
+    stderr.writeln(r);
+    return null;
+  },
+  onResponse: (r, _) async {
+    stderr.writeln(r);
+    return null;
+  },
+  onError: (e, t, _) async {
+    stderr.writeln(e);
+    stderr.writeln(t);
+    return null;
+  },
+);
diff --git a/lib/client.dart b/lib/client.dart
index 034266e..1556780 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -21,7 +21,7 @@
 /// representing the most common use cases of resource fetching and manipulation.
 /// It can conveniently construct and parse JSON:API documents and URIs.
 /// The [RoutingClient] should be your default choice.
-library client;
+library;
 
 export 'package:json_api/src/client/client.dart';
 export 'package:json_api/src/client/request.dart';
diff --git a/lib/document.dart b/lib/document.dart
index 04efafb..843207a 100644
--- a/lib/document.dart
+++ b/lib/document.dart
@@ -1,8 +1,9 @@
 /// JSON:API Document model.
-library document;
+library;
 
 export 'package:json_api/src/document/error_object.dart';
 export 'package:json_api/src/document/inbound_document.dart';
+export 'package:json_api/src/document/json_encodable.dart';
 export 'package:json_api/src/document/link.dart';
 export 'package:json_api/src/document/new_identifier.dart';
 export 'package:json_api/src/document/new_relationship.dart';
@@ -12,5 +13,6 @@ export 'package:json_api/src/document/new_to_one.dart';
 export 'package:json_api/src/document/outbound_document.dart';
 export 'package:json_api/src/document/relationship.dart';
 export 'package:json_api/src/document/resource.dart';
+export 'package:json_api/src/document/to_json_encodable.dart';
 export 'package:json_api/src/document/to_many.dart';
 export 'package:json_api/src/document/to_one.dart';
diff --git a/lib/http.dart b/lib/http.dart
index 9a3ced9..fbb191b 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -1,69 +1,5 @@
-import 'package:http_interop/http_interop.dart';
+/// Common HTTP utilities for JSON:API clients and servers.
+/// WARNING: This library is in beta stage. The API is subject to change.
+library;
 
-class StatusCode {
-  const StatusCode(this.value);
-
-  static const ok = 200;
-  static const created = 201;
-  static const accepted = 202;
-  static const noContent = 204;
-  static const badRequest = 400;
-  static const notFound = 404;
-  static const methodNotAllowed = 405;
-  static const unacceptable = 406;
-  static const unsupportedMediaType = 415;
-
-  final int value;
-
-  /// True for the requests processed asynchronously.
-  /// @see https://jsonapi.org/recommendations/#asynchronous-processing).
-  bool get isPending => value == accepted;
-
-  /// True for successfully processed requests
-  bool get isSuccessful => value >= ok && value < 300 && !isPending;
-
-  /// True for failed requests (i.e. neither successful nor pending)
-  bool get isFailed => !isSuccessful && !isPending;
-}
-
-Handler loggingMiddleware(Handler handler,
-        {Function(Request request)? onRequest,
-        Function(Response response)? onResponse}) =>
-    (Request request) async {
-      onRequest?.call(request);
-      final response = await handler(request);
-      onResponse?.call(response);
-      return response;
-    };
-
-/// CORS middleware
-Handler corsMiddleware(Handler handler) => (Request request) async {
-      final corsHeaders = {
-        'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'],
-        'Access-Control-Expose-Headers': ['Location'],
-      };
-
-      if (request.method == 'options') {
-        const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'];
-        return Response(
-            204,
-            Body(),
-            Headers.from({
-              ...corsHeaders,
-              'Access-Control-Allow-Methods':
-                  request.headers['Access-Control-Request-Method'] ?? methods,
-              'Access-Control-Allow-Headers':
-                  request.headers['Access-Control-Request-Headers'] ?? ['*'],
-            }));
-      }
-      final response = await handler(request);
-      response.headers.addAll(corsHeaders);
-      return response;
-    };
-
-extension HeaderExt on Headers {
-  String? last(String key) {
-    final v = this[key];
-    return (v != null && v.isNotEmpty) ? v.last : null;
-  }
-}
+export 'package:json_api/src/http/status_code.dart';
diff --git a/lib/query.dart b/lib/query.dart
index 335eb69..f031111 100644
--- a/lib/query.dart
+++ b/lib/query.dart
@@ -1,5 +1,5 @@
 /// A set of builders/parsers for special query parameters used in JSON:API.
-library query;
+library;
 
 export 'package:json_api/src/query/fields.dart';
 export 'package:json_api/src/query/include.dart';
diff --git a/lib/routing.dart b/lib/routing.dart
index 4f267e5..98553ed 100644
--- a/lib/routing.dart
+++ b/lib/routing.dart
@@ -1,6 +1,6 @@
 /// Routing describes the design of URLs on the server.
 /// See https://jsonapi.org/recommendations/#urls
-library routing;
+library;
 
 export 'package:json_api/src/routing/standard_uri_design.dart';
 export 'package:json_api/src/routing/target.dart';
diff --git a/lib/server.dart b/lib/server.dart
index 1cf7a97..e6e60e1 100644
--- a/lib/server.dart
+++ b/lib/server.dart
@@ -1,13 +1,17 @@
 /// JSON:API server on top of dart:io.
-library server;
+/// WARNING: This library is in beta stage. The API is subject to change.
+library;
 
 export 'package:json_api/src/server/controller.dart';
-export 'package:json_api/src/server/controller_router.dart';
+export 'package:json_api/src/server/cors_middleware.dart';
 export 'package:json_api/src/server/error_converter.dart';
 export 'package:json_api/src/server/errors/collection_not_found.dart';
 export 'package:json_api/src/server/errors/method_not_allowed.dart';
+export 'package:json_api/src/server/errors/not_acceptable.dart';
 export 'package:json_api/src/server/errors/relationship_not_found.dart';
 export 'package:json_api/src/server/errors/resource_not_found.dart';
 export 'package:json_api/src/server/errors/unmatched_target.dart';
+export 'package:json_api/src/server/errors/unsupported_media_type.dart';
+export 'package:json_api/src/server/request_validator.dart';
 export 'package:json_api/src/server/response.dart';
-export 'package:json_api/src/server/try_catch_middleware.dart';
+export 'package:json_api/src/server/router.dart';
diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index c5b1f0f..f3f32cc 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -2,7 +2,6 @@ import 'dart:convert';
 
 import 'package:http_interop/extensions.dart';
 import 'package:http_interop/http_interop.dart' as i;
-import 'package:json_api/http.dart';
 import 'package:json_api/src/client/payload_codec.dart';
 import 'package:json_api/src/client/request.dart';
 import 'package:json_api/src/client/response.dart';
@@ -45,9 +44,8 @@ class Client {
   Future<Map?> _decode(i.Response response) async {
     final json = await response.body.decode(utf8);
     if (json.isNotEmpty &&
-        response.headers
-                .last('Content-Type')
-                ?.toLowerCase()
+        response.headers['Content-Type']?.last
+                .toLowerCase()
                 .startsWith(mediaType) ==
             true) {
       return await _codec.decode(json);
diff --git a/lib/src/client/payload_codec.dart b/lib/src/client/payload_codec.dart
index e3ed74b..0d9d517 100644
--- a/lib/src/client/payload_codec.dart
+++ b/lib/src/client/payload_codec.dart
@@ -1,6 +1,8 @@
 import 'dart:async';
 import 'dart:convert';
 
+import 'package:json_api/src/document/to_json_encodable.dart';
+
 /// Encodes/decodes JSON payload.
 ///
 /// The methods are designed to be asynchronous to allow for conversion to be
@@ -16,5 +18,6 @@ class PayloadCodec {
   }
 
   /// Encodes a JSON:API document into a JSON string.
-  FutureOr<String> encode(Object document) => jsonEncode(document);
+  FutureOr<String> encode(Object document) =>
+      jsonEncode(document, toEncodable: toJsonEncodable);
 }
diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart
index f77b709..08f0f60 100644
--- a/lib/src/client/response/collection_fetched.dart
+++ b/lib/src/client/response/collection_fetched.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -12,12 +11,6 @@ class CollectionFetched {
     links.addAll(document.links());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart
index ab3cd60..7b94287 100644
--- a/lib/src/client/response/related_resource_fetched.dart
+++ b/lib/src/client/response/related_resource_fetched.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -15,12 +14,6 @@ class RelatedResourceFetched {
     links.addAll(document.links());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart
index 889070f..6f40891 100644
--- a/lib/src/client/response/relationship_fetched.dart
+++ b/lib/src/client/response/relationship_fetched.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -20,12 +19,6 @@ class RelationshipFetched<R extends Relationship> {
       ..included.addAll(document.included());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart
index 9a885d8..da77fc9 100644
--- a/lib/src/client/response/relationship_updated.dart
+++ b/lib/src/client/response/relationship_updated.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -18,12 +17,6 @@ class RelationshipUpdated<R extends Relationship> {
         response, json == null ? null : InboundDocument(json).asToOne());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart
index 1f69695..c6e0159 100644
--- a/lib/src/client/response/request_failure.dart
+++ b/lib/src/client/response/request_failure.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -12,12 +11,6 @@ class RequestFailure implements Exception {
     meta.addAll(document.meta());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart
index d67a8ec..b83eebf 100644
--- a/lib/src/client/response/resource_created.dart
+++ b/lib/src/client/response/resource_created.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -16,12 +15,6 @@ class ResourceCreated {
     links.addAll(document.links());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart
index 2b69446..7054ae4 100644
--- a/lib/src/client/response/resource_fetched.dart
+++ b/lib/src/client/response/resource_fetched.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -13,12 +12,6 @@ class ResourceFetched {
     links.addAll(document.links());
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart
index 381ebe9..e9a1bc8 100644
--- a/lib/src/client/response/resource_updated.dart
+++ b/lib/src/client/response/resource_updated.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
 import 'package:json_api/src/client/response.dart';
 
@@ -21,12 +20,6 @@ class ResourceUpdated {
     return null;
   }
 
-  // coverage:ignore-start
-  /// The raw HTTP response
-  @Deprecated('Use rawResponse.httpResponse instead')
-  i.Response get httpResponse => rawResponse.httpResponse;
-  // coverage:ignore-end
-
   /// The raw JSON:API response
   final Response rawResponse;
 
diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart
index 07ef02c..3202ea9 100644
--- a/lib/src/document/error_object.dart
+++ b/lib/src/document/error_object.dart
@@ -1,10 +1,10 @@
+import 'package:json_api/document.dart';
 import 'package:json_api/src/document/error_source.dart';
-import 'package:json_api/src/document/link.dart';
 
 /// [ErrorObject] represents an error occurred on the server.
 ///
 /// More on this: https://jsonapi.org/format/#errors
-class ErrorObject {
+class ErrorObject implements JsonEncodable {
   /// Creates an instance of a JSON:API Error.
   /// The [links] map may contain custom links. The about link
   /// passed through the [links['about']] argument takes precedence and will overwrite
@@ -43,6 +43,7 @@ class ErrorObject {
   /// Meta data.
   final meta = <String, Object?>{};
 
+  @override
   Map<String, Object> toJson() => {
         if (id.isNotEmpty) 'id': id,
         if (status.isNotEmpty) 'status': status,
diff --git a/lib/src/document/error_source.dart b/lib/src/document/error_source.dart
index ae8c6ce..66b6c36 100644
--- a/lib/src/document/error_source.dart
+++ b/lib/src/document/error_source.dart
@@ -1,5 +1,7 @@
+import 'package:json_api/src/document/json_encodable.dart';
+
 /// An object containing references to the source of the error.
-class ErrorSource {
+class ErrorSource implements JsonEncodable {
   const ErrorSource({this.pointer = '', this.parameter = ''});
 
   /// A JSON Pointer [RFC6901] to the associated entity in the request document.
@@ -12,6 +14,7 @@ class ErrorSource {
 
   bool get isNotEmpty => !isEmpty;
 
+  @override
   Map<String, String> toJson() => {
         if (parameter.isNotEmpty) 'parameter': parameter,
         if (pointer.isNotEmpty) 'pointer': pointer
diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart
new file mode 100644
index 0000000..ea5c471
--- /dev/null
+++ b/lib/src/document/json_encodable.dart
@@ -0,0 +1,4 @@
+abstract interface class JsonEncodable {
+  /// Converts the object to a JSON-encodable object.
+  Object? toJson();
+}
diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart
index 6bd3480..b2bcebe 100644
--- a/lib/src/document/link.dart
+++ b/lib/src/document/link.dart
@@ -1,6 +1,8 @@
+import 'package:json_api/src/document/json_encodable.dart';
+
 /// A JSON:API link
 /// https://jsonapi.org/format/#document-links
-class Link {
+class Link implements JsonEncodable {
   Link(this.uri);
 
   /// Link URL
@@ -12,6 +14,7 @@ class Link {
   @override
   String toString() => uri.toString();
 
+  @override
   Object toJson() =>
       meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta};
 }
diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart
index 1c0653d..82eeea9 100644
--- a/lib/src/document/new_identifier.dart
+++ b/lib/src/document/new_identifier.dart
@@ -1,5 +1,7 @@
+import 'package:json_api/src/document/json_encodable.dart';
+
 /// A new Resource Identifier object, used when creating new resources on the server.
-sealed class NewIdentifier {
+sealed class NewIdentifier implements JsonEncodable {
   /// Resource type.
   String get type;
 
@@ -12,6 +14,7 @@ sealed class NewIdentifier {
   /// Identifier meta-data.
   Map<String, Object?> get meta;
 
+  @override
   Map<String, Object> toJson();
 }
 
diff --git a/lib/src/document/new_relationship.dart b/lib/src/document/new_relationship.dart
index 65de471..6e10797 100644
--- a/lib/src/document/new_relationship.dart
+++ b/lib/src/document/new_relationship.dart
@@ -1,12 +1,16 @@
 import 'dart:collection';
 
+import 'package:json_api/src/document/json_encodable.dart';
 import 'package:json_api/src/document/link.dart';
 import 'package:json_api/src/document/new_identifier.dart';
 
-class NewRelationship with IterableMixin<NewIdentifier> {
+class NewRelationship
+    with IterableMixin<NewIdentifier>
+    implements JsonEncodable {
   final links = <String, Link>{};
   final meta = <String, Object?>{};
 
+  @override
   Map<String, dynamic> toJson() => {
         if (links.isNotEmpty) 'links': links,
         if (meta.isNotEmpty) 'meta': meta,
diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart
index 3cf4be7..51f6ddf 100644
--- a/lib/src/document/new_resource.dart
+++ b/lib/src/document/new_resource.dart
@@ -1,3 +1,4 @@
+import 'package:json_api/src/document/json_encodable.dart';
 import 'package:json_api/src/document/new_identifier.dart';
 import 'package:json_api/src/document/new_relationship.dart';
 import 'package:json_api/src/document/new_to_many.dart';
@@ -8,7 +9,7 @@ import 'package:json_api/src/document/to_many.dart';
 import 'package:json_api/src/document/to_one.dart';
 
 /// A set of properties for a to-be-created resource which does not have the id yet.
-class NewResource {
+class NewResource implements JsonEncodable {
   NewResource(this.type, {this.id, this.lid});
 
   /// Resource type
@@ -33,6 +34,7 @@ class NewResource {
   /// See https://jsonapi.org/format/#document-resource-object-relationships
   final relationships = <String, NewRelationship>{};
 
+  @override
   Map<String, Object> toJson() => {
         'type': type,
         if (id != null) 'id': id!,
diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart
index 78192f6..332f9e7 100644
--- a/lib/src/document/outbound_document.dart
+++ b/lib/src/document/outbound_document.dart
@@ -1,4 +1,5 @@
 import 'package:json_api/src/document/error_object.dart';
+import 'package:json_api/src/document/json_encodable.dart';
 import 'package:json_api/src/document/link.dart';
 import 'package:json_api/src/document/new_resource.dart';
 import 'package:json_api/src/document/resource.dart';
@@ -6,11 +7,11 @@ import 'package:json_api/src/document/to_many.dart';
 import 'package:json_api/src/document/to_one.dart';
 
 /// A sever-to-client document.
-class OutboundDocument {
+class OutboundDocument implements JsonEncodable {
   /// The document "meta" object.
   final meta = <String, Object?>{};
 
-  /// Returns the JSON representation.
+  @override
   Map<String, Object?> toJson() => {'meta': meta};
 }
 
diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart
index 3526ffc..d5a7dcb 100644
--- a/lib/src/document/relationship.dart
+++ b/lib/src/document/relationship.dart
@@ -1,13 +1,15 @@
 import 'dart:collection';
 
+import 'package:json_api/src/document/json_encodable.dart';
 import 'package:json_api/src/document/link.dart';
 import 'package:json_api/src/document/new_identifier.dart';
 
-class Relationship with IterableMixin<Identifier> {
+class Relationship with IterableMixin<Identifier> implements JsonEncodable {
   final links = <String, Link>{};
   final meta = <String, Object?>{};
 
-  Map<String, dynamic> toJson() => {
+  @override
+  Map<String, Object?> toJson() => {
         if (links.isNotEmpty) 'links': links,
         if (meta.isNotEmpty) 'meta': meta,
       };
diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart
index b9ca5f7..fecd2cb 100644
--- a/lib/src/document/resource.dart
+++ b/lib/src/document/resource.dart
@@ -1,10 +1,11 @@
+import 'package:json_api/src/document/json_encodable.dart';
 import 'package:json_api/src/document/link.dart';
 import 'package:json_api/src/document/new_identifier.dart';
 import 'package:json_api/src/document/relationship.dart';
 import 'package:json_api/src/document/to_many.dart';
 import 'package:json_api/src/document/to_one.dart';
 
-class Resource {
+class Resource implements JsonEncodable {
   Resource(this.type, this.id);
 
   /// Resource type.
@@ -45,6 +46,7 @@ class Resource {
     return null;
   }
 
+  @override
   Map<String, Object> toJson() => {
         'type': type,
         'id': id,
diff --git a/lib/src/document/to_json_encodable.dart b/lib/src/document/to_json_encodable.dart
new file mode 100644
index 0000000..e275bbe
--- /dev/null
+++ b/lib/src/document/to_json_encodable.dart
@@ -0,0 +1,9 @@
+import 'package:json_api/document.dart';
+
+/// A helper function to be used in `toJsonEncodable`
+/// parameter of `jsonEncode()`.
+Object? toJsonEncodable(Object? v) => switch (v) {
+      JsonEncodable() => v.toJson(),
+      DateTime() => v.toIso8601String(),
+      _ => throw UnsupportedError('Cannot convert to JSON: $v'),
+    };
diff --git a/lib/src/document/to_many.dart b/lib/src/document/to_many.dart
index 902b1ff..6bb5d52 100644
--- a/lib/src/document/to_many.dart
+++ b/lib/src/document/to_many.dart
@@ -9,9 +9,9 @@ class ToMany extends Relationship {
   final _ids = <Identifier>[];
 
   @override
-  Map<String, Object> toJson() => {
+  Map<String, Object?> toJson() => {
         'data': [..._ids],
-        ...super.toJson()
+        ...super.toJson(),
       };
 
   @override
diff --git a/lib/src/document/to_one.dart b/lib/src/document/to_one.dart
index ae61979..d5a402b 100644
--- a/lib/src/document/to_one.dart
+++ b/lib/src/document/to_one.dart
@@ -7,7 +7,7 @@ class ToOne extends Relationship {
   ToOne.empty() : this(null);
 
   @override
-  Map<String, dynamic> toJson() => {'data': identifier, ...super.toJson()};
+  Map<String, Object?> toJson() => {'data': identifier, ...super.toJson()};
 
   final Identifier? identifier;
 
diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart
new file mode 100644
index 0000000..fc723cd
--- /dev/null
+++ b/lib/src/http/status_code.dart
@@ -0,0 +1,23 @@
+extension type StatusCode(int value) {
+  static const ok = 200;
+  static const created = 201;
+  static const accepted = 202;
+  static const noContent = 204;
+  static const badRequest = 400;
+  static const notFound = 404;
+  static const methodNotAllowed = 405;
+  static const notAcceptable = 406;
+  static const conflict = 409;
+  static const unsupportedMediaType = 415;
+  static const internalServerError = 500;
+
+  /// True for the requests processed asynchronously.
+  /// @see https://jsonapi.org/recommendations/#asynchronous-processing).
+  bool get isPending => value == accepted;
+
+  /// True for successfully processed requests
+  bool get isSuccessful => value >= ok && value < 300 && !isPending;
+
+  /// True for failed requests (i.e. neither successful nor pending)
+  bool get isFailed => !isSuccessful && !isPending;
+}
diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart
index 2b474f6..cc56394 100644
--- a/lib/src/routing/standard_uri_design.dart
+++ b/lib/src/routing/standard_uri_design.dart
@@ -5,20 +5,30 @@ import 'package:json_api/routing.dart';
 class StandardUriDesign implements UriDesign {
   /// Creates an instance of [UriDesign] recommended by JSON:API standard.
   /// The [base] URI will be used as a prefix for the generated URIs.
-  const StandardUriDesign(this.base);
+  StandardUriDesign(Uri base)
+      : base = base.path.endsWith('/')
+            ? base
+            : base.replace(path: '${base.path}/');
 
   /// A "path only" version of the recommended URL design, e.g.
   /// `/books`, `/books/42`, `/books/42/authors`
   static final pathOnly = StandardUriDesign(Uri(path: '/'));
 
-  static Target? matchTarget(Uri uri) => switch ((uri.pathSegments)) {
-        [var type] => Target(type),
-        [var type, var id] => ResourceTarget(type, id),
-        [var type, var id, var rel] => RelatedTarget(type, id, rel),
-        [var type, var id, 'relationships', var rel] =>
-          RelationshipTarget(type, id, rel),
-        _ => null
-      };
+  /// Matches a [uri] to a [Target] object.
+  Target? matchTarget(Uri uri) => !uri.path.startsWith(base.path) ||
+          (base.scheme.isNotEmpty && uri.scheme != base.scheme) ||
+          (base.host.isNotEmpty && uri.host != base.host) ||
+          (base.port != 0 && uri.port != base.port)
+      ? null
+      : switch (uri.pathSegments
+          .sublist(base.pathSegments.where((it) => it.isNotEmpty).length)) {
+          [var type] => Target(type),
+          [var type, var id] => ResourceTarget(type, id),
+          [var type, var id, var rel] => RelatedTarget(type, id, rel),
+          [var type, var id, 'relationships', var rel] =>
+            RelationshipTarget(type, id, rel),
+          _ => null
+        };
 
   final Uri base;
 
diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart
index 4bb1519..199b410 100644
--- a/lib/src/routing/uri_design.dart
+++ b/lib/src/routing/uri_design.dart
@@ -1,4 +1,4 @@
-abstract class UriDesign {
+abstract interface class UriDesign {
   Uri collection(String type);
 
   Uri resource(String type, String id);
diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart
index 6936003..b3d97d6 100644
--- a/lib/src/server/controller.dart
+++ b/lib/src/server/controller.dart
@@ -1,43 +1,36 @@
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/routing.dart';
 
 /// JSON:API controller
 abstract class Controller {
   /// Fetch a primary resource collection
-  Future<http.Response> fetchCollection(http.Request request, Target target);
+  Future<Response> fetchCollection(Request request, Target target);
 
   /// Create resource
-  Future<http.Response> createResource(http.Request request, Target target);
+  Future<Response> createResource(Request request, Target target);
 
   /// Fetch a single primary resource
-  Future<http.Response> fetchResource(
-      http.Request request, ResourceTarget target);
+  Future<Response> fetchResource(Request request, ResourceTarget target);
 
   /// Updates a primary resource
-  Future<http.Response> updateResource(
-      http.Request request, ResourceTarget target);
+  Future<Response> updateResource(Request request, ResourceTarget target);
 
   /// Deletes the primary resource
-  Future<http.Response> deleteResource(
-      http.Request request, ResourceTarget target);
+  Future<Response> deleteResource(Request request, ResourceTarget target);
 
   /// Fetches a relationship
-  Future<http.Response> fetchRelationship(
-      http.Request rq, RelationshipTarget target);
+  Future<Response> fetchRelationship(Request rq, RelationshipTarget target);
 
   /// Add new entries to a to-many relationship
-  Future<http.Response> addMany(
-      http.Request request, RelationshipTarget target);
+  Future<Response> addMany(Request request, RelationshipTarget target);
 
   /// Updates the relationship
-  Future<http.Response> replaceRelationship(
-      http.Request request, RelationshipTarget target);
+  Future<Response> replaceRelationship(
+      Request request, RelationshipTarget target);
 
   /// Deletes the members from the to-many relationship
-  Future<http.Response> deleteMany(
-      http.Request request, RelationshipTarget target);
+  Future<Response> deleteMany(Request request, RelationshipTarget target);
 
   /// Fetches related resource or collection
-  Future<http.Response> fetchRelated(
-      http.Request request, RelatedTarget target);
+  Future<Response> fetchRelated(Request request, RelatedTarget target);
 }
diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart
deleted file mode 100644
index 4171d1f..0000000
--- a/lib/src/server/controller_router.dart
+++ /dev/null
@@ -1,65 +0,0 @@
-import 'package:http_interop/http_interop.dart';
-import 'package:http_parser/http_parser.dart';
-import 'package:json_api/http.dart';
-import 'package:json_api/routing.dart';
-import 'package:json_api/src/media_type.dart';
-import 'package:json_api/src/server/controller.dart';
-import 'package:json_api/src/server/errors/method_not_allowed.dart';
-import 'package:json_api/src/server/errors/unacceptable.dart';
-import 'package:json_api/src/server/errors/unmatched_target.dart';
-import 'package:json_api/src/server/errors/unsupported_media_type.dart';
-
-class ControllerRouter {
-  ControllerRouter(this._controller, this._matchTarget);
-
-  final Controller _controller;
-  final Target? Function(Uri uri) _matchTarget;
-
-  Future<Response> handle(Request request) async {
-    _validate(request);
-    final target = _matchTarget(request.uri);
-    return await switch (target) {
-      RelationshipTarget() => switch (request.method) {
-          'get' => _controller.fetchRelationship(request, target),
-          'post' => _controller.addMany(request, target),
-          'patch' => _controller.replaceRelationship(request, target),
-          'delete' => _controller.deleteMany(request, target),
-          _ => throw MethodNotAllowed(request.method)
-        },
-      RelatedTarget() => switch (request.method) {
-          'get' => _controller.fetchRelated(request, target),
-          _ => throw MethodNotAllowed(request.method)
-        },
-      ResourceTarget() => switch (request.method) {
-          'get' => _controller.fetchResource(request, target),
-          'patch' => _controller.updateResource(request, target),
-          'delete' => _controller.deleteResource(request, target),
-          _ => throw MethodNotAllowed(request.method)
-        },
-      Target() => switch (request.method) {
-          'get' => _controller.fetchCollection(request, target),
-          'post' => _controller.createResource(request, target),
-          _ => throw MethodNotAllowed(request.method)
-        },
-      _ => throw UnmatchedTarget(request.uri)
-    };
-  }
-
-  void _validate(Request request) {
-    final contentType = request.headers.last('Content-Type');
-    if (contentType != null && _isInvalid(MediaType.parse(contentType))) {
-      throw UnsupportedMediaType();
-    }
-    if ((request.headers['Accept'] ?? [])
-        .expand((it) => it.split(','))
-        .map((it) => it.trim())
-        .map(MediaType.parse)
-        .any(_isInvalid)) {
-      throw Unacceptable();
-    }
-  }
-
-  bool _isInvalid(MediaType mt) =>
-      mt.mimeType == mediaType &&
-      mt.parameters.isNotEmpty; // TODO: check for ext and profile
-}
diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart
new file mode 100644
index 0000000..d174de5
--- /dev/null
+++ b/lib/src/server/cors_middleware.dart
@@ -0,0 +1,22 @@
+import 'package:http_interop/http_interop.dart';
+import 'package:http_interop_middleware/http_interop_middleware.dart';
+
+final corsMiddleware = middleware(
+    onRequest: (rq) async => switch (rq.method) {
+          'options' => Response(
+              204,
+              Body(),
+              Headers.from({
+                'Access-Control-Allow-Methods':
+                    rq.headers['Access-Control-Request-Method'] ??
+                        const ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'],
+                'Access-Control-Allow-Headers':
+                    rq.headers['Access-Control-Request-Headers'] ?? const ['*'],
+              })),
+          _ => null
+        },
+    onResponse: (rs, rq) async => rs
+      ..headers.addAll({
+        'Access-Control-Allow-Origin': [rq.headers['origin']?.last ?? '*'],
+        'Access-Control-Expose-Headers': const ['Location'],
+      }));
diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart
index 6300408..45af2ad 100644
--- a/lib/src/server/error_converter.dart
+++ b/lib/src/server/error_converter.dart
@@ -1,70 +1,57 @@
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart';
+import 'package:http_interop_middleware/http_interop_middleware.dart';
 import 'package:json_api/document.dart';
 import 'package:json_api/src/server/errors/collection_not_found.dart';
 import 'package:json_api/src/server/errors/method_not_allowed.dart';
+import 'package:json_api/src/server/errors/not_acceptable.dart';
 import 'package:json_api/src/server/errors/relationship_not_found.dart';
 import 'package:json_api/src/server/errors/resource_not_found.dart';
-import 'package:json_api/src/server/errors/unacceptable.dart';
 import 'package:json_api/src/server/errors/unmatched_target.dart';
 import 'package:json_api/src/server/errors/unsupported_media_type.dart';
 import 'package:json_api/src/server/response.dart';
 
-/// The error converter maps server exceptions to JSON:API responses.
-/// It is designed to be used with the TryCatchHandler from the `json_api:http`
-/// package and provides some meaningful defaults out of the box.
-class ErrorConverter {
-  ErrorConverter({
-    this.onMethodNotAllowed,
-    this.onUnmatchedTarget,
-    this.onCollectionNotFound,
-    this.onResourceNotFound,
-    this.onRelationshipNotFound,
-    this.onError,
-  });
-
-  final Future<http.Response> Function(MethodNotAllowed)? onMethodNotAllowed;
-  final Future<http.Response> Function(UnmatchedTarget)? onUnmatchedTarget;
-  final Future<http.Response> Function(CollectionNotFound)?
-      onCollectionNotFound;
-  final Future<http.Response> Function(ResourceNotFound)? onResourceNotFound;
-  final Future<http.Response> Function(RelationshipNotFound)?
-      onRelationshipNotFound;
-  final Future<http.Response> Function(dynamic, StackTrace)? onError;
-
-  Future<http.Response> call(Object? error, StackTrace trace) async =>
-      switch (error) {
-        MethodNotAllowed() =>
-          await onMethodNotAllowed?.call(error) ?? methodNotAllowed(),
-        UnmatchedTarget() =>
-          await onUnmatchedTarget?.call(error) ?? badRequest(),
-        CollectionNotFound() => await onCollectionNotFound?.call(error) ??
-            notFound(OutboundErrorDocument([
-              ErrorObject(
-                title: 'Collection Not Found',
-                detail: 'Type: ${error.type}',
-              )
-            ])),
-        ResourceNotFound() => await onResourceNotFound?.call(error) ??
-            notFound(OutboundErrorDocument([
-              ErrorObject(
-                title: 'Resource Not Found',
-                detail: 'Type: ${error.type}, id: ${error.id}',
-              )
-            ])),
-        RelationshipNotFound() => await onRelationshipNotFound?.call(error) ??
-            notFound(OutboundErrorDocument([
-              ErrorObject(
-                title: 'Relationship Not Found',
-                detail: 'Type: ${error.type}'
-                    ', id: ${error.id}'
-                    ', relationship: ${error.relationship}',
-              )
-            ])),
-        UnsupportedMediaType() => unsupportedMediaType(),
-        Unacceptable() => unacceptable(),
-        _ => await onError?.call(error, trace) ??
-            response(500,
-                document: OutboundErrorDocument(
-                    [ErrorObject(title: 'Internal Server Error')]))
-      };
-}
+/// Creates a middleware that maps server exceptions to HTTP responses.
+Middleware errorConverter({
+  Future<Response?> Function(MethodNotAllowed)? onMethodNotAllowed,
+  Future<Response?> Function(UnmatchedTarget)? onUnmatchedTarget,
+  Future<Response?> Function(CollectionNotFound)? onCollectionNotFound,
+  Future<Response?> Function(ResourceNotFound)? onResourceNotFound,
+  Future<Response?> Function(RelationshipNotFound)? onRelationshipNotFound,
+  Future<Response?> Function(Object, StackTrace)? onError,
+}) =>
+    middleware(
+        onError: (error, trace, _) async => switch (error) {
+              MethodNotAllowed() =>
+                await onMethodNotAllowed?.call(error) ?? methodNotAllowed(),
+              UnmatchedTarget() =>
+                await onUnmatchedTarget?.call(error) ?? badRequest(),
+              CollectionNotFound() => await onCollectionNotFound?.call(error) ??
+                  notFound(OutboundErrorDocument([
+                    ErrorObject(
+                      title: 'Collection Not Found',
+                      detail: 'Type: ${error.type}',
+                    )
+                  ])),
+              ResourceNotFound() => await onResourceNotFound?.call(error) ??
+                  notFound(OutboundErrorDocument([
+                    ErrorObject(
+                      title: 'Resource Not Found',
+                      detail: 'Type: ${error.type}, id: ${error.id}',
+                    )
+                  ])),
+              RelationshipNotFound() =>
+                await onRelationshipNotFound?.call(error) ??
+                    notFound(OutboundErrorDocument([
+                      ErrorObject(
+                        title: 'Relationship Not Found',
+                        detail: 'Type: ${error.type}'
+                            ', id: ${error.id}'
+                            ', relationship: ${error.relationship}',
+                      )
+                    ])),
+              UnsupportedMediaType() => unsupportedMediaType(),
+              NotAcceptable() => notAcceptable(),
+              _ => await onError?.call(error, trace) ??
+                  internalServerError(OutboundErrorDocument(
+                      [ErrorObject(title: 'Internal Server Error')]))
+            });
diff --git a/lib/src/server/errors/not_acceptable.dart b/lib/src/server/errors/not_acceptable.dart
new file mode 100644
index 0000000..fbd9443
--- /dev/null
+++ b/lib/src/server/errors/not_acceptable.dart
@@ -0,0 +1 @@
+class NotAcceptable implements Exception {}
diff --git a/lib/src/server/errors/unacceptable.dart b/lib/src/server/errors/unacceptable.dart
deleted file mode 100644
index ac6d685..0000000
--- a/lib/src/server/errors/unacceptable.dart
+++ /dev/null
@@ -1 +0,0 @@
-class Unacceptable implements Exception {}
diff --git a/lib/src/server/request_validator.dart b/lib/src/server/request_validator.dart
new file mode 100644
index 0000000..3f2b9a8
--- /dev/null
+++ b/lib/src/server/request_validator.dart
@@ -0,0 +1,25 @@
+import 'package:http_interop/http_interop.dart';
+import 'package:http_interop_middleware/http_interop_middleware.dart';
+import 'package:http_parser/http_parser.dart';
+import 'package:json_api/src/media_type.dart';
+import 'package:json_api/src/server/errors/not_acceptable.dart';
+import 'package:json_api/src/server/errors/unsupported_media_type.dart';
+
+final requestValidator = middleware(onRequest: (Request request) async {
+  final contentType = request.headers['Content-Type']?.last;
+  if (contentType != null && _isInvalid(MediaType.parse(contentType))) {
+    throw UnsupportedMediaType();
+  }
+  if ((request.headers['Accept'] ?? [])
+      .expand((it) => it.split(','))
+      .map((it) => it.trim())
+      .map(MediaType.parse)
+      .any(_isInvalid)) {
+    throw NotAcceptable();
+  }
+  return null;
+});
+
+bool _isInvalid(MediaType mt) =>
+    mt.mimeType == mediaType &&
+    mt.parameters.isNotEmpty; // TODO: check for ext and profile
diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart
index 16109f9..b523e76 100644
--- a/lib/src/server/response.dart
+++ b/lib/src/server/response.dart
@@ -4,14 +4,15 @@ import 'package:json_api/http.dart';
 import 'package:json_api/src/media_type.dart';
 
 /// JSON:API response
-Response response(int statusCode, {OutboundDocument? document}) {
-  final r = Response(
-      statusCode, document != null ? Body.json(document) : Body(), Headers());
-  if (document != null) {
-    r.headers['Content-Type'] = [mediaType];
-  }
-  return r;
-}
+Response response(int statusCode, {OutboundDocument? document}) => Response(
+    statusCode,
+    document != null
+        ? Body.json(document, toEncodable: toJsonEncodable)
+        : Body(),
+    Headers())
+  ..headers.addAll({
+    if (document != null) 'Content-Type': [mediaType]
+  });
 
 Response ok(OutboundDocument document) =>
     response(StatusCode.ok, document: document);
@@ -25,6 +26,9 @@ Response created(OutboundDocument document, String location) =>
 Response notFound([OutboundErrorDocument? document]) =>
     response(StatusCode.notFound, document: document);
 
+Response conflict([OutboundErrorDocument? document]) =>
+    response(StatusCode.conflict, document: document);
+
 Response methodNotAllowed([OutboundErrorDocument? document]) =>
     response(StatusCode.methodNotAllowed, document: document);
 
@@ -34,5 +38,8 @@ Response badRequest([OutboundErrorDocument? document]) =>
 Response unsupportedMediaType([OutboundErrorDocument? document]) =>
     response(StatusCode.unsupportedMediaType, document: document);
 
-Response unacceptable([OutboundErrorDocument? document]) =>
-    response(StatusCode.unacceptable, document: document);
+Response notAcceptable([OutboundErrorDocument? document]) =>
+    response(StatusCode.notAcceptable, document: document);
+
+Response internalServerError([OutboundErrorDocument? document]) =>
+    response(StatusCode.internalServerError, document: document);
diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart
new file mode 100644
index 0000000..8f985e2
--- /dev/null
+++ b/lib/src/server/router.dart
@@ -0,0 +1,32 @@
+import 'package:http_interop/http_interop.dart';
+import 'package:json_api/routing.dart';
+import 'package:json_api/src/server/controller.dart';
+import 'package:json_api/src/server/errors/method_not_allowed.dart';
+import 'package:json_api/src/server/errors/unmatched_target.dart';
+
+Handler router(Controller controller, Target? Function(Uri uri) matchTarget) =>
+    (request) => switch (matchTarget(request.uri)) {
+          RelationshipTarget target => switch (request.method) {
+              'get' => controller.fetchRelationship(request, target),
+              'post' => controller.addMany(request, target),
+              'patch' => controller.replaceRelationship(request, target),
+              'delete' => controller.deleteMany(request, target),
+              _ => throw MethodNotAllowed(request.method)
+            },
+          RelatedTarget target => switch (request.method) {
+              'get' => controller.fetchRelated(request, target),
+              _ => throw MethodNotAllowed(request.method)
+            },
+          ResourceTarget target => switch (request.method) {
+              'get' => controller.fetchResource(request, target),
+              'patch' => controller.updateResource(request, target),
+              'delete' => controller.deleteResource(request, target),
+              _ => throw MethodNotAllowed(request.method)
+            },
+          Target target => switch (request.method) {
+              'get' => controller.fetchCollection(request, target),
+              'post' => controller.createResource(request, target),
+              _ => throw MethodNotAllowed(request.method)
+            },
+          _ => throw UnmatchedTarget(request.uri)
+        };
diff --git a/lib/src/server/try_catch_middleware.dart b/lib/src/server/try_catch_middleware.dart
deleted file mode 100644
index b9cba2b..0000000
--- a/lib/src/server/try_catch_middleware.dart
+++ /dev/null
@@ -1,21 +0,0 @@
-import 'package:http_interop/http_interop.dart';
-
-/// An [Handler] wrapper which calls the wrapped [handler] and does
-/// the following:
-/// - when an instance of [Response] is returned or thrown by the
-///   wrapped handler, the response is returned
-/// - when another error is thrown by the wrapped handler and
-///   the [onError] callback is set, the error will be converted to a response
-/// - otherwise the error will be rethrown.
-
-Handler tryCatchMiddleware(Handler handler,
-        {Future<Response> Function(dynamic, StackTrace)? onError}) =>
-    (Request request) async {
-      try {
-        return await handler(request);
-      } on Response catch (response) {
-        return response;
-      } catch (error, stacktrace) {
-        return await onError?.call(error, stacktrace) ?? (throw error);
-      }
-    };
diff --git a/pubspec.yaml b/pubspec.yaml
index 75619d3..00afda3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,16 +1,17 @@
 name: json_api
-version: 8.1.0
+version: 9.0.0-alpha.9
 homepage: https://github.com/f3ath/json-api-dart
 description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org)
 environment:
-  sdk: '>=3.4.0 <4.0.0'
+  sdk: '>=3.5.0 <4.0.0'
 
 dependencies:
   http_parser: ^4.0.0
-  http_interop: ^2.0.0
+  http_interop: ^2.1.0
+  http_interop_middleware: ^0.1.0
 
 dev_dependencies:
-  lints: ^4.0.0
+  lints: ^5.0.0
   test: ^1.21.1
   stream_channel: ^2.1.0
   uuid: ^4.2.0
diff --git a/test/test_handler.dart b/test/test_handler.dart
index e9177b1..4b4288b 100644
--- a/test/test_handler.dart
+++ b/test/test_handler.dart
@@ -1,5 +1,5 @@
 import 'package:http_interop/http_interop.dart';
-import 'package:json_api/http.dart';
+import 'package:http_interop_middleware/http_interop_middleware.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/server.dart';
 
@@ -9,23 +9,10 @@ import '../example/server/repository_controller.dart';
 Handler testHandler(
         {Iterable<String> types = const ['users', 'posts', 'comments'],
         Function(Request request)? onRequest,
-        Function(Response response)? onResponse,
-        Future<Response> Function(dynamic, StackTrace)? onError}) =>
-    loggingMiddleware(
-        corsMiddleware(tryCatchMiddleware(
-            ControllerRouter(
-                    RepositoryController(
-                        InMemoryRepo(types), () => (_counter++).toString()),
-                    StandardUriDesign.matchTarget)
-                .handle,
-            onError: ErrorConverter(
-                    onError: onError ??
-                        (err, trace) {
-                          print(trace);
-                          throw err;
-                        })
-                .call)),
-        onRequest: onRequest,
-        onResponse: onResponse);
+        Function(Response response)? onResponse}) =>
+    corsMiddleware.add(requestValidator).add(errorConverter()).call(router(
+        RepositoryController(InMemoryRepo(types), _nextId),
+        StandardUriDesign.pathOnly.matchTarget));
 
+String _nextId() => (_counter++).toString();
 int _counter = 0;
diff --git a/test/unit/document/to_json_encodable_test.dart b/test/unit/document/to_json_encodable_test.dart
new file mode 100644
index 0000000..6acf024
--- /dev/null
+++ b/test/unit/document/to_json_encodable_test.dart
@@ -0,0 +1,10 @@
+import 'package:json_api/document.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('toJsonEncodable()', () {
+    test('throws UnsupportedError', () {
+      expect(() => toJsonEncodable('wow'), throwsUnsupportedError);
+    });
+  });
+}
diff --git a/test/unit/http/logging_middleware_test.dart b/test/unit/http/logging_middleware_test.dart
deleted file mode 100644
index ca1faaf..0000000
--- a/test/unit/http/logging_middleware_test.dart
+++ /dev/null
@@ -1,26 +0,0 @@
-import 'dart:convert';
-
-import 'package:http_interop/http_interop.dart';
-import 'package:json_api/http.dart';
-import 'package:test/expect.dart';
-import 'package:test/scaffolding.dart';
-
-main() {
-  Response response = Response(200, Body.text('hello', utf8), Headers());
-
-  Future<Response> handler(Request rq) async => response;
-
-  group('Logging Middleware', () {
-    test('Can log', () async {
-      Request? loggedRq;
-      Response? loggedRs;
-
-      final request = Request('get', Uri(host: 'localhost'), Body(), Headers());
-      final response = await loggingMiddleware(handler,
-          onRequest: (r) => loggedRq = r,
-          onResponse: (r) => loggedRs = r)(request);
-      expect(loggedRq, same(request));
-      expect(loggedRs, same(response));
-    });
-  });
-}
diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart
index e3781eb..4a8c026 100644
--- a/test/unit/routing/url_test.dart
+++ b/test/unit/routing/url_test.dart
@@ -3,33 +3,171 @@ import 'package:test/test.dart';
 
 void main() {
   test('uri generation', () {
-    final url = StandardUriDesign.pathOnly;
-    expect(url.collection('books').toString(), '/books');
-    expect(url.resource('books', '42').toString(), '/books/42');
-    expect(url.related('books', '42', 'author').toString(), '/books/42/author');
-    expect(url.relationship('books', '42', 'author').toString(),
+    final d = StandardUriDesign.pathOnly;
+    expect(d.base.path, equals('/'));
+    expect(d.collection('books').toString(), '/books');
+    expect(d.resource('books', '42').toString(), '/books/42');
+    expect(d.related('books', '42', 'author').toString(), '/books/42/author');
+    expect(d.relationship('books', '42', 'author').toString(),
         '/books/42/relationships/author');
   });
 
+  test('uri generation with base, trailing slash', () {
+    final d = StandardUriDesign(Uri.parse('https://example.com/api/'));
+    expect(d.base.path, equals('/api/'));
+    expect(d.collection('books').toString(), 'https://example.com/api/books');
+    expect(d.resource('books', '42').toString(),
+        'https://example.com/api/books/42');
+    expect(d.related('books', '42', 'author').toString(),
+        'https://example.com/api/books/42/author');
+    expect(d.relationship('books', '42', 'author').toString(),
+        'https://example.com/api/books/42/relationships/author');
+  });
+
+  test('uri generation with base, no trailing slash', () {
+    final d = StandardUriDesign(Uri.parse('https://example.com/api'));
+    expect(d.base.path, equals('/api/'));
+    expect(d.collection('books').toString(), 'https://example.com/api/books');
+    expect(d.resource('books', '42').toString(),
+        'https://example.com/api/books/42');
+    expect(d.related('books', '42', 'author').toString(),
+        'https://example.com/api/books/42/author');
+    expect(d.relationship('books', '42', 'author').toString(),
+        'https://example.com/api/books/42/relationships/author');
+  });
+
   test('Authority is retained if exists in base', () {
-    final url = StandardUriDesign(Uri.parse('https://example.com'));
-    expect(url.collection('books').toString(), 'https://example.com/books');
-    expect(
-        url.resource('books', '42').toString(), 'https://example.com/books/42');
-    expect(url.related('books', '42', 'author').toString(),
-        'https://example.com/books/42/author');
-    expect(url.relationship('books', '42', 'author').toString(),
-        'https://example.com/books/42/relationships/author');
+    final d = StandardUriDesign(Uri.parse('https://example.com:8080'));
+    expect(d.collection('books').toString(), 'https://example.com:8080/books');
+    expect(d.resource('books', '42').toString(),
+        'https://example.com:8080/books/42');
+    expect(d.related('books', '42', 'author').toString(),
+        'https://example.com:8080/books/42/author');
+    expect(d.relationship('books', '42', 'author').toString(),
+        'https://example.com:8080/books/42/relationships/author');
   });
 
-  test('Authority and path is retained if exists in base (directory path)', () {
-    final url = StandardUriDesign(Uri.parse('https://example.com/foo/'));
-    expect(url.collection('books').toString(), 'https://example.com/foo/books');
-    expect(url.resource('books', '42').toString(),
+  test('Host and path is retained if exists in base (directory path)', () {
+    final d = StandardUriDesign(Uri.parse('https://example.com/foo/'));
+    expect(d.collection('books').toString(), 'https://example.com/foo/books');
+    expect(d.resource('books', '42').toString(),
         'https://example.com/foo/books/42');
-    expect(url.related('books', '42', 'author').toString(),
+    expect(d.related('books', '42', 'author').toString(),
         'https://example.com/foo/books/42/author');
-    expect(url.relationship('books', '42', 'author').toString(),
+    expect(d.relationship('books', '42', 'author').toString(),
         'https://example.com/foo/books/42/relationships/author');
   });
+  group('Target matching', () {
+    test('Path only', () {
+      final d = StandardUriDesign.pathOnly;
+      expect(d.matchTarget(Uri.parse('/books')), isA<Target>());
+      expect(d.matchTarget(Uri.parse('/books/42')), isA<ResourceTarget>());
+      expect(
+          d.matchTarget(Uri.parse('/books/42/authors')), isA<RelatedTarget>());
+      expect(d.matchTarget(Uri.parse('/books/42/relationships/authors')),
+          isA<RelationshipTarget>());
+      expect(d.matchTarget(Uri.parse('/a/b/c/d')), isNull);
+    });
+    test('Path only, full url', () {
+      final d = StandardUriDesign.pathOnly;
+      expect(
+          d.matchTarget(Uri.parse('https://example.com/books')), isA<Target>());
+      expect(d.matchTarget(Uri.parse('https://example.com/books/42')),
+          isA<ResourceTarget>());
+      expect(d.matchTarget(Uri.parse('https://example.com/books/42/authors')),
+          isA<RelatedTarget>());
+      expect(
+          d.matchTarget(
+              Uri.parse('https://example.com/books/42/relationships/authors')),
+          isA<RelationshipTarget>());
+      expect(d.matchTarget(Uri.parse('https://example.com/a/b/c/d')), isNull);
+    });
+    test('Authority', () {
+      final d = StandardUriDesign(Uri.parse('https://example.com:8080'));
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/books')),
+          isA<Target>());
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/books/42')),
+          isA<ResourceTarget>());
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')),
+          isA<RelatedTarget>());
+      expect(
+          d.matchTarget(Uri.parse(
+              'https://example.com:8080/books/42/relationships/authors')),
+          isA<RelationshipTarget>());
+
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull);
+      expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull);
+      expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull);
+    });
+
+    test('Authority and path', () {
+      final d = StandardUriDesign(Uri.parse('https://example.com:8080/api'));
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/api/books')),
+          isA<Target>().having((it) => it.type, 'type', equals('books')));
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')),
+          isA<ResourceTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42')));
+      expect(
+          d.matchTarget(
+              Uri.parse('https://example.com:8080/api/books/42/authors')),
+          isA<RelatedTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42'))
+              .having(
+                  (it) => it.relationship, 'relationship', equals('authors')));
+      expect(
+          d.matchTarget(Uri.parse(
+              'https://example.com:8080/api/books/42/relationships/authors')),
+          isA<RelationshipTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42'))
+              .having(
+                  (it) => it.relationship, 'relationship', equals('authors')));
+
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull);
+      expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull);
+      expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull);
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')),
+          isNull);
+    });
+
+    test('Authority and path, trailing slash', () {
+      final d = StandardUriDesign(Uri.parse('https://example.com:8080/api/'));
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/api/books')),
+          isA<Target>().having((it) => it.type, 'type', equals('books')));
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')),
+          isA<ResourceTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42')));
+      expect(
+          d.matchTarget(
+              Uri.parse('https://example.com:8080/api/books/42/authors')),
+          isA<RelatedTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42'))
+              .having(
+                  (it) => it.relationship, 'relationship', equals('authors')));
+      expect(
+          d.matchTarget(Uri.parse(
+              'https://example.com:8080/api/books/42/relationships/authors')),
+          isA<RelationshipTarget>()
+              .having((it) => it.type, 'type', equals('books'))
+              .having((it) => it.id, 'id', equals('42'))
+              .having(
+                  (it) => it.relationship, 'relationship', equals('authors')));
+
+      expect(
+          d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull);
+      expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull);
+      expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull);
+      expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')),
+          isNull);
+    });
+  });
 }
diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart
similarity index 98%
rename from test/unit/http/cors_middleware_test.dart
rename to test/unit/server/cors_middleware_test.dart
index 6dc2939..761bba8 100644
--- a/test/unit/http/cors_middleware_test.dart
+++ b/test/unit/server/cors_middleware_test.dart
@@ -1,7 +1,7 @@
 import 'dart:convert';
 
 import 'package:http_interop/http_interop.dart';
-import 'package:json_api/http.dart';
+import 'package:json_api/server.dart';
 import 'package:test/expect.dart';
 import 'package:test/scaffolding.dart';
 
diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart
index b2fbc4b..4bb17df 100644
--- a/test/unit/server/error_converter_test.dart
+++ b/test/unit/server/error_converter_test.dart
@@ -1,14 +1,84 @@
-import 'dart:convert';
-
-import 'package:http_interop/extensions.dart';
+import 'package:http_interop/http_interop.dart';
+import 'package:json_api/http.dart';
 import 'package:json_api/server.dart';
-import 'package:test/test.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
 
 void main() {
-  test('500', () async {
-    final r = await ErrorConverter().call('Foo', StackTrace.current);
-    expect(r.statusCode, equals(500));
-    expect(await r.body.decode(utf8),
-        equals('{"errors":[{"title":"Internal Server Error"}]}'));
+  final get = Request('get', Uri(), Body(), Headers());
+
+  group('default handlers', () {
+    final converter = errorConverter();
+    test('can catch MethodNotAllowed', () async {
+      final r = await converter((_) => throw MethodNotAllowed('foo'))(get);
+      expect(r.statusCode, equals(StatusCode.methodNotAllowed));
+    });
+    test('can catch UnmatchedTarget', () async {
+      final r = await converter((_) => throw UnmatchedTarget(Uri()))(get);
+      expect(r.statusCode, equals(StatusCode.badRequest));
+    });
+    test('can catch CollectionNotFound', () async {
+      final r = await converter((_) => throw CollectionNotFound('foo'))(get);
+      expect(r.statusCode, equals(StatusCode.notFound));
+    });
+    test('can catch ResourceNotFound', () async {
+      final r =
+          await converter((_) => throw ResourceNotFound('foo', 'bar'))(get);
+      expect(r.statusCode, equals(StatusCode.notFound));
+    });
+    test('can catch RelationshipNotFound', () async {
+      final r = await converter(
+          (_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get);
+      expect(r.statusCode, equals(StatusCode.notFound));
+    });
+    test('can catch UnsupportedMediaType', () async {
+      final r = await converter((_) => throw UnsupportedMediaType())(get);
+      expect(r.statusCode, equals(StatusCode.unsupportedMediaType));
+    });
+    test('can catch Unacceptable', () async {
+      final r = await converter((_) => throw NotAcceptable())(get);
+      expect(r.statusCode, equals(StatusCode.notAcceptable));
+    });
+    test('can catch any other error', () async {
+      final r = await converter((_) => throw 'foo')(get);
+      expect(r.statusCode, equals(StatusCode.internalServerError));
+    });
+  });
+
+  group('custom handlers', () {
+    final converter = errorConverter(
+      onMethodNotAllowed: (_) async => Response(550, Body(), Headers()),
+      onUnmatchedTarget: (_) async => Response(551, Body(), Headers()),
+      onCollectionNotFound: (_) async => Response(552, Body(), Headers()),
+      onResourceNotFound: (_) async => Response(553, Body(), Headers()),
+      onRelationshipNotFound: (_) async => Response(554, Body(), Headers()),
+      onError: (_, __) async => Response(555, Body(), Headers()),
+    );
+    test('can catch MethodNotAllowed', () async {
+      final r = await converter((_) => throw MethodNotAllowed('foo'))(get);
+      expect(r.statusCode, equals(550));
+    });
+    test('can catch UnmatchedTarget', () async {
+      final r = await converter((_) => throw UnmatchedTarget(Uri()))(get);
+      expect(r.statusCode, equals(551));
+    });
+    test('can catch CollectionNotFound', () async {
+      final r = await converter((_) => throw CollectionNotFound('foo'))(get);
+      expect(r.statusCode, equals(552));
+    });
+    test('can catch ResourceNotFound', () async {
+      final r =
+          await converter((_) => throw ResourceNotFound('foo', 'bar'))(get);
+      expect(r.statusCode, equals(553));
+    });
+    test('can catch RelationshipNotFound', () async {
+      final r = await converter(
+          (_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get);
+      expect(r.statusCode, equals(554));
+    });
+    test('can catch any other error', () async {
+      final r = await converter((_) => throw 'foo')(get);
+      expect(r.statusCode, equals(555));
+    });
   });
 }
diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart
new file mode 100644
index 0000000..756c56f
--- /dev/null
+++ b/test/unit/server/response_test.dart
@@ -0,0 +1,23 @@
+import 'package:http_interop/extensions.dart';
+import 'package:json_api/document.dart';
+import 'package:json_api/server.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('Response', () {
+    test('converts DateTime to ISO-8601', () async {
+      final r = response(200,
+          document: OutboundDocument()..meta['date'] = DateTime(2021));
+      expect(
+          await r.body.decodeJson(),
+          equals({
+            'meta': {'date': '2021-01-01T00:00:00.000'}
+          }));
+    });
+  });
+
+  test('conflict', () async {
+    final r = conflict();
+    expect(r.statusCode, equals(409));
+  });
+}