diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index da3efce2..0e855320 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -1,5 +1,6 @@
 package docspell.backend.ops
 
+import cats.data.NonEmptyList
 import cats.data.OptionT
 import cats.effect.{Effect, Resource}
 import cats.implicits._
@@ -13,21 +14,38 @@ import docspell.store.queue.JobQueue
 import docspell.store.records._
 import docspell.store.{AddResult, Store}
 
-import doobie._
 import doobie.implicits._
 import org.log4s.getLogger
 
 trait OItem[F[_]] {
 
   /** Sets the given tags (removing all existing ones). */
-  def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult]
+  def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[UpdateResult]
+
+  /** Sets tags for multiple items. The tags of the items will be
+    * replaced with the given ones. Same as `setTags` but for multiple
+    * items.
+    */
+  def setTagsMultipleItems(
+      items: NonEmptyList[Ident],
+      tags: List[Ident],
+      collective: Ident
+  ): F[UpdateResult]
 
   /** Create a new tag and add it to the item. */
   def addNewTag(item: Ident, tag: RTag): F[AddResult]
 
-  /** Apply all tags to the given item. Tags must exist, but can be IDs or names. */
+  /** Apply all tags to the given item. Tags must exist, but can be IDs
+    * or names. Existing tags on the item are left unchanged.
+    */
   def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
 
+  def linkTagsMultipleItems(
+      items: NonEmptyList[Ident],
+      tags: List[String],
+      collective: Ident
+  ): F[UpdateResult]
+
   /** Toggles tags of the given item. Tags must exist, but can be IDs or names. */
   def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
 
@@ -55,7 +73,14 @@ trait OItem[F[_]] {
 
   def setName(item: Ident, name: String, collective: Ident): F[AddResult]
 
-  def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult]
+  def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] =
+    setStates(NonEmptyList.of(item), state, collective)
+
+  def setStates(
+      item: NonEmptyList[Ident],
+      state: ItemState,
+      collective: Ident
+  ): F[AddResult]
 
   def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
 
@@ -130,21 +155,30 @@ object OItem {
             item: Ident,
             tags: List[String],
             collective: Ident
+        ): F[UpdateResult] =
+          linkTagsMultipleItems(NonEmptyList.of(item), tags, collective)
+
+        def linkTagsMultipleItems(
+            items: NonEmptyList[Ident],
+            tags: List[String],
+            collective: Ident
         ): F[UpdateResult] =
           tags.distinct match {
             case Nil => UpdateResult.success.pure[F]
-            case kws =>
-              val db =
+            case ws =>
+              store.transact {
                 (for {
-                  _     <- OptionT(RItem.checkByIdAndCollective(item, collective))
-                  given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective))
-                  exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId)))
+                  itemIds <- OptionT
+                    .liftF(RItem.filterItems(items, collective))
+                    .filter(_.nonEmpty)
+                  given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
                   _ <- OptionT.liftF(
-                    RTagItem.setAllTags(item, given.map(_.tagId).diff(exist.map(_.tagId)))
+                    itemIds.traverse(item =>
+                      RTagItem.appendTags(item, given.map(_.tagId).toList)
+                    )
                   )
                 } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
