diff --git a/NEWS.md b/NEWS.md
index c67a4a98..a9c408f0 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,6 @@
 # httr2 (development version)
 
+* `curl_transform()` will now use `req_body_json_modify()` for JSON data (#258).
 * `resp_stream_is_complete()` tells you if there is still data remaining to be streamed (#559).
 * New `url_modify()`, `url_modify_query()`, and `url_modify_relative()` make it easier to modify an existing url (#464).
 * New `url_query_parse()` and `url_query_build()` allow you to parse and build a query string (#425).
diff --git a/R/curl.R b/R/curl.R
index acd4b946..962c6398 100644
--- a/R/curl.R
+++ b/R/curl.R
@@ -65,8 +65,14 @@ curl_translate <- function(cmd, simplify_headers = TRUE) {
 
   if (!identical(data$data, "")) {
     type <- type %||% "application/x-www-form-urlencoded"
-    body <- data$data
-    steps <- add_curl_step(steps, "req_body_raw", main_args = c(body, type))
+    if (type == "application/json" && idempotent_json(data$data)) {
+      json <- jsonlite::parse_json(data$data)
+      args <- list(data = I(deparse1(json)))
+      steps <- add_curl_step(steps, "req_body_json", dots = args)
+    } else {
+      body <- data$data
+      steps <- add_curl_step(steps, "req_body_raw", main_args = c(body, type))
+    }
   }
 
   steps <- add_curl_step(steps, "req_auth_basic", main_args = unname(data$auth))
@@ -148,6 +154,11 @@ curl_normalize <- function(cmd, error_call = caller_env()) {
     method <- NULL
   }
 
+  if (has_name(args, "--json")) {
+    args <- c(args, list(`--data-raw` = args[["--json"]]))
+    headers[["Content-Type"]] <- "application/json"
+  }
+
   # https://curl.se/docs/manpage.html#-d
   # --data-ascii, --data
   #   * if first element is @, treat as path to read from, stripping CRLF
@@ -208,6 +219,7 @@ curl_opts <- "Usage: curl [<url>] [-H <header> ...] [-d <data> ...] [options] [<
       --data-ascii <data>      HTTP POST ASCII data
       --data-binary <data>     HTTP POST binary data
       --data-urlencode <data>  HTTP POST data url encoded
+      --json <data>            HTTP POST JSON
   -G, --get                    Put the post data in the URL and use GET
   -I, --head                   Show document info only
   -H, --header <header>        Pass custom header(s) to server
@@ -264,11 +276,9 @@ quote_name <- function(x) {
 
 add_curl_step <- function(steps,
                           f,
-                          ...,
                           main_args = NULL,
                           dots = NULL,
                           keep_if_empty = FALSE) {
-  check_dots_empty0(...)
   args <- c(main_args, dots)
 
   if (is_empty(args) && !keep_if_empty) {
@@ -276,7 +286,7 @@ add_curl_step <- function(steps,
   }
 
   names <- quote_name(names2(args))
-  string <- vapply(args, is.character, logical(1L))
+  string <- map_lgl(args, function(x) is.character(x) && !inherits(x, "AsIs"))
   values <- unlist(args)
   values <- ifelse(string, encode_string2(values), values)
 
@@ -318,3 +328,9 @@ encode_string2 <- function(x) {
   names(out) <- names(x)
   out
 }
+
+idempotent_json <- function(old) {
+  args <- formals(req_body_json)[c("auto_unbox", "null", "digits")]
+  new <- exec(jsonlite::toJSON, jsonlite::parse_json(old), !!!args)
+  jsonlite::minify(old) == jsonlite::minify(new)
+}
diff --git a/tests/testthat/_snaps/curl.md b/tests/testthat/_snaps/curl.md
index 1c5cface..d36b55d8 100644
--- a/tests/testthat/_snaps/curl.md
+++ b/tests/testthat/_snaps/curl.md
@@ -104,6 +104,26 @@
         req_body_raw("abcdef", "text/plain") |> 
         req_perform()
 
+# can translate json
+
+    Code
+      curl_translate(
+        "curl http://example.com --data-raw '{\"a\": 1, \"b\": \"text\"}' -H Content-Type:application/json")
+    Output
+      request("http://example.com/") |> 
+        req_body_json(
+          data = list(a = 1L, b = "text"),
+        ) |> 
+        req_perform()
+    Code
+      curl_translate("curl http://example.com --json '{\"a\": 1, \"b\": \"text\"}'")
+    Output
+      request("http://example.com/") |> 
+        req_body_json(
+          data = list(a = 1L, b = "text"),
+        ) |> 
+        req_perform()
+
 # content type stays in header if no data
 
     Code
@@ -145,6 +165,8 @@
     Output
       request("http://example.com/") |> 
         req_method("PATCH") |> 
-        req_body_raw('{"data":{"x":1,"y":"a","nested":{"z":[1,2,3]}}}', "application/json") |> 
+        req_body_json(
+          data = list(data = list(x = 1L, y = "a", nested = list(z = list(1L, 2L, 3L)))),
+        ) |> 
         req_perform()
 
diff --git a/tests/testthat/test-curl.R b/tests/testthat/test-curl.R
index de170670..bd991704 100644
--- a/tests/testthat/test-curl.R
+++ b/tests/testthat/test-curl.R
@@ -142,6 +142,15 @@ test_that("can translate data", {
   })
 })
 
+test_that("can translate json", {
+  skip_if(getRversion() < "4.1")
+
+  expect_snapshot({
+    curl_translate(r"--{curl http://example.com --data-raw '{"a": 1, "b": "text"}' -H Content-Type:application/json}--")
+    curl_translate(r"--{curl http://example.com --json '{"a": 1, "b": "text"}'}--")
+  })
+})
+
 test_that("content type stays in header if no data", {
   skip_if(getRversion() < "4.1")