diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,6 @@
+# jsdom-extras changelog
+
+## 0.1.0.0
+
+* Initial release
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2024, Obsidian Systems LLC
+
+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 Obsidian Systems LLC 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/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# jsdom-extras
+
+Javascript interop convenience functions
+
+[![Haskell](https://img.shields.io/badge/language-Haskell-orange.svg)](https://haskell.org) [![Hackage](https://img.shields.io/hackage/v/jsdom-extras.svg)](https://hackage.haskell.org/package/jsdom-extras) [![BSD3 License](https://img.shields.io/badge/license-BSD3-blue.svg)](https://github.com/obsidiansystems/beam-automigrate/blob/master/LICENSE)
+
+
+* [JSON](src/JSDOM/Extras/JSON.hs)
+  * JSON.stringify
+  * JSON.parse
+* [console](src/JSDOM/Extras/Console.hs)
+  * console.log
+* [Object](src/JSDOM/Extras/Object.hs): Convenience functions for convertion to/from JS Objects
+  * toObject
+  * fromObject
+  * lookup
+    
diff --git a/jsdom-extras.cabal b/jsdom-extras.cabal
new file mode 100644
--- /dev/null
+++ b/jsdom-extras.cabal
@@ -0,0 +1,37 @@
+cabal-version:      >=1.10
+name:               jsdom-extras
+version:            0.1.0.0
+synopsis:           Convenience utilities for JSDOM
+description:        Commonly used javascript functions
+license:            BSD3
+license-file:       LICENSE
+author:             Obsidian Systems LLC
+maintainer:         maintainer@obsidian.systems
+copyright:          2024 Obsidian Systems LLC
+category:           Web
+build-type:         Simple
+extra-source-files:
+  CHANGELOG.md
+  README.md
+
+library
+  exposed-modules:
+    JSDOM.Extras.ConsoleLog
+    JSDOM.Extras.JSON
+    JSDOM.Extras.Object
+
+  build-depends:
+      aeson        >=2.0   && <2.3
+    , base         >=4.14  && <4.19
+    , jsaddle      >=0.9.4 && <0.10
+    , jsaddle-dom  >=0.9.4 && <0.10
+    , lens         >=5.0.0 && <5.4
+    , text         >=1.2.4 && <2.1
+
+  hs-source-dirs:   src
+  default-language: Haskell2010
+  ghc-options:      -Wall
+
+source-repository head
+  type:     git
+  location: https://github.com/obsidiansystems/jsdom-extras
diff --git a/src/JSDOM/Extras/ConsoleLog.hs b/src/JSDOM/Extras/ConsoleLog.hs
new file mode 100644
--- /dev/null
+++ b/src/JSDOM/Extras/ConsoleLog.hs
@@ -0,0 +1,21 @@
+{-|
+  Description:
+    Logging in javascript via console.log. This doesn't require a 'Show'
+    instance or any other conversion of the JSVal to be shown.
+-}
+{-# Language OverloadedStrings #-}
+module JSDOM.Extras.ConsoleLog where
+
+import Prelude hiding (log)
+
+import Control.Lens
+import Control.Monad
+import Language.Javascript.JSaddle
+
+-- | <https://developer.mozilla.org/en-US/docs/Web/API/Console/log console.log>
+consoleLog :: (MonadJSM m, ToJSVal a) => a -> m ()
+consoleLog arg = void $ liftJSM $ jsg console ^. js1 log arg
+  where
+    console, log :: JSString
+    console = "console"
+    log = "log"
diff --git a/src/JSDOM/Extras/JSON.hs b/src/JSDOM/Extras/JSON.hs
new file mode 100644
--- /dev/null
+++ b/src/JSDOM/Extras/JSON.hs
@@ -0,0 +1,39 @@
+{-|
+  Description:
+    Platform-independent JSON-to-JSVal and JSVal-to-JSON functions
+-}
+{-# Language CPP #-}
+{-# Language OverloadedStrings #-}
+module JSDOM.Extras.JSON where
+
+import Control.Lens
+import Data.Aeson
+import Language.Javascript.JSaddle
+#ifndef ghcjs_HOST_OS
+import Language.Javascript.JSaddle.Native.Internal (jsonValueToValue, valueToJSONValue)
+#endif
+
+-- | Convert a JSON 'Value' to 'JSVal'
+jsValFromJSON :: Value -> JSM JSVal
+#ifdef ghcjs_HOST_OS
+jsValFromJSON = toJSVal
+#else
+jsValFromJSON = jsonValueToValue
+#endif
+
+-- | Try to convert a 'JSVal' to a JSON 'Value'
+jsValToJSON :: JSVal -> JSM (Maybe Value)
+#ifdef ghcjs_HOST_OS
+jsValToJSON = fromJSVal
+#else
+jsValToJSON = fmap Just . valueToJSONValue
+#endif
+
+-- | <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify JSON.stringify>
+stringify :: (MonadJSM m) => JSVal -> m JSString
+stringify arg = liftJSM $ fromJSValUnchecked =<< jsg ("JSON"::JSString) ^. js1 ("stringify"::JSString) arg
+
+-- | <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse JSON.parse>
+parse :: (MonadJSM m) => JSVal -> m JSVal
+parse arg = liftJSM $ jsg ("JSON"::JSString) ^. js1 ("parse"::JSString) arg
+
diff --git a/src/JSDOM/Extras/Object.hs b/src/JSDOM/Extras/Object.hs
new file mode 100644
--- /dev/null
+++ b/src/JSDOM/Extras/Object.hs
@@ -0,0 +1,64 @@
+{-|
+  Description:
+    Constructing and manipulating javascript objects
+-}
+{-# Language ConstraintKinds #-}
+module JSDOM.Extras.Object where
+
+import Control.Monad
+import Language.Javascript.JSaddle
+
+type ToJSObject k v = (ToJSString k, ToJSVal v)
+
+-- | Turn a single key, value pair into a javascript object
+singleton
+  :: (ToJSObject a b, MonadJSM m)
+  => a
+  -> b
+  -> m Object
+singleton k v = toObject [(k, v)]
+
+-- | Turn a set of key, value pairs into a javascript object
+toObject
+  :: (MonadJSM m, ToJSObject a b)
+  => [(a, b)]
+  -> m Object
+toObject args = liftJSM $ do
+  o <- create
+  let mk (k, v) = do
+        v' <- toJSVal v
+        setProp (toJSString k) v' o
+  mapM_ mk args
+  pure o
+
+-- | Turns a javascript Object into a list of keys and values
+fromObject
+  :: MonadJSM m
+  => Object
+  -> m [(JSString, JSVal)]
+fromObject o = liftJSM $ do
+  keys <- listProps o
+  forM keys $ \k -> do
+    v <- unsafeGetProp k o
+    pure (k, v)
+
+-- | A function that does nothing
+doNothing :: JSCallAsFunction
+doNothing = fun $ \_ _ _ -> pure ()
+
+-- | Lookup a key in an object. Treats both @null@s and @undefined@s as
+-- 'Nothing'
+lookup
+  :: MonadJSM m
+  => JSString
+  -> Object
+  -> m (Maybe JSVal)
+lookup k o = liftJSM $ toMaybe =<< getProp k o
+
+-- | Wraps a javascript value in 'Maybe', treating @undefined@ and @null@ as
+-- 'Nothing'
+toMaybe :: MonadJSM m => JSVal -> m (Maybe JSVal)
+toMaybe a = liftJSM $ do
+  resultIsUndefined <- valIsUndefined a
+  resultIsNull <- valIsNull a
+  pure $ if resultIsUndefined || resultIsNull then Nothing else Just a
