diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2013, Chris Done
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Chris Done nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/snap-app.cabal b/snap-app.cabal
new file mode 100644
--- /dev/null
+++ b/snap-app.cabal
@@ -0,0 +1,26 @@
+name:                snap-app
+version:             0.1.0.0
+synopsis:            Simple modules for writing apps with Snap, abstracted from hpaste.
+homepage:            Simple modules for writing apps with Snap, abstracted from hpaste.
+license:             BSD3
+license-file:        LICENSE
+author:              Chris Done
+maintainer:          chrisdone@gmail.com
+category:            Web
+build-type:          Simple
+cabal-version:       >=1.8
+
+library
+  hs-source-dirs:    src
+  exposed-modules:   Snap.App, Snap.App.Types, Snap.App.Controller, Snap.App.Model
+  build-depends:     base >= 4 && <5,
+                     snap-core,
+                     network,
+                     pgsql-simple >= 0.1.1,
+                     mtl,
+                     blaze-html == 0.4.3.4,
+                     safe,
+                     text,
+                     utf8-string,
+                     bytestring,
+                     MonadCatchIO-transformers
diff --git a/src/Snap/App.hs b/src/Snap/App.hs
new file mode 100644
--- /dev/null
+++ b/src/Snap/App.hs
@@ -0,0 +1,11 @@
+module Snap.App
+  (module Snap.Core
+  ,module Snap.App.Types
+  ,module Snap.App.Controller
+  ,module Snap.App.Model)
+  where
+
+import Snap.Core
+import Snap.App.Types
+import Snap.App.Controller
+import Snap.App.Model
diff --git a/src/Snap/App/Controller.hs b/src/Snap/App/Controller.hs
new file mode 100644
--- /dev/null
+++ b/src/Snap/App/Controller.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE BangPatterns #-}
+{-# OPTIONS -Wall #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Controller routing/handling.
+
+module Snap.App.Controller
+  (runHandler
+  ,output
+  ,outputText
+  ,goHome
+  ,justOrGoHome
+  ,getInteger
+  ,getString
+  ,getStringMaybe
+  ,getPagination
+  ,getMyURI)
+  where
+
+import Snap.Core
+import Snap.App.Types
+
+import Control.Applicative
+import Control.Monad.Env
+import Control.Monad.Reader       (runReaderT)
+import Data.ByteString            (ByteString)
+import Data.ByteString.UTF8       (toString)
+import Data.Maybe
+import Network.URI
+import Data.Text.Lazy             (Text,toStrict)
+import Database.PostgreSQL.Base   (withPoolConnection)
+import Database.PostgreSQL.Simple (Pool)
+import Safe                       (readMay)
+import Text.Blaze                 (Html)
+import Text.Blaze.Renderer.Text   (renderHtml)
+
+-- | Run a controller handler.
+runHandler :: s -> c -> Pool -> Controller c s () -> Snap ()
+runHandler st conf pool ctrl = do
+  withPoolConnection pool $ \conn -> do
+    let state = ControllerState conf conn st
+    -- Default to HTML, can be overridden.
+    modifyResponse $ setContentType "text/html"
+    runReaderT (runController ctrl) state
+
+-- | Strictly renders HTML to Text before outputting it via Snap.
+--   This ensures that any lazy exceptions are caught by the Snap
+--   handler.
+output :: Html -> Controller c s ()
+output html = outputText $ renderHtml $ html
+
+-- | Strictly renders text before outputting it via Snap.
+--   This ensures that any lazy exceptions are caught by the Snap
+--   handler.
+outputText :: Text -> Controller c s ()
+outputText text = do
+  let !x = toStrict $ text
+  writeText x
+
+-- | Generic redirect to home page.
+goHome :: Controller c s ()
+goHome = redirect "/"
+
+-- | Extract a Just value or go home.
+justOrGoHome :: Maybe a -> (a -> Controller c s ()) -> Controller c s ()
+justOrGoHome x m = maybe goHome m x
+
+-- | Get integer parmater.
+getInteger :: ByteString -> Integer -> Controller c s Integer
+getInteger name def = do
+  pid <- (>>= readMay . toString) <$> getParam name
+  maybe (return def) return pid
+
+-- | Get string.
+getString :: ByteString -> String -> Controller c s String
+getString name def = do
+  pid <- (>>= return . toString) <$> getParam name
+  maybe (return def) return pid
+
+-- | Get string (maybe).
+getStringMaybe :: ByteString -> Controller c s (Maybe String)
+getStringMaybe name = do
+  pid <- (>>= return . toString) <$> getParam name
+  return pid
+
+-- | Get pagination data.
+getPagination :: AppConfig c => Controller c s Pagination
+getPagination = do
+  p <- getInteger "page" 1
+  limit <- getInteger "limit" 35
+  uri <- getMyURI
+  return Pagination { pnPage = max 1 p
+                    , pnLimit = max 1 (min 100 limit)
+                    , pnURI = uri
+                    , pnResults = 0
+                    , pnTotal = 0
+                    }
+
+getMyURI :: AppConfig c => Controller c s URI
+getMyURI = do
+  domain <- env (getConfigDomain . controllerStateConfig)
+  fmap (fromJust .
+	parseURI .
+	(("http://" ++ domain) ++) .
+	toString .
+	rqURI)
+       getRequest
diff --git a/src/Snap/App/Model.hs b/src/Snap/App/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Snap/App/Model.hs
@@ -0,0 +1,63 @@
+{-# OPTIONS -Wall #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE FlexibleContexts #-}
+
+-- | Model running.
+
+module Snap.App.Model
+  (model
+  ,query
+  ,single
+  ,singleNoParams
+  ,queryNoParams
+  ,exec
+  ,DB.Only(..))
+  where
+
+import           Control.Monad.Env                       (env)
+
+import           Control.Monad.Reader
+import           Data.String
+import           Database.PostgreSQL.Simple              (Only(..))
+import qualified Database.PostgreSQL.Simple              as DB
+import           Database.PostgreSQL.Simple.QueryParams
+import           Database.PostgreSQL.Simple.QueryResults
+import           Snap.App.Types
+
+-- | Run a model action.
+model :: AppLiftModel c s => Model c s a -> Controller c s a
+model = liftModel
+
+-- | Query with some parameters.
+query :: (QueryParams ps,QueryResults r) => [String] -> ps -> Model c s [r]
+query q ps = do
+  conn <- env modelStateConn
+  Model $ ReaderT (\_ -> DB.query conn (fromString (unlines q)) ps)
+
+-- | Query a single field from a single result.
+single :: (QueryParams ps,QueryResults (Only r)) => [String] -> ps -> Model c s (Maybe r)
+single q ps = do
+  rows <- query q ps
+  case rows of
+    [(Only r)] -> return (Just r)
+    _          -> return Nothing
+
+-- | Query a single field from a single result (no params).
+singleNoParams :: (QueryResults (Only r)) => [String] -> Model c s (Maybe r)
+singleNoParams q = do
+  rows <- queryNoParams q
+  case rows of
+    [(Only r)] -> return (Just r)
+    _          -> return Nothing
+
+-- | Query with no parameters.
+queryNoParams :: (QueryResults r) => [String] -> Model c s [r]
+queryNoParams q = do
+  conn <- env modelStateConn
+  Model $ ReaderT (\_ -> DB.query_ conn (fromString (unlines q)))
+
+-- | Execute some SQL returning the rows affected.
+exec :: (QueryParams ps) => [String] -> ps -> Model c s Integer
+exec q ps = do
+  conn <- env modelStateConn
+  Model $ ReaderT (\_ -> DB.execute conn (fromString (unlines q)) ps)
diff --git a/src/Snap/App/Types.hs b/src/Snap/App/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Snap/App/Types.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE FunctionalDependencies #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# OPTIONS -Wall #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+-- | Model-view-controller app types.
+
+module Snap.App.Types
+       (Controller(..)
+       ,Model(..)
+       ,ControllerState(..)
+       ,ModelState(..)
+       ,AppConfig(..)
+       ,AppLiftModel(..)
+       ,Pagination(..))
+       where
+
+import Control.Applicative        (Applicative,Alternative)
+import Control.Monad              (MonadPlus)
+import Control.Monad.Catch        (MonadCatchIO)
+import Control.Monad.Reader       (ReaderT,MonadReader)
+import Control.Monad.Trans        (MonadIO)
+import Database.PostgreSQL.Simple (Connection)
+import Network.URI (URI)
+import Snap.Core                  (Snap,MonadSnap)
+
+-- | The state accessible to the controller (DB/session stuff).
+data ControllerState config state = ControllerState {
+    controllerStateConfig :: config
+  , controllerStateConn   :: Connection
+  , controllerState       :: state
+  }
+
+-- | The controller monad.
+newtype Controller config state a = Controller {
+    runController :: ReaderT (ControllerState config state) Snap a
+  } deriving (Monad
+             ,Functor
+             ,Applicative
+             ,Alternative
+             ,MonadReader (ControllerState config state)
+             ,MonadSnap
+             ,MonadIO
+             ,MonadPlus
+             ,MonadCatchIO)
+
+-- | The state accessible to the model (just DB connection).
+data ModelState config state = ModelState {
+    modelStateConn   :: Connection
+  , modelStateAnns   :: state
+  , modelStateConfig :: config
+  }
+
+-- | The model monad (limited access to IO, only DB access).
+newtype Model config state a = Model {
+    runModel :: ReaderT (ModelState config state) IO a
+  } deriving (Monad,Functor,Applicative,MonadReader (ModelState config state),MonadIO)
+
+-- | Pagination data.
+data Pagination = Pagination {
+   pnPage :: Integer
+ , pnLimit :: Integer
+ , pnURI :: URI
+ , pnResults :: Integer
+ , pnTotal :: Integer
+} deriving Show
+
+class AppConfig config where
+  getConfigDomain :: config -> String
+
+class AppLiftModel c s where
+  liftModel :: Model c s a -> Controller c s a
