diff --git a/assets/src/bundles/save/artifact-form-row.ejs b/assets/src/bundles/save/artifact-form-row.ejs
new file mode 100644
--- /dev/null
+++ b/assets/src/bundles/save/artifact-form-row.ejs
@@ -0,0 +1,34 @@
+<%#
+  Copyright (C) 2021  The Software Heritage developers
+  See the AUTHORS file at the top-level directory of this distribution
+  License: GNU Affero General Public License version 3, or any later version
+  See top-level LICENSE file for more information
+%>
+
+<div class="swh-save-origin-archives-form swh-save-origin-artifact-form form-row">
+  <div class="form-group col-md-7">
+    <label for="swh-input-artifact-url-<%= formId %>">Artifact url</label>
+    <input type="text" class="form-control" id="swh-input-artifact-url-<%= formId %>" required>
+    <div class="invalid-feedback">The artifact url is mandatory</div>
+  </div>
+  <div class="form-group col-md-2">
+    <label for="swh-input-artifact-version-<%= formId %>">Artifact version</label>
+    <input type="text" class="form-control" id="swh-input-artifact-version-<%= formId %>" required>
+    <div class="invalid-feedback">The artifact version is mandatory</div>
+  </div>
+  <div class="col-md-2">
+    <% if (deletableRow) { %>
+      <label for="swh-remove-archive-artifact-<%= formId %>">&nbsp;</label>
+      <button id="swh-remove-archive-artifact-<%= formId %>" type="button"
+              class="btn btn-default btn-block" onclick="swh.save.deleteArtifactFormRow(event)">
+              <i class="mdi mdi-file-remove mdi-fw" aria-hidden="true"></i>Remove artifact
+      </button>
+    <% } else { %>
+      <label for="swh-add-archive-artifact">&nbsp;</label>
+      <button id="swh-add-archive-artifact" type="button"
+              class="btn btn-default btn-block" onclick="swh.save.addArtifactFormRow()">
+        <i class="mdi mdi-file-plus mdi-fw" aria-hidden="true"></i>Add artifact
+      </button>
+    <% } %>
+  </div>
+</div>
\ No newline at end of file
diff --git a/assets/src/bundles/save/index.js b/assets/src/bundles/save/index.js
--- a/assets/src/bundles/save/index.js
+++ b/assets/src/bundles/save/index.js
@@ -7,6 +7,7 @@
 
 import {csrfPost, handleFetchError, isGitRepoUrl, htmlAlert, removeUrlFragment} from 'utils/functions';
 import {swhSpinnerSrc} from 'utils/constants';
+import artifactFormRowTemplate from './artifact-form-row.ejs';
 
 let saveRequestsTable;
 
@@ -49,19 +50,38 @@
   // Read the actual selected value and depending on the origin type, display some extra
   // inputs or hide them. This makes the extra inputs disabled when not displayed.
   const originType = $('#swh-input-visit-type').val();
-  let cssDisplay;
-  let disabled;
+  let display = 'none';
+  let disabled = true;
 
   if (originType === 'archives') {
+    display = 'flex';
     disabled = false;
-    cssDisplay = 'flex';
-  } else {
-    cssDisplay = 'none';
-    disabled = true;
   }
