diff --git a/crates/treehouse/src/cli/serve.rs b/crates/treehouse/src/cli/serve.rs
index 4f7fa5f..4f2f0db 100644
--- a/crates/treehouse/src/cli/serve.rs
+++ b/crates/treehouse/src/cli/serve.rs
@@ -18,7 +18,7 @@ use axum::{
 };
 use serde::Deserialize;
 use tokio::net::TcpListener;
-use tracing::{info, instrument};
+use tracing::{error, info, instrument};
 
 use crate::dirs::Dirs;
 use crate::sources::Sources;
@@ -67,17 +67,6 @@ pub async fn serve(
     Ok(axum::serve(listener, app).await?)
 }
 
-fn get_content_type(extension: &str) -> Option<&'static str> {
-    match extension {
-        "html" => Some("text/html"),
-        "js" => Some("text/javascript"),
-        "woff" => Some("font/woff2"),
-        "svg" => Some("image/svg+xml"),
-        "atom" => Some("application/atom+xml"),
-        _ => None,
-    }
-}
-
 #[derive(Debug, Deserialize)]
 struct VfsQuery {
     #[serde(rename = "v")]
@@ -87,16 +76,12 @@ struct VfsQuery {
 #[instrument(skip(state))]
 async fn get_static_file(path: &str, query: &VfsQuery, state: &Server) -> Option<Response> {
     let vpath = VPath::try_new(path).ok()?;
-    let content = state.target.content(vpath).await.map(|c| c.bytes())?;
-    let mut response = content.into_response();
-
-    if let Some(content_type) = vpath.extension().and_then(get_content_type) {
-        response
-            .headers_mut()
-            .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
-    } else {
-        response.headers_mut().remove(CONTENT_TYPE);
-    }
+    let content = state.target.content(vpath).await?;
+    let content_type = HeaderValue::from_str(content.kind()).inspect_err(
+        |err| error!(?err, content_type = ?content.kind(), "content type cannot be used as an HTTP header"),
+    ).ok()?;
+    let mut response = content.bytes().into_response();
+    response.headers_mut().insert(CONTENT_TYPE, content_type);
 
     if query.content_version.is_some() {
         response.headers_mut().insert(
diff --git a/crates/treehouse/src/generate.rs b/crates/treehouse/src/generate.rs
index 547e77e..ec43d98 100644
--- a/crates/treehouse/src/generate.rs
+++ b/crates/treehouse/src/generate.rs
@@ -134,6 +134,7 @@ impl TreehouseDir {
             .get(path)
             .map(|&file_id| {
                 Content::new(
+                    "text/html",
                     tree::generate_or_error(&self.sources, &self.dirs, &self.handlebars, file_id)
                         .into(),
                 )
@@ -143,6 +144,7 @@ impl TreehouseDir {
                     let template_name = path.with_extension("hbs");
                     if self.handlebars.has_template(template_name.as_str()) {
                         return Some(Content::new(
+                            "text/html",
                             simple_template::generate_or_error(
                                 &self.sources,
                                 &self.handlebars,
diff --git a/crates/treehouse/src/generate/atom.rs b/crates/treehouse/src/generate/atom.rs
index 9f0d9c5..6223253 100644
--- a/crates/treehouse/src/generate/atom.rs
+++ b/crates/treehouse/src/generate/atom.rs
@@ -51,7 +51,6 @@ impl FeedDir {
     }
 
     fn content(&self, path: &VPath) -> Option<Content> {
-        info!("{path}");
         if path.extension() == Some("atom") {
             let feed_name = path.with_extension("").to_string();
             self.sources
@@ -60,6 +59,7 @@ impl FeedDir {
                 .get(&feed_name)
                 .map(|file_id| {
                     Content::new(
+                        "application/atom+xml",
                         generate_or_error(&self.sources, &self.dirs, &self.handlebars, *file_id)
                             .into(),
                     )
diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs
index bfe9b7e..e8dcde1 100644
--- a/crates/treehouse/src/main.rs
+++ b/crates/treehouse/src/main.rs
@@ -13,7 +13,7 @@ use treehouse::generate;
 use treehouse::sources::Sources;
 use treehouse::vfs::asynch::AsyncDir;
 use treehouse::vfs::{
-    AnchoredAtExt, Blake3ContentVersionCache, DynDir, ImageSizeCache, ToDynDir, VPathBuf,
+    AnchoredAtExt, Blake3ContentVersionCache, Content, DynDir, ImageSizeCache, ToDynDir, VPathBuf,
 };
 use treehouse::vfs::{Cd, PhysicalDir};
 use treehouse::{
@@ -30,7 +30,11 @@ fn vfs_sources() -> anyhow::Result<DynDir> {
 
     root.add(
         VPath::new("treehouse.toml"),
-        BufferedFile::new(fs::read("treehouse.toml")?).to_dyn(),
+        BufferedFile::new(Content::new(
+            "application/toml",
+            fs::read("treehouse.toml")?,
+        ))
+        .to_dyn(),
     );
     root.add(
         VPath::new("static"),
diff --git a/crates/treehouse/src/vfs.rs b/crates/treehouse/src/vfs.rs
index d121274..7d9c6b4 100644
--- a/crates/treehouse/src/vfs.rs
+++ b/crates/treehouse/src/vfs.rs
@@ -239,6 +239,8 @@ pub struct Entries(pub Vec<VPathBuf>);
 /// Byte content in an entry.
 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
 pub struct Content {
+    /// Media type string. <https://en.wikipedia.org/wiki/Media_type>
+    kind: String,
     bytes: Vec<u8>,
 }
 
@@ -262,8 +264,15 @@ pub struct ImageSize {
 }
 
 impl Content {
-    pub fn new(bytes: Vec<u8>) -> Self {
-        Self { bytes }
+    pub fn new(kind: impl Into<String>, bytes: Vec<u8>) -> Self {
+        Self {
+            kind: kind.into(),
+            bytes,
+        }
+    }
+
+    pub fn kind(&self) -> &str {
+        &self.kind
     }
 
     pub fn bytes(self) -> Vec<u8> {
diff --git a/crates/treehouse/src/vfs/file.rs b/crates/treehouse/src/vfs/file.rs
index 19fa3cd..c492e9d 100644
--- a/crates/treehouse/src/vfs/file.rs
+++ b/crates/treehouse/src/vfs/file.rs
@@ -3,11 +3,11 @@ use std::fmt;
 use super::{Content, Dir, Query, VPath};
 
 pub struct BufferedFile {
-    pub content: Vec<u8>,
+    pub content: Content,
 }
 
 impl BufferedFile {
-    pub fn new(content: Vec<u8>) -> Self {
+    pub fn new(content: Content) -> Self {
         Self { content }
     }
 }
@@ -15,7 +15,7 @@ impl BufferedFile {
 impl Dir for BufferedFile {
     fn query(&self, path: &VPath, query: &mut Query) {
         if path == VPath::ROOT {
-            query.provide(|| Content::new(self.content.clone()));
+            query.provide(|| self.content.clone());
         }
     }
 }
diff --git a/crates/treehouse/src/vfs/physical.rs b/crates/treehouse/src/vfs/physical.rs
index 4835f47..871a044 100644
--- a/crates/treehouse/src/vfs/physical.rs
+++ b/crates/treehouse/src/vfs/physical.rs
@@ -62,7 +62,14 @@ impl PhysicalDir {
         std::fs::read(self.root.join(physical_path(path)))
             .inspect_err(|err| error!("{self:?} cannot read file at vpath {path:?}: {err:?}",))
             .ok()
-            .map(Content::new)
+            .map(|bytes| {
+                Content::new(
+                    path.extension()
+                        .and_then(guess_content_type)
+                        .unwrap_or("text/plain"),
+                    bytes,
+                )
+            })
     }
 
     fn edit_path(&self, path: &VPath) -> EditPath {
@@ -83,3 +90,14 @@ impl Dir for PhysicalDir {
 fn physical_path(path: &VPath) -> &Path {
     Path::new(path.as_str())
 }
+
+fn guess_content_type(extension: &str) -> Option<&'static str> {
+    match extension {
+        "html" => Some("text/html"),
+        "js" => Some("text/javascript"),
+        "woff" => Some("font/woff2"),
+        "svg" => Some("image/svg+xml"),
+        "atom" => Some("application/atom+xml"),
+        _ => None,
+    }
+}
diff --git a/crates/treehouse/tests/it/vfs/cd.rs b/crates/treehouse/tests/it/vfs/cd.rs
index cad2583..3c7c0b7 100644
--- a/crates/treehouse/tests/it/vfs/cd.rs
+++ b/crates/treehouse/tests/it/vfs/cd.rs
@@ -7,9 +7,9 @@ const FWOOFEE: &[u8] = b"fwoofee -w-";
 const BOOP: &[u8] = b"boop >w<";
 
 fn vfs() -> MemDir {
-    let file1 = BufferedFile::new(HEWWO.to_vec());
-    let file2 = BufferedFile::new(FWOOFEE.to_vec());
-    let file3 = BufferedFile::new(BOOP.to_vec());
+    let file1 = BufferedFile::new(Content::new("text/plain", HEWWO.to_vec()));
+    let file2 = BufferedFile::new(Content::new("text/plain", FWOOFEE.to_vec()));
+    let file3 = BufferedFile::new(Content::new("text/plain", BOOP.to_vec()));
 
     let mut innermost = MemDir::new();
     innermost.add(VPath::new("file3.txt"), file3.to_dyn());
diff --git a/crates/treehouse/tests/it/vfs/file.rs b/crates/treehouse/tests/it/vfs/file.rs
index 077b51a..2085a7f 100644
--- a/crates/treehouse/tests/it/vfs/file.rs
+++ b/crates/treehouse/tests/it/vfs/file.rs
@@ -1,7 +1,7 @@
 use treehouse::vfs::{entries, query, BufferedFile, Content, VPath};
 
 fn vfs() -> BufferedFile {
-    BufferedFile::new(b"hewwo :3".to_vec())
+    BufferedFile::new(Content::new("text/plain", b"hewwo :3".to_vec()))
 }
 
 #[test]
diff --git a/crates/treehouse/tests/it/vfs/mount_points.rs b/crates/treehouse/tests/it/vfs/mount_points.rs
index c030784..fbd3214 100644
--- a/crates/treehouse/tests/it/vfs/mount_points.rs
+++ b/crates/treehouse/tests/it/vfs/mount_points.rs
@@ -5,9 +5,9 @@ const FWOOFEE: &[u8] = b"fwoofee -w-";
 const BOOP: &[u8] = b"boop >w<";
 
 fn vfs() -> MemDir {
-    let file1 = BufferedFile::new(HEWWO.to_vec());
-    let file2 = BufferedFile::new(FWOOFEE.to_vec());
-    let file3 = BufferedFile::new(BOOP.to_vec());
+    let file1 = BufferedFile::new(Content::new("text/plain", HEWWO.to_vec()));
+    let file2 = BufferedFile::new(Content::new("text/plain", FWOOFEE.to_vec()));
+    let file3 = BufferedFile::new(Content::new("text/plain", BOOP.to_vec()));
 
     let mut inner = MemDir::new();
     inner.add(VPath::new("file3.txt"), file3.to_dyn());