-
-              store.transact(db)
+              }
           }
 
         def toggleTags(
@@ -169,20 +203,23 @@ object OItem {
               store.transact(db)
           }
 
-        def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = {
-          val db = for {
-            cid <- RItem.getCollective(item)
-            nd <-
-              if (cid.contains(collective)) RTagItem.deleteItemTags(item)
-              else 0.pure[ConnectionIO]
-            ni <-
-              if (tagIds.nonEmpty && cid.contains(collective))
-                RTagItem.insertItemTags(item, tagIds)
-              else 0.pure[ConnectionIO]
-          } yield nd + ni
+        def setTags(
+            item: Ident,
+            tagIds: List[Ident],
+            collective: Ident
+        ): F[UpdateResult] =
+          setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective)
 
-          store.transact(db).attempt.map(AddResult.fromUpdate)
-        }
+        def setTagsMultipleItems(
+            items: NonEmptyList[Ident],
+            tags: List[Ident],
+            collective: Ident
+        ): F[UpdateResult] =
+          UpdateResult.fromUpdate(store.transact(for {
+            k   <- RTagItem.deleteItemTags(items, collective)
+            res <- items.traverse(i => RTagItem.setAllTags(i, tags))
+            n = res.fold
+          } yield k + n))
 
         def addNewTag(item: Ident, tag: RTag): F[AddResult] =
           (for {
@@ -192,7 +229,7 @@ object OItem {
             _ <- addres match {
               case AddResult.Success =>
                 OptionT.liftF(
-                  store.transact(RTagItem.insertItemTags(item, List(tag.tagId)))
+                  store.transact(RTagItem.setAllTags(item, List(tag.tagId)))
                 )
               case AddResult.EntityExists(_) =>
                 OptionT.pure[F](0)
@@ -371,9 +408,13 @@ object OItem {
               onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name))
             )
 
-        def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] =
+        def setStates(
+            items: NonEmptyList[Ident],
+            state: ItemState,
+            collective: Ident
+        ): F[AddResult] =
           store
-            .transact(RItem.updateStateForCollective(item, state, collective))
+            .transact(RItem.updateStateForCollective(items, state, collective))
             .attempt
             .map(AddResult.fromUpdate)
 
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index dcc81815..9a051e7e 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1927,7 +1927,10 @@ paths:
         - Item (Multi Edit)
       summary: Add tags to multiple items
       description: |
-        Add the given tags to all given items.
+        Add the given tags to all given items. The tags that are
+        currently attached to the items are not changed. If there are
+        new tags in the given list, then they are added. Otherwise,
+        the item is left unchanged.
       security:
         - authTokenHeader: []
       requestBody:
@@ -1948,7 +1951,7 @@ paths:
       summary: Sets tags to multiple items
       description: |
         Sets the given tags to all given items. If the tag list is
-        empty, then tags are removed from the items.
+        empty, then all tags are removed from the items.
       security:
         - authTokenHeader: []
       requestBody:
diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
index 6d856522..de4dfbfb 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala
@@ -73,6 +73,7 @@ object RestServer {
       "collective"              -> CollectiveRoutes(restApp.backend, token),
       "queue"                   -> JobQueueRoutes(restApp.backend, token),
       "item"                    -> ItemRoutes(cfg, restApp.backend, token),
+      "items"                   -> ItemMultiRoutes(restApp.backend, token),
       "attachment"              -> AttachmentRoutes(restApp.backend, token),
       "upload"                  -> UploadRoutes.secured(restApp.backend, cfg, token),
       "checkfile"               -> CheckFileRoutes.secured(restApp.backend, token),
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
new file mode 100644
index 00000000..e064c18b
--- /dev/null
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala
@@ -0,0 +1,189 @@
+package docspell.restserver.routes
+
+import cats.ApplicativeError
+import cats.MonadError
+import cats.data.NonEmptyList
+import cats.effect._
+import cats.implicits._
+
+import docspell.backend.BackendApp
+import docspell.backend.auth.AuthToken
+import docspell.common.{Ident, ItemState}
+import docspell.restapi.model._
+import docspell.restserver.conv.Conversions
+
+import io.circe.DecodingFailure
+import org.http4s.HttpRoutes
+import org.http4s.circe.CirceEntityDecoder._
+import org.http4s.circe.CirceEntityEncoder._
+import org.http4s.dsl.Http4sDsl
+
+object ItemMultiRoutes {
+//  private[this] val logger = getLogger
+
+  def apply[F[_]: Effect](
+      backend: BackendApp[F],
+      user: AuthToken
+  ): HttpRoutes[F] = {
+    val dsl = new Http4sDsl[F] {}
+    import dsl._
+
+    HttpRoutes.of {
+      case req @ PUT -> Root / "confirm" =>
+        for {
+          json <- req.as[IdList]
+          data <- readIds[F](json.ids)
+          res <- backend.item.setStates(
+            data,
+            ItemState.Confirmed,
+            user.account.collective
+          )
+          resp <- Ok(Conversions.basicResult(res, "Item data confirmed"))
+        } yield resp
+
+      case req @ PUT -> Root / "unconfirm" =>
+        for {
+          json <- req.as[IdList]
+          data <- readIds[F](json.ids)
+          res <- backend.item.setStates(
+            data,
+            ItemState.Created,
+            user.account.collective
+          )
+          resp <- Ok(Conversions.basicResult(res, "Item back to created."))
+        } yield resp
+
+      case req @ PUT -> Root / "tags" =>
+        for {
+          json  <- req.as[ItemsAndRefs]
+          items <- readIds[F](json.items)
+          tags  <- json.refs.traverse(readId[F])
+          res   <- backend.item.setTagsMultipleItems(items, tags, user.account.collective)
+          resp  <- Ok(Conversions.basicResult(res, "Tags updated"))
+        } yield resp
+
+      case req @ POST -> Root / "tags" =>
+        for {
+          json  <- req.as[ItemsAndRefs]
+          items <- readIds[F](json.items)
+          res <- backend.item.linkTagsMultipleItems(
+            items,
+            json.refs,
+            user.account.collective
+          )
+          resp <- Ok(Conversions.basicResult(res, "Tags added."))
+        } yield resp
+
+      // case req @ PUT -> Root / "direction" =>
+      //   for {
+      //     dir  <- req.as[DirectionValue]
+      //     res  <- backend.item.setDirection(id, dir.direction, user.account.collective)
+      //     resp <- Ok(Conversions.basicResult(res, "Direction updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "folder" =>
+      //   for {
+      //     idref <- req.as[OptionalId]
+      //     res   <- backend.item.setFolder(id, idref.id, user.account.collective)
+      //     resp  <- Ok(Conversions.basicResult(res, "Folder updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "corrOrg" =>
+      //   for {
+      //     idref <- req.as[OptionalId]
+      //     res   <- backend.item.setCorrOrg(id, idref.id, user.account.collective)
+      //     resp  <- Ok(Conversions.basicResult(res, "Correspondent organization updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "corrPerson" =>
+      //   for {
+      //     idref <- req.as[OptionalId]
+      //     res   <- backend.item.setCorrPerson(id, idref.id, user.account.collective)
+      //     resp  <- Ok(Conversions.basicResult(res, "Correspondent person updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "concPerson" =>
+      //   for {
+      //     idref <- req.as[OptionalId]
+      //     res   <- backend.item.setConcPerson(id, idref.id, user.account.collective)
+      //     resp  <- Ok(Conversions.basicResult(res, "Concerned person updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "concEquipment" =>
+      //   for {
+      //     idref <- req.as[OptionalId]
+      //     res   <- backend.item.setConcEquip(id, idref.id, user.account.collective)
+      //     resp  <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "name" =>
+      //   for {
+      //     text <- req.as[OptionalText]
+      //     res <- backend.item.setName(
+      //       id,
+      //       text.text.notEmpty.getOrElse(""),
+      //       user.account.collective
+      //     )
+      //     resp <- Ok(Conversions.basicResult(res, "Name updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "duedate" =>
+      //   for {
+      //     date <- req.as[OptionalDate]
+      //     _    <- logger.fdebug(s"Setting item due date to ${date.date}")
+      //     res  <- backend.item.setItemDueDate(id, date.date, user.account.collective)
+      //     resp <- Ok(Conversions.basicResult(res, "Item due date updated"))
+      //   } yield resp
+
+      // case req @ PUT -> Root / "date" =>
+      //   for {
+      //     date <- req.as[OptionalDate]
+      //     _    <- logger.fdebug(s"Setting item date to ${date.date}")
+      //     res  <- backend.item.setItemDate(id, date.date, user.account.collective)
+      //     resp <- Ok(Conversions.basicResult(res, "Item date updated"))
+      //   } yield resp
+
+      // case req @ POST -> Root / "reprocess" =>
+      //   for {
+      //     data <- req.as[IdList]
+      //     ids = data.ids.flatMap(s => Ident.fromString(s).toOption)
+      //     _    <- logger.fdebug(s"Re-process item ${id.id}")
+      //     res  <- backend.item.reprocess(id, ids, user.account, true)
+      //     resp <- Ok(Conversions.basicResult(res, "Re-process task submitted."))
+      //   } yield resp
+
+      // case POST -> Root / "deleteAll" =>
+      //   for {
+      //     n <- backend.item.deleteItem(id, user.account.collective)
+      //     res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.")
+      //     resp <- Ok(res)
+      //   } yield resp
+    }
+  }
+
+  implicit final class OptionString(opt: Option[String]) {
+    def notEmpty: Option[String] =
+      opt.map(_.trim).filter(_.nonEmpty)
+  }
+
+  private def readId[F[_]](
+      id: String
+  )(implicit F: ApplicativeError[F, Throwable]): F[Ident] =
+    Ident
+      .fromString(id)
+      .fold(
+        err => F.raiseError(DecodingFailure(err, Nil)),
+        F.pure
+      )
+
+  private def readIds[F[_]](ids: List[String])(implicit
+      F: MonadError[F, Throwable]
+  ): F[NonEmptyList[Ident]] =
+    ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap {
+      case Some(nel) => nel.pure[F]
+      case None =>
+        F.raiseError(
+          DecodingFailure("Empty list found, at least one element required", Nil)
+        )
+    }
+}
diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala
index 87253163..4dec4d6c 100644
--- a/modules/store/src/main/scala/docspell/store/impl/Column.scala
+++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala
@@ -57,7 +57,12 @@ case class Column(name: String, ns: String = "", alias: String = "") {
     f ++ fr"IN (" ++ commas(values) ++ fr")"
 
   def isIn[A: Put](values: NonEmptyList[A]): Fragment =
-    isIn(values.map(a => sql"$a").toList)
+    values.tail match {
+      case Nil =>
+        is(values.head)
+      case _ =>
+        isIn(values.map(a => sql"$a").toList)
+    }
 
   def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment =
     fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")"
diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala
index a0025ddb..fddf12e3 100644
--- a/modules/store/src/main/scala/docspell/store/records/RItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala
@@ -132,7 +132,7 @@ object RItem {
     } yield n
 
   def updateStateForCollective(
-      itemId: Ident,
+      itemIds: NonEmptyList[Ident],
       itemState: ItemState,
       coll: Ident
   ): ConnectionIO[Int] =
@@ -140,7 +140,7 @@ object RItem {
       t <- currentTime
       n <- updateRow(
         table,
-        and(id.is(itemId), cid.is(coll)),
+        and(id.isIn(itemIds), cid.is(coll)),
         commas(state.setTo(itemState), updated.setTo(t))
       ).update.run
     } yield n
@@ -324,4 +324,10 @@ object RItem {
     val empty: Option[Ident] = None
     updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run
   }
+
+  def filterItemsFragment(items: NonEmptyList[Ident], coll: Ident): Fragment =
+    selectSimple(Seq(id), table, and(cid.is(coll), id.isIn(items)))
+
+  def filterItems(items: NonEmptyList[Ident], coll: Ident): ConnectionIO[Vector[Ident]] =
+    filterItemsFragment(items, coll).query[Ident].to[Vector]
 }
diff --git a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
index 706e64b4..c9aad9db 100644
--- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
@@ -30,18 +30,17 @@ object RTagItem {
   def deleteItemTags(item: Ident): ConnectionIO[Int] =
     deleteFrom(table, itemId.is(item)).update.run
 
+  def deleteItemTags(items: NonEmptyList[Ident], cid: Ident): ConnectionIO[Int] = {
+    val itemsFiltered =
+      RItem.filterItemsFragment(items, cid)
+    val sql = fr"DELETE FROM" ++ table ++ fr"WHERE" ++ itemId.isIn(itemsFiltered)
+
+    sql.update.run
+  }
+
   def deleteTag(tid: Ident): ConnectionIO[Int] =
     deleteFrom(table, tagId.is(tid)).update.run
 
-  def insertItemTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] =
-    for {
-      tagValues <- tags.toList.traverse(id =>
-        Ident.randomId[ConnectionIO].map(rid => RTagItem(rid, item, id))
-      )
-      tagFrag = tagValues.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}")
-      ins <- insertRows(table, all, tagFrag).update.run
-    } yield ins
-
   def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] =
     selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector]
 