-  $('#optional-origin-forms').css('display', cssDisplay);
-  $('#swh-input-artifact-url').prop('disabled', disabled);
-  $('#swh-input-artifact-version').prop('disabled', disabled);
+  $('.swh-save-origin-archives-form').css('display', display);
+  if (!disabled) {
+    // help paragraph must have block display for proper rendering
+    $('#swh-save-origin-archives-help').css('display', 'block');
+  }
+  $('.swh-save-origin-archives-form .form-control').prop('disabled', disabled);
+
+  if (originType === 'archives' && $('.swh-save-origin-archives-form').length === 1) {
+    // insert first artifact row when the archives visit type is selected for the first time
+    $('.swh-save-origin-archives-form').last().after(
+      artifactFormRowTemplate({deletableRow: false, formId: 0}));
+  }
+}
+
+export function addArtifactFormRow() {
+  $('.swh-save-origin-artifact-form').last().after(
+    artifactFormRowTemplate({
+      deletableRow: true,
+      formId: $('.swh-save-origin-artifact-form').length
+    })
+  );
+}
+
+export function deleteArtifactFormRow(event) {
+  $(event.target).closest('.swh-save-origin-artifact-form').remove();
 }
 
 const userRequestsFilterCheckbox = `
@@ -254,10 +274,16 @@
         let originUrl = $('#swh-input-origin-url').val();
 
         // read the extra inputs for the 'archives' type
-        let extraData = originType !== 'archives' ? {} : {
-          'artifact_url': $('#swh-input-artifact-url').val(),
-          'artifact_version': $('#swh-input-artifact-version').val()
-        };
+        let extraData = {};
+        if (originType === 'archives') {
+          extraData['archives_data'] = [];
+          for (let i = 0; i < $('.swh-save-origin-artifact-form').length; ++i) {
+            extraData['archives_data'].push({
+              'artifact_url': $(`#swh-input-artifact-url-${i}`).val(),
+              'artifact_version': $(`#swh-input-artifact-version-${i}`).val()
+            });
+          }
+        }
 
         originSaveRequest(originType, originUrl, extraData,
                           () => $('#swh-origin-save-request-status').html(saveRequestAcceptedAlert),
diff --git a/cypress/integration/origin-save.spec.js b/cypress/integration/origin-save.spec.js
--- a/cypress/integration/origin-save.spec.js
+++ b/cypress/integration/origin-save.spec.js
@@ -507,12 +507,12 @@
 
     for (let visitType of anonymousVisitTypes) {
       cy.get('#swh-input-visit-type').select(visitType);
-      cy.get('#optional-origin-forms').should('not.be.visible');
+      cy.get('.swh-save-origin-archives-form').should('not.be.visible');
     }
 
     // this should display more inputs with the 'archives' type
     cy.get('#swh-input-visit-type').select('archives');
-    cy.get('#optional-origin-forms').should('be.visible');
+    cy.get('.swh-save-origin-archives-form').should('be.visible');
 
   });
 
@@ -535,9 +535,9 @@
       .type(originUrl)
       .get('#swh-input-visit-type')
       .select('archives')
-      .get('#swh-input-artifact-url')
+      .get('#swh-input-artifact-url-0')
       .type(artifactUrl)
-      .get('#swh-input-artifact-version')
+      .get('#swh-input-artifact-version-0')
       .type(artifactVersion)
       .get('#swh-save-origin-form')
       .submit();
@@ -548,4 +548,81 @@
 
   });
 
+  it('should submit multiple artifacts for the archives visit type', function() {
+    let originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf';
+    let artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz';
+    let artifactVersion = '1.1.4';
+    let artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz';
+    let artifact2Version = '1.1.5';
+
+    cy.ambassadorLogin();
+    cy.visit(url);
+
+    cy.get('#swh-input-origin-url')
+      .type(originUrl)
+      .get('#swh-input-visit-type')
+      .select('archives');
+
+    // fill first artifact info
+    cy.get('#swh-input-artifact-url-0')
+      .type(artifactUrl)
+      .get('#swh-input-artifact-version-0')
+      .type(artifactVersion);
+
+    // add new artifact form row
+    cy.get('#swh-add-archive-artifact')
+      .click();
+
+    // check new row is displayed
+    cy.get('#swh-input-artifact-url-1')
+        .should('exist');
+
+    // request removal of newly added row
+    cy.get('#swh-remove-archive-artifact-1')
+      .click();
+
+    // check row has been removed
+    cy.get('#swh-input-artifact-url-1')
+      .should('not.exist');
+
+    // add new artifact form row
+    cy.get('#swh-add-archive-artifact')
+      .click();
+
+    // fill second artifact info
+    cy.get('#swh-input-artifact-url-1')
+      .type(artifact2Url)
+      .get('#swh-input-artifact-version-1')
+      .type(artifact2Version);
+
+    // setup request interceptor to check POST data and stub response
+    cy.intercept('POST', this.Urls.api_1_save_origin('archives', originUrl), (req) => {
+      expect(req.body).to.deep.equal({
+        archives_data: [
+          {artifact_url: artifactUrl, artifact_version: artifactVersion},
+          {artifact_url: artifact2Url, artifact_version: artifact2Version}
+        ]
+      });
+      req.reply(genOriginSaveResponse({
+        visitType: 'archives',
+        saveRequestStatus: 'accepted',
+        originUrl: originUrl,
+        saveRequestDate: new Date(),
+        saveTaskStatus: 'not yet scheduled',
+        visitDate: null,
+        visitStatus: null
+      }));
+    }).as('saveRequest');
+
+    // submit form
+    cy.get('#swh-save-origin-form')
+      .submit();
+
+    // submission should be successful
+    cy.wait('@saveRequest').then(() => {
+      checkAlertVisible('success', saveCodeMsg['success']);
+    });
+
+  });
+
 });
