Dart 74 🎯 Building Production Web Servers in Dart using package:shelf
package:shelf is the Dart team’s official HTTP server library. It is deliberately minimal. Where a framework like Express or Dart Frog provides routing, middleware, and project structure out of the box, Shelf provides a single abstraction—the Handler—and a Pipeline for composing middleware around it . Everything else is assembled from smaller packages: shelf_router for route matching, shelf_static for serving files, shelf_limiter for rate limiting. This is both the strength and the weakness. The strength is that you understand every layer of the stack because you wrote it. The weakness is that you must write it.
A production server is not just a serve() call. It is a set of concerns layered on top of the handler: logging, error handling, CORS, request parsing, health checks, graceful shutdown, and a deployment artifact that does not ship the Dart SDK. Shelf provides the primitive—the function that takes a Request and returns a Response—and the ecosystem provides the rest. The production discipline is in how those pieces are composed and how the server is deployed.
Key point: Shelf’s Handler is a function Future<Response> Function(Request), and Pipeline composes middleware around it. A production server adds routing (shelf_router), logging (logRequests), error handling (custom middleware), request parsing (shelf_essentials), rate limiting (shelf_limiter), and deploys as an AOT-compiled binary in a minimal container, not as dart run with the full SDK.
Why Shelf is the foundation, not the framework
The Handler abstraction. A Shelf Handler is a function that receives a Request and returns a Future<Response>. That is the entire contract. A router is a handler that dispatches based on the URL. A static file server is a handler that reads from disk. A middleware is a function that wraps a handler and returns a new handler, allowing it to inspect or modify the request before passing it down, and the response after it comes back . The composition is explicit and visible.
The Pipeline. A Pipeline chains middleware around a handler. The order matters: the first middleware added is the outermost, so it sees the request first and the response last. This is where logging, CORS, error handling, and authentication are placed . The pipeline is created once, and the resulting handler is passed to shelf_io.serve().
The routing layer. shelf_router provides a Router that matches request paths to handlers. It supports path parameters (/user/<user>), HTTP method matching (router.get, router.post), and route-specific middleware. The Router itself is a Handler, so it can be passed directly to the pipeline .
The production gap. Shelf does not include request body parsing, JSON helpers, or CORS out of the box. The shelf_essentials package fills this gap with request extensions (request.json(), request.formData()) and a corsHeaders() middleware . Rate limiting is provided by shelf_limiter, which offers global and per-endpoint limits with custom responses . These are not optional for a production API—they are the difference between a working server and a usable one.
a. The Handler, the Pipeline, and the Router
The handler is the core. The pipeline is the composition mechanism. The router is the dispatch mechanism. Understanding how they fit together is the foundation for everything else.
// Import the core Shelf packages
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
void main() async {
// The Router matches paths to handlers
final router = Router();
// A GET route with no parameters
// The handler receives the Request and returns a Response
router.get('/hello', (Request request) {
return Response.ok('hello-world');
});
// A GET route with a path parameter
// The parameter is captured from the URL and passed as an argument
router.get('/user/<user>', (Request request, String user) {
return Response.ok('hello $user');
});
// The Pipeline composes middleware around the router
// The order is outermost to innermost:
// 1. logRequests logs the request and response
// 2. The router dispatches to the matching handler
final app = const Pipeline()
// Middleware that logs method, path, status, and duration
.addMiddleware(logRequests())
// The router is the final handler in the chain
.addHandler(router.call);
// Start the server on localhost:8080
// The handler is the composed pipeline
final server = await shelf_io.serve(app, 'localhost', 8080);
// Print the address so the operator knows where it is listening
print('Serving at http://${server.address.host}:${server.port}');
}
The router.call is passed to addHandler rather than the router itself because Router is a class with a call method that matches the Handler signature. The call tear-off is explicit and avoids any ambiguity .
The logRequests() middleware is the simplest production addition. It logs the request method, the path, the response status, and the elapsed time. Without it, the server produces no observability at all. In a production deployment, this log is the first thing you check when a request fails.
b. Production middleware: CORS, request parsing, and error handling
A production API needs to handle cross-origin requests, parse request bodies, and return consistent error responses. Shelf does not include these, so they are added as middleware.
The shelf_essentials package provides CORS middleware and request extensions. The corsHeaders() middleware adds the standard CORS headers to every response and handles preflight OPTIONS requests . The request.json() extension parses the request body as JSON and returns a Map, List, or scalar.
// Import shelf_essentials for CORS and request extensions
import 'package:shelf_essentials/shelf_essentials.dart';
void main() async {
final router = Router();
// A POST route that parses a JSON body
router.post('/api/users', (Request request) async {
try {
// request.json() reads the body and parses it
// it throws if the body is not valid JSON
final data = await request.json();
// Validate the required field
if (data is! Map || !data.containsKey('name')) {
return Response.badRequest(body: 'Missing name field');
}
// Return the parsed data as the response
return Response.ok('Created user: ${data['name']}');
} catch (e) {
// If parsing fails, return a 400 with the error
return Response.badRequest(body: 'Invalid JSON: $e');
}
});
// The pipeline with CORS, logging, and error handling
final app = const Pipeline()
// CORS headers on every response, including OPTIONS
.addMiddleware(corsHeaders())
// Log every request
.addMiddleware(logRequests())
// Catch unhandled errors and return a 500
.addMiddleware(_errorHandler())
// Dispatch to the router
.addHandler(router.call);
await shelf_io.serve(app, '0.0.0.0', 8080);
}
// A middleware that catches errors thrown by inner handlers
Middleware _errorHandler() {
return (Handler inner) {
return (Request request) async {
try {
// Run the inner handler
return await inner(request);
} catch (error, stackTrace) {
// Log the error with its stack trace
print('Unhandled error: $error\n$stackTrace');
// Return a generic 500 response
// Do not leak the stack trace to the client
return Response.internalServerError(body: 'Internal server error');
}
};
};
}
The _errorHandler middleware is the safety net. Without it, an unhandled exception in a route handler propagates out of the pipeline, and the server returns a raw error or closes the connection. With it, the error is logged and the client receives a clean 500 response. The stack trace stays in the server logs, not in the response body .
The corsHeaders() middleware handles the OPTIONS preflight request automatically. Without it, a browser making a cross-origin POST request would receive a CORS error because the preflight is not answered. The middleware also sets Access-Control-Allow-Origin on every response, which is what the browser checks before delivering the response to the JavaScript .
c. Rate limiting, health checks, and the production binary
Rate limiting protects the API from abuse and from a single client consuming all the capacity. shelf_limiter provides a middleware that limits requests per time window, with per-endpoint configuration .
// Import shelf_limiter for rate limiting
import 'package:shelf_limiter/shelf_limiter.dart';
void main() async {
// Configure the rate limiter
final limiter = shelfLimiter(
RateLimiterOptions(
// Maximum 100 requests per client per minute
maxRequests: 100,
windowSize: const Duration(minutes: 1),
// Custom response when the limit is exceeded
onRateLimitExceeded: (request) async {
return Response(
429,
body: 'Too many requests. Please slow down.',
headers: {'content-type': 'text/plain'},
);
},
),
);
// Health check endpoint for container orchestration
final router = Router()
..get('/health', (Request request) {
// Return a simple JSON response with the process ID
return Response.ok(
'{"status":"ok","pid":$pid}',
headers: {'content-type': 'application/json'},
);
});
final app = const Pipeline()
// Rate limiting applies to all routes
.addMiddleware(limiter)
.addMiddleware(logRequests())
.addHandler(router.call);
// Read the port from the environment, fallback to 8080
final port = int.parse(Platform.environment['PORT'] ?? '8080');
// Bind to 0.0.0.0 so the container is reachable from outside
await shelf_io.serve(app, '0.0.0.0', port);
}
The /health endpoint is required by container orchestrators. Kubernetes, Cloud Run, and Docker Compose all use it to determine whether the container is ready to receive traffic. A server without a health check is a server that the orchestrator cannot manage .
The production binary is the other half of the deployment. dart run bin/server.dart requires the Dart SDK on the target machine, which means a ~500 MB image and a JIT-compiled server. dart compile exe bin/server.dart -o bin/server produces a native binary that runs without the SDK. The multi-stage Dockerfile compiles in a builder stage and copies the binary to a minimal runtime image .
# Stage 1: compile the server to a native binary
FROM dart:stable AS builder
WORKDIR /app
# Copy dependency files first for layer caching
COPY pubspec.* ./
RUN dart pub get
# Copy the source and compile
COPY . .
RUN dart pub get --offline
RUN dart compile exe bin/server.dart -o bin/server
# Stage 2: minimal runtime image
FROM scratch
# Copy the Dart runtime dependencies
COPY --from=builder /runtime/ /
# Copy the compiled binary
COPY --from=builder /app/bin/server /app/bin/server
# Expose the port
EXPOSE 8080
# Run the binary
CMD ["/app/bin/server"]
The scratch base image is the smallest possible runtime. It contains no shell, no package manager, and no SDK—only the compiled binary and the Dart runtime libraries it depends on. The image size drops from hundreds of megabytes to tens of megabytes, and the attack surface is reduced to the binary itself .
Complete Example Session
The following session builds a production-ready Shelf server with routing, CORS, error handling, rate limiting, a health check, and an AOT-compiled Docker image.
// ============================================
// PART 1: THE SERVER ENTRY POINT
// ============================================
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_essentials/shelf_essentials.dart';
import 'package:shelf_limiter/shelf_limiter.dart';
void main() async {
// The router dispatches requests to handlers
final router = Router();
// Health check for container orchestration
router.get('/health', (Request request) {
return Response.ok(
'{"status":"ok","pid":$pid}',
headers: {'content-type': 'application/json'},
);
});
// A JSON API endpoint
router.post('/api/echo', (Request request) async {
try {
// Parse the request body as JSON
final data = await request.json();
// Echo the parsed data back
return Response.ok(
'{"received":${data is Map ? data['message'] : 'null'}}',
headers: {'content-type': 'application/json'},
);
} catch (e) {
return Response.badRequest(body: 'Invalid JSON');
}
});
// ============================================
// PART 2: THE MIDDLEWARE PIPELINE
// ============================================
// Rate limiter: 100 requests per minute
final limiter = shelfLimiter(
RateLimiterOptions(
maxRequests: 100,
windowSize: const Duration(minutes: 1),
),
);
// Compose the pipeline: outermost to innermost
final app = const Pipeline()
// CORS headers for browser clients
.addMiddleware(corsHeaders())
// Rate limiting
.addMiddleware(limiter)
// Request logging
.addMiddleware(logRequests())
// Error handling
.addMiddleware(_errorHandler())
// Routing
.addHandler(router.call);
// ============================================
// PART 3: START THE SERVER
// ============================================
// Read the port from the environment
final port = int.parse(Platform.environment['PORT'] ?? '8080');
// Bind to all interfaces for container access
final server = await shelf_io.serve(app, '0.0.0.0', port);
print('Server running on port ${server.port}');
}
// ============================================
// PART 4: THE ERROR HANDLER MIDDLEWARE
// ============================================
Middleware _errorHandler() {
return (Handler inner) {
return (Request request) async {
try {
return await inner(request);
} catch (error, stackTrace) {
// Log the error with stack trace
print('Unhandled error: $error\n$stackTrace');
// Return a generic 500 to the client
return Response.internalServerError(body: 'Internal server error');
}
};
};
}
# ============================================
# PART 5: THE PRODUCTION DOCKERFILE
# ============================================
# Stage 1: compile with the full SDK
FROM dart:stable AS builder
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart compile exe bin/server.dart -o bin/server
# Stage 2: run with no SDK
FROM scratch
COPY --from=builder /runtime/ /
COPY --from=builder /app/bin/server /app/bin/server
EXPOSE 8080
CMD ["/app/bin/server"]
The five parts cover the server entry point with routing and health check, the middleware pipeline with CORS, rate limiting, logging, and error handling, the environment-based port binding, the error handler, and the production Dockerfile. What the session demonstrates is that a production Shelf server is a composition of small, explicit layers—and that the deployment artifact is a compiled binary, not a dart run command.
Quick Reference
Core Shelf API
| Type | Purpose |
|---|---|
Handler | Future<Response> Function(Request) |
Request | Incoming HTTP request |
Response | Outgoing HTTP response |
Pipeline | Composes middleware around a handler |
Middleware | Handler Function(Handler) |
Essential Packages
| Package | Purpose |
|---|---|
shelf | Core handler and pipeline |
shelf_router | Route matching |
shelf_static | Static file serving |
shelf_essentials | CORS, JSON parsing, form data |
shelf_limiter | Rate limiting |
Production Checklist
| Concern | Solution |
|---|---|
| Routing | shelf_router |
| Logging | logRequests() |
| CORS | corsHeaders() |
| Error handling | Custom middleware |
| JSON parsing | request.json() |
| Rate limiting | shelf_limiter |
| Health check | /health route |
| Port binding | 0.0.0.0 + PORT env |
| Deployment | AOT binary + scratch image |
Best Practices
✅ Do This:
// Compose middleware explicitly in a Pipeline
final app = const Pipeline()
.addMiddleware(corsHeaders())
.addMiddleware(logRequests())
.addMiddleware(_errorHandler())
.addHandler(router.call);
// Bind to 0.0.0.0 for container access
await shelf_io.serve(app, '0.0.0.0', port);
// Read PORT from environment
final port = int.parse(Platform.environment['PORT'] ?? '8080');
// Compile to a native binary for production
// dart compile exe bin/server.dart -o bin/server
❌ Don’t Do This:
// Run with dart run in production
// dart run bin/server.dart // requires SDK, JIT, slow
// Bind to localhost in a container
await shelf_io.serve(app, 'localhost', 8080); // unreachable from outside
// Return stack traces to the client
return Response.internalServerError(body: stackTrace.toString());
// Serve without CORS in a browser-facing API
// browser requests fail at the preflight
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Container unreachable | Bound to localhost | Bind to 0.0.0.0 |
| Preflight fails | No CORS middleware | corsHeaders() |
| Unhandled error crashes request | No error middleware | Wrap in try/catch |
| JSON parsing fails | No body parser | request.json() |
| Port mismatch | Hardcoded port | PORT environment variable |
Real-World Examples
1. Basic route
router.get('/hello', (Request req) => Response.ok('hello'));
2. Path parameter
router.get('/user/<id>', (Request req, String id) => Response.ok('User $id'));
3. JSON body parsing
final data = await request.json();
4. CORS middleware
.addMiddleware(corsHeaders())
5. Error middleware
.addMiddleware((inner) => (req) async {
try { return await inner(req); }
catch (e) { return Response.internalServerError(body: 'Error'); }
})
6. Health check
router.get('/health', (req) => Response.ok('ok'));
7. Rate limiting
shelfLimiter(RateLimiterOptions(maxRequests: 100, windowSize: Duration(minutes: 1)))
8. Static files
createStaticHandler('public', defaultDocument: 'index.html')
9. AOT compile
dart compile exe bin/server.dart -o bin/server
10. Docker build
docker build -t my-server .
Visual
The Shelf Pipeline
┌──────────────────────────────────────────────┐
│ REQUEST │
│ │ │
│ ▼ │
│ corsHeaders() ← adds CORS headers │
│ │ │
│ ▼ │
│ shelfLimiter() ← checks rate limit │
│ │ │
│ ▼ │
│ logRequests() ← logs the request │
│ │ │
│ ▼ │
│ _errorHandler() ← catches errors │
│ │ │
│ ▼ │
│ Router ← dispatches to route │
│ │ │
│ ▼ │
│ RESPONSE │
└──────────────────────────────────────────────┘
Production Deployment
┌──────────────────────────────────────────────┐
│ BUILDER STAGE (dart:stable) │
│ - full SDK │
│ - dart pub get │
│ - dart compile exe │
│ │ │
│ ▼ │
│ RUNTIME STAGE (scratch) │
│ - compiled binary │
│ - Dart runtime libraries │
│ - no SDK, no shell, no package manager │
│ │ │
│ ▼ │
│ CONTAINER │
│ - listens on 0.0.0.0:PORT │
│ - /health for orchestration │
└──────────────────────────────────────────────┘
Handler Composition
┌──────────────────────────────────────────────┐
│ Handler = Future<Response> Function(Request)│
│ │
│ Router is a Handler │
│ Middleware is a Handler → Handler │
│ Pipeline is a Handler → Handler │
│ │
│ serve(handler, address, port) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Core abstraction | Handler function |
| Composition | Pipeline |
| Routing | shelf_router |
| CORS | shelf_essentials |
| Rate limiting | shelf_limiter |
| JSON parsing | request.json() |
| Error handling | Custom middleware |
| Health check | /health route |
| Production binary | dart compile exe |
| Runtime image | scratch |
Key takeaways:
- Shelf is a primitive, not a framework. The
Handleris a function, and thePipelinecomposes middleware around it. Routing, CORS, and rate limiting are added as packages, not built in . - The pipeline order matters. The first middleware added is the outermost. Logging, CORS, and error handling belong early so they see every request and every response .
- Error handling is mandatory. An unhandled exception in a route handler will escape the pipeline unless a middleware catches it. The middleware should log the error and return a generic 500, not the stack trace .
- CORS and JSON parsing are not optional for an API.
shelf_essentialsprovidescorsHeaders()andrequest.json(), which are required for browser clients and API consumers . - The health check is required for orchestration. Kubernetes, Cloud Run, and Docker Compose use
/healthto determine readiness. A server without it cannot be managed . - Bind to
0.0.0.0in containers. Binding tolocalhostmakes the server unreachable from outside the container. ThePORTenvironment variable allows the orchestrator to assign the port . - Compile to a native binary for production.
dart compile exeproduces a standalone binary that runs without the SDK. The multi-stage Dockerfile keeps the image small and the attack surface minimal .
Remember: Shelf is the foundation of Dart’s server ecosystem. It gives you the Handler and the Pipeline, and you assemble the rest. A production server is not the serve() call—it is the composition of routing, logging, CORS, error handling, rate limiting, and health checks around that call, deployed as a compiled binary in a minimal container. The primitives are small, and that is the point: you see every layer because you wrote every layer. The packages fill the gaps, and the deployment discipline turns a working server into a production one.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!