diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -12,15 +12,21 @@
 - Helpers for redirects, headers, status codes, and errors.
 
 ```haskell
+{-# language OverloadedStrings #-}
+
 import Network.Wai.Handler.Warp (run)
 import Web.Twain
 
 main :: IO ()
 main = do
-  run 8080
-    $ get "/" index
-    $ post "/echo/:name" echoName
-    $ notFound missing
+  run 8080 $
+    foldr ($) (notFound missing) routes
+
+routes :: [Middleware]
+routes =
+  [ get "/" index
+  , post "/echo/:name" echoName
+  ]
 
 index :: ResponderM a
 index = send $ html "Hello World!"
diff --git a/Setup.hs b/Setup.hs
deleted file mode 100644
--- a/Setup.hs
+++ /dev/null
@@ -1,2 +0,0 @@
-import Distribution.Simple
-main = defaultMain
diff --git a/src/Web/Twain.hs b/src/Web/Twain.hs
--- a/src/Web/Twain.hs
+++ b/src/Web/Twain.hs
@@ -6,15 +6,19 @@
 -- - Helpers for redirects, headers, status codes, and errors.
 --
 -- @
+-- {-# language OverloadedStrings #-}
+--
 -- import Network.Wai.Handler.Warp (run)
 -- import Web.Twain
 --
 -- main :: IO ()
 -- main = do
---   run 8080
---     $ get "/" index
---     $ post "/echo/:name" echo
---     $ notFound missing
+--   run 8080 $
+--     foldr ($)
+--       (notFound missing)
+--       [ get "/" index
+--       , post "/echo/:name" echo
+--       ]
 --
 -- index :: ResponderM a
 -- index = send $ html "Hello World!"
@@ -62,6 +66,7 @@
     html,
     json,
     xml,
+    css,
     raw,
     status,
     withHeader,
@@ -296,41 +301,35 @@
 --
 -- Sets the Content-Type and Content-Length headers.
 text :: Text -> Response
-text body =
-  let lbs = BL.fromStrict (encodeUtf8 body)
-      typ = (hContentType, "text/plain; charset=utf-8")
-      len = (hContentLength, Char8.pack (show (BL.length lbs)))
-   in raw status200 [typ, len] lbs
+text =
+   raw status200 [(hContentType, "text/plain; charset=utf-8")] . BL.fromStrict . encodeUtf8
 
 -- | Construct an HTML response.
 --
 -- Sets the Content-Type and Content-Length headers.
 html :: BL.ByteString -> Response
-html body =
-  let lbs = body
-      typ = (hContentType, "text/html; charset=utf-8")
-      len = (hContentLength, Char8.pack (show (BL.length lbs)))
-   in raw status200 [typ, len] lbs
+html =
+   raw status200 [(hContentType, "text/html; charset=utf-8")]
 
 -- | Construct a JSON response using `ToJSON`.
 --
 -- Sets the Content-Type and Content-Length headers.
 json :: ToJSON a => a -> Response
-json val =
-  let lbs = JSON.encode val
-      typ = (hContentType, "application/json; charset=utf-8")
-      len = (hContentLength, Char8.pack (show (BL.length lbs)))
-   in raw status200 [typ, len] lbs
+json =
+  raw status200 [(hContentType, "application/json; charset=utf-8")] . JSON.encode
 
+-- | Construct a CSS response.
+--
+-- Sets the Content-Type and Content-Length headers.
+css :: BL.ByteString -> Response
+css =
+  raw status200 [(hContentType, "text/css; charset=utf-8")]
+
 -- | Construct an XML response.
 --
 -- Sets the Content-Type and Content-Length headers.
 xml :: BL.ByteString -> Response
-xml body =
-  let lbs = body
-      typ = (hContentType, "application/xml; charset=utf-8")
-      len = (hContentLength, Char8.pack (show (BL.length lbs)))
-   in raw status200 [typ, len] lbs
+xml = raw status200 [(hContentType, "application/xml; charset=utf-8")]
 
 -- | Construct a raw response from a lazy `ByteString`.
 --
diff --git a/src/Web/Twain/Internal.hs b/src/Web/Twain/Internal.hs
--- a/src/Web/Twain/Internal.hs
+++ b/src/Web/Twain/Internal.hs
@@ -16,7 +16,7 @@
 import qualified Data.Vault.Lazy as V
 import Data.Word (Word64)
 import Network.HTTP.Types (Method, hCookie, mkStatus, status204, status400, status413, status500)
-import Network.HTTP2 (ErrorCodeId (..), HTTP2Error (..))
+import Network.HTTP2.Frame (ErrorCodeId (..), HTTP2Error (..))
 import Network.Wai (Application, Middleware, Request (..), lazyRequestBody, queryString, requestHeaders, requestMethod, responseLBS)
 import Network.Wai.Parse (File, ParseRequestBodyOptions, lbsBackEnd, noLimitParseRequestBodyOptions, parseRequestBodyEx)
 import Network.Wai.Request (RequestSizeException (..), requestSizeCheck)
diff --git a/src/Web/Twain/Types.hs b/src/Web/Twain/Types.hs
--- a/src/Web/Twain/Types.hs
+++ b/src/Web/Twain/Types.hs
@@ -52,11 +52,10 @@
   fmap f (ResponderM g) = ResponderM $ \r -> mapRight (\(a, b) -> (f a, b)) `fmap` g r
 
 instance Applicative ResponderM where
-  pure = return
+  pure a = ResponderM $ \r -> pure (Right (a, r))
   (<*>) = ap
 
 instance Monad ResponderM where
-  return a = ResponderM $ \r -> return (Right (a, r))
   (ResponderM act) >>= fn = ResponderM $ \r -> do
     eres <- act r
     case eres of
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/test/TwainSpec.hs b/test/TwainSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/TwainSpec.hs
@@ -0,0 +1,102 @@
+module TwainSpec where
+
+import Web.Twain
+import Control.Monad (when)
+import Control.Monad.IO.Class (liftIO)
+import Data.String (fromString)
+import Test.Hspec
+import qualified Test.Hspec.Wai as Test
+
+-- * Tests
+
+spec :: Spec
+spec = Test.with (pure app) $ do
+  describe "GET" $ do
+    describe "GET /" $ do
+      it "responds with 200 & html 'hello world'" $
+        Test.shouldRespondWith
+          (Test.get "/")
+          "Hello World!"
+            { Test.matchStatus = 200
+            , Test.matchHeaders = ["Content-Type" Test.<:> "text/html; charset=utf-8"]
+            }
+
+    describe "GET /json" $ do
+      it "responds with Content-Type application/json" $
+        Test.shouldRespondWith
+          (Test.get "/json")
+          "[true]"
+            { Test.matchStatus = 200
+            , Test.matchHeaders = ["Content-Type" Test.<:> "application/json; charset=utf-8"]
+            }
+
+    describe "GET /css" $ do
+      it "responds with Content-Type text/css" $
+        Test.shouldRespondWith
+          (Test.get "/css")
+          (fromString style)
+            { Test.matchStatus = 200
+            , Test.matchHeaders = ["Content-Type" Test.<:> "text/css; charset=utf-8"]
+            }
+
+    describe "GET /notfound" $ do
+      it "responds with 404 / 'Not found...'" $
+        Test.shouldRespondWith
+          (Test.get "/notfound")
+          "Not found..." { Test.matchStatus = 404 }
+
+  describe "POST" $ do
+    describe "POST /echo/James" $ do
+      it "Without body - responds with 200 & 'Hello, James'" $
+        Test.shouldRespondWith
+          (Test.post "/echo/James" "")
+          "Hello, James" { Test.matchStatus = 200 }
+
+      it "With body - responds with 200 & 'Hello, Jameson'" $
+        Test.shouldRespondWith
+          (Test.postHtmlForm "/echo/James" [("bodyname", "Jameson")])
+          "Hello, Jameson" { Test.matchStatus = 200 }
+
+    describe "POST /echo/" $ do
+      it "responds with 404 & 'Person not specified...' (next works correctly)" $
+        Test.shouldRespondWith
+          (Test.postHtmlForm "/echo/" [])
+          "Person not specified..." { Test.matchStatus = 404 }
+
+-- * App
+
+app :: Application
+app = foldr ($) (notFound missing) routes
+
+routes :: [Middleware]
+routes =
+  [ get "/" index
+  , get "/json" myjson
+  , get "/css" (send $ css $ fromString style)
+  , post "/echo/:name" echo
+  , post "/echo/:name" missingPerson
+  ]
+
+index :: ResponderM a
+index = send $ html "Hello World!"
+
+myjson :: ResponderM a
+myjson = send $ json [True]
+
+style :: String
+style = "body { width: 900px; margin: auto; }"
+
+echo :: ResponderM a
+echo = do
+  name <- fmap (unwords . words) $ paramMaybe "bodyname" >>= maybe (param "name") pure
+  when (null name) $ next
+  send $ html $ "Hello, " <> fromString name
+
+missing :: ResponderM a
+missing = send $ html "Not found..."
+
+missingPerson :: ResponderM a
+missingPerson =
+  send $
+    raw status404 [("Content-Type", "text/html; charset=utf-8")] $
+      "Person not specified..."
diff --git a/twain.cabal b/twain.cabal
--- a/twain.cabal
+++ b/twain.cabal
@@ -1,11 +1,11 @@
-cabal-version: 1.12
+cabal-version: 2.2
 
 -- This file has been generated from package.yaml by hpack version 0.34.4.
 --
 -- see: https://github.com/sol/hpack
 
 name:           twain
-version:        2.0.1.0
+version:        2.1.0.0
 synopsis:       Tiny web application framework for WAI.
 description:    Twain is tiny web application framework for WAI. It provides routing, parameter parsing, and an either-like monad for composing responses.
 category:       Web
@@ -14,7 +14,7 @@
 author:         Alex Mingoia
 maintainer:     alex@alexmingoia.com
 copyright:      2021 Alexander C. Mingoia
-license:        BSD3
+license:        BSD-3-Clause
 license-file:   LICENSE
 build-type:     Simple
 extra-source-files:
@@ -27,27 +27,48 @@
 
 library
   exposed-modules:
-      Web.Twain Web.Twain.Types
+    Web.Twain
+    Web.Twain.Types
   other-modules:
-      Web.Twain.Internal
+    Web.Twain.Internal
   hs-source-dirs:
-      src
+    src
   default-extensions:
-      OverloadedStrings
+    OverloadedStrings
   build-depends:
-      aeson >=1.4 && <1.7
+      aeson >=2 && <3
     , base >=4.7 && <5
-    , bytestring ==0.10.*
+    , bytestring >=0.10 && < 0.12
     , case-insensitive ==1.2.*
     , cookie >=0.4 && <0.6
     , either ==5.0.*
     , exceptions ==0.10.*
     , http-types ==0.12.*
     , http2 >=1.0.0 && <3.3.0
-    , text >=1.2.3 && <1.3
-    , time >=1.8 && <1.9.9
+    , text >=1.2.3 && <3
+    , time >=1.8 && <2
     , transformers >=0.5.6 && <0.6
     , vault ==0.3.*
     , wai ==3.2.*
     , wai-extra >=3.0 && <3.2
+  default-language: Haskell2010
+
+test-suite twain-test
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test
+  main-is: Spec.hs
+  other-modules:
+    TwainSpec
+  default-extensions:
+    OverloadedStrings
+  build-depends:
+      base
+    , hspec
+    , hspec-discover
+    , hspec-wai
+    , twain
+  ghc-options:
+    -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:
+    hspec-discover:hspec-discover
   default-language: Haskell2010