diff --git a/swh/web/common/origin_save.py b/swh/web/common/origin_save.py
--- a/swh/web/common/origin_save.py
+++ b/swh/web/common/origin_save.py
@@ -225,15 +225,11 @@
     )
 
 
-def _check_origin_exists(origin_url: Optional[str]) -> OriginExistenceCheckInfo:
-    """Ensure the origin exists, if not raise an explicit message."""
-    if not origin_url:
-        raise BadInputExc("The origin url provided must be set!")
-    metadata = origin_exists(origin_url)
+def _check_origin_exists(url: str) -> OriginExistenceCheckInfo:
+    """Ensure an URL exists, if not raise an explicit message."""
+    metadata = origin_exists(url)
     if not metadata["exists"]:
-        raise BadInputExc(
-            f"The provided origin url ({escape(origin_url)}) does not exist!"
-        )
+        raise BadInputExc(f"The provided url ({escape(url)}) does not exist!")
 
     return metadata
 
@@ -429,10 +425,6 @@
     _check_visit_type_savable(visit_type, privileged_user)
     _check_origin_url_valid(origin_url)
 
-    artifact_url = kwargs.get("artifact_url")
-    if visit_type == "archives":
-        metadata = _check_origin_exists(artifact_url)
-
     # if all checks passed so far, we can try and save the origin
     save_request_status = can_save_origin(origin_url, privileged_user)
     task = None
@@ -447,18 +439,27 @@
         }
         if visit_type == "archives":
             # extra arguments for that type are required
-            assert metadata is not None
-            task_kwargs = dict(
-                **task_kwargs,
-                artifacts=[
+            archives_data = kwargs.get("archives_data", [])
+            if not archives_data:
+                raise BadInputExc(
+                    "Artifacts data are missing for the archives visit type."
+                )
+            artifacts = []
+            for artifact in archives_data:
+                artifact_url = artifact.get("artifact_url")
+                artifact_version = artifact.get("artifact_version")
+                if not artifact_url or not artifact_version:
+                    raise BadInputExc("Missing url or version for an artifact to load.")
+                metadata = _check_origin_exists(artifact_url)
+                artifacts.append(
                     {
                         "url": artifact_url,
-                        "version": kwargs["artifact_version"],
+                        "version": artifact_version,
                         "time": metadata["last_modified"],
                         "length": metadata["content_length"],
                     }
-                ],
-            )
+                )
+            task_kwargs = dict(**task_kwargs, artifacts=artifacts, snapshot_append=True)
         sor = None
         # get list of previously sumitted save requests
         current_sors = list(
diff --git a/swh/web/templates/misc/origin-save.html b/swh/web/templates/misc/origin-save.html
--- a/swh/web/templates/misc/origin-save.html
+++ b/swh/web/templates/misc/origin-save.html
@@ -50,20 +50,16 @@
       </div>
     </div>
   </div>
-  <!-- extra inputs for the 'archives' type, hidden by default -->
-  <div id="optional-origin-forms" class="form-row" style="display: none;">
-    <div class="col-md-1">Artifact information:</div>
-    <div class="form-group col-md-6">
-      <label for="swh-input-artifact-url">Url</label>
-      <input type="text" class="form-control" id="swh-input-artifact-url" required disabled>
-      <div class="invalid-feedback">The artifact url is mandatory</div>
-    </div>
-    <div class="form-group col-md-1">
-      <label for="swh-input-artifact-version">Version</label>
-      <input type="text" class="form-control" id="swh-input-artifact-version" required disabled>
-      <div class="invalid-feedback">The artifact version is mandatory</div>
-    </div>
-  </div>
+  <!-- extra help text for the 'archives' type, hidden by default -->
+  <p id="swh-save-origin-archives-help" class="swh-save-origin-archives-form" style="display: none;">
+    The <code>archives</code> visit type enables to save multiple source code archive files (*.tar.gz, *.zip)
+    under a same software origin. <br/>
+    For each archive file to save, the source code will then be available inside a branch named
+    <code>releases/&lt;version&gt;</code> from the snapshot generated by Software Heritage.<br/>
+    All archive files previously saved under the software origin will always be available in
+    each snapshot generated by a new visit.<br>
+    Please use the form below to add such artifacts to save before submitting a request.
+  </p>
 </form>
 <div class="swh-processing-save-request text-center" style="display: none;">
   <img src="{% static 'img/swh-spinner.gif' %}">
diff --git a/swh/web/tests/api/views/test_origin_save.py b/swh/web/tests/api/views/test_origin_save.py
--- a/swh/web/tests/api/views/test_origin_save.py
+++ b/swh/web/tests/api/views/test_origin_save.py
@@ -507,7 +507,6 @@
         },
     ]
 
