Web Server #

Dart is a highly capable server-side language — one language for the frontend (Flutter web) and the backend (Dart server). dart:io provides a low-level HttpServer that gives full control, while the shelf package provides a more ergonomic middleware abstraction. This article covers both: from building a server from scratch with HttpServer to understand the fundamentals, to using shelf for higher productivity in building real REST APIs.

HttpServer — the Foundation of Dart Servers #

import 'dart:io';
import 'dart:convert';

Future<void> main() async {
  final server = await HttpServer.bind(
    InternetAddress.anyIPv4,
    8080,
    shared: true,   // allow rebinding after a fast restart
  );

  print('Server running at http://localhost:${server.port}');

  // Graceful shutdown on SIGINT (Ctrl+C)
  ProcessSignal.sigint.watch().listen((_) async {
    print('\nShutting down the server...');
    await server.close(force: false); // wait for active requests to finish
    exit(0);
  });

  await for (final request in server) {
    _tanganiRequest(request); // without await — non-blocking per request
  }
}

Future<void> _tanganiRequest(HttpRequest request) async {
  try {
    // Set CORS headers for every response
    request.response.headers
      ..set('Access-Control-Allow-Origin', '*')
      ..set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
      ..set('Access-Control-Allow-Headers', 'Content-Type, Authorization');

    // Handle the preflight OPTIONS
    if (request.method == 'OPTIONS') {
      request.response.statusCode = HttpStatus.ok;
      await request.response.close();
      return;
    }

    await _router(request);
  } catch (e, stackTrace) {
    print('Error: $e\n$stackTrace');
    await _kirimJson(request.response, HttpStatus.internalServerError,
        {'error': 'Internal Server Error'});
  }
}

Routing — Mapping URLs to Handlers #

A good router separates HTTP methods and paths, supports path parameters, and returns 404 for routes not found:

import 'dart:io';

typedef Handler = Future<void> Function(HttpRequest request);

class Router {
  // Map from 'METHOD /path' to a handler
  final Map<String, Handler> _routes = {};

  void get(String path, Handler handler) =>
      _routes['GET $path'] = handler;
  void post(String path, Handler handler) =>
      _routes['POST $path'] = handler;
  void put(String path, Handler handler) =>
      _routes['PUT $path'] = handler;
  void delete(String path, Handler handler) =>
      _routes['DELETE $path'] = handler;

  Future<void> tangani(HttpRequest request) async {
    final kunci = '${request.method} ${request.uri.path}';

    // Check for an exact match
    final handler = _routes[kunci];
    if (handler != null) {
      await handler(request);
      return;
    }

    // Check path parameters — e.g. /pengguna/:id
    for (final entry in _routes.entries) {
      final paramMap = _cocokkanPath(entry.key, request.method, request.uri.path);
      if (paramMap != null) {
        // Store path params in URI attributes
        request.uri.queryParameters; // access params via _extractParams
        await entry.value(request);
        return;
      }
    }

    // 404 if nothing matches
    await _kirimJson(request.response, HttpStatus.notFound,
        {'error': 'Route not found: ${request.uri.path}'});
  }

  Map<String, String>? _cocokkanPath(
      String kunciRoute, String metode, String path) {
    final bagianRoute = kunciRoute.split(' ');
    if (bagianRoute[0] != metode) return null;

    final segmenRoute = bagianRoute[1].split('/');
    final segmenPath = path.split('/');
    if (segmenRoute.length != segmenPath.length) return null;

    final params = <String, String>{};
    for (int i = 0; i < segmenRoute.length; i++) {
      if (segmenRoute[i].startsWith(':')) {
        params[segmenRoute[i].substring(1)] = segmenPath[i];
      } else if (segmenRoute[i] != segmenPath[i]) {
        return null;
      }
    }
    return params;
  }
}

Using the Router #

import 'dart:io';
import 'dart:convert';

Future<void> main() async {
  final router = Router()
    ..get('/', _halamanUtama)
    ..get('/api/pengguna', _daftarPengguna)
    ..get('/api/pengguna/:id', _detailPengguna)
    ..post('/api/pengguna', _buatPengguna)
    ..put('/api/pengguna/:id', _updatePengguna)
    ..delete('/api/pengguna/:id', _hapusPengguna);

  final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
  await for (final request in server) {
    router.tangani(request).catchError((e) {
      print('Unhandled error: $e');
    });
  }
}

