diff --git a/bricktracker/app.py b/bricktracker/app.py
index 15cb9a3d..240bc637 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -19,6 +19,7 @@ from bricktracker.views.admin.instructions import admin_instructions_page
 from bricktracker.views.admin.owner import admin_owner_page
 from bricktracker.views.admin.retired import admin_retired_page
 from bricktracker.views.admin.status import admin_status_page
+from bricktracker.views.admin.tag import admin_tag_page
 from bricktracker.views.admin.theme import admin_theme_page
 from bricktracker.views.error import error_404
 from bricktracker.views.index import index_page
@@ -85,6 +86,7 @@ def setup_app(app: Flask) -> None:
     app.register_blueprint(admin_retired_page)
     app.register_blueprint(admin_owner_page)
     app.register_blueprint(admin_status_page)
+    app.register_blueprint(admin_tag_page)
     app.register_blueprint(admin_theme_page)
 
     # An helper to make global variables available to the
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index bb2e337b..b0d42c3b 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -6,10 +6,11 @@ from .fields import BrickRecordFields
 from .record_list import BrickRecordList
 from .set_owner import BrickSetOwner
 from .set_status import BrickSetStatus
+from .set_tag import BrickSetTag
 
 logger = logging.getLogger(__name__)
 
-T = TypeVar('T', 'BrickSetStatus', 'BrickSetOwner')
+T = TypeVar('T', BrickSetStatus, BrickSetOwner, BrickSetTag)
 
 
 # Lego sets metadata list
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 8927e717..3de9bf96 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
     from .set import BrickSet
     from .set_owner import BrickSetOwner
     from .set_status import BrickSetStatus