@@ -76,4 +75,12 @@ object RTagItem {
           entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}")
         ).update.run
       } yield n
+
+  def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Int] =
+    for {
+      existing <- findByItem(item)
+      toadd = tags.toSet.diff(existing.map(_.tagId).toSet)
+      n <- setAllTags(item, toadd.toSeq)
+    } yield n
+
 }
diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index c90865ab..dc648f75 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -5,6 +5,7 @@ module Api exposing
     , addCorrPerson
     , addMember
     , addTag
+    , addTagsMultiple
     , cancelJob
     , changeFolderName
     , changePassword
@@ -88,6 +89,7 @@ module Api exposing
     , setItemNotes
     , setJobPrio
     , setTags
+    , setTagsMultiple
     , setUnconfirmed
     , startClassifier
     , startOnceNotifyDueItems
@@ -130,6 +132,7 @@ import Api.Model.ItemLightList exposing (ItemLightList)
 import Api.Model.ItemProposals exposing (ItemProposals)
 import Api.Model.ItemSearch exposing (ItemSearch)
 import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
+import Api.Model.ItemsAndRefs exposing (ItemsAndRefs)
 import Api.Model.JobPriority exposing (JobPriority)
 import Api.Model.JobQueueState exposing (JobQueueState)
 import Api.Model.MoveAttachment exposing (MoveAttachment)
