diff --git a/gh-badges/lib/make-badge.js b/gh-badges/lib/make-badge.js
index a86622a096f853f7073d7c6b0194cfc07d0f51be..cbd8af8477b7f1a214824008819ecafb5e167e01 100644
--- a/gh-badges/lib/make-badge.js
+++ b/gh-badges/lib/make-badge.js
@@ -105,7 +105,6 @@ function assignColor(color = '', colorschemeType = 'colorB') {
 
 const definedColorschemes = require(path.join(__dirname, 'colorscheme.json'))
 
-// Inject the measurer to avoid placing any persistent state in this module.
 function makeBadge({
   format,
   template,
diff --git a/lib/request-handler.js b/lib/request-handler.js
index cfca8780e229b9db1040ddc094eae70f95dae17b..7fc717b278eb900cf0ba41b68c3900b1d339c9c5 100644
--- a/lib/request-handler.js
+++ b/lib/request-handler.js
@@ -62,13 +62,13 @@ function getBadgeMaxAge(handlerOptions, queryParams) {
     ? parseInt(process.env.BADGE_MAX_AGE_SECONDS)
     : 120
   if (handlerOptions.cacheLength) {
-    // if we've set a more specific cache length for this badge (or category),
-    // use that instead of env.BADGE_MAX_AGE_SECONDS
-    maxAge = parseInt(handlerOptions.cacheLength)
+    // If we've set a more specific cache length for this badge (or category),
+    // use that instead of env.BADGE_MAX_AGE_SECONDS.
+    maxAge = handlerOptions.cacheLength
   }
   if (isInt(queryParams.maxAge) && parseInt(queryParams.maxAge) > maxAge) {
-    // only allow queryParams.maxAge to override the default
-    // if it is greater than the default
+    // Only allow queryParams.maxAge to override the default if it is greater
+    // than the default.
     maxAge = parseInt(queryParams.maxAge)
   }
   return maxAge
diff --git a/server.js b/server.js
index d386545070a743314641ff514480928a0c487a4d..ea256dbed56b99e3038fa74b6eb531333e704c2b 100644
--- a/server.js
+++ b/server.js
@@ -22,8 +22,6 @@ const log = require('./lib/log')
 const makeBadge = require('./gh-badges/lib/make-badge')
 const suggest = require('./lib/suggest')
 const {
-  makeColorB,
-  makeLabel: getLabel,
   makeBadgeData: getBadgeData,
   setBadgeColor,
 } = require('./lib/badge-data')
@@ -33,7 +31,6 @@ const {
 } = require('./lib/request-handler')
 const { clearRegularUpdateCache } = require('./lib/regular-update')
 const { makeSend } = require('./lib/result-sender')
-const { escapeFormat } = require('./lib/path-helpers')
 
 const serverStartTime = new Date(new Date().toGMTString())
 
@@ -107,7 +104,10 @@ camp.notfound(/.*/, (query, match, end, request) => {
 loadServiceClasses().forEach(serviceClass =>
   serviceClass.register(
     { camp, handleRequest: cache, githubApiProvider },
-    { handleInternalErrors: config.handleInternalErrors }
+    {
+      handleInternalErrors: config.handleInternalErrors,
+      profiling: config.profiling,
+    }
   )
 )
 
@@ -227,53 +227,6 @@ camp.route(
   })
 )
 
-// Any badge.
-camp.route(
-  /^\/(:|badge\/)(([^-]|--)*?)-?(([^-]|--)*)-(([^-]|--)+)\.(svg|png|gif|jpg)$/,
-  (data, match, end, ask) => {
-    const subject = escapeFormat(match[2])
-    const status = escapeFormat(match[4])
-    const color = escapeFormat(match[6])
-    const format = match[8]
-
-    analytics.noteRequest(data, match)
-
-    // Cache management - the badge is constant.
-    const cacheDuration = (3600 * 24 * 1) | 0 // 1 day.
-    ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
-    if (+new Date(ask.req.headers['if-modified-since']) >= +serverStartTime) {
-      ask.res.statusCode = 304
-      ask.res.end() // not modified.
-      return
-    }
-    ask.res.setHeader('Last-Modified', serverStartTime.toGMTString())
-
-    // Badge creation.
-    try {
-      const badgeData = getBadgeData(subject, data)
-      badgeData.text[0] = getLabel(undefined, { label: subject })
-      badgeData.text[1] = status
-      badgeData.colorB = makeColorB(color, data)
-      badgeData.template = data.style
-      if (config.profiling.makeBadge) {
-        console.time('makeBadge total')
-      }
-      const svg = makeBadge(badgeData)
-      if (config.profiling.makeBadge) {
-        console.timeEnd('makeBadge total')
-      }
-      makeSend(format, ask.res, end)(svg)
-    } catch (e) {
-      log.error(e.stack)
-      const svg = makeBadge({
-        text: ['error', 'bad badge'],
-        colorscheme: 'red',
-      })
-      makeSend(format, ask.res, end)(svg)
-    }
-  }
-)
-
 // Production cache debugging.
 let bitFlip = false
 camp.route(/^\/flip\.svg$/, (data, match, end, ask) => {
diff --git a/services/base-static.js b/services/base-static.js
new file mode 100644
index 0000000000000000000000000000000000000000..cbaac7ea03b3a661dd0b5cbd282f0659a5d9e8e4
--- /dev/null
+++ b/services/base-static.js
@@ -0,0 +1,58 @@
+'use strict'
+
+const makeBadge = require('../gh-badges/lib/make-badge')
+const { makeSend } = require('../lib/result-sender')
+const analytics = require('../lib/analytics')
+const BaseService = require('./base')
+
+const serverStartTime = new Date(new Date().toGMTString())
+
+module.exports = class BaseStaticService extends BaseService {
+  // Note: Since this is a static service, it is not `async`.
+  handle(namedParams, queryParams) {
+    throw new Error(`Handler not implemented for ${this.constructor.name}`)
+  }
+
+  static register({ camp }, serviceConfig) {
+    camp.route(this._regex, (queryParams, match, end, ask) => {
+      analytics.noteRequest(queryParams, match)
+
+      if (+new Date(ask.req.headers['if-modified-since']) >= +serverStartTime) {
+        // Send Not Modified.
+        ask.res.statusCode = 304
+        ask.res.end()
+        return
+      }
+
+      const serviceInstance = new this({}, serviceConfig)
+      const namedParams = this._namedParamsForMatch(match)
+      let serviceData
+      try {
+        // Note: no `await`.
+        serviceData = serviceInstance.handle(namedParams, queryParams)
+      } catch (error) {
+        serviceData = serviceInstance._handleError(error)
+      }
+
+      const badgeData = this._makeBadgeData(queryParams, serviceData)
+
+      // The final capture group is the extension.
+      const format = match.slice(-1)[0]
+      badgeData.format = format
+
+      if (serviceConfig.profiling.makeBadge) {
+        console.time('makeBadge total')
+      }
+      const svg = makeBadge(badgeData)
+      if (serviceConfig.profiling.makeBadge) {
+        console.timeEnd('makeBadge total')
+      }
+
+      const cacheDuration = 3600 * 24 * 1 // 1 day.
+      ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
+      ask.res.setHeader('Last-Modified', serverStartTime.toGMTString())
+
+      makeSend(format, ask.res, end)(svg)
+    })
+  }
+}
diff --git a/services/base.js b/services/base.js
index 87869ddefb56d1b8830ecf4cbf982d9bbce26bde..f3d7c543afddc4cd9c259fd252a6a39b5e34a360 100644
--- a/services/base.js
+++ b/services/base.js
@@ -22,6 +22,10 @@ const {
 const { staticBadgeUrl } = require('../lib/make-badge-url')
 const trace = require('./trace')
 
+function coalesce(...candidates) {
+  return candidates.find(c => typeof c === 'string')
+}
+
 class BaseService {
   constructor({ sendAndCacheRequest }, { handleInternalErrors }) {
     this._requestFetcher = sendAndCacheRequest
@@ -248,6 +252,52 @@ class BaseService {
     return result
   }
 
+  _handleError(error) {
+    if (error instanceof NotFound || error instanceof InvalidParameter) {
+      trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
+      return {
+        message: error.prettyMessage,
+        color: 'red',
+      }
+    } else if (
+      error instanceof InvalidResponse ||
+      error instanceof Inaccessible ||
+      error instanceof Deprecated
+    ) {
+      trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
+      return {
+        message: error.prettyMessage,
+        color: 'lightgray',
+      }
+    } else if (this._handleInternalErrors) {
+      if (
+        !trace.logTrace(
+          'unhandledError',
+          emojic.boom,
+          'Unhandled internal error',
+          error
+        )
+      ) {
+        // This is where we end up if an unhandled exception is thrown in
+        // production. Send the error to the logs.
+        console.log(error)
+      }
+      return {
+        label: 'shields',
+        message: 'internal error',
+        color: 'lightgray',
+      }
+    } else {
+      trace.logTrace(
+        'unhandledError',
+        emojic.boom,
+        'Unhandled internal error',
+        error
+      )
+      throw error
+    }
+  }
+
   async invokeHandler(namedParams, queryParams) {
     trace.logTrace(
       'inbound',
@@ -260,49 +310,7 @@ class BaseService {
     try {
       return await this.handle(namedParams, queryParams)
     } catch (error) {
-      if (error instanceof NotFound || error instanceof InvalidParameter) {
-        trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
-        return {
-          message: error.prettyMessage,
-          color: 'red',
-        }
-      } else if (
-        error instanceof InvalidResponse ||
-        error instanceof Inaccessible ||
-        error instanceof Deprecated
-      ) {
-        trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
-        return {
-          message: error.prettyMessage,
-          color: 'lightgray',
-        }
-      } else if (this._handleInternalErrors) {
-        if (
-          !trace.logTrace(
-            'unhandledError',
-            emojic.boom,
-            'Unhandled internal error',
-            error
-          )
-        ) {
-          // This is where we end up if an unhandled exception is thrown in
-          // production. Send the error to the logs.
-          console.log(error)
-        }
-        return {
-          label: 'shields',
-          message: 'internal error',
-          color: 'lightgray',
-        }
-      } else {
-        trace.logTrace(
-          'unhandledError',
-          emojic.boom,
-          'Unhandled internal error',
-          error
-        )
-        throw error
-      }
+      return this._handleError(error)
     }
   }
 
@@ -333,8 +341,10 @@ class BaseService {
 
     const badgeData = {
       text: [
-        overrideLabel || serviceLabel || defaultLabel || this.category,
-        serviceMessage || 'n/a',
+        // Use `coalesce()` to support empty labels and messages, as in the
+        // static badge.
+        coalesce(overrideLabel, serviceLabel, defaultLabel, this.category),
+        coalesce(serviceMessage, 'n/a'),
       ],
       template: style,
       logo: makeLogo(style === 'social' ? defaultLogo : undefined, {
@@ -352,15 +362,12 @@ class BaseService {
   }
 
   static register({ camp, handleRequest, githubApiProvider }, serviceConfig) {
-    const ServiceClass = this // In a static context, "this" is the class.
-
     camp.route(
       this._regex,
       handleRequest({
         queryParams: this.route.queryParams,
         handler: async (queryParams, match, sendBadge, request) => {
-          const namedParams = this._namedParamsForMatch(match)
-          const serviceInstance = new ServiceClass(
+          const serviceInstance = new this(
             {
               sendAndCacheRequest: request.asPromise,
               sendAndCacheRequestWithCallbacks: request,
@@ -368,6 +375,7 @@ class BaseService {
             },
             serviceConfig
           )
+          const namedParams = this._namedParamsForMatch(match)
           const serviceData = await serviceInstance.invokeHandler(
             namedParams,
             queryParams
@@ -375,7 +383,7 @@ class BaseService {
           trace.logTrace('outbound', emojic.shield, 'Service data', serviceData)
           const badgeData = this._makeBadgeData(queryParams, serviceData)
 
-          // Assumes the final capture group is the extension
+          // The final capture group is the extension.
           const format = match.slice(-1)[0]
           sendBadge(format, badgeData)
         },
diff --git a/services/gitter/gitter.service.js b/services/gitter/gitter.service.js
index e6913b3b73d93432d0788916fc190c8a70595c25..1abb3ff4a4b76a912956f381829745ceaa115cea 100644
--- a/services/gitter/gitter.service.js
+++ b/services/gitter/gitter.service.js
@@ -1,38 +1,39 @@
 'use strict'
 
-const LegacyService = require('../legacy-service')
-const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
+const BaseStaticService = require('../base-static')
 
-module.exports = class Gitter extends LegacyService {
+module.exports = class Gitter extends BaseStaticService {
   static get category() {
     return 'chat'
   }
 
   static get route() {
-    return { base: 'gitter/room' }
+    return {
+      base: 'gitter/room',
+      pattern: ':user/:repo',
+    }
   }
 
   static get examples() {
     return [
       {
         title: 'Gitter',
-        previewUrl: 'nwjs/nw.js',
+        urlPattern: ':user/:repo',
+        staticExample: this.render(),
+        exampleUrl: 'nwjs/nw.js',
       },
     ]
   }
 
-  static registerLegacyRouteHandler({ camp, cache }) {
-    camp.route(
-      /^\/gitter\/room\/([^/]+\/[^/]+)\.(svg|png|gif|jpg|json)$/,
-      cache((data, match, sendBadge, request) => {
-        // match[1] is the repo, which is not used.
-        const format = match[2]
+  static get defaultBadgeData() {
+    return { label: 'chat' }
+  }
+
+  static render() {
+    return { message: 'on gitter', color: 'brightgreen' }
+  }
 
-        const badgeData = getBadgeData('chat', data)
-        badgeData.text[1] = 'on gitter'
-        badgeData.colorscheme = 'brightgreen'
-        sendBadge(format, badgeData)
-      })
-    )
+  handle() {
+    return this.constructor.render()
   }
 }
diff --git a/services/static-badge/static-badge.service.js b/services/static-badge/static-badge.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..e6211378399d7cb16c50458e81e41ac94d31bcb0
--- /dev/null
+++ b/services/static-badge/static-badge.service.js
@@ -0,0 +1,20 @@
+'use strict'
+
+const BaseStaticService = require('../base-static')
+
+module.exports = class StaticBadge extends BaseStaticService {
+  static get category() {
+    return 'other'
+  }
+
+  static get route() {
+    return {
+      format: '(?::|badge/)((?:[^-]|--)*?)-?((?:[^-]|--)*)-((?:[^-]|--)+)',
+      capture: ['label', 'message', 'color'],
+    }
+  }
+
+  handle({ label, message, color }) {
+    return { label, message, color }
+  }
+}
diff --git a/services/static-badge/static-badge.tester.js b/services/static-badge/static-badge.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..13371ea3afdacf8b28cc3f38a3685a965034925c
--- /dev/null
+++ b/services/static-badge/static-badge.tester.js
@@ -0,0 +1,40 @@
+'use strict'
+
+const t = require('../create-service-tester')()
+module.exports = t
+
+t.create('Shields colorscheme color')
+  .get('/badge/label-message-blue.json?style=_shields_test')
+  .expectJSON({ name: 'label', value: 'message', colorB: '#007ec6' })
+
+t.create('CSS named color')
+  .get('/badge/label-message-whitesmoke.json?style=_shields_test')
+  .expectJSON({ name: 'label', value: 'message', colorB: 'whitesmoke' })
+
+t.create('RGB color')
+  .get('/badge/label-message-rgb(123,123,123).json?style=_shields_test')
+  .expectJSON({ name: 'label', value: 'message', colorB: 'rgb(123,123,123)' })
+
+t.create('All one color')
+  .get('/badge/all%20one%20color-red.json?style=_shields_test')
+  .expectJSON({ name: '', value: 'all one color', colorB: '#e05d44' })
+
+t.create('Not a valid color')
+  .get('/badge/label-message-notacolor.json?style=_shields_test')
+  .expectJSON({ name: 'label', value: 'message', colorB: '#e05d44' })
+
+t.create('Missing message')
+  .get('/badge/label--blue.json?style=_shields_test')
+  .expectJSON({ name: 'label', value: '', colorB: '#007ec6' })
+
+t.create('Missing label')
+  .get('/badge/-message-blue.json?style=_shields_test')
+  .expectJSON({ name: '', value: 'message', colorB: '#007ec6' })
+
+t.create('Override colorB')
+  .get('/badge/label-message-blue.json?style=_shields_test&colorB=yellow')
+  .expectJSON({ name: 'label', value: 'message', colorB: '#dfb317' })
+
+t.create('Override label')
+  .get('/badge/label-message-blue.json?style=_shields_test&label=mylabel')
+  .expectJSON({ name: 'mylabel', value: 'message', colorB: '#007ec6' })