diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Revision history for jsaddle-wasm
 
+## 0.1.2.0 -- 2025-06-27
+
+ * Internally, stop using JS `eval`. This allows usage with a `Content-Security-Policy` without `unsafe-eval` (but still with `wasm-unsafe-eval`).
+
+   For the same reason, expose `eval` and `evalFile` from `Language.Javascript.JSaddle.Wasm.TH` which allow to generate corresponding Wasm JSFFI imports for statically known strings.
+
+   Useful as a replacement of JSaddle's `eval` in downstream libraries
+
 ## 0.1.1.0 -- 2025-05-01
 
  * Bug fix: make GHCJS helpers globally available.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -103,6 +103,12 @@
    runJSaddle(worker);
    ```
 
+Additionally, when other packages use the `Language.Javascript.JSaddle.Wasm.TH` module, you need to disable the `eval-via-jsffi` flag, e.g. by adding the following to your `cabal.project`:
+```cabal
+package jsaddle-wasm
+  flags: -eval-via-jsffi
+```
+
 ## Potential future work
 
  - Testing (e.g. via Selenium).
diff --git a/jsaddle-wasm.cabal b/jsaddle-wasm.cabal
--- a/jsaddle-wasm.cabal
+++ b/jsaddle-wasm.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: jsaddle-wasm
-version: 0.1.1.0
+version: 0.1.2.0
 synopsis: Run JSaddle JSM with the GHC Wasm backend
 description: Run JSaddle @JSM@ with the GHC Wasm backend.
 category: Web, Javascript
@@ -17,6 +17,14 @@
   location: https://github.com/amesgen/jsaddle-wasm
   type: git
 
+flag eval-via-jsffi
+  description:
+    Generate Wasm JSFFI imports for the TemplateHaskell utilities in
+    @Language.Javascript.JSaddle.Wasm.TH@.
+
+  default: True
+  manual: True
+
 common common
   ghc-options:
     -Wall
@@ -26,7 +34,9 @@
   default-language: GHC2021
   default-extensions:
     BlockArguments
+    LambdaCase
     OverloadedStrings
+    TemplateHaskellQuotes
 
 library js
   import: common
@@ -45,6 +55,7 @@
   hs-source-dirs: src
   exposed-modules:
     Language.Javascript.JSaddle.Wasm
+    Language.Javascript.JSaddle.Wasm.TH
 
   other-modules:
     Language.Javascript.JSaddle.Wasm.Internal
@@ -53,14 +64,22 @@
     base >=4.16 && <5,
     jsaddle ^>=0.9,
     jsaddle-wasm:js,
+    template-haskell >=2.20 && <2.24,
 
   if arch(wasm32)
     build-depends:
       aeson >=2 && <2.3,
       bytestring >=0.11 && <0.13,
       ghc-experimental ^>=0.1 || >=9.1000 && <9.1300,
+      parser-regex ^>=0.3,
       stm ^>=2.5,
 
+    other-modules:
+      Language.Javascript.JSaddle.Wasm.Internal.TH
+
     hs-source-dirs: src-wasm
+
+    if flag(eval-via-jsffi)
+      cpp-options: -DEVAL_VIA_JSFFI
   else
     hs-source-dirs: src-native
diff --git a/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
--- a/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
+++ b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE TemplateHaskell #-}
+
 module Language.Javascript.JSaddle.Wasm.Internal
   ( run,
     runWorker,
@@ -19,6 +21,7 @@
 import Language.Javascript.JSaddle.Run (runJavaScript)
 import Language.Javascript.JSaddle.Run.Files qualified as JSaddle.Files
 import Language.Javascript.JSaddle.Types (Batch, JSM, Results)
+import Language.Javascript.JSaddle.Wasm.Internal.TH qualified as JSaddle.Wasm.TH
 
 -- Note: It is also possible to implement this succinctly on top of 'runWorker'
 -- and 'jsaddleScript' (using MessageChannel), but then e.g.
@@ -37,27 +40,31 @@
 
   let jsaddleRunner :: JSVal -> JSVal -> IO ()
       jsaddleRunner processResultCallback processResultSyncCallback = do
-        s <-
-          byteStringToJSString . BLC8.toStrict . BLC8.unlines $
-            [ JSaddle.Files.initState,
-              "(async () => {",
-              "  while (true) {",
-              "    const batch = JSON.parse(await readBatch());",
-              JSaddle.Files.runBatch
-                (\r -> "processResult(JSON.stringify(" <> r <> "));")
-                (Just \r -> "JSON.parse(processResultSync(JSON.stringify(" <> r <> ")))"),
-              "  }",
-              "})();"
-            ]
+        let eval :: JSVal -> JSVal -> JSVal -> IO ()
+            eval =
+              $( do
+                   let s =
+                         BLC8.unlines $
+                           [ JSaddle.Files.initState,
+                             "var syncDepth = 0;",
+                             "(async () => {",
+                             "  while (true) {",
+                             "    const batch = JSON.parse(await $3());",
+                             JSaddle.Files.runBatch
+                               (\r -> "$1(JSON.stringify(" <> r <> "));")
+                               (Just \r -> "JSON.parse($2(JSON.stringify(" <> r <> ")))"),
+                             "  }",
+                             "})();"
+                           ]
+                   JSaddle.Wasm.TH.eval (BLC8.unpack s) (replicate 3 [t|JSVal|])
+               )
         evaluate
-          =<< js_eval_jsaddle
-            s
+          =<< eval
             processResultCallback
             processResultSyncCallback
             readBatchCallback
 
-  -- The GHCJS helpers need to be available in the global scope.
-  js_global_eval =<< byteStringToJSString (BLC8.toStrict JSaddle.Files.ghcjsHelpers)
+  $(JSaddle.Wasm.TH.eval JSaddle.Wasm.TH.patchedGhcjsHelpers [])
 
   runHelper entryPoint sendOutgoingMessage jsaddleRunner
 
@@ -113,11 +120,6 @@
 foreign import javascript "wrapper sync" mkSyncCallback :: (JSString -> IO JSString) -> IO JSVal
 
 foreign import javascript "wrapper" mkPushCallback :: IO JSString -> IO JSVal
-
-foreign import javascript unsafe "eval.call(globalThis, $1)" js_global_eval :: JSString -> IO ()
-
-foreign import javascript safe "new Function('processResult','processResultSync','readBatch',`(()=>{${$1}})()`)($2, $3, $4)"
-  js_eval_jsaddle :: JSString -> JSVal -> JSVal -> JSVal -> IO ()
 
 -- Worker
 
diff --git a/src-wasm/Language/Javascript/JSaddle/Wasm/Internal/TH.hs b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal/TH.hs
new file mode 100644
--- /dev/null
+++ b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal/TH.hs
@@ -0,0 +1,63 @@
+module Language.Javascript.JSaddle.Wasm.Internal.TH
+  ( eval,
+    patchedGhcjsHelpers,
+  )
+where
+
+import Control.Applicative (asum, many)
+import Data.ByteString.Lazy.Char8 qualified as BLC8
+import Language.Haskell.TH qualified as TH
+import Language.Haskell.TH.Syntax qualified as TH
+import Language.Javascript.JSaddle.Run.Files qualified as JSaddle.Files
+import Regex.List qualified as Re
+
+eval :: String -> [TH.Q TH.Type] -> TH.Q TH.Exp
+eval jsChunk argTys = do
+  ffiImportName <- TH.newName . show =<< TH.newName "wasm_ffi_import_eval"
+  sig <- mkSig argTys
+  let ffiImport =
+        TH.ForeignD $
+          TH.ImportF
+            TH.JavaScript
+            TH.Safe
+            jsChunk
+            ffiImportName
+            sig
+  TH.addTopDecls [ffiImport]
+  TH.varE ffiImportName
+  where
+    mkSig = \case
+      [] -> [t|IO ()|]
+      t : ts -> [t|$t -> $(mkSig ts)|]
+
+-- | The JSaddle GHCJS helpers need to be available in the global scope.
+-- Usually, this is done by evaluating them in a global scope; however, we want
+-- to avoid JS eval (due to CSP, see
+-- https://github.com/tweag/ghc-wasm-miso-examples/issues/33), so we instead use
+-- a hack, namely transforming
+--
+-- > function foo(a) {
+-- >   return a + 1;
+-- > }
+--
+-- into
+--
+-- > globalThis["foo"] = function(a) {
+-- >   return a + 1;
+-- > }
+--
+-- Of course, this only works because of the very particular structure of
+-- 'ghcjsHelpers'; but it changes very rarely (didn't change non-trivially in
+-- the last 10 years), so this seems acceptable.
+patchedGhcjsHelpers :: String
+patchedGhcjsHelpers =
+  Re.replaceAll re $ BLC8.unpack JSaddle.Files.ghcjsHelpers
+  where
+    re :: Re.RE Char String
+    re =
+      asum
+        [ f <$> (Re.list "function " *> many (Re.satisfy (/= '('))),
+          "\n};" <$ Re.list "\n}"
+        ]
+      where
+        f name = "globalThis[" <> show name <> "] = function"
diff --git a/src/Language/Javascript/JSaddle/Wasm/TH.hs b/src/Language/Javascript/JSaddle/Wasm/TH.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Javascript/JSaddle/Wasm/TH.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE CPP #-}
+
+-- | Utilities for evaluating (at runtime) JS code that is known at compile time
+-- via TemplateHaskell /without/ relying on JS @eval@.
+--
+-- For niche use cases, generating JSaddle-based 'JSaddle.eval' instead is
+-- possible by disabling the @eval-via-jsffi@ Cabal flag.
+--
+-- For convenience, on non-Wasm GHCs, the semantics of having @eval-via-jsffi@
+-- disabled are used.
+module Language.Javascript.JSaddle.Wasm.TH where
+
+import Language.Haskell.TH qualified as TH
+import Language.Javascript.JSaddle qualified as JSaddle
+#ifdef EVAL_VIA_JSFFI
+import Control.Monad.IO.Class (liftIO)
+import Language.Javascript.JSaddle.Wasm.Internal.TH qualified as Internal
+#else
+import Data.Functor (void)
+#endif
+
+-- | Generate an expression that, when called, evaluates the given chunk of JS
+-- code. Additionally, a list of argument types can be specified.
+--
+-- For example,
+--
+-- > $(eval "console.log('hi')") :: JSM ()
+--
+-- will print \"hi\" to the console when executed.
+--
+-- Internally, this generates the following code:
+--
+--  * If the Cabal flag @eval-via-jsffi@ is enabled (the default): An
+--    appropriate safe Wasm JSFFI import.
+--
+--  * If @eval-via-jsffi@ is disabled: Use JSaddle's 'JSaddle.eval'.
+eval :: String -> TH.Q TH.Exp
+#if EVAL_VIA_JSFFI
+eval chunk = [|liftIO $(Internal.eval chunk []) :: JSaddle.JSM ()|]
+#else
+eval chunk = [|void $ JSaddle.eval chunk|]
+#endif
+
+-- | Like 'eval', but read the JS code to evaluate from a file.
+evalFile :: FilePath -> TH.Q TH.Exp
+evalFile path = do
+  chunk <- TH.runIO $ readFile path
+  eval chunk