Future<void> _halamanUtama(HttpRequest request) async {
  request.response
    ..headers.contentType = ContentType.html
    ..write('<h1>Dart Web Server</h1>')
    ..close();
}

Future<void> _daftarPengguna(HttpRequest request) async {
  // Read query parameters
  final halaman = int.tryParse(request.uri.queryParameters['halaman'] ?? '1') ?? 1;
  final perHalaman = int.tryParse(request.uri.queryParameters['perHalaman'] ?? '10') ?? 10;

  final pengguna = await _repository.ambilSemua(halaman: halaman, perHalaman: perHalaman);
  await _kirimJson(request.response, HttpStatus.ok, {
    'data': pengguna.map((p) => p.toJson()).toList(),
    'halaman': halaman,
    'perHalaman': perHalaman,
  });
}

Reading and Writing JSON #

import 'dart:io';
import 'dart:convert';

// Read the JSON body from a request
Future<Map<String, dynamic>?> bacaJson(HttpRequest request) async {
  try {
    final contentType = request.headers.contentType;
    if (contentType?.mimeType != 'application/json') {
      return null;
    }
    final body = await utf8.decoder.bind(request).join();
    return jsonDecode(body) as Map<String, dynamic>;
  } on FormatException {
    return null;
  }
}

// Send a JSON response
Future<void> _kirimJson(
  HttpResponse response,
  int statusCode,
  Object data,
) async {
  response
    ..statusCode = statusCode
    ..headers.contentType = ContentType.json
    ..write(jsonEncode(data));
  await response.close();
}

// Example POST endpoint that reads and validates JSON
Future<void> _buatPengguna(HttpRequest request) async {
  final body = await bacaJson(request);

  if (body == null) {
    await _kirimJson(request.response, HttpStatus.badRequest,
        {'error': 'Body must be valid JSON'});
    return;
  }

  final nama = body['nama'] as String?;
  final email = body['email'] as String?;

  if (nama == null || nama.isEmpty) {
    await _kirimJson(request.response, HttpStatus.unprocessableEntity,
        {'error': 'Field "nama" is required'});
    return;
  }

  if (email == null || !email.contains('@')) {
    await _kirimJson(request.response, HttpStatus.unprocessableEntity,
        {'error': 'Field "email" is invalid'});
    return;
  }

  final pengguna = await _repository.buat(nama: nama, email: email);
  await _kirimJson(request.response, HttpStatus.created, pengguna.toJson());
}

The Middleware Pattern — Request Pipelines #

Middleware is a function that wraps a handler and can do something before or after the handler runs — logging, authentication, rate limiting, and more:

import 'dart:io';

typedef MiddlewareHandler = Future<void> Function(HttpRequest request);
typedef Middleware = MiddlewareHandler Function(MiddlewareHandler next);

// Logging middleware
Middleware loggingMiddleware() {
  return (next) => (request) async {
    final mulai = DateTime.now();
    print('[${mulai.toIso8601String()}] ${request.method} ${request.uri.path}');
    await next(request);
    final durasi = DateTime.now().difference(mulai);
    print('[${request.method}] ${request.uri.path}${durasi.inMilliseconds}ms '
        '— ${request.response.statusCode}');
  };
}

// Bearer token authentication middleware
Middleware authMiddleware(Future<bool> Function(String token) verifikasi) {
  return (next) => (request) async {
    final authHeader = request.headers.value(HttpHeaders.authorizationHeader);

    if (authHeader == null || !authHeader.startsWith('Bearer ')) {
      await _kirimJson(request.response, HttpStatus.unauthorized,
          {'error': 'Authentication token required'});
      return;
    }

    final token = authHeader.substring(7);
    if (!await verifikasi(token)) {
      await _kirimJson(request.response, HttpStatus.forbidden,
          {'error': 'Token invalid or expired'});
      return;
    }

    await next(request);
  };
}

