| |

Dart 74 🎯 Building Production Web Servers in Dart using package:shelf

⏱️ Estimated read: 15 min | |

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

TypePurpose
HandlerFuture<Response> Function(Request)
RequestIncoming HTTP request
ResponseOutgoing HTTP response
PipelineComposes middleware around a handler
MiddlewareHandler Function(Handler)

Essential Packages

PackagePurpose
shelfCore handler and pipeline
shelf_routerRoute matching
shelf_staticStatic file serving
shelf_essentialsCORS, JSON parsing, form data
shelf_limiterRate limiting

Production Checklist

ConcernSolution
Routingshelf_router
LogginglogRequests()
CORScorsHeaders()
Error handlingCustom middleware
JSON parsingrequest.json()
Rate limitingshelf_limiter
Health check/health route
Port binding0.0.0.0 + PORT env
DeploymentAOT 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

PitfallWhy It HappensFix
Container unreachableBound to localhostBind to 0.0.0.0
Preflight failsNo CORS middlewarecorsHeaders()
Unhandled error crashes requestNo error middlewareWrap in try/catch
JSON parsing failsNo body parserrequest.json()
Port mismatchHardcoded portPORT 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

ItemValue
Core abstractionHandler function
CompositionPipeline
Routingshelf_router
CORSshelf_essentials
Rate limitingshelf_limiter
JSON parsingrequest.json()
Error handlingCustom middleware
Health check/health route
Production binarydart compile exe
Runtime imagescratch

Key takeaways:

  • Shelf is a primitive, not a framework. The Handler is a function, and the Pipeline composes 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_essentials provides corsHeaders() and request.json(), which are required for browser clients and API consumers .
  • The health check is required for orchestration. Kubernetes, Cloud Run, and Docker Compose use /health to determine readiness. A server without it cannot be managed .
  • Bind to 0.0.0.0 in containers. Binding to localhost makes the server unreachable from outside the container. The PORT environment variable allows the orchestrator to assign the port .
  • Compile to a native binary for production. dart compile exe produces 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.


Was this helpful?


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!