Migration from Shelf
This guide helps you move a server from Shelf to Relic. The two share their core concepts, handlers, middleware, requests and responses, so most of the work is changing types and names. The steps below go through the changes one at a time, each with the Shelf code first and the Relic code after it.
The steps at a glance
- Replace the
shelf,shelf_routerandshelf_web_socketdependencies withrelic. - Serve the app with
RelicApp().serve()instead ofshelf_io.serve(). - Keep handlers as functions from a
Requestto aResponse. - Register routes on a
RelicApp, with:idparameters in place of<id>. - Give each response a
Body, and set its type there rather than in the headers. - Read headers through typed accessors instead of strings.
- Attach middleware to paths with
useinstead of composing aPipeline. - Replace the request context map with typed context properties.
- Return a
WebSocketUpgradeinstead of usingshelf_web_socket.
1. Update dependencies
In pubspec.yaml, remove the Shelf packages and add Relic.
Shelf:
dependencies:
shelf: <shelf_version>
shelf_router: <shelf_router_version>
shelf_web_socket: <shelf_web_socket_version>
Relic:
dependencies:
relic: <latest_version>
One package replaces three, since routing and WebSockets are part of Relic. The Installation page says which version to pick.
2. Bootstrap the server
Shelf:
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
void main() async {
final app = Router();
// Add routes...
await io.serve(app, 'localhost', 8080);
}
Relic:
import 'dart:io';
import 'package:relic/relic.dart';
Future<void> main() async {
final app = RelicApp()
..use('/', logRequests())
..get('/users/:id', (Request request) {
final id = request.rawPathParameters[#id];
final name = request.url.queryParameters['name'] ?? 'Unknown';
return Response.ok(body: Body.fromString('User $id: $name'));
});
await app.serve(address: InternetAddress.loopbackIPv4, port: 8080);
}
RelicApp is the router and the application in one, and serve starts it, here on port 8080 of the loopback address, which is also where it listens when given no arguments. This program already has a route and logging, which the steps below explain.
3. Update handler signatures
Shelf:
import 'package:shelf/shelf.dart';
Response handler(Request request) {
return Response.ok('Hello from Shelf!');
}
Relic:
Response handler(Request request) {
return Response.ok(body: Body.fromString('Hello from Relic!'));
}
The signature stays the same, a function from a Request to a Response. What changes is the body of the response, which step 5 covers. A Relic handler may also return the other kinds of Result, which the Handlers page describes, but a handler that returns a Response needs nothing more.
4. Switch to Relic routing
Shelf:
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
final router = Router()
..get('/users/<id>', (Request request, String id) {
return Response.ok('User $id');
});
Relic:
final router = RelicApp()
..get('/users/:id', (request) {
final id = request.pathParameters.raw[#id];
return Response.ok(body: Body.fromString('User $id'));
});
A parameter is written :id instead of <id>. Its value no longer arrives as an extra argument to the handler. The handler reads it from the request, by the parameter's name as a Dart symbol, #id. Typed accessors can also parse it into a number or any other type, as the Routing page shows.
5. Create responses with Body
Shelf accepts strings directly and manages headers separately:
// Shelf - the body can be a plain string.
final textResponse = Response.ok('Hello, World!');
// Content-Type is set separately in headers.
final htmlResponse = Response.ok(
'<html>...</html>',
headers: {'content-type': 'text/html'},
);
Relic puts the content, its encoding and its type together in a Body:
// Relic - an explicit Body object is required.
final textResponse = Response.ok(body: Body.fromString('Hello, World!'));
// Content-Length is calculated, and Content-Type and encoding are part of
// the Body.
final htmlResponse = Response.ok(
body: Body.fromString('<html>...</html>', mimeType: MimeType.html),
);
A content-type or content-length that Shelf code puts in the response headers has no effect in Relic, because the body decides both. Move them to the Body. The Body page shows what each kind of body decides.
6. Replace header access
Shelf uses string-based headers with manual parsing:
final contentType = request.headers['content-type']; // String?
final cookies = request.headers['cookie']; // String?
final date = request.headers['date']; // String?
Relic has a typed accessor for each common header:
final contentType = request.headers.contentType; // ContentTypeHeader?
final cookies = request.headers.cookie; // CookieHeader?
final date = request.headers.date; // DateTime?
Each accessor parses its header into a value of the right type. When a header does not parse, reading it throws, and Relic answers the request with 400. A header without an accessor is still there by name, as request.headers['x-custom'].
7. Replace middleware and scoping
Shelf:
final app = Router()
..get('/api/users', (Request request) {
return Response.ok('User data');
});
final handler = Pipeline()
.addMiddleware(logRequests())
.addMiddleware(authentication())
.addHandler(app);
Relic:
final app = RelicApp()
..use('/', logRequests())
..use('/api', authenticate)
..get('/api/users', (request) {
return Response.ok(body: Body.fromString('User data'));
});
use attaches middleware to a path in the router, so authentication can cover /api while logging covers everything. One difference matters for a logger. Shelf's Pipeline().addMiddleware() runs middleware for every request, 404s included, while Relic's use runs it only for requests that match a route. The Middleware page shows how to reach every request, and Relic also has a Pipeline of its own, described on the Pipeline page.
8. Replace request.context usage
Shelf:
// Shelf - Dynamic types.
final modifiedRequest = request.change(context: {
'user': currentUser,
'session': sessionData,
});
// Later...
final user = request.context['user'] as User?; // Manual casting
Relic:
// Create each property once, as a global.
final userProperty = ContextProperty<User>('user');
final sessionProperty = ContextProperty<Session>('session');
// An extension for convenient access.
extension AuthContext on Request {
User get currentUser => userProperty.get(this);
Session get session => sessionProperty.get(this);
}
// Set the values in a middleware, for example after a login check.
Handler loadSession(Handler next) {
return (request) {
userProperty[request] = const User('Nova');
sessionProperty[request] = Session(DateTime.now());
return next(request);
};
}
// Read them in a handler further down.
Response profile(Request request) {
final user = request.currentUser;
final session = request.session;
return Response.ok(
body: Body.fromString('${user.name}, since ${session.started}'),
);
}
A ContextProperty is a typed slot on every request. A middleware sets it with property[request] = value, and a handler reads it with property.get(request), or with property[request] when the value may be absent. No new request is made and no cast is needed. The Context properties page explains why each property is created once, as a global.
9. Update WebSockets
Shelf requires a separate package:
import 'package:shelf_web_socket/shelf_web_socket.dart';
var handler = webSocketHandler((webSocket) {
webSocket.stream.listen((message) {
print('Received: $message');
});
webSocket.sink.add('Hello!');
});
In Relic, a handler returns a WebSocketUpgrade:
WebSocketUpgrade websocketHandler(Request request) {
return WebSocketUpgrade((ws) async {
ws.events.listen((event) {
log('Received: $event');
});
ws.sendText('Hello!');
});
}
webSocketHandler accepts a browser connection from any origin unless you pass allowedOrigins. Relic refuses an upgrade from a page on another origin with 403. Set allowAnyOrigin: true on the WebSocketUpgrade only for an endpoint meant to be opened by other sites, and that does not authenticate with cookies.
A complete example
Here is a small server in Shelf, with one route, a query parameter and logging:
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
void main() async {
final router = Router()
..get('/users/<id>', (Request request, String id) {
final name = request.url.queryParameters['name'] ?? 'Unknown';
return Response.ok('User $id: $name');
});
final handler = Pipeline()
.addMiddleware(logRequests())
.addHandler(router);
await shelf_io.serve(handler, 'localhost', 8080);
}
And here it is in Relic:
import 'dart:io';
import 'package:relic/relic.dart';
Future<void> main() async {
final app = RelicApp()
..use('/', logRequests())
..get('/users/:id', (Request request) {
final id = request.rawPathParameters[#id];
final name = request.url.queryParameters['name'] ?? 'Unknown';
return Response.ok(body: Body.fromString('User $id: $name'));
});
await app.serve(address: InternetAddress.loopbackIPv4, port: 8080);
}
It answers the same way:
curl 'http://localhost:8080/users/42?name=Nova'
curl http://localhost:8080/users/7
User 42: Nova
User 7: Unknown
and it logs each request:
2026-10-23T14:21:16.388161 0:00:00.007249 GET [200] /users/42?name=Nova
2026-10-23T14:21:16.446968 0:00:00.001342 GET [200] /users/7