// Simple rate limiter middleware
Middleware rateLimitMiddleware({int maks = 100, Duration per = const Duration(minutes: 1)}) {
  final hitungan = <String, List<DateTime>>{};
  return (next) => (request) async {
    final ip = request.connectionInfo?.remoteAddress.address ?? 'unknown';
    final sekarang = DateTime.now();
    final batas = sekarang.subtract(per);

    hitungan.putIfAbsent(ip, () => []);
    hitungan[ip]!.removeWhere((t) => t.isBefore(batas));

    if (hitungan[ip]!.length >= maks) {
      request.response.headers.set('Retry-After', per.inSeconds.toString());
      await _kirimJson(request.response, HttpStatus.tooManyRequests,
          {'error': 'Too many requests, try again later'});
      return;
    }

    hitungan[ip]!.add(sekarang);
    await next(request);
  };
}

// Compose middleware — executes left to right
MiddlewareHandler compose(List<Middleware> middlewares, MiddlewareHandler handler) {
  return middlewares.reversed.fold(handler, (next, mw) => mw(next));
}

// Usage
void main() async {
  final pipeline = compose([
    loggingMiddleware(),
    rateLimitMiddleware(maks: 60),
    authMiddleware(_verifikasiToken),
  ], _tanganiRequest);

  final server = await HttpServer.bind(InternetAddress.anyIPv4, 8080);
  await for (final request in server) {
    pipeline(request).catchError((e) => print('Error: $e'));
  }
}

Shelf — the Ergonomic Middleware Framework #

For larger projects, the shelf package provides a more mature, composable abstraction:

dart pub add shelf shelf_router shelf_static
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_static/shelf_static.dart';

// Handlers in Shelf: Request → Response (pure functions)
Handler buatApp() {
  final router = Router();

  // API endpoints
  router.get('/api/produk', _daftarProduk);
  router.get('/api/produk/<id>', _detailProduk);
  router.post('/api/produk', _buatProduk);
  router.put('/api/produk/<id>', _updateProduk);
  router.delete('/api/produk/<id>', _hapusProduk);

  // Static files
  final staticHandler = createStaticHandler('public', defaultDocument: 'index.html');

  // Combine the router and static handler
  final cascade = Cascade()
      .add(router)
      .add(staticHandler)
      .handler;

  // Middleware pipeline
  return Pipeline()
      .addMiddleware(logRequests())    // shelf's built-in logging
      .addMiddleware(_corsMiddleware())
      .addMiddleware(_authMiddleware())
      .addHandler(cascade);
}

// Shelf handler — takes path parameters with <namaParam>
Future<Response> _detailProduk(Request request, String id) async {
  final produk = await _repository.ambilById(id);
  if (produk == null) {
    return Response.notFound('{"error": "Product not found"}',
        headers: {'Content-Type': 'application/json'});
  }
  return Response.ok(jsonEncode(produk.toJson()),
      headers: {'Content-Type': 'application/json'});
}

// CORS middleware for Shelf
Middleware _corsMiddleware() {
  const headers = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  };

  return (innerHandler) => (request) async {
    if (request.method == 'OPTIONS') {
      return Response.ok('', headers: headers);
    }
    final response = await innerHandler(request);
    return response.change(headers: headers);
  };
}

// Auth middleware for Shelf
Middleware _authMiddleware() {
  // Routes that don't need auth
  const publik = {'/api/auth/login', '/api/auth/register'};

  return (innerHandler) => (request) async {
    if (publik.contains(request.url.path)) {
      return innerHandler(request);
    }

    final auth = request.headers['Authorization'];
    if (auth == null || !auth.startsWith('Bearer ')) {
      return Response.unauthorized('{"error": "Token required"}',
          headers: {'Content-Type': 'application/json'});
    }

    final token = auth.substring(7);
    if (!await _verifikasiToken(token)) {
      return Response.forbidden('{"error": "Invalid token"}',
          headers: {'Content-Type': 'application/json'});
    }

    return innerHandler(request);
  };
}

Future<void> main() async {
  final handler = buatApp();
  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('Shelf server running at http://localhost:${server.port}');
}

Static Files with Correct Content-Types #

import 'dart:io';

// Extension-to-MIME-type map
const _mimeTypes = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff': 'font/woff',
  '.woff2': 'font/woff2',
  '.pdf': 'application/pdf',
};