+    from .set_tag import BrickSetTag
     from .wish import BrickWish
 
 T = TypeVar(
@@ -17,6 +18,7 @@ T = TypeVar(
     'BrickSet',
     'BrickSetOwner',
     'BrickSetStatus',
+    'BrickSetTag',
     'BrickPart',
     'BrickMinifigure',
     'BrickWish',
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 73e9e241..16fca2f5 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -4,6 +4,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .theme_list import BrickThemeList
 
 
@@ -20,6 +22,9 @@ def reload() -> None:
         # Reload the set statuses
         BrickSetStatusList(BrickSetStatus, force=True)
 
+        # Reload the set tags
+        BrickSetTagList(BrickSetTag, force=True)
+
         # Reload retired sets
         BrickRetiredList(force=True)
 
diff --git a/bricktracker/set.py b/bricktracker/set.py
index f4bf1a26..eb2bafb5 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -13,6 +13,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .sql import BrickSQL
 if TYPE_CHECKING:
     from .socket import BrickSocket
@@ -77,6 +79,13 @@ class BrickSet(RebrickableSet):
                 owner = BrickSetOwnerList(BrickSetOwner).get(id)
                 owner.update_set_state(self, state=True)
 
+            # Save the tags
+            tags: list[str] = list(data.get('tags', []))
+
+            for id in tags:
+                tag = BrickSetTagList(BrickSetTag).get(id)
+                tag.update_set_state(self, state=True)
+
             # Commit the transaction to the database
             socket.auto_progress(
                 message='Set {set}: writing to the database'.format(
@@ -172,7 +181,8 @@ class BrickSet(RebrickableSet):
         # Load from database
         if not self.select(
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True)
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(all=True),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             raise NotFoundException(
                 'Set with ID {id} was not found in the database'.format(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index e071dc63..54a3cb8c 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -7,6 +7,8 @@ from .set_owner import BrickSetOwner
 from .set_owner_list import BrickSetOwnerList
 from .set_status import BrickSetStatus
 from .set_status_list import BrickSetStatusList
+from .set_tag import BrickSetTag
+from .set_tag_list import BrickSetTagList
 from .set import BrickSet
 
 
@@ -41,7 +43,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
         for record in self.select(
             order=self.order,
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             brickset = BrickSet(record=record)
 
@@ -78,7 +81,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
             order=order,
             limit=limit,
             owners=BrickSetOwnerList(BrickSetOwner).as_columns(),
-            statuses=BrickSetStatusList(BrickSetStatus).as_columns()
+            statuses=BrickSetStatusList(BrickSetStatus).as_columns(),
+            tags=BrickSetTagList(BrickSetTag).as_columns(),
         ):
             brickset = BrickSet(record=record)
 
diff --git a/bricktracker/set_tag.py b/bricktracker/set_tag.py
new file mode 100644
index 00000000..6d81c18c
--- /dev/null
+++ b/bricktracker/set_tag.py
@@ -0,0 +1,16 @@
+from .metadata import BrickMetadata
+
+
+# Lego set tag metadata
+class BrickSetTag(BrickMetadata):
+    kind: str = 'tag'
+
+    # Set state endpoint
+    set_state_endpoint: str = 'set.update_tag'
+
+    # Queries
+    delete_query: str = 'set/metadata/tag/delete'
+    insert_query: str = 'set/metadata/tag/insert'
+    select_query: str = 'set/metadata/tag/select'
+    update_field_query: str = 'set/metadata/tag/update/field'
+    update_set_state_query: str = 'set/metadata/tag/update/state'
diff --git a/bricktracker/set_tag_list.py b/bricktracker/set_tag_list.py
new file mode 100644
index 00000000..92806f22
--- /dev/null
+++ b/bricktracker/set_tag_list.py
@@ -0,0 +1,17 @@
+import logging
+
+from .metadata_list import BrickMetadataList
+from .set_tag import BrickSetTag
+
+logger = logging.getLogger(__name__)
+
+
+# Lego sets tag list
+class BrickSetTagList(BrickMetadataList[BrickSetTag]):
+    kind: str = 'set tags'
+
+    # Database table
+    table: str = 'bricktracker_set_tags'
+
+    # Queries
+    select_query = 'set/metadata/tag/list'
diff --git a/bricktracker/sql/migrations/0014.sql b/bricktracker/sql/migrations/0014.sql
new file mode 100644
index 00000000..37c655ed
--- /dev/null
+++ b/bricktracker/sql/migrations/0014.sql
@@ -0,0 +1,19 @@
+-- description: Add set tags
+
+BEGIN TRANSACTION;
+
+-- Create a table to define each set tags: an id and a name
+CREATE TABLE "bricktracker_metadata_tags" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    PRIMARY KEY("id")
+);
+
+-- Create a table for the set tags
+CREATE TABLE "bricktracker_set_tags" (
+    "id" TEXT NOT NULL,
+    PRIMARY KEY("id"),
+    FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
+);
+
+COMMIT;
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index abc85229..1bab7d65 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,12 +1,14 @@
 BEGIN transaction;
 
 DROP TABLE IF EXISTS "bricktracker_metadata_statuses";
+DROP TABLE IF EXISTS "bricktracker_metadata_tags";
 DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_parts";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
 DROP TABLE IF EXISTS "bricktracker_set_storages";
+DROP TABLE IF EXISTS "bricktracker_set_tags";
 DROP TABLE IF EXISTS "bricktracker_wishes";
 DROP TABLE IF EXISTS "inventory";
 DROP TABLE IF EXISTS "inventory_old";
diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql
index 940dab9f..ffefe956 100644
--- a/bricktracker/sql/set/base/base.sql
+++ b/bricktracker/sql/set/base/base.sql
@@ -12,6 +12,9 @@ SELECT
     {% block owners %}
         {% if owners %}{{ owners }},{% endif %}
     {% endblock %}
+    {% block tags %}
+        {% if tags %}{{ tags }},{% endif %}
+    {% endblock %}
     {% block statuses %}
         {% if statuses %}{{ statuses }},{% endif %}
     {% endblock %}
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index 725b56dc..271f8909 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -23,6 +23,11 @@ LEFT JOIN "bricktracker_set_statuses"
 ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
 {% endif %}
 
+{% if tags %}
+LEFT JOIN "bricktracker_set_tags"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
+{% endif %}
+
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index 2db140de..4eca8456 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -12,6 +12,9 @@ WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
 DELETE FROM "bricktracker_set_statuses"
 WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
 
+DELETE FROM "bricktracker_set_tags"
+WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+
 DELETE FROM "bricktracker_minifigures"
 WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
 
diff --git a/bricktracker/sql/set/metadata/tag/base.sql b/bricktracker/sql/set/metadata/tag/base.sql
new file mode 100644
index 00000000..3ec57259
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/base.sql
@@ -0,0 +1,6 @@
+SELECT
+    "bricktracker_metadata_tags"."id",
+    "bricktracker_metadata_tags"."name"
+FROM "bricktracker_metadata_tags"
+
+{% block where %}{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/delete.sql b/bricktracker/sql/set/metadata/tag/delete.sql
new file mode 100644
index 00000000..a80bb8f2
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/delete.sql
@@ -0,0 +1,9 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_tags"
+DROP COLUMN "tag_{{ id }}";
+
+DELETE FROM "bricktracker_metadata_tags"
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/insert.sql b/bricktracker/sql/set/metadata/tag/insert.sql
new file mode 100644
index 00000000..7a62866b
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/insert.sql
@@ -0,0 +1,14 @@
+BEGIN TRANSACTION;
+
+ALTER TABLE "bricktracker_set_tags"
+ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
+INSERT INTO "bricktracker_metadata_tags" (
+    "id",
+    "name"
+) VALUES (
+    '{{ id }}',
+    '{{ name }}'
+);
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/list.sql b/bricktracker/sql/set/metadata/tag/list.sql
new file mode 100644
index 00000000..fe44b5fc
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/list.sql
@@ -0,0 +1 @@
+{% extends 'set/metadata/tag/base.sql' %}
diff --git a/bricktracker/sql/set/metadata/tag/select.sql b/bricktracker/sql/set/metadata/tag/select.sql
new file mode 100644
index 00000000..2d52076d
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/select.sql
@@ -0,0 +1,5 @@
+{% extends 'set/metadata/tag/base.sql' %}
+
+{% block where %}
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id
+{% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/tag/update/field.sql b/bricktracker/sql/set/metadata/tag/update/field.sql
new file mode 100644
index 00000000..629a9e8a
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/update/field.sql
@@ -0,0 +1,3 @@
+UPDATE "bricktracker_metadata_tags"
+SET "{{field}}" = :value
+WHERE "bricktracker_metadata_tags"."id" IS NOT DISTINCT FROM :id
diff --git a/bricktracker/sql/set/metadata/tag/update/state.sql b/bricktracker/sql/set/metadata/tag/update/state.sql
new file mode 100644
index 00000000..18de40a7
--- /dev/null
+++ b/bricktracker/sql/set/metadata/tag/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_set_tags" (
+    "id",
+    "{{name}}"
+) VALUES (
+    :set_id,
+    :state
+)
+ON CONFLICT("id")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM :set_id
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 30c83d6d..74c18cc8 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -9,6 +9,7 @@ ALIASES: dict[str, Tuple[str, str]] = {
     'bricktracker_set_owners': ('Bricktracker set owners', 'checkbox-line'),  # noqa: E501
     'bricktracker_set_statuses': ('Bricktracker set statuses', 'user-line'),  # noqa: E501
     'bricktracker_set_storages': ('Bricktracker set storages', 'archive-2-line'),  # noqa: E501
+    'bricktracker_set_tags': ('Bricktracker set tags', 'price-tag-2-line'),  # noqa: E501
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
diff --git a/bricktracker/version.py b/bricktracker/version.py
index 996b4f61..767fad59 100644
--- a/bricktracker/version.py
+++ b/bricktracker/version.py
@@ -1,4 +1,4 @@
 from typing import Final
 
 __version__: Final[str] = '1.2.0'
-__database_version__: Final[int] = 13
+__database_version__: Final[int] = 14
diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py
index 20607dcf..90729733 100644
--- a/bricktracker/views/add.py
+++ b/bricktracker/views/add.py
@@ -5,6 +5,8 @@ from ..configuration_list import BrickConfigurationList
 from .exceptions import exception_handler
 from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
 add_page = Blueprint('add', __name__, url_prefix='/add')
@@ -20,6 +22,7 @@ def add() -> str:
     return render_template(
         'add.html',
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES
@@ -36,6 +39,7 @@ def bulk() -> str:
     return render_template(
         'add.html',
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         path=current_app.config['SOCKET_PATH'],
         namespace=current_app.config['SOCKET_NAMESPACE'],
         messages=MESSAGES,
diff --git a/bricktracker/views/admin/admin.py b/bricktracker/views/admin/admin.py
index 36037d36..415cf487 100644
--- a/bricktracker/views/admin/admin.py
+++ b/bricktracker/views/admin/admin.py
@@ -12,6 +12,8 @@ from ...set_owner import BrickSetOwner
 from ...set_owner_list import BrickSetOwnerList
 from ...set_status import BrickSetStatus
 from ...set_status_list import BrickSetStatusList
+from ...set_tag import BrickSetTag
+from ...set_tag_list import BrickSetTagList
 from ...sql_counter import BrickCounter
 from ...sql import BrickSQL
 from ...theme_list import BrickThemeList
@@ -32,6 +34,7 @@ def admin() -> str:
     database_version: int = -1
     metadata_owners: list[BrickSetOwner] = []
     metadata_statuses: list[BrickSetStatus] = []
+    metadata_tags: list[BrickSetTag] = []
     nil_minifigure_name: str = ''
     nil_minifigure_url: str = ''
     nil_part_name: str = ''
@@ -46,6 +49,7 @@ def admin() -> str:
 
         metadata_owners = BrickSetOwnerList(BrickSetOwner).list()
         metadata_statuses = BrickSetStatusList(BrickSetStatus).list(all=True)
+        metadata_tags = BrickSetTagList(BrickSetTag).list()
     except Exception as e:
         database_exception = e
 
@@ -72,6 +76,7 @@ def admin() -> str:
     open_owner = request.args.get('open_owner', None)
     open_retired = request.args.get('open_retired', None)
     open_status = request.args.get('open_status', None)
+    open_tag = request.args.get('open_tag', None)
     open_theme = request.args.get('open_theme', None)
 
     open_database = (
@@ -81,6 +86,7 @@ def admin() -> str:
         open_owner is None and
         open_retired is None and
         open_status is None and
+        open_tag is None and
         open_theme is None
     )
 
@@ -95,20 +101,23 @@ def admin() -> str:
         instructions=BrickInstructionsList(),
         metadata_owners=metadata_owners,
         metadata_statuses=metadata_statuses,
+        metadata_tags=metadata_tags,
         nil_minifigure_name=nil_minifigure_name,
         nil_minifigure_url=nil_minifigure_url,
         nil_part_name=nil_part_name,
         nil_part_url=nil_part_url,
-        open_status=open_status,
         open_database=open_database,
         open_image=open_image,
         open_instructions=open_instructions,
         open_logout=open_logout,
         open_owner=open_owner,
         open_retired=open_retired,
+        open_status=open_status,
+        open_tag=open_tag,
         open_theme=open_theme,
         owner_error=request.args.get('owner_error'),
-        status_error=request.args.get('status_error'),
         retired=BrickRetiredList(),
+        status_error=request.args.get('status_error'),
+        tag_error=request.args.get('tag_error'),
         theme=BrickThemeList(),
     )
diff --git a/bricktracker/views/admin/tag.py b/bricktracker/views/admin/tag.py
new file mode 100644
index 00000000..d31bc49c
--- /dev/null
+++ b/bricktracker/views/admin/tag.py
@@ -0,0 +1,84 @@
+from flask import (
+    Blueprint,
+    redirect,
+    request,
+    render_template,
+    url_for,
+)
+from flask_login import login_required
+from werkzeug.wrappers.response import Response
+
+from ..exceptions import exception_handler
+from ...reload import reload
+from ...set_tag import BrickSetTag
+
+admin_tag_page = Blueprint(
+    'admin_tag',
+    __name__,
+    url_prefix='/admin/tag'
+)
+
+
+# Add a metadata tag
+@admin_tag_page.route('/add', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='tag_error',
+    open_tag=True
+)
+def add() -> Response:
+    BrickSetTag().from_form(request.form).insert()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
+
+
+# Delete the metadata tag
+@admin_tag_page.route('<id>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, id: str) -> str:
+    return render_template(
+        'admin.html',
+        delete_tag=True,
+        tag=BrickSetTag().select_specific(id),
+        error=request.args.get('tag_error')
+    )
+
+
+# Actually delete the metadata tag
+@admin_tag_page.route('<id>/delete', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin_tag.delete',
+    error_name='tag_error'
+)
+def do_delete(*, id: str) -> Response:
+    tag = BrickSetTag().select_specific(id)
+    tag.delete()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
+
+
+# Rename the metadata tag
+@admin_tag_page.route('<id>/rename', methods=['POST'])
+@login_required
+@exception_handler(
+    __file__,
+    post_redirect='admin.admin',
+    error_name='tag_error',
+    open_tag=True
+)
+def rename(*, id: str) -> Response:
+    tag = BrickSetTag().select_specific(id)
+    tag.from_form(request.form).rename()
+
+    reload()
+
+    return redirect(url_for('admin.admin', open_tag=True))
diff --git a/bricktracker/views/index.py b/bricktracker/views/index.py
index 3d8a55ec..1cbcd564 100644
--- a/bricktracker/views/index.py
+++ b/bricktracker/views/index.py
@@ -6,6 +6,8 @@ from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..set_list import BrickSetList
 
 index_page = Blueprint('index', __name__)
@@ -20,5 +22,6 @@ def index() -> str:
         brickset_collection=BrickSetList().last(),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
         minifigure_collection=BrickMinifigureList().last(),
     )
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index fd922ec4..344b0e60 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -16,11 +16,13 @@ from .exceptions import exception_handler
 from ..minifigure import BrickMinifigure
 from ..part import BrickPart
 from ..set import BrickSet
+from ..set_list import BrickSetList
 from ..set_owner import BrickSetOwner
 from ..set_owner_list import BrickSetOwnerList
 from ..set_status import BrickSetStatus
 from ..set_status_list import BrickSetStatusList
-from ..set_list import BrickSetList
+from ..set_tag import BrickSetTag
+from ..set_tag_list import BrickSetTagList
 from ..socket import MESSAGES
 
 logger = logging.getLogger(__name__)
@@ -37,6 +39,7 @@ def list() -> str:
         collection=BrickSetList().all(),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
     )
 
 
@@ -66,6 +69,19 @@ def update_status(*, id: str, metadata_id: str) -> Response:
     return jsonify({'value': state})
 
 
+# Change the state of a tag
+@set_page.route('/<id>/tag/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_tag(*, id: str, metadata_id: str) -> Response:
+    brickset = BrickSet().select_light(id)
+    tag = BrickSetTagList(BrickSetTag).get(metadata_id)
+
+    state = tag.update_set_state(brickset, json=request.json)
+
+    return jsonify({'value': state})
+
+
 # Ask for deletion of a set
 @set_page.route('/<id>/delete', methods=['GET'])
 @login_required
@@ -116,6 +132,7 @@ def details(*, id: str) -> str:
         open_instructions=request.args.get('open_instructions'),
         brickset_owners=BrickSetOwnerList(BrickSetOwner).list(),
         brickset_statuses=BrickSetStatusList(BrickSetStatus).list(all=True),
+        brickset_tags=BrickSetTagList(BrickSetTag).list(),
     )
 
 
diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js
index 08e70dd8..a7e660b4 100644
--- a/static/scripts/socket/set.js
+++ b/static/scripts/socket/set.js
@@ -16,6 +16,7 @@ class BrickSetSocket extends BrickSocket {
         this.html_input = document.getElementById(`${id}-set`);
         this.html_no_confim = document.getElementById(`${id}-no-confirm`);
         this.html_owners = document.getElementById(`${id}-owners`);
+        this.html_tags = document.getElementById(`${id}-tags`);
 
         // Card elements
         this.html_card = document.getElementById(`${id}-card`);
@@ -150,11 +151,22 @@ class BrickSetSocket extends BrickSocket {
                 });
             }
 
+            // Grab the tags
+            const tags = [];
+            if (this.html_tags) {
+                this.html_tags.querySelectorAll('input').forEach(input => {
+                    if (input.checked) {
+                        tags.push(input.value);
+                    }
+                });
+            }
+
             this.spinner(true);
 
             this.socket.emit(this.messages.IMPORT_SET, {
                 set: (set !== undefined) ? set : this.html_input.value,
                 owners: owners,
+                tags: tags,
                 refresh: this.refresh
             });
         } else {
@@ -267,6 +279,10 @@ class BrickSetSocket extends BrickSocket {
             this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
         }
 
+        if (this.html_tags) {
+            this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled);
+        }
+
         if (this.html_card_confirm) {
             this.html_card_confirm.disabled = !enabled;
         }
diff --git a/templates/add.html b/templates/add.html
index 59c50299..3a0b7840 100644
--- a/templates/add.html
+++ b/templates/add.html
@@ -46,6 +46,19 @@
             {% endfor %}
             </div>
           {% endif %}
+          {% if brickset_tags | length %}
+            <h5 class="border-bottom mt-2">Tags</h5>
+            <div id="add-tags">
+            {% for tag in brickset_tags %}
+              {% with id=tag.as_dataset() %}
+                <div class="form-check">
+                  <input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
+                  <label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
+                </div>
+              {% endwith %}
+            {% endfor %}
+            </div>
+          {% endif %}
           <hr>
           <div class="mb-3">
             <p>
diff --git a/templates/admin.html b/templates/admin.html
index 962730bc..064526d8 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -18,6 +18,8 @@
             {% include 'admin/owner/delete.html' %}
             {% elif delete_status %}
             {% include 'admin/status/delete.html' %}
+            {% elif delete_tag %}
+            {% include 'admin/tag/delete.html' %}
             {% elif drop_database %}
             {% include 'admin/database/drop.html' %}
             {% elif import_database %}
@@ -34,6 +36,7 @@
               {% include 'admin/retired.html' %}
               {% include 'admin/owner.html' %}
               {% include 'admin/status.html' %}
+              {% include 'admin/tag.html' %}
               {% include 'admin/database.html' %}
               {% include 'admin/configuration.html' %}
             {% endif %}
diff --git a/templates/admin/tag.html b/templates/admin/tag.html
new file mode 100644
index 00000000..7c67a56a
--- /dev/null
+++ b/templates/admin/tag.html
@@ -0,0 +1,42 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set tags', 'tag', 'admin', expanded=open_tag, icon='price-tag-2-line', class='p-0') }}
+{% if tag_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ tag_error }}.</div>{% endif %}
+<ul class="list-group list-group-flush">
+  {% if metadata_tags | length %}
+    {% for tag in metadata_tags %}
+      <li class="list-group-item">
+        <form action="{{ url_for('admin_tag.rename', id=tag.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+          <div class="col-12 flex-grow-1">
+            <label class="visually-hidden" for="name-{{ tag.fields.id }}">Name</label>
+            <div class="input-group">
+              <div class="input-group-text">Name</div>
+              <input type="text" class="form-control" id="name-{{ tag.fields.id }}" name="name" value="{{ tag.fields.name }}">
+              <button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
+            </div>
+          </div>
+          <div class="col-12">
+            <a href="{{ url_for('admin_tag.delete', id=tag.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
+          </div>
+        </form>
+      </li>
+    {% endfor %}
+  {% else %}
+    <li class="list-group-item"><i class="ri-error-warning-line"></i> No tag found.</li>
+  {% endif %}
+  <li class="list-group-item">
+    <form action="{{ url_for('admin_tag.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
+      <div class="col-12 flex-grow-1">
+        <label class="visually-hidden" for="name">Name</label>
+        <div class="input-group">
+          <div class="input-group-text">Name</div>
+          <input type="text" class="form-control" id="name" name="name" value="">
+        </div>
+      </div>
+      <div class="col-12">
+        <button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
+      </div>
+    </form>
+  </li>
+</ul>
+{{ accordion.footer() }}
diff --git a/templates/admin/tag/delete.html b/templates/admin/tag/delete.html
new file mode 100644
index 00000000..69dd3342
--- /dev/null
+++ b/templates/admin/tag/delete.html
@@ -0,0 +1,19 @@
+{% import 'macro/accordion.html' as accordion %}
+
+{{ accordion.header('Set tags danger zone', 'tag-danger', 'admin', expanded=true, danger=true, class='text-end') }}
+<form action="{{ url_for('admin_tag.do_delete', id=tag.fields.id) }}" method="post">
+  {% if tag_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ tag_error }}.</div>{% endif %}
+  <div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a set tag</strong>. This action is irreversible.</div>
+  <div class="row row-cols-lg-auto g-3 align-items-center">
+    <div class="col-12 flex-grow-1">
+      <div class="input-group">
+        <div class="input-group-text">Name</div>
+        <input type="text" class="form-control" value="{{ tag.fields.name }}" disabled>
+      </div>
+    </div>
+  </div>
+  <hr class="border-bottom">
+  <a class="btn btn-danger" href="{{ url_for('admin.admin', open_tag=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
+  <button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the set tag</strong></button>
+</form>
+{{ accordion.footer() }}
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index 2be95ca8..ca577693 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -51,7 +51,7 @@
 {% endmacro %}
 
 {% macro owner(item, owner, solo=false, last=false) %}
-  {% if last  %}
+  {% if last %}
     {% set tooltip=owner.fields.name %}
   {% else %}
     {% set text=owner.fields.name %}
@@ -72,8 +72,17 @@
   {{ badge(check=set, url=url, solo=solo, last=last, color='secondary', icon='hashtag', collapsible='Set:', text=set, alt='Set') }}
 {% endmacro %}
 
+{% macro tag(item, tag, solo=false, last=false) %}
+  {% if last %}
+    {% set tooltip=tag.fields.name %}
+  {% else %}
+    {% set text=tag.fields.name %}
+  {% endif %}
+  {{ badge(check=item.fields[tag.as_column()], solo=solo, last=last, color='light text-primary-emphasis bg-primary-subtle border border-primary-subtle', icon='price-tag-2-line', text=text, alt='Tag', tooltip=tooltip) }}
+{% endmacro %}
+
 {% macro theme(theme, solo=false, last=false) %}
-  {% if last  %}
+  {% if last %}
     {% set tooltip=theme %}
   {% else %}
     {% set text=theme %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 9f2f83a7..bc302e36 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -12,7 +12,13 @@
     {% for owner in brickset_owners %}
       {% with checked=item.fields[owner.as_column()] %}
         data-{{ owner.as_dataset() }}="{{ checked }}"
-        {% if checked %} data-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+        {% if checked %} data-search-owner-{{ loop.index }}="{{ owner.fields.name | lower }}"{% endif %}
+      {% endwith %}
+    {% endfor %}
+    {% for tag in brickset_tags %}
+      {% with checked=item.fields[tag.as_column()] %}
+        data-{{ tag.as_dataset() }}="{{ checked }}"
+        {% if checked %} data-search-tag-{{ loop.index }}="{{ tag.fields.name | lower }}"{% endif %}
       {% endwith %}
     {% endfor %}
   {% endif %}
@@ -21,6 +27,9 @@
   {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
   <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
     {{ badge.theme(item.theme.name, solo=solo, last=last) }}
+    {% for tag in brickset_tags %}
+      {{ badge.tag(item, tag, solo=solo, last=last) }}
+    {% endfor %}
     {{ badge.year(item.fields.year, solo=solo, last=last) }}
     {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
     {{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
diff --git a/templates/set/management.html b/templates/set/management.html
index 957db816..b1b20194 100644
--- a/templates/set/management.html
+++ b/templates/set/management.html
@@ -1,20 +1,34 @@
 {% if g.login.is_authenticated() %}
-{{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
-  <ul class="list-group list-group-flush">
-  {% if brickset_owners | length %}
-    {% for owner in brickset_owners %}
-      <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
-    {% endfor %}
-  {% else %}
-    <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
-    {% endif %}
-  </ul>
-  <div class="list-group list-group-flush border-top">
-    <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
-  </div>
-{{ accordion.footer() }}
-{{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
-  <h5 class="border-bottom">Data</h5>
-  <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
-{{ accordion.footer() }}
+  {{ accordion.header('Owners', 'owner', 'set-details', icon='group-line', class='p-0') }}
+    <ul class="list-group list-group-flush">
+    {% if brickset_owners | length %}
+      {% for owner in brickset_owners %}
+        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, owner, delete=delete) }}</li>
+      {% endfor %}
+    {% else %}
+      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No owner found.</li>
+      {% endif %}
+    </ul>
+    <div class="list-group list-group-flush border-top">
+      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+    </div>
+  {{ accordion.footer() }}
+  {{ accordion.header('Tags', 'tag', 'set-details', icon='price-tag-2-line', class='p-0') }}
+    <ul class="list-group list-group-flush">
+    {% if brickset_tags | length %}
+      {% for tag in brickset_tags %}
+        <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(item, tag, delete=delete) }}</li>
+      {% endfor %}
+    {% else %}
+      <li class="list-group-item list-group-item-action"><i class="ri-error-warning-line"></i> No tag found.</li>
+      {% endif %}
+    </ul>
+    <div class="list-group list-group-flush border-top">
+      <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
+    </div>
+  {{ accordion.footer() }}
+  {{ accordion.header('Management', 'management', 'set-details', expanded=true, icon='settings-4-line') }}
+    <h5 class="border-bottom">Data</h5>
+    <a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
+  {{ accordion.footer() }}
 {% endif %}
diff --git a/templates/sets.html b/templates/sets.html
index b06c94dd..7541f1c9 100644
--- a/templates/sets.html
+++ b/templates/sets.html
@@ -10,7 +10,7 @@
       <label class="visually-hidden" for="grid-search">Search</label>
       <div class="input-group">
         <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-xl-inline"> Search</span></span>
-        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="owner" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner" value="">
+        <input id="grid-search" data-search-exact="name,number,parts,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, tag" value="">
       </div>
     </div>
     <div class="col-12">
@@ -89,6 +89,20 @@
         </select>
       </div>
     </div>
+    <div class="col-12 flex-grow-1">
+      <label class="visually-hidden" for="grid-tag">Tag</label>
+      <div class="input-group">
+        <span class="input-group-text"><i class="ri-price-tag-2-line"></i><span class="ms-1 d-none d-xl-inline"> Tag</span></span>
+        <select id="grid-tag" class="form-select"
+          data-filter="metadata"
+          autocomplete="off">
+          <option value="" selected>All</option>
+          {% for tag in brickset_tags %}
+            <option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
+          {% endfor %}
+        </select>
+      </div>
+    </div>
   </div>
   <div class="row" data-grid="true" id="grid">
   {% for item in collection %}