diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt
index 3d4abf7b18..4f6b8454bf 100644
--- a/common/CMakeLists.txt
+++ b/common/CMakeLists.txt
@@ -118,6 +118,9 @@ set( KICOMMON_SRCS
     project/project_file.cpp
     project/project_local_settings.cpp
 
+    # This is basically a settings object, but for the toolbar
+    tool/ui/toolbar_configuration.cpp
+
     dialogs/dialog_migrate_settings.cpp
     dialogs/dialog_migrate_settings_base.cpp
     dialogs/dialog_rc_job.cpp
diff --git a/common/settings/settings_manager.cpp b/common/settings/settings_manager.cpp
index ece784683d..022f194850 100644
--- a/common/settings/settings_manager.cpp
+++ b/common/settings/settings_manager.cpp
@@ -822,6 +822,27 @@ wxString SETTINGS_MANAGER::GetColorSettingsPath()
 }
 
 
+wxString SETTINGS_MANAGER::GetToolbarSettingsPath()
+{
+    wxFileName path;
+
+    path.AssignDir( PATHS::GetUserSettingsPath() );
+    path.AppendDir( wxS( "toolbars" ) );
+
+    if( !path.DirExists() )
+    {
+        if( !wxMkdir( path.GetPath() ) )
+        {
+            wxLogTrace( traceSettings,
+                        wxT( "GetToolbarSettingsPath(): Path %s missing and could not be created!" ),
+                        path.GetPath() );
+        }
+    }
+
+    return path.GetPath();
+}
+
+
 std::string SETTINGS_MANAGER::GetSettingsVersion()
 {
     // CMake computes the major.minor string for us.
diff --git a/common/tool/ui/toolbar_configuration.cpp b/common/tool/ui/toolbar_configuration.cpp
index 8c57fd4aec..7883d98c7e 100644
--- a/common/tool/ui/toolbar_configuration.cpp
+++ b/common/tool/ui/toolbar_configuration.cpp
@@ -24,8 +24,126 @@
 
 #include <nlohmann/json.hpp>
 
-#include <action_toolbar.h>
-#include <tools/ui/toolbar_configuration.h>
+#include <tool/action_toolbar.h>
+#include <tool/ui/toolbar_configuration.h>
 
 ///! Update the schema version whenever a migration is required
 const int toolbarSchemaVersion = 1;
+
+
+void to_json( nlohmann::json& aJson, const TOOLBAR_CONFIGURATION& aConfig )
+{
+    nlohmann::json groups = nlohmann::json::array();
+
+    // Serialize the group object
+    for( const TOOLBAR_GROUP_CONFIG& grp : aConfig.m_toolbarGroups )
+    {
+        nlohmann::json jsGrp = {
+            { "name", grp.m_groupName }
+        };
+
+        nlohmann::json grpItems = nlohmann::json::array();
+
+        for( const auto& it : grp.m_groupItems )
+            grpItems.push_back( it );
+
+        jsGrp["items"] = grpItems;
+
+        groups.push_back( jsGrp );
+    }
+
+    // Serialize the items
+    nlohmann::json tbItems = nlohmann::json::array();
+
+    for( const auto& it : aConfig.m_toolbarItems )
+        tbItems.push_back( it );
+
+    aJson = {
+        { "groups", groups },
+        { "items",  tbItems }
+    };
+}
+
+
+void from_json( const nlohmann::json& aJson, TOOLBAR_CONFIGURATION& aConfig )
+{
+    if( aJson.empty() )
+        return;
+
+    aConfig.m_toolbarItems.clear();
+    aConfig.m_toolbarGroups.clear();
+
+    // Deserialize the groups
+    if( aJson.contains( "groups" ) && aJson.at( "groups" ).is_array())
+    {
+        for( const nlohmann::json& grp : aJson.at( "groups" ) )
+        {
+            std::string name = "";
+
+            if( grp.contains( "name" ) )
+                name = grp.at( "name" ).get<std::string>();
+
+            TOOLBAR_GROUP_CONFIG cfg( name );
+
+            // Deserialize the items
+            if( grp.contains( "items" ) )
+            {
+                for( const nlohmann::json& it : grp.at( "items" ) )
+                {
+                    if( it.is_string() )
+                        cfg.m_groupItems.push_back( it.get<std::string>() );
+                }
+            }
+            aConfig.m_toolbarGroups.push_back( cfg );
+        }
+    }
+
+    // Deserialize the items
+    if( aJson.contains( "items" ) )
+    {
+        for( const nlohmann::json& it : aJson.at( "items" ) )
+        {
+            if( it.is_string() )
+            aConfig.m_toolbarItems.push_back( it.get<std::string>() );
+        }
+    }
+}
+
+
+TOOLBAR_SETTINGS::TOOLBAR_SETTINGS( const wxString& aFullPath ) :
+        JSON_SETTINGS( aFullPath, SETTINGS_LOC::NONE, toolbarSchemaVersion )
+{
+    m_params.emplace_back( new PARAM_LAMBDA<nlohmann::json>( "toolbars",
+        [&]() -> nlohmann::json
+        {
+            // Serialize the toolbars
+            nlohmann::json js = nlohmann::json::array();
+
+            for( const auto& [name, tb] : m_Toolbars )
+            {
+                js.push_back( nlohmann::json( { { "name", name },
+                                                  { "contents", tb } } ) );
+            }
+
+            return js;
+        },
+        [&]( const nlohmann::json& aObj )
+        {
+            // Deserialize the toolbars
+            m_Toolbars.clear();
+
+            if( !aObj.is_array() )
+                return;
+
+            for( const auto& entry : aObj )
+            {
+                if( entry.empty() || !entry.is_object() )
+                    continue;
+
+                m_Toolbars.emplace(
+                    std::make_pair( entry["name"].get<std::string>(),
+                                    entry["contents"].get<TOOLBAR_CONFIGURATION>() ) );
+            }
+        },
+        nlohmann::json::array() ) );
+}
diff --git a/cvpcb/toolbars_cvpcb.cpp b/cvpcb/toolbars_cvpcb.cpp
index dd9f373952..45d6924eab 100644
--- a/cvpcb/toolbars_cvpcb.cpp
+++ b/cvpcb/toolbars_cvpcb.cpp
@@ -27,6 +27,8 @@
 #include <tools/cvpcb_actions.h>
 #include <wx/stattext.h>
 
+#include <settings/settings_manager.h>
+
 
 std::optional<TOOLBAR_CONFIGURATION> CVPCB_MAINFRAME::DefaultTopMainToolbarConfig()
 {
@@ -104,4 +106,20 @@ void CVPCB_MAINFRAME::configureToolbars()
     RegisterCustomToolbarControlFactory( "control.CVPCBFilters", _( "Footprint filters" ),
                                          _( "Footprint filtering controls" ),
                                          footprintFilterFactory );
+
+    TOOLBAR_SETTINGS tb( "cvpcb-toolbars" );
+
+    if( m_tbConfigLeft.has_value() )
+        tb.m_Toolbars.emplace( "left", m_tbConfigLeft.value() );
+
+    if( m_tbConfigRight.has_value() )
+        tb.m_Toolbars.emplace( "right", m_tbConfigRight.value() );
+
+    if( m_tbConfigTopAux.has_value() )
+        tb.m_Toolbars.emplace( "top_aux", m_tbConfigTopAux.value() );
+
+    if( m_tbConfigTopMain.has_value() )
+        tb.m_Toolbars.emplace( "top_main", m_tbConfigTopMain.value() );
+
+    tb.SaveToFile( SETTINGS_MANAGER::GetToolbarSettingsPath(), true );
 }
diff --git a/include/settings/settings_manager.h b/include/settings/settings_manager.h
index 7eb03daec3..ea15dd30da 100644
--- a/include/settings/settings_manager.h
+++ b/include/settings/settings_manager.h
@@ -378,6 +378,12 @@ public:
      */
     static wxString GetColorSettingsPath();
 
+    /**
+     * Return the path where toolbar configuration files are stored; creating it if missing
+     * (normally ./toolbars/ under the user settings path).
+     */
+    static wxString GetToolbarSettingsPath();
+
     /**
      * Parse the current KiCad build version and extracts the major and minor revision to use
      * as the name of the settings directory for this KiCad version.
diff --git a/include/tool/ui/toolbar_configuration.h b/include/tool/ui/toolbar_configuration.h
index d2eb5d3f10..91e2739577 100644
--- a/include/tool/ui/toolbar_configuration.h
+++ b/include/tool/ui/toolbar_configuration.h
@@ -29,6 +29,7 @@
 #include <vector>
 
 #include <settings/json_settings.h>
+#include <settings/parameters.h>
 #include <tool/tool_action.h>
 
 
@@ -64,7 +65,9 @@ public:
         return m_groupItems;
     }
 
-private:
+public:
+    // These are public to write the JSON, but are lower-cased to encourage people not to directly
+    // access them and treat them as private.
     std::string              m_groupName;
     std::vector<std::string> m_groupItems;
 };
@@ -135,9 +138,24 @@ public:
         m_toolbarGroups.clear();
     }
 
-private:
+public:
+    // These are public to write the JSON, but are lower-cased to encourage people not to directly
+    // access them and treat them as private.
     std::vector<std::string>            m_toolbarItems;
     std::vector<TOOLBAR_GROUP_CONFIG>   m_toolbarGroups;
 };
 
+
+class KICOMMON_API TOOLBAR_SETTINGS : public JSON_SETTINGS
+{
+public:
+    TOOLBAR_SETTINGS( const wxString& aFilename );
+
+    virtual ~TOOLBAR_SETTINGS() {}
+
+public:
+    // The toolbars
+    std::map<std::string,TOOLBAR_CONFIGURATION> m_Toolbars;
+};
+
 #endif /* TOOLBAR_CONFIGURATION_H_ */
diff --git a/pcbnew/toolbars_pcb_editor.cpp b/pcbnew/toolbars_pcb_editor.cpp
index 78fc78ca3f..72e87e6ca3 100644
--- a/pcbnew/toolbars_pcb_editor.cpp
+++ b/pcbnew/toolbars_pcb_editor.cpp
@@ -59,6 +59,8 @@
 #include <wx/wupdlock.h>
 #include <wx/combobox.h>
 
+#include <settings/settings_manager.h>
+
 #include "../scripting/python_scripting.h"
 
 
@@ -451,6 +453,22 @@ void PCB_EDIT_FRAME::configureToolbars()
     RegisterCustomToolbarControlFactory( "control.PCBPlugin", _( "IPC/Scripting plugins" ),
                                          _( "Region to hold the IPC/Scripting action buttons" ),
                                          pluginControlFactory );
+
+    TOOLBAR_SETTINGS tb( "pcbnew-toolbars" );
+
+    if( m_tbConfigLeft.has_value() )
+        tb.m_Toolbars.emplace( "left", m_tbConfigLeft.value() );
+
+    if( m_tbConfigRight.has_value() )
+        tb.m_Toolbars.emplace( "right", m_tbConfigRight.value() );
+
+    if( m_tbConfigTopAux.has_value() )
+        tb.m_Toolbars.emplace( "top_aux", m_tbConfigTopAux.value() );
+
+    if( m_tbConfigTopMain.has_value() )
+        tb.m_Toolbars.emplace( "top_main", m_tbConfigTopMain.value() );
+
+    tb.SaveToFile( SETTINGS_MANAGER::GetToolbarSettingsPath(), true );
 }