diff --git a/.travis.yml b/.travis.yml
index e190a34..93cbc23 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,6 +12,8 @@ script:
       printf 'Checking for tabs in %s\n' "$TRAVIS_COMMIT_RANGE"
       ! git diff --name-only --diff-filter=ACMR "$TRAVIS_COMMIT_RANGE" | xargs grep $'\t'
     fi
+  - ( cd maud_htmlescape && cargo test --all-features )
+  - if command -v cargo-clippy > /dev/null; then ( cd maud_htmlescape && cargo clippy -- -D warnings ); fi
   - ( cd maud && cargo test --all-features )
   - if command -v cargo-clippy > /dev/null; then ( cd maud && cargo clippy -- -D warnings ); fi
   - ( cd maud_macros && cargo test --all-features )
diff --git a/Cargo.toml b/Cargo.toml
index 123dc41..87876cd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,7 @@
 [workspace]
 members = [
-    "maud",
+    "maud_htmlescape",
     "maud_macros",
+    "maud",
     "maud_extras",
 ]
diff --git a/maud/Cargo.toml b/maud/Cargo.toml
index bfcb7bf..3001991 100644
--- a/maud/Cargo.toml
+++ b/maud/Cargo.toml
@@ -13,6 +13,7 @@ description = "Compile-time HTML templates."
 categories = ["template-engine"]
 
 [dependencies]
+maud_htmlescape = { version = "0.17.0", path = "../maud_htmlescape" }
 maud_macros = { version = "0.16.3", path = "../maud_macros" }
 iron = { version = "0.5.1", optional = true }
 rocket = { version = "0.3", optional = true }
diff --git a/maud/src/lib.rs b/maud/src/lib.rs
index 95b3d71..115d7df 100644
--- a/maud/src/lib.rs
+++ b/maud/src/lib.rs
@@ -13,6 +13,7 @@
 #[cfg(feature = "iron")] extern crate iron;
 #[cfg(feature = "rocket")] extern crate rocket;
 
+extern crate maud_htmlescape;
 extern crate maud_macros;
 
 use std::fmt::{self, Write};
@@ -121,52 +122,7 @@ impl<T: AsRef<str> + Into<String>> Into<String> for PreEscaped<T> {
     }
 }
 
