diff --git a/compendium_v2/publishers/survey_publisher_v2.py b/compendium_v2/publishers/survey_publisher_v2.py
new file mode 100644
index 0000000000000000000000000000000000000000..09570b376dd81976e6259bfea5666fffb2eb9c15
--- /dev/null
+++ b/compendium_v2/publishers/survey_publisher_v2.py
@@ -0,0 +1,112 @@
+from decimal import Decimal
+
+from sqlalchemy import delete, select
+
+from compendium_v2.db import db
+from compendium_v2.db.model import BudgetEntry, ChargingStructure, ECProject, FeeType, FundingSource, InstitutionURLs,\
+    NrenStaff, ParentOrganization, Policy, SubOrganization, TrafficVolume
+from compendium_v2.db.survey_model import ResponseStatus, SurveyResponse
+
+
+def map_2023(year, nren, answers):
+    for table_class in [BudgetEntry, ChargingStructure, ECProject, FundingSource, InstitutionURLs,
+                        NrenStaff, ParentOrganization, Policy, SubOrganization, TrafficVolume]:
+        db.session.execute(delete(table_class).where(table_class.year == 2023))
+
+    answers = answers["data"]
+    budget = answers.get("budget")
+    if budget:
+        db.session.add(BudgetEntry(nren_id=nren.id, nren=nren, year=year, budget=Decimal(budget)))
+
+    funding_source = answers.get("income_sources")
+    if funding_source:
+        db.session.add(FundingSource(
+            nren_id=nren.id, nren=nren, year=year,
+            client_institutions=Decimal(funding_source.get("client_institutions", 0)),
+            european_funding=Decimal(funding_source.get("european_funding", 0)),
+            gov_public_bodies=Decimal(funding_source.get("gov_public_bodies", 0)),
+            commercial=Decimal(funding_source.get("commercial", 0)),
+            other=Decimal(funding_source.get("other", 0))
+        ))
+
+    charging = answers.get("charging_mechanism")
+    if charging:
+        db.session.add(ChargingStructure(nren_id=nren.id, nren=nren, year=year, fee_type=FeeType[charging]))
+
+    staff_roles = answers.get("staff_roles", {})
+    staff_employment_type = answers.get("staff_employment_type", {})
+    if staff_roles or staff_employment_type:
+        db.session.add(NrenStaff(
+            nren_id=nren.id, nren=nren, year=year,
+            permanent_fte=Decimal(staff_employment_type.get("permanent_fte", 0)),
+            subcontracted_fte=Decimal(staff_employment_type.get("subcontracted_fte", 0)),
+            technical_fte=Decimal(staff_roles.get("technical_fte", 0)),
+            non_technical_fte=Decimal(staff_roles.get("nontechnical_fte", 0))
+        ))
+
+    parent = answers.get("parent_organization_name")
+    if parent:
+        db.session.add(ParentOrganization(nren_id=nren.id, nren=nren, year=year, organization=parent))
+
+    subs = answers.get("suborganization_details")
+    if subs:
+        for sub in subs:
+            db.session.add(SubOrganization(
+                nren_id=nren.id, nren=nren, year=year,
+                organization=sub.get("suborganization_name"),
+                role=sub.get("suborganization_role")  # TODO handle 'other' option properly
+            ))
+
+    ec_projects = answers.get("ec_project_names")
+    if ec_projects:
+        for ec_project in ec_projects:
+            db.session.add(ECProject(nren_id=nren.id, nren=nren, year=year, project=ec_project.get("ec_project_name")))
+
+    strategy = answers.get("corporate_strategy_url", "")
+    policies = answers.get("policies", {})
+    if strategy or policies:
+        db.session.add(Policy(
+            nren_id=nren.id, nren=nren, year=year,
+            strategic_plan=strategy,
+            environmental=policies.get("environmental_policy", {}).get("url", ""),
+            equal_opportunity=policies.get("equal_opportunity_policy", {}).get("url", ""),
+            connectivity=policies.get("connectivity_policy", {}).get("url", ""),
+            acceptable_use=policies.get("acceptable_use_policy", {}).get("url", ""),
+            privacy_notice=policies.get("privacy_notice", {}).get("url", ""),
+            data_protection=policies.get("data_protection_contact", {}).get("url", "")
+            # TODO gender_equality_policy missing?
+        ))
+
+    traffic_estimate = answers.get("traffic_estimate")
+    if traffic_estimate:
+        db.session.add(TrafficVolume(
+            nren_id=nren.id, nren=nren, year=year,
+            strategic_plan=strategy,
+            to_customers=Decimal(traffic_estimate.get("to_customers")),
+            from_customers=Decimal(traffic_estimate.get("from_customers")),
+            to_external=Decimal(traffic_estimate.get("to_external")),
+            from_external=Decimal(traffic_estimate.get("from_external")),
+        ))
+
+    institution_urls = answers.get("connected_sites_lists")
+    if institution_urls:
+        db.session.add(InstitutionURLs(
+            nren_id=nren.id, nren=nren, year=year,
+            urls=institution_urls,
+        ))
+
+
+def publish(year):
+    responses = db.session.scalars(
+        select(SurveyResponse).where(SurveyResponse.survey_year == year)
+                              .where(SurveyResponse.status == ResponseStatus.completed)
+    ).unique()
+
+    question_mapping = {
+        2023: map_2023
+    }
+
+    mapping_function = question_mapping[year]
+
+    for response in responses:
+        mapping_function(year, response.nren, response.answers)
diff --git a/compendium_v2/routes/survey.py b/compendium_v2/routes/survey.py
index 041e60f9c91ffc992da03a3e5e3bf3ce2ffffa60..6b4a2c5a47e51a7821be08b17e23f0f5007e77d5 100644
--- a/compendium_v2/routes/survey.py
+++ b/compendium_v2/routes/survey.py
@@ -8,6 +8,7 @@ from sqlalchemy.orm import joinedload, load_only
 from compendium_v2.db import db
 from compendium_v2.db.model import NREN, PreviewYear
 from compendium_v2.db.survey_model import Survey, SurveyResponse, SurveyStatus, RESPONSE_NOT_STARTED
+from compendium_v2.publishers.survey_publisher_v2 import publish
 from compendium_v2.routes import common
 from compendium_v2.auth.session_management import admin_required
 
@@ -195,7 +196,10 @@ def preview_survey(year) -> Any:
     if survey.status not in [SurveyStatus.closed, SurveyStatus.preview]:
         return {'message': 'Survey is not closed or in preview and can therefore not be published for preview'}, 400
 
-    # TODO call new survey_publisher with all completed responses and the year
+    if year < 2023:
+        return {'message': 'The 2023 survey is the first that can be published from this application'}, 400
+
+    publish(year)
 
     preview = db.session.scalar(select(PreviewYear).where(PreviewYear.year == year))
     if not preview:
@@ -223,7 +227,10 @@ def publish_survey(year) -> Any:
     if survey.status not in [SurveyStatus.preview, SurveyStatus.published]:
         return {'message': 'Survey is not in preview or published and can therefore not be published'}, 400
 
-    # TODO call new survey_publisher with all completed responses and the year
+    if year < 2023:
+        return {'message': 'The 2023 survey is the first that can be published from this application'}, 400
+
+    publish(year)
 
     db.session.execute(delete(PreviewYear).where(PreviewYear.year == year))