@@ -1262,6 +1265,38 @@ getJobQueueStateTask flags =
 
 
 
+--- Item (Mulit Edit)
+
+
+setTagsMultiple :
+    Flags
+    -> ItemsAndRefs
+    -> (Result Http.Error BasicResult -> msg)
+    -> Cmd msg
+setTagsMultiple flags data receive =
+    Http2.authPut
+        { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags"
+        , account = getAccount flags
+        , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data)
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+addTagsMultiple :
+    Flags
+    -> ItemsAndRefs
+    -> (Result Http.Error BasicResult -> msg)
+    -> Cmd msg
+addTagsMultiple flags data receive =
+    Http2.authPost
+        { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags"
+        , account = getAccount flags
+        , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data)
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+
 --- Item
 
 
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm
index 4cb0c91a..4a483c44 100644
--- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm
@@ -40,5 +40,12 @@ multiUpdate flags ids change receive =
             Set.toList ids
     in
     case change of
+        TagChange tags ->
+            let
+                data =
+                    ItemsAndRefs items (List.map .id tags.items)
+            in
+            Api.setTagsMultiple flags data receive
+
         _ ->
             Cmd.none
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index 05a8259b..9d0884ea 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -435,7 +435,10 @@ update mId key flags settings msg model =
                                 res.change
                                 MultiUpdateResp
                     in
-                    ( { model | viewMode = SelectView svm_ }, Cmd.batch [ cmd_, upCmd ], sub_ )
+                    ( { model | viewMode = SelectView svm_ }
+                    , Cmd.batch [ cmd_, upCmd ]
+                    , sub_
+                    )
 
                 _ ->
                     noSub ( model, Cmd.none )