-/// An adapter that escapes HTML special characters.
-///
-/// The following characters are escaped:
-///
-/// * `&` is escaped as `&amp;`
-/// * `<` is escaped as `&lt;`
-/// * `>` is escaped as `&gt;`
-/// * `"` is escaped as `&quot;`
-///
-/// All other characters are passed through unchanged.
-///
-/// **Note:** In versions prior to 0.13, the single quote (`'`) was
-/// escaped as well.
-///
-/// # Example
-///
-/// ```
-/// # use maud::Escaper;
-/// use std::fmt::Write;
-/// let mut s = String::new();
-/// write!(Escaper::new(&mut s), "<script>launchMissiles()</script>").unwrap();
-/// assert_eq!(s, "&lt;script&gt;launchMissiles()&lt;/script&gt;");
-/// ```
-pub struct Escaper<'a>(&'a mut String);
-
-impl<'a> Escaper<'a> {
-    /// Creates an `Escaper` from a `String`.
-    pub fn new(buffer: &'a mut String) -> Escaper<'a> {
-        Escaper(buffer)
-    }
-}
-
-impl<'a> fmt::Write for Escaper<'a> {
-    fn write_str(&mut self, s: &str) -> fmt::Result {
-        for b in s.bytes() {
-            match b {
-                b'&' => self.0.push_str("&amp;"),
-                b'<' => self.0.push_str("&lt;"),
-                b'>' => self.0.push_str("&gt;"),
-                b'"' => self.0.push_str("&quot;"),
-                _ => unsafe { self.0.as_mut_vec().push(b) },
-            }
-        }
-        Ok(())
-    }
-}
+pub use maud_htmlescape::Escaper;
 
 /// The literal string `<!DOCTYPE html>`.
 ///
diff --git a/maud_htmlescape/Cargo.toml b/maud_htmlescape/Cargo.toml
new file mode 100644
index 0000000..a5caae0
--- /dev/null
+++ b/maud_htmlescape/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+
+name = "maud_htmlescape"
+# When releasing a new version, please update html_root_url in lib.rs
+version = "0.17.0"
+authors = ["Chris Wong <lambda.fairy@gmail.com>"]
+
+license = "MIT/Apache-2.0"
+documentation = "https://docs.rs/maud_htmlescape/"
+homepage = "https://maud.lambda.xyz/"
+repository = "https://github.com/lfairy/maud"
+description = "Internal support code used by Maud."
+
+[lib]
+path = "lib.rs"
diff --git a/maud_htmlescape/lib.rs b/maud_htmlescape/lib.rs
new file mode 100644
index 0000000..873df13
--- /dev/null
+++ b/maud_htmlescape/lib.rs
@@ -0,0 +1,68 @@
+//! Internal support code used by the [Maud] template engine.
+//!
+//! You should not need to depend on this crate directly.
+//!
+//! [Maud]: https://maud.lambda.xyz
+
+#![doc(html_root_url = "https://docs.rs/maud_htmlescape/0.17.0")]
+
+use std::fmt;
+
+/// An adapter that escapes HTML special characters.
+///
+/// The following characters are escaped:
+///
+/// * `&` is escaped as `&amp;`
+/// * `<` is escaped as `&lt;`
+/// * `>` is escaped as `&gt;`
+/// * `"` is escaped as `&quot;`
+///
+/// All other characters are passed through unchanged.
+///
+/// **Note:** In versions prior to 0.13, the single quote (`'`) was
+/// escaped as well.
+///
+/// # Example
+///
+/// ```rust,ignore
+/// use std::fmt::Write;
+/// let mut s = String::new();
+/// write!(Escaper::new(&mut s), "<script>launchMissiles()</script>").unwrap();
+/// assert_eq!(s, "&lt;script&gt;launchMissiles()&lt;/script&gt;");
+/// ```
+pub struct Escaper<'a>(&'a mut String);
+
+impl<'a> Escaper<'a> {
+    /// Creates an `Escaper` from a `String`.
+    pub fn new(buffer: &'a mut String) -> Escaper<'a> {
+        Escaper(buffer)
+    }
+}
+
+impl<'a> fmt::Write for Escaper<'a> {
+    fn write_str(&mut self, s: &str) -> fmt::Result {
+        for b in s.bytes() {
+            match b {
+                b'&' => self.0.push_str("&amp;"),
+                b'<' => self.0.push_str("&lt;"),
+                b'>' => self.0.push_str("&gt;"),
+                b'"' => self.0.push_str("&quot;"),
+                _ => unsafe { self.0.as_mut_vec().push(b) },
+            }
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::fmt::Write;
+    use Escaper;
+
+    #[test]
+    fn it_works() {
+        let mut s = String::new();
+        write!(Escaper::new(&mut s), "<script>launchMissiles()</script>").unwrap();
+        assert_eq!(s, "&lt;script&gt;launchMissiles()&lt;/script&gt;");
+    }
+}
diff --git a/maud_macros/Cargo.toml b/maud_macros/Cargo.toml
index 5765624..40907eb 100644
--- a/maud_macros/Cargo.toml
+++ b/maud_macros/Cargo.toml
@@ -13,6 +13,7 @@ description = "Compile-time HTML templates."
 
 [dependencies]
 literalext = { version = "0.1", default-features = false, features = ["proc-macro"] }
+maud_htmlescape = { version = "0.17.0", path = "../maud_htmlescape" }
 
 [lib]
 name = "maud_macros"
diff --git a/maud_macros/src/lib.rs b/maud_macros/src/lib.rs
index cbdbb18..409c7ab 100644
--- a/maud_macros/src/lib.rs
+++ b/maud_macros/src/lib.rs
@@ -4,6 +4,7 @@
 #![doc(html_root_url = "https://docs.rs/maud_macros/0.16.3")]
 
 extern crate literalext;
+extern crate maud_htmlescape;
 extern crate proc_macro;
 
 // TODO move lints into their own `maud_lints` crate
diff --git a/maud_macros/src/render.rs b/maud_macros/src/render.rs
index 04b5100..b286502 100644
--- a/maud_macros/src/render.rs
+++ b/maud_macros/src/render.rs
@@ -1,6 +1,7 @@
 use proc_macro::{Literal, Term, TokenNode, TokenStream};
 use proc_macro::quote;
-use std::fmt;
+
+use maud_htmlescape::Escaper;
 
 pub struct Renderer {
     output: TokenNode,
@@ -166,28 +167,3 @@ fn html_escape(s: &str) -> String {
     Escaper::new(&mut buffer).write_str(s).unwrap();
     buffer
 }
-
-// TODO move this into a common `maud_htmlescape` crate
-struct Escaper<'a>(&'a mut String);
-
-impl<'a> Escaper<'a> {
-    /// Creates an `Escaper` from a `String`.
-    pub fn new(buffer: &'a mut String) -> Escaper<'a> {
-        Escaper(buffer)
-    }
-}
-
-impl<'a> fmt::Write for Escaper<'a> {
-    fn write_str(&mut self, s: &str) -> fmt::Result {
-        for b in s.bytes() {
-            match b {
-                b'&' => self.0.push_str("&amp;"),
-                b'<' => self.0.push_str("&lt;"),
-                b'>' => self.0.push_str("&gt;"),
-                b'"' => self.0.push_str("&quot;"),
-                _ => unsafe { self.0.as_mut_vec().push(b) },
-            }
-        }
-        Ok(())
-    }
-}