Future<void> sajikanFile(HttpRequest request, String direktoriPublik) async {
  var path = request.uri.path;

  // Security: prevent path traversal
  if (path.contains('..')) {
    await _kirimJson(request.response, HttpStatus.forbidden,
        {'error': 'Access denied'});
    return;
  }

  if (path == '/') path = '/index.html';

  // Join the public directory with the requested path
  final filePath = '$direktoriPublik$path';
  final file = File(filePath);

  if (!await file.exists()) {
    // Try serving index.html for SPA routing
    final index = File('$direktoriPublik/index.html');
    if (await index.exists()) {
      await _sajikanFileObjek(request, index, 'text/html; charset=utf-8');
      return;
    }
    request.response.statusCode = HttpStatus.notFound;
    await request.response.close();
    return;
  }

  final ekstensi = filePath.substring(filePath.lastIndexOf('.'));
  final mimeType = _mimeTypes[ekstensi] ?? 'application/octet-stream';
  await _sajikanFileObjek(request, file, mimeType);
}

Future<void> _sajikanFileObjek(
    HttpRequest request, File file, String mimeType) async {
  request.response.headers
    ..set('Content-Type', mimeType)
    ..set('Cache-Control', 'public, max-age=3600'); // cache for 1 hour

  // Pipe the file directly to the response — efficient, no need to load into RAM first
  await file.openRead().pipe(request.response);
}

HTTPS with TLS #

import 'dart:io';

Future<void> main() async {
  final konteks = SecurityContext()
    ..useCertificateChain('certs/server.crt')
    ..usePrivateKey('certs/server.key');

  final server = await HttpServer.bindSecure(
    InternetAddress.anyIPv4,
    443,
    konteks,
  );

  print('HTTPS server running at https://localhost:443');

  // Redirect HTTP to HTTPS (run a separate HTTP server on port 80)
  _jalankanRedirectHTTP();

  await for (final request in server) {
    // Add the HSTS header
    request.response.headers.set(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains',
    );
    await _tanganiRequest(request);
  }
}

void _jalankanRedirectHTTP() async {
  final serverHttp = await HttpServer.bind(InternetAddress.anyIPv4, 80);
  serverHttp.listen((request) {
    request.response
      ..statusCode = HttpStatus.movedPermanently
      ..headers.set('Location',
          'https://${request.headers.host}${request.uri}')
      ..close();
  });
}

Comparison: dart:io HttpServer vs Shelf vs Frameworks #

Aspectdart:io directlyShelfFrameworks (Conduit, Serverpod)
BoilerplateA lotModerateLittle
FlexibilityFullHighLimited by configuration
MiddlewareManual✓ composable✓ built-in
RoutingManualshelf_router✓ built-in
TestingDifficultEasy (pure functions)Varies
Best forLearning, full controlProduction APIsLarge projects, ORM, auth
USE dart:io directly when:
  ✓ Learning how HTTP works at a low level
  ✓ You need full control over every aspect of the request lifecycle
  ✓ Special cases not supported by frameworks

USE Shelf when:
  ✓ Building production REST APIs
  ✓ You need composable, easily testable middleware
  ✓ The team is familiar with the middleware concept (like Express.js)

USE frameworks (Conduit, Serverpod) when:
  ✓ Large projects with ORM, authentication, and database migration needs
  ✓ You want established conventions and structure

Summary #

  • await for without await per request — handle each request without await in the main loop so the server can accept the next request. The Dart event loop handles concurrency automatically.
  • The middleware pattern separates cross-cutting concerns (logging, auth, rate limiting) from business logic. Compose as a pipeline from left to right.
  • Always close request.response with close() — otherwise the client will hang waiting for a response that never comes.
  • JSON APIs: use Content-Type: application/json in response headers, jsonDecode to read the body, and jsonEncode to write the response.
  • Path traversal is a serious vulnerability when serving static files — always check whether the path contains .. before opening a file.
  • file.openRead().pipe(request.response) for serving large files without loading them into RAM — more efficient than readAsBytes() then writing.
  • CORS — set Access-Control-Allow-* headers and handle preflight OPTIONS requests so web frontends can access the API.
  • Shelf is the recommended choice for production REST APIs — its handlers are pure functions (Request → Response) that are easy to test without a real server.
  • HTTPS is mandatory in production — use HttpServer.bindSecure with valid certificates, add the HSTS header, and redirect all HTTP to HTTPS.
  • Graceful shutdown with server.close(force: false) waits for all active requests to finish before shutting down — important for zero-downtime deployments.

← Previous: Web Socket   Next: Unit Testing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact