serverpod-webserver

star 3.2k

Serverpod web server (Relic) — REST APIs, webhooks, middleware, static files, server-rendered HTML, SPAs, Flutter web. Use when adding HTTP routes, serving web pages or web apps, intercepting requests, or working with the Relic web server.

serverpod By serverpod schedule Updated 5/29/2026

name: serverpod-webserver description: Serverpod web server (Relic) — REST APIs, webhooks, middleware, static files, server-rendered HTML, SPAs, Flutter web. Use when adding HTTP routes, serving web pages or web apps, intercepting requests, or working with the Relic web server.

Serverpod Web Server (Relic)

Built on Relic, shares Session (DB, logging, auth) with the main server. This skill is for the optional webServer listener (default port 8082), not the main API server (default port 8080).

Routes

Extend Route, implement handleCall(Session, Request). Register before pod.start():

class HelloRoute extends Route {
  @override
  Future<Result> handleCall(Session session, Request request) async {
    return Response.ok(
      body: Body.fromString(
        jsonEncode({'message': 'Hello'}),
        mimeType: MimeType.json,
      ),
    );
  }
}

pod.webServer.addRoute(HelloRoute(), '/api/hello');

Routes matched in registration order. Session provides DB, logging, and auth access just like in endpoints.

HTTP methods

Restrict which methods a route accepts:

class UserRoute extends Route {
  UserRoute() : super(methods: {Method.get, Method.post, Method.delete});

  @override
  Future<Result> handleCall(Session session, Request request) async {
    if (request.method == Method.post) {
      final body = await request.readAsString();
      final data = jsonDecode(body);
      return Response.created(
        body: Body.fromString(jsonEncode({'status': 'created', 'data': data}),
          mimeType: MimeType.json),
      );
    }
    final users = await User.db.find(session);
    return Response.ok(
      body: Body.fromString(jsonEncode(users.map((u) => u.toJson()).toList()),
        mimeType: MimeType.json),
    );
  }
}

Path parameters

pod.webServer.addRoute(UserRoute(), '/api/users/:id');
pod.webServer.addRoute(route, '/:userId/posts/:postId');

Access typed params:

class UserRoute extends Route {
  static const _idParam = IntPathParam(#id);

  @override
  Future<Result> handleCall(Session session, Request request) async {
    int userId = request.pathParameters.get(_idParam);
    final user = await User.db.findById(session, userId);
    if (user == null) return Response.notFound();
    return Response.ok(
      body: Body.fromString(jsonEncode(user.toJson()), mimeType: MimeType.json),
    );
  }
}

Raw access: request.pathParameters.raw[#id].

Wildcards

pod.webServer.addRoute(route, '/item/*');   // One segment: /item/foo
pod.webServer.addRoute(route, '/item/**');  // Tail-match: /item/foo/bar/baz

** only at end of path. Access matched path via request.remainingPath.

Query parameters

class SearchRoute extends Route {
  static const _pageParam = IntQueryParam('page');

  @override
  Future<Result> handleCall(Session session, Request request) async {
    int page = request.queryParameters.get(_pageParam);
    String? query = request.queryParameters.raw['query'];
    // ...
  }
}

Headers and body

final userAgent = request.headers.userAgent;
final contentLength = request.headers.contentLength;
final auth = request.headers.authorization;
final apiKey = request.headers['X-API-Key']?.first;

final body = await request.readAsString();  // JSON, form data
final stream = request.read();              // Stream for large uploads

Body can only be read once.

Response types

Response.ok(body: Body.fromString('Success'));
Response.created(body: Body.fromString('Created'));
Response.noContent();
Response.badRequest(body: Body.fromString('Invalid'));
Response.unauthorized(body: Body.fromString('Not authenticated'));
Response.forbidden(body: Body.fromString('Forbidden'));
Response.notFound(body: Body.fromString('Not found'));
Response.internalServerError(body: Body.fromString('Error'));

Use Body.fromString(content, mimeType: MimeType.json) for JSON responses.

Fallback route

pod.webServer.fallbackRoute = NotFoundRoute();

Handles requests when no other route matches.

Route modules (injectIn)

Group related endpoints by overriding injectIn():

class UserCrudModule extends Route {
  @override
  void injectIn(RelicRouter router) {
    router
      ..get('/', _list)
      ..get('/:id', _get);
  }

  Future<Result> _list(Request request) async {
    final session = await request.session;
    final users = await User.db.find(session);
    return Response.ok(
      body: Body.fromString(jsonEncode(users.map((u) => u.toJson()).toList()),
        mimeType: MimeType.json),
    );
  }

  static const _idParam = IntPathParam(#id);
  Future<Result> _get(Request request) async {
    final session = await request.session;
    int userId = request.pathParameters.get(_idParam);
    final user = await User.db.findById(session, userId);
    if (user == null) return Response.notFound();
    return Response.ok(
      body: Body.fromString(jsonEncode(user.toJson()), mimeType: MimeType.json),
    );
  }
}

pod.webServer.addRoute(UserCrudModule(), '/api/users');
// Creates GET /api/users and GET /api/users/:id

Note: injectIn handlers receive only Request; access Session with await request.session.

Middleware

Middleware wraps handlers. Register with path prefix:

Handler apiKeyMiddleware(Handler next) {
  return (Request request) async {
    final apiKey = request.headers['X-API-Key']?.firstOrNull;
    if (apiKey == null) {
      return Response.unauthorized(body: Body.fromString('API key required'));
    }
    if (!await isValidApiKey(apiKey)) {
      return Response.forbidden(body: Body.fromString('Invalid API key'));
    }
    return await next(request);
  };
}

pod.webServer.addMiddleware(apiKeyMiddleware, '/api');

Execution order

More specific paths run as inner middleware. Within the same path, order of registration:

pod.webServer.addMiddleware(rateLimitMiddleware, '/api/users'); // Inner (last before handler)
pod.webServer.addMiddleware(apiKeyMiddleware, '/api');           // Outer (first)

For /api/users/list: apiKeyMiddleware → rateLimitMiddleware → handler → rateLimitMiddleware → apiKeyMiddleware.

Request-scoped data (ContextProperty)

Pass data from middleware to routes without modifying the request:

final _tenantProperty = ContextProperty<String>('tenant');

extension TenantEx on Request {
  String get tenant => _tenantProperty.get(this);
}

Handler tenantMiddleware(Handler next) {
  return (Request request) async {
    final session = await request.session;
    final tenant = await extractTenant(session, request.headers.host);
    if (tenant == null) return Response.notFound();
    _tenantProperty[request] = tenant;
    return await next(request);
  };
}

// In route:
final tenant = request.tenant;

Data cleaned up automatically when request completes. Host-specific middleware: pod.webServer.addMiddleware(mw, '/api', host: 'api.example.com').

Static files

pod.webServer.addRoute(
  StaticRoute.directory(Directory('web/static')),
  '/static/',
);

Serves all files under the prefix. Automatic content-type detection, ETag, and Last-Modified.

Cache control

pod.webServer.addRoute(
  StaticRoute.directory(Directory('web/static'),
    cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(minutes: 5))),
  '/static/',
);

Built-in factories: StaticRoute.public(maxAge:), StaticRoute.publicImmutable(maxAge:), StaticRoute.privateNoCache(), StaticRoute.noStore().

Cache-busting

final cacheBustingConfig = CacheBustingConfig(
  mountPrefix: '/static',
  fileSystemRoot: Directory('web/static'),
  separator: '@',
);

pod.webServer.addRoute(
  StaticRoute.directory(Directory('web/static'),
    cacheBustingConfig: cacheBustingConfig,
    cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(minutes: 5))),
  '/static/',
);

// Generate versioned URL:
final url = await cacheBustingConfig.assetPath('/static/logo.png');
// → /static/logo@<hash>.png

Virtual host routing

Restrict routes/middleware to a specific Host header:

pod.webServer.addRoute(ApiRoute(), '/v1');  // ApiRoute has host: 'api.example.com'
pod.webServer.addRoute(SpaRoute(webDir, fallback: index, host: 'www.example.com'), '/');
pod.webServer.addRoute(HealthRoute(), '/health');  // All hosts (default)

All route types support host: Route, StaticRoute, SpaRoute, FlutterRoute.

Server-side HTML

Extend WidgetRoute, return a TemplateWidget from build():

class MyRoute extends WidgetRoute {
  @override
  Future<TemplateWidget> build(Session session, Request request) async {
    final users = await User.db.find(session);
    return UserListWidget(users: users);
  }
}

class UserListWidget extends TemplateWidget {
  UserListWidget({required List<User> users}) : super(name: 'user_list') {
    values = {'users': users.map((u) => u.userName).join(', ')};
  }
}

pod.webServer.addRoute(MyRoute(), '/users');

Place Mustache templates in web/templates/ (e.g. web/templates/user_list.html):

<html><body><h1>Users</h1><p>{{users}}</p></body></html>

Other widgets: ListWidget(children: [...]) concatenates widgets; JsonWidget({'key': 'value'}) renders JSON; RedirectWidget('/new/location') redirects.

Single-page apps (SPA)

SpaRoute serves a directory with fallback to index.html for client-side routing:

pod.webServer.addRoute(
  SpaRoute(
    Directory('web/app'),
    fallback: File('web/app/index.html'),
    cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(minutes: 5)),
  ),
  '/app',  // Or omit for root
);

Serves static files when they exist; falls back to index.html for unmatched paths so client-side routing (React Router, Vue Router, etc.) works.

For custom fallback logic, use FallbackMiddleware directly:

pod.webServer.addMiddleware(
  FallbackMiddleware(
    fallback: StaticRoute.file(File('web/app/index.html')),
    on: (response) => response.statusCode == 404,
  ),
);
pod.webServer.addRoute(StaticRoute.directory(Directory('web/app')), '/');

Flutter web apps

FlutterRoute serves Flutter web builds with SPA fallback and smart caching:

final appDir = Directory('web/app');
if (appDir.existsSync()) {
  pod.webServer.addRoute(
    FlutterRoute(
      appDir,
      enableWasmHeaders: false,
    ),
  );
}

Build: cd my_project_flutter && flutter build web --base-href /app/ -o ../my_project_server/web/app.

Generated projects set enableWasmHeaders: false on the FlutterRoute because the default build is non-WASM. To opt into Flutter WASM, add --wasm to the build command and remove the enableWasmHeaders: false line.

Default caching

  • All files: served with private, no-cache by default, so browsers revalidate with ETags and avoid stale Flutter assets after rebuilds.

Override with cacheControlFactory when using cache-busted assets.

WASM headers

FlutterRoute automatically adds Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp for SharedArrayBuffer support. If using SpaRoute instead, add WasmHeadersMiddleware manually:

pod.webServer.addMiddleware(const WasmHeadersMiddleware());

Both SpaRoute and FlutterRoute support host, cache-busting, and sub-path mounting.

Install via CLI
npx skills add https://github.com/serverpod/serverpod --skill serverpod-webserver
Repository Details
star Stars 3,207
call_split Forks 364
navigation Branch main
article Path SKILL.md
More from Creator