-    # then
     url = reverse(
         "api-1-save-origin",
         url_args={"visit_type": "archives", "origin_url": originUrl,},
@@ -517,7 +516,11 @@
         api_client,
         url,
         status_code=200,
-        data={"artifact_url": artifact_url, "artifact_version": artifact_version,},
+        data={
+            "archives_data": [
+                {"artifact_url": artifact_url, "artifact_version": artifact_version,}
+            ]
+        },
     )
 
     assert response.data["save_request_status"] == SAVE_REQUEST_ACCEPTED
@@ -525,6 +528,33 @@
     assert SaveAuthorizedOrigin.objects.get(url=originUrl)
 
 
+def test_create_save_request_archives_missing_artifacts_data(
+    api_client, origin_to_review, keycloak_oidc, mocker, requests_mock,
+):
+
+    keycloak_oidc.realm_permissions = [SWH_AMBASSADOR_PERMISSION]
+    oidc_profile = keycloak_oidc.login()
+    api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {oidc_profile['refresh_token']}")
+
+    originUrl = "https://somewhere.org/simple"
+
+    url = reverse(
+        "api-1-save-origin",
+        url_args={"visit_type": "archives", "origin_url": originUrl,},
+    )
+
+    response = check_api_post_response(api_client, url, status_code=400, data={},)
+    assert "Artifacts data are missing" in response.data["reason"]
+
+    response = check_api_post_response(
+        api_client,
+        url,
+        status_code=400,
+        data={"archives_data": [{"artifact_url": "", "arttifact_version": "1.0"}]},
+    )
+    assert "Missing url or version for an artifact to load" in response.data["reason"]
+
+
 def test_create_save_request_archives_accepted_ambassador_user(
     api_client, origin_to_review, keycloak_oidc, mocker
 ):
diff --git a/swh/web/tests/common/test_origin_save.py b/swh/web/tests/common/test_origin_save.py
--- a/swh/web/tests/common/test_origin_save.py
+++ b/swh/web/tests/common/test_origin_save.py
@@ -360,12 +360,6 @@
         _check_origin_exists(url_ko)
 
 
-@pytest.mark.parametrize("invalid_origin", [None, ""])
-def test__check_origin_invalid_input(invalid_origin):
-    with pytest.raises(BadInputExc, match="must be set"):
-        _check_origin_exists(invalid_origin)
-
-
 def test__check_origin_exists_200(requests_mock):
     url = "https://example.org/url"
     requests_mock.head(url, status_code=200)