Repository: sissbruecker/linkding

Issue:
Add Merge Tags and Delete Tags operations
Tag operations in Linkding are limited to the bare minimum: add, search, and recently search untagged entries.

**Merging tag** and **Deleting Tags** are operations that should be included, IMO.

Currently, here are the operations required to merge two tags in Linkding:

1. detect the duplicates in the "tag cloud" on the right (not really practical)
2. select the first duplicate and sevaluate the number of linked entries
3. select the second duplicate and sevaluate the number of linked entries
4. now select the less-used tag in the couple and batch-add the other tag

That is too complex and prone to errors (personnaly, I do it using a SQLite client, but even then it is awkward).

So PLEASE:
1. add a Delete Tag operation
2. add a Merge Tags operation

[Feature request] Delete tags which are not used anymore automatically
It would be great if tags which are used in the past, but not used anymore are automatically deleted or an option to delete not used tags.

In the screenshot we can see that only "Projekt" is currently used, but when I try to create a new bookmark "Projekt" and "Projekte" are displayed. I expect that only "Projekt" is displayed, because this tag is used for another bookmark, but "Projekte" should not be displayed.

![Image](https://github.com/user-attachments/assets/ea71ce78-4871-4b98-ad48-6a42c6677897)
![Image](https://github.com/user-attachments/assets/e3bc9bcb-d38f-4afd-8878-9745faa2da04)


Discussion:
Deleting tags is possible through the admin UI.

Merging would be nice to have. In the meantime it can be worked around with the bulk operations as you described.

Yeah, would be really great as you also may have a typo you want to get rid of. 
_#meanwhile_

1. You can go to `(https://$YOURDOMAIN)/admin/bookmarks/tag/?o=2.-4`
2. Login with your Admin account
3. Now you can select and delete all tags with zero count … :)

KR
Thx, but doesn't work for me as I use linnkding as a service. I'm not the admin. So to have to ask your admin each time you need a tag deleted is only viable in exceptional cases.



Patch:
diff --git a/bookmarks/forms.py b/bookmarks/forms.py
index 244fbbf93..87b546ba7 100644
--- a/bookmarks/forms.py
+++ b/bookmarks/forms.py
@@ -1,11 +1,18 @@
 from django import forms
 from django.forms.utils import ErrorList
+from django.utils import timezone
 
-from bookmarks.models import Bookmark, build_tag_string
-from bookmarks.validators import BookmarkURLValidator
-from bookmarks.type_defs import HttpRequest
+from bookmarks.models import (
+    Bookmark,
+    Tag,
+    build_tag_string,
+    parse_tag_string,
+    sanitize_tag_name,
+)
 from bookmarks.services.bookmarks import create_bookmark, update_bookmark
+from bookmarks.type_defs import HttpRequest
 from bookmarks.utils import normalize_url
+from bookmarks.validators import BookmarkURLValidator
 
 
 class CustomErrorList(ErrorList):
@@ -105,3 +112,88 @@ def convert_tag_string(tag_string: str):
     # Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
     # strings
     return tag_string.replace(" ", ",")
+
+
+class TagForm(forms.ModelForm):
+    class Meta:
+        model = Tag
+        fields = ["name"]
+
+    def __init__(self, user, *args, **kwargs):
+        super().__init__(*args, **kwargs, error_class=CustomErrorList)
+        self.user = user
+
+    def clean_name(self):
+        name = self.cleaned_data.get("name", "").strip()
+
+        name = sanitize_tag_name(name)
+
+        queryset = Tag.objects.filter(name__iexact=name, owner=self.user)
+        if self.instance.pk:
+            queryset = queryset.exclude(pk=self.instance.pk)
+
+        if queryset.exists():
+            raise forms.ValidationError(f'Tag "{name}" already exists.')
+
+        return name
+
+    def save(self, commit=True):
+        tag = super().save(commit=False)
+        if not self.instance.pk:
+            tag.owner = self.user
+            tag.date_added = timezone.now()
+        else:
+            tag.date_modified = timezone.now()
+        if commit:
+            tag.save()
+        return tag
+
+
+class TagMergeForm(forms.Form):
+    target_tag = forms.CharField()
+    merge_tags = forms.CharField()
+
+    def __init__(self, user, *args, **kwargs):
+        super().__init__(*args, **kwargs, error_class=CustomErrorList)
+        self.user = user
+
+    def clean_target_tag(self):
+        target_tag_name = self.cleaned_data.get("target_tag", "")
+
+        target_tag_names = parse_tag_string(target_tag_name, " ")
+        if len(target_tag_names) != 1:
+            raise forms.ValidationError(
+                "Please enter only one tag name for the target tag."
+            )
+
+        target_tag_name = target_tag_names[0]
+
+        try:
+            target_tag = Tag.objects.get(name__iexact=target_tag_name, owner=self.user)
+        except Tag.DoesNotExist:
+            raise forms.ValidationError(f'Tag "{target_tag_name}" does not exist.')
+
+        return target_tag
+
+    def clean_merge_tags(self):
+        merge_tags_string = self.cleaned_data.get("merge_tags", "")
+
+        merge_tag_names = parse_tag_string(merge_tags_string, " ")
+        if not merge_tag_names:
+            raise forms.ValidationError("Please enter at least one tag to merge.")
+
+        merge_tags = []
+        for tag_name in merge_tag_names:
+            try:
+                tag = Tag.objects.get(name__iexact=tag_name, owner=self.user)
+                merge_tags.append(tag)
+            except Tag.DoesNotExist:
+                raise forms.ValidationError(f'Tag "{tag_name}" does not exist.')
+
+        target_tag = self.cleaned_data.get("target_tag")
+        if target_tag and target_tag in merge_tags:
+            raise forms.ValidationError(
+                "The target tag cannot be selected for merging."
+            )
+
+        return merge_tags
diff --git a/bookmarks/frontend/behaviors/filter-drawer.js b/bookmarks/frontend/behaviors/filter-drawer.js
index e5c340e42..62c441367 100644
--- a/bookmarks/frontend/behaviors/filter-drawer.js
+++ b/bookmarks/frontend/behaviors/filter-drawer.js
@@ -24,7 +24,7 @@ class FilterDrawerTriggerBehavior extends Behavior {
       <div class="modal-container" role="dialog" aria-modal="true">
         <div class="modal-header">
           <h2>Filters</h2>
-          <button class="close" aria-label="Close dialog">
+          <button class="btn btn-noborder close" aria-label="Close dialog">
             <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
                  stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
               <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
diff --git a/bookmarks/frontend/behaviors/form.js b/bookmarks/frontend/behaviors/form.js
index 8df61cf9a..98cbc6674 100644
--- a/bookmarks/frontend/behaviors/form.js
+++ b/bookmarks/frontend/behaviors/form.js
@@ -39,6 +39,36 @@ class AutoSubmitBehavior extends Behavior {
   }
 }
 
+// Resets form controls to their initial values before Turbo caches the DOM.
+// Useful for filter forms where navigating back would otherwise still show
+// values from after the form submission, which means the filters would be out
+// of sync with the URL.
+class FormResetBehavior extends Behavior {
+  constructor(element) {
+    super(element);
+
+    this.controls = this.element.querySelectorAll("input, select");
+    this.controls.forEach((control) => {
+      if (control.type === "checkbox" || control.type === "radio") {
+        control.__initialValue = control.checked;
+      } else {
+        control.__initialValue = control.value;
+      }
+    });
+  }
+
+  destroy() {
+    this.controls.forEach((control) => {
+      if (control.type === "checkbox" || control.type === "radio") {
+        control.checked = control.__initialValue;
+      } else {
+        control.value = control.__initialValue;
+      }
+      delete control.__initialValue;
+    });
+  }
+}
+
 class UploadButton extends Behavior {
   constructor(element) {
     super(element);
@@ -75,4 +105,5 @@ class UploadButton extends Behavior {
 
 registerBehavior("ld-form-submit", FormSubmit);
 registerBehavior("ld-auto-submit", AutoSubmitBehavior);
+registerBehavior("ld-form-reset", FormResetBehavior);
 registerBehavior("ld-upload-button", UploadButton);
diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css
index 47d2cbb16..f3b5e691c 100644
--- a/bookmarks/styles/bookmark-page.css
+++ b/bookmarks/styles/bookmark-page.css
@@ -346,12 +346,6 @@ li[ld-bookmark-item] {
 .bookmark-pagination {
   margin-top: var(--unit-4);
 
-  /* Remove left padding from first pagination link */
-
-  & .page-item:first-child a {
-    padding-left: 0;
-  }
-
   &.sticky {
     position: sticky;
     bottom: 0;
diff --git a/bookmarks/styles/bundles.css b/bookmarks/styles/bundles.css
index 97c24bcd0..8c60abe4c 100644
--- a/bookmarks/styles/bundles.css
+++ b/bookmarks/styles/bundles.css
@@ -1,20 +1,15 @@
 .bundles-page {
-  h1 {
-    font-size: var(--font-size-lg);
-    margin-bottom: var(--unit-6);
-  }
-
-  .item-list {
-    .list-item .list-item-icon {
+  .crud-table {
+    svg {
       cursor: grab;
     }
 
-    .list-item.drag-start {
+    tr.drag-start {
       --secondary-border-color: transparent;
     }
 
-    .list-item.dragging > * {
-      visibility: hidden;
+    tr.dragging > * {
+      opacity: 0;
     }
   }
 }
diff --git a/bookmarks/styles/crud.css b/bookmarks/styles/crud.css
new file mode 100644
index 000000000..65a14acca
--- /dev/null
+++ b/bookmarks/styles/crud.css
@@ -0,0 +1,65 @@
+.crud-page {
+  .crud-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: var(--unit-6);
+
+    h1 {
+      font-size: var(--font-size-xl);
+      margin: 0;
+    }
+  }
+
+  .crud-filters {
+    background: var(--gray-50);
+    border-radius: var(--border-radius);
+    border: solid 1px var(--secondary-border-color);
+    padding: var(--unit-3);
+    margin-bottom: var(--unit-4);
+
+    form {
+      display: flex;
+      flex-wrap: wrap;
+      gap: var(--unit-4);
+
+      & .form-group {
+        margin: 0;
+      }
+
+      &.form-input,
+      &.form-select {
+        width: auto;
+      }
+
+      & .form-group:has(.form-checkbox) {
+        align-self: flex-end;
+      }
+    }
+  }
+
+  .crud-table {
+    .btn.btn-link {
+      padding: 0;
+      height: unset;
+    }
+
+    th,
+    td {
+      max-width: 0;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    th.actions,
+    td.actions {
+      width: 1%;
+      max-width: 150px;
+
+      *:not(:last-child) {
+        margin-right: var(--unit-2);
+      }
+    }
+  }
+}
diff --git a/bookmarks/styles/tags.css b/bookmarks/styles/tags.css
new file mode 100644
index 000000000..14d1df063
--- /dev/null
+++ b/bookmarks/styles/tags.css
@@ -0,0 +1,6 @@
+.tags-editor-page {
+  main {
+    max-width: 550px;
+    margin: 0 auto;
+  }
+}
diff --git a/bookmarks/styles/theme-light.css b/bookmarks/styles/theme-light.css
index ac68d3958..03dacfd1c 100644
--- a/bookmarks/styles/theme-light.css
+++ b/bookmarks/styles/theme-light.css
@@ -22,6 +22,7 @@
 @import "responsive.css";
 @import "layout.css";
 @import "components.css";
+@import "crud.css";
 @import "bookmark-details.css";
 @import "bookmark-form.css";
 @import "bookmark-page.css";
@@ -29,3 +30,4 @@
 @import "reader-mode.css";
 @import "settings.css";
 @import "bundles.css";
+@import "tags.css";
diff --git a/bookmarks/styles/theme/buttons.css b/bookmarks/styles/theme/buttons.css
index 21bb7e3b1..3508b2d75 100644
--- a/bookmarks/styles/theme/buttons.css
+++ b/bookmarks/styles/theme/buttons.css
@@ -119,6 +119,12 @@
     }
   }
 
+  /* Button no border */
+  &.btn-noborder {
+    border-color: transparent;
+    box-shadow: none;
+  }
+
   /* Button Link */
 
   &.btn-link {
diff --git a/bookmarks/styles/theme/forms.css b/bookmarks/styles/theme/forms.css
index 6be877364..b36eb789c 100644
--- a/bookmarks/styles/theme/forms.css
+++ b/bookmarks/styles/theme/forms.css
@@ -430,13 +430,21 @@ textarea.form-input {
 /* Form element: Input groups */
 .input-group {
   display: flex;
+  border-radius: var(--border-radius);
+  box-shadow: var(--input-box-shadow);
+
+  > * {
+    box-shadow: none !important;
+  }
 
   .input-group-addon {
+    display: flex;
+    align-items: center;
     background: var(--body-color);
     border: var(--border-width) solid var(--input-border-color);
     border-radius: var(--border-radius);
     line-height: var(--line-height);
-    padding: var(--control-padding-y) var(--control-padding-x);
+    padding: 0 var(--control-padding-x);
     white-space: nowrap;
 
     &.addon-sm {
diff --git a/bookmarks/styles/theme/modals.css b/bookmarks/styles/theme/modals.css
index 9291deb56..08840f8d2 100644
--- a/bookmarks/styles/theme/modals.css
+++ b/bookmarks/styles/theme/modals.css
@@ -80,17 +80,8 @@
     }
 
     & .close {
-      background: none;
-      border: none;
       padding: 0;
-      line-height: 0;
-      cursor: pointer;
-      opacity: 0.85;
-      color: var(--secondary-text-color);
-
-      &:hover {
-        opacity: 1;
-      }
+      height: auto;
     }
   }
 
diff --git a/bookmarks/styles/theme/pagination.css b/bookmarks/styles/theme/pagination.css
index 5661d6c5d..704ef8215 100644
--- a/bookmarks/styles/theme/pagination.css
+++ b/bookmarks/styles/theme/pagination.css
@@ -33,6 +33,11 @@
       }
     }
 
+    &:first-child a {
+      /* Remove left padding from first pagination link */
+      padding-left: 0;
+    }
+
     &.active {
       & a {
         background: var(--primary-color);
diff --git a/bookmarks/styles/theme/tables.css b/bookmarks/styles/theme/tables.css
index 1c7e9b660..3e20900fa 100644
--- a/bookmarks/styles/theme/tables.css
+++ b/bookmarks/styles/theme/tables.css
@@ -5,22 +5,19 @@
   width: 100%;
   text-align: left;
 
-  /* Scrollable tables */
-
-  &.table-scroll {
-    display: block;
-    overflow-x: auto;
-    padding-bottom: 0.75rem;
-    white-space: nowrap;
+  td,
+  th {
+    border-bottom: var(--border-width) solid var(--secondary-border-color);
+    padding: var(--unit-2) var(--unit-2);
   }
 
-  & td,
-  & th {
-    border-bottom: var(--border-width) solid var(--border-color);
-    padding: var(--unit-3) var(--unit-2);
+  th {
+    font-weight: 500;
+    border-bottom-color: var(--border-color);
   }
 
-  & th {
-    border-bottom-width: var(--border-width-lg);
+  th:first-child,
+  td:first-child {
+    padding-left: 0;
   }
 }
diff --git a/bookmarks/styles/theme/utilities.css b/bookmarks/styles/theme/utilities.css
index 91437f4e1..45dad3e50 100644
--- a/bookmarks/styles/theme/utilities.css
+++ b/bookmarks/styles/theme/utilities.css
@@ -242,6 +242,36 @@
   margin-top: var(--unit-4) !important;
 }
 
+.m-6 {
+  margin: var(--unit-6) !important;
+}
+
+.mb-6 {
+  margin-bottom: var(--unit-6) !important;
+}
+
+.ml-6 {
+  margin-left: var(--unit-6) !important;
+}
+
+.mr-6 {
+  margin-right: var(--unit-6) !important;
+}
+
+.mt-6 {
+  margin-top: var(--unit-6) !important;
+}
+
+.mx-6 {
+  margin-left: var(--unit-6) !important;
+  margin-right: var(--unit-6) !important;
+}
+
+.my-6 {
+  margin-bottom: var(--unit-6) !important;
+  margin-top: var(--unit-6) !important;
+}
+
 .ml-auto {
   margin-left: auto;
 }
@@ -291,6 +321,10 @@
 }
 
 /* Flex */
+.flex-column {
+  flex-direction: column;
+}
+
 .align-baseline {
   align-items: baseline;
 }
@@ -302,3 +336,7 @@
 .justify-between {
   justify-content: space-between;
 }
+
+.gap-2 {
+  gap: var(--unit-2);
+}
diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html
index bfe8c60d5..74aa29be7 100644
--- a/bookmarks/templates/bookmarks/bundle_section.html
+++ b/bookmarks/templates/bookmarks/bundle_section.html
@@ -3,7 +3,7 @@
     <div class="section-header no-wrap">
       <h2 id="bundles-heading">Bundles</h2>
       <div ld-dropdown class="dropdown dropdown-right ml-auto">
-        <button class="btn dropdown-toggle" aria-label="Bundles menu">
+        <button class="btn btn-noborder dropdown-toggle" aria-label="Bundles menu">
           <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
                stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
             <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
diff --git a/bookmarks/templates/bookmarks/details/modal.html b/bookmarks/templates/bookmarks/details/modal.html
index ddd3f9928..813165fcb 100644
--- a/bookmarks/templates/bookmarks/details/modal.html
+++ b/bookmarks/templates/bookmarks/details/modal.html
@@ -4,7 +4,7 @@
   <div class="modal-container" role="dialog" aria-modal="true">
     <div class="modal-header">
       <h2 class="title">{{ details.bookmark.resolved_title }}</h2>
-      <button class="close" aria-label="Close dialog">
+      <button class="btn btn-noborder close" aria-label="Close dialog">
         <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
              stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
           <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
diff --git a/bookmarks/templates/bookmarks/tag_section.html b/bookmarks/templates/bookmarks/tag_section.html
index 78558e042..b22c66e1a 100644
--- a/bookmarks/templates/bookmarks/tag_section.html
+++ b/bookmarks/templates/bookmarks/tag_section.html
@@ -1,6 +1,22 @@
 <section aria-labelledby="tags-heading">
-  <div class="section-header">
+  <div class="section-header no-wrap">
     <h2 id="tags-heading">Tags</h2>
+    <div ld-dropdown class="dropdown dropdown-right ml-auto">
+      <button class="btn btn-noborder dropdown-toggle" aria-label="Tabs menu">
+        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
+             stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+          <path d="M4 6l16 0"/>
+          <path d="M4 12l16 0"/>
+          <path d="M4 18l16 0"/>
+        </svg>
+      </button>
+      <ul class="menu" role="list" tabindex="-1">
+        <li class="menu-item">
+          <a href="{% url 'linkding:tags.index' %}" class="menu-link">Manage tags</a>
+        </li>
+      </ul>
+    </div>
   </div>
   <div id="tag-cloud-container">
     {% include 'bookmarks/tag_cloud.html' %}
diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html
index a8d1b5f23..f42fe1ef6 100644
--- a/bookmarks/templates/bundles/index.html
+++ b/bookmarks/templates/bundles/index.html
@@ -7,41 +7,55 @@
 {% endblock %}
 
 {% block content %}
-  <main class="bundles-page" aria-labelledby="main-heading">
-    <h1 id="main-heading">Bundles</h1>
+  <main class="bundles-page crud-page" aria-labelledby="main-heading">
+    <div class="crud-header">
+      <h1 id="main-heading">Bundles</h1>
+      <a href="{% url 'linkding:bundles.new' %}" class="btn">Add bundle</a>
+    </div>
 
     {% include 'shared/messages.html' %}
 
     {% if bundles %}
       <form action="{% url 'linkding:bundles.action' %}" method="post">
         {% csrf_token %}
-        <div class="item-list bundles">
+        <table class="table crud-table">
+          <thead>
+          <tr>
+            <th>Name</th>
+            <th class="actions">
+              <span class="text-assistive">Actions</span>
+            </th>
+          </tr>
+          </thead>
+          <tbody>
           {% for bundle in bundles %}
-            <div class="list-item" data-bundle-id="{{ bundle.id }}" draggable="true">
-              <div class="list-item-icon text-secondary">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
-                     stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                  <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
-                  <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                </svg>
-              </div>
-              <div class="list-item-text">
-                <span class="truncate">{{ bundle.name }}</span>
-              </div>
-              <div class="list-item-actions">
+            <tr data-bundle-id="{{ bundle.id }}" draggable="true">
+              <td>
+                <div class="d-flex align-center">
+                  <svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
+                       stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                    <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+                    <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  </svg>
+                  <span>{{ bundle.name }}</span>
+                </div>
+              </td>
+              <td class="actions">
                 <a class="btn btn-link" href="{% url 'linkding:bundles.edit' bundle.id %}">Edit</a>
                 <button ld-confirm-button type="submit" name="remove_bundle" value="{{ bundle.id }}"
                         class="btn btn-link">Remove
                 </button>
-              </div>
-            </div>
+              </td>
+            </tr>
           {% endfor %}
-        </div>
+          </tbody>
+        </table>
+
         <input type="submit" name="move_bundle" value="" class="d-none">
         <input type="hidden" name="move_position" value="">
       </form>
@@ -51,21 +65,17 @@ <h1 id="main-heading">Bundles</h1>
         <p class="empty-subtitle">Create your first bundle to get started</p>
       </div>
     {% endif %}
-
-    <div class="mt-4">
-      <a href="{% url 'linkding:bundles.new' %}" class="btn btn-primary">Add new bundle</a>
-    </div>
   </main>
 
   <script>
     (function init() {
-      const bundlesList = document.querySelector(".item-list.bundles");
-      if (!bundlesList) return;
+      const tableBody = document.querySelector(".crud-table tbody");
+      if (!tableBody) return;
 
       let draggedElement = null;
 
-      const listItems = bundlesList.querySelectorAll('.list-item');
-      listItems.forEach((item) => {
+      const rows = tableBody.querySelectorAll('tr');
+      rows.forEach((item) => {
         item.addEventListener('dragstart', handleDragStart);
         item.addEventListener('dragend', handleDragEnd);
         item.addEventListener('dragover', handleDragOver);
@@ -91,7 +101,7 @@ <h1 id="main-heading">Bundles</h1>
         const moveBundleInput = document.querySelector('input[name="move_bundle"]');
         const movePositionInput = document.querySelector('input[name="move_position"]');
         moveBundleInput.value = draggedElement.getAttribute('data-bundle-id');
-        movePositionInput.value = Array.from(bundlesList.children).indexOf(draggedElement);
+        movePositionInput.value = Array.from(tableBody.children).indexOf(draggedElement);
 
         const form = this.closest('form');
         form.requestSubmit(moveBundleInput);
@@ -108,7 +118,7 @@ <h1 id="main-heading">Bundles</h1>
 
       function handleDragEnter() {
         if (this !== draggedElement) {
-          const listItems = Array.from(bundlesList.children);
+          const listItems = Array.from(tableBody.children);
           const draggedIndex = listItems.indexOf(draggedElement);
           const currentIndex = listItems.indexOf(this);
 
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index a1d093120..a79a2db04 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -394,17 +394,17 @@ <h2 id="about-heading">About</h2>
           <td>{{ version_info }}</td>
         </tr>
         <tr>
-          <td rowspan="3" style="vertical-align: top">Links</td>
-          <td><a href="https://github.com/sissbruecker/linkding/"
-                 target="_blank">GitHub</a></td>
-        </tr>
-        <tr>
-          <td><a href="https://linkding.link/"
-                 target="_blank">Documentation</a></td>
-        </tr>
-        <tr>
-          <td><a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
-                 target="_blank">Changelog</a></td>
+          <td style="vertical-align: top">Links</td>
+          <td>
+            <div class="d-flex flex-column gap-2">
+              <a href="https://github.com/sissbruecker/linkding/"
+                 target="_blank">GitHub</a>
+              <a href="https://linkding.link/"
+                 target="_blank">Documentation</a>
+              <a href="https://github.com/sissbruecker/linkding/blob/master/CHANGELOG.md"
+                 target="_blank">Changelog</a>
+            </div>
+          </td>
         </tr>
         </tbody>
       </table>
diff --git a/bookmarks/templates/tags/edit.html b/bookmarks/templates/tags/edit.html
new file mode 100644
index 000000000..663320f12
--- /dev/null
+++ b/bookmarks/templates/tags/edit.html
@@ -0,0 +1,23 @@
+{% extends "bookmarks/layout.html" %}
+{% load shared %}
+
+{% block head %}
+  {% with page_title="Edit tag - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="tags-editor-page">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">Edit tag</h1>
+      </div>
+
+      <form method="post" novalidate>
+        {% csrf_token %}
+        {% include 'tags/form.html' %}
+      </form>
+    </main>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/tags/form.html b/bookmarks/templates/tags/form.html
new file mode 100644
index 000000000..77e41114c
--- /dev/null
+++ b/bookmarks/templates/tags/form.html
@@ -0,0 +1,19 @@
+{% load widget_tweaks %}
+
+<div class="form-group {% if form.name.errors %}has-error{% endif %}">
+  <label class="form-label" for="{{ form.name.id_for_label }}">Name</label>
+  {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
+  <div class="form-input-hint">Tag names are case-insensitive and cannot contain spaces (spaces will be replaced with hyphens).</div>
+  {% if form.name.errors %}
+    <div class="form-input-hint">
+      {{ form.name.errors }}
+    </div>
+  {% endif %}
+</div>
+
+<div class="divider"></div>
+
+<div class="form-group d-flex justify-between">
+  <button type="submit" class="btn btn-primary">Save</button>
+  <a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
+</div>
diff --git a/bookmarks/templates/tags/index.html b/bookmarks/templates/tags/index.html
new file mode 100644
index 000000000..4b648b936
--- /dev/null
+++ b/bookmarks/templates/tags/index.html
@@ -0,0 +1,125 @@
+{% extends "bookmarks/layout.html" %}
+{% load shared %}
+{% load pagination %}
+
+{% block head %}
+  {% with page_title="Tags - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="tags-page crud-page">
+    <main aria-labelledby="main-heading">
+      <div class="crud-header">
+        <h1 id="main-heading">Tags</h1>
+        <div class="d-flex gap-2 ml-auto">
+          <a href="{% url 'linkding:tags.new' %}" class="btn">Add Tag</a>
+          <a href="{% url 'linkding:tags.merge' %}" class="btn">Merge Tags</a>
+        </div>
+      </div>
+
+      {% include 'shared/messages.html' %}
+
+      {# Filters #}
+      <div class="crud-filters">
+        <form method="get" class="mb-2" ld-form-reset>
+          <div class="form-group">
+            <label class="form-label text-assistive" for="search">Search tags</label>
+            <div class="input-group">
+              <input type="text" id="search" name="search" value="{{ search }}" placeholder="Search tags..."
+                     class="form-input">
+              <button type="submit" class="btn input-group-btn">Search</button>
+            </div>
+          </div>
+          <div class="form-group">
+            <label class="form-label text-assistive" for="sort">Sort by</label>
+            <div class="input-group">
+              <span class="input-group-addon text-secondary">
+                <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
+                     stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path
+                    stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 9l4 -4l4 4m-4 -4v14"/><path
+                    d="M21 15l-4 4l-4 -4m4 4v-14"/></svg>
+              </span>
+              <select id="sort" name="sort" class="form-select" ld-auto-submit>
+                <option value="name-asc" {% if sort == "name-asc" %}selected{% endif %}>Name A-Z</option>
+                <option value="name-desc" {% if sort == "name-desc" %}selected{% endif %}>Name Z-A</option>
+                <option value="count-asc" {% if sort == "count-asc" %}selected{% endif %}>Fewest bookmarks</option>
+                <option value="count-desc" {% if sort == "count-desc" %}selected{% endif %}>Most bookmarks</option>
+              </select>
+            </div>
+          </div>
+          <div class="form-group">
+            <label class="form-checkbox">
+              <input type="checkbox" name="unused" value="true" {% if unused_only %}checked{% endif %} ld-auto-submit>
+              <i class="form-icon"></i> Show only unused tags
+            </label>
+          </div>
+        </form>
+
+        {# Tags count #}
+        <p class="text-secondary text-small m-0">
+          {% if search or unused_only %}
+            Showing {{ page.paginator.count }} of {{ total_tags }} tags
+          {% else %}
+            {{ total_tags }} tags total
+          {% endif %}
+        </p>
+      </div>
+
+      {# Tags List #}
+      {% if page.object_list %}
+        <form method="post">
+          {% csrf_token %}
+
+          <table class="table crud-table">
+            <thead>
+            <tr>
+              <th>Name</th>
+              <th style="width: 25%">Bookmarks</th>
+              <th class="actions">
+                <span class="text-assistive">Actions</span>
+              </th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for tag in page.object_list %}
+              <tr>
+                <td>
+                  {{ tag.name }}
+                </td>
+                <td style="width: 25%">
+                  <a class="btn btn-link" href="{% url 'linkding:bookmarks.index' %}?q=%23{{ tag.name|urlencode }}">
+                    {{ tag.bookmark_count }}
+                  </a>
+                </td>
+                <td class="actions">
+                  <a class="btn btn-link" href="{% url 'linkding:tags.edit' tag.id %}">Edit</a>
+                  <button type="submit" name="delete_tag" value="{{ tag.id }}" class="btn btn-link text-error"
+                          ld-confirm-button>
+                    Remove
+                  </button>
+                </td>
+              </tr>
+            {% endfor %}
+            </tbody>
+          </table>
+
+        </form>
+
+        {% pagination page %}
+
+      {% else %}
+        <div class="empty">
+          {% if search or unused_only %}
+            <p class="empty-title h5">No tags found</p>
+            <p class="empty-subtitle">Try adjusting your search or filters</p>
+          {% else %}
+            <p class="empty-title h5">You have no tags yet</p>
+            <p class="empty-subtitle">Tags will appear here when you add bookmarks with tags</p>
+          {% endif %}
+        </div>
+      {% endif %}
+    </main>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/tags/merge.html b/bookmarks/templates/tags/merge.html
new file mode 100644
index 000000000..2479ad508
--- /dev/null
+++ b/bookmarks/templates/tags/merge.html
@@ -0,0 +1,68 @@
+{% extends "bookmarks/layout.html" %}
+{% load shared %}
+{% load widget_tweaks %}
+
+{% block head %}
+  {% with page_title="Merge tags - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="tags-editor-page">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">Merge tags</h1>
+      </div>
+
+      <details class="mb-4">
+        <summary>
+          <span class="text-bold mb-1">How to merge tags</span>
+        </summary>
+        <ol>
+          <li>Enter the name of the tag you want to keep</li>
+          <li>Enter the names of tags to merge into the target tag</li>
+          <li>The target tag is added to all bookmarks that have any of the merge tags</li>
+          <li>The merged tags are deleted</li>
+        </ol>
+      </details>
+
+      <form method="post">
+        {% csrf_token %}
+
+        <div class="form-group {% if form.target_tag.errors %}has-error{% endif %}" ld-tag-autocomplete>
+          <label for="{{ form.target_tag.id_for_label }}" class="form-label">Target tag</label>
+          {{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
+          <div class="form-input-hint">
+            Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
+          </div>
+          {% if form.target_tag.errors %}
+            <div class="form-input-hint">
+              {{ form.target_tag.errors }}
+            </div>
+          {% endif %}
+        </div>
+
+        <div class="form-group {% if form.merge_tags.errors %}has-error{% endif %}" ld-tag-autocomplete>
+          <label for="{{ form.merge_tags.id_for_label }}" class="form-label">Tags to merge</label>
+          {{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }}
+          <div class="form-input-hint">Enter the names of tags to merge into the target tag, separated by spaces. These
+            tags will be deleted after merging.
+          </div>
+          {% if form.merge_tags.errors %}
+            <div class="form-input-hint">
+              {{ form.merge_tags.errors }}
+            </div>
+          {% endif %}
+        </div>
+
+        <div class="divider"></div>
+
+        <div class="form-group d-flex justify-between">
+          <button type="submit" class="btn btn-primary">Merge Tags</button>
+          <a href="{% url 'linkding:tags.index' %}" class="btn ml-auto">Cancel</a>
+        </div>
+      </form>
+    </main>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/tags/new.html b/bookmarks/templates/tags/new.html
new file mode 100644
index 000000000..8b6abbb8a
--- /dev/null
+++ b/bookmarks/templates/tags/new.html
@@ -0,0 +1,23 @@
+{% extends "bookmarks/layout.html" %}
+{% load shared %}
+
+{% block head %}
+  {% with page_title="Add tag - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="tags-editor-page">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">New tag</h1>
+      </div>
+
+      <form method="post" novalidate>
+        {% csrf_token %}
+        {% include 'tags/form.html' %}
+      </form>
+    </main>
+  </div>
+{% endblock %}
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 288a9020e..7c33b7c09 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -49,6 +49,11 @@
     path("bundles/new", views.bundles.new, name="bundles.new"),
     path("bundles/<int:bundle_id>/edit", views.bundles.edit, name="bundles.edit"),
     path("bundles/preview", views.bundles.preview, name="bundles.preview"),
+    # Tags
+    path("tags", views.tags.tags_index, name="tags.index"),
+    path("tags/new", views.tags.tag_new, name="tags.new"),
+    path("tags/<int:tag_id>/edit", views.tags.tag_edit, name="tags.edit"),
+    path("tags/merge", views.tags.tag_merge, name="tags.merge"),
     # Settings
     path("settings", views.settings.general, name="settings.index"),
     path("settings/general", views.settings.general, name="settings.general"),
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 397cda523..7c610671f 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -2,6 +2,7 @@
 from .auth import *
 from .bookmarks import *
 from . import bundles
+from . import tags
 from .settings import *
 from .toasts import *
 from .health import health
diff --git a/bookmarks/views/tags.py b/bookmarks/views/tags.py
new file mode 100644
index 000000000..864519880
--- /dev/null
+++ b/bookmarks/views/tags.py
@@ -0,0 +1,151 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core.paginator import Paginator
+from django.db import transaction
+from django.db.models import Count
+from django.http import HttpResponseRedirect
+from django.shortcuts import render, get_object_or_404
+from django.urls import reverse
+
+from bookmarks.forms import TagForm, TagMergeForm
+from bookmarks.models import Bookmark, Tag
+from bookmarks.type_defs import HttpRequest
+
+
+@login_required
+def tags_index(request: HttpRequest):
+    if request.method == "POST" and "delete_tag" in request.POST:
+        tag_id = request.POST.get("delete_tag")
+        tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
+        tag_name = tag.name
+        tag.delete()
+        messages.success(request, f'Tag "{tag_name}" deleted successfully.')
+
+        redirect_url = reverse("linkding:tags.index")
+        if request.GET:
+            redirect_url += "?" + request.GET.urlencode()
+
+        return HttpResponseRedirect(redirect_url)
+
+    search = request.GET.get("search", "").strip()
+    unused_only = request.GET.get("unused", "") == "true"
+    sort = request.GET.get("sort", "name-asc")
+
+    tags_queryset = Tag.objects.filter(owner=request.user).annotate(
+        bookmark_count=Count("bookmark")
+    )
+
+    if sort == "name-desc":
+        tags_queryset = tags_queryset.order_by("-name")
+    elif sort == "count-asc":
+        tags_queryset = tags_queryset.order_by("bookmark_count", "name")
+    elif sort == "count-desc":
+        tags_queryset = tags_queryset.order_by("-bookmark_count", "name")
+    else:  # Default: name-asc
+        tags_queryset = tags_queryset.order_by("name")
+    total_tags = tags_queryset.count()
+
+    if search:
+        tags_queryset = tags_queryset.filter(name__icontains=search)
+
+    if unused_only:
+        tags_queryset = tags_queryset.filter(bookmark_count=0)
+
+    paginator = Paginator(tags_queryset, 50)
+    page_number = request.GET.get("page")
+    page = paginator.get_page(page_number)
+
+    context = {
+        "page": page,
+        "search": search,
+        "unused_only": unused_only,
+        "sort": sort,
+        "total_tags": total_tags,
+    }
+
+    return render(request, "tags/index.html", context)
+
+
+@login_required
+def tag_new(request: HttpRequest):
+    form_data = request.POST if request.method == "POST" else None
+    form = TagForm(user=request.user, data=form_data)
+
+    if request.method == "POST":
+        if form.is_valid():
+            tag = form.save()
+            messages.success(request, f'Tag "{tag.name}" created successfully.')
+            return HttpResponseRedirect(reverse("linkding:tags.index"))
+
+    status = 422 if request.method == "POST" and not form.is_valid() else 200
+    return render(request, "tags/new.html", {"form": form}, status=status)
+
+
+@login_required
+def tag_edit(request: HttpRequest, tag_id: int):
+    tag = get_object_or_404(Tag, id=tag_id, owner=request.user)
+    form_data = request.POST if request.method == "POST" else None
+    form = TagForm(user=request.user, data=form_data, instance=tag)
+
+    if request.method == "POST":
+        if form.is_valid():
+            form.save()
+            messages.success(request, f'Tag "{tag.name}" updated successfully.')
+            return HttpResponseRedirect(reverse("linkding:tags.index"))
+
+    status = 422 if request.method == "POST" and not form.is_valid() else 200
+    context = {
+        "tag": tag,
+        "form": form,
+    }
+    return render(request, "tags/edit.html", context, status=status)
+
+
+@login_required
+def tag_merge(request: HttpRequest):
+    form_data = request.POST if request.method == "POST" else None
+    form = TagMergeForm(user=request.user, data=form_data)
+
+    if request.method == "POST":
+        if form.is_valid():
+            target_tag = form.cleaned_data["target_tag"]
+            merge_tags = form.cleaned_data["merge_tags"]
+
+            with transaction.atomic():
+                BookmarkTag = Bookmark.tags.through
+
+                # Get all bookmarks that have any of the merge tags, but do not
+                # already have the target tag
+                bookmark_ids = list(
+                    Bookmark.objects.filter(tags__in=merge_tags)
+                    .exclude(tags=target_tag)
+                    .values_list("id", flat=True)
+                    .distinct()
+                )
+
+                # Create new relationships to the target tag
+                new_relationships = [
+                    BookmarkTag(tag_id=target_tag.id, bookmark_id=bookmark_id)
+                    for bookmark_id in bookmark_ids
+                ]
+
+                if new_relationships:
+                    BookmarkTag.objects.bulk_create(new_relationships)
+
+                # Bulk delete all relationships for merge tags
+                merge_tag_ids = [tag.id for tag in merge_tags]
+                BookmarkTag.objects.filter(tag_id__in=merge_tag_ids).delete()
+
+                # Delete the merged tags
+                tag_names = [tag.name for tag in merge_tags]
+                Tag.objects.filter(id__in=merge_tag_ids).delete()
+
+                messages.success(
+                    request,
+                    f'Successfully merged {len(merge_tags)} tags ({", ".join(tag_names)}) into "{target_tag.name}".',
+                )
+
+            return HttpResponseRedirect(reverse("linkding:tags.index"))
+
+    status = 422 if request.method == "POST" and not form.is_valid() else 200
+    return render(request, "tags/merge.html", {"form": form}, status=status)


Test patch:
diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py
index e4dff8e06..559b75da9 100644
--- a/bookmarks/tests/test_bundles_index_view.py
+++ b/bookmarks/tests/test_bundles_index_view.py
@@ -25,27 +25,27 @@ def test_render_bundle_list(self):
 
         for bundle in bundles:
             expected_list_item = f"""
-            <div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
-              <div class="list-item-icon text-secondary">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
-                     stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                  <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
-                  <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                  <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
-                </svg>
-              </div>
-              <div class="list-item-text">
-                <span class="truncate">{bundle.name}</span>
-              </div>
-              <div class="list-item-actions">
+            <tr data-bundle-id="{bundle.id}" draggable="true">
+              <td>
+                <div class="d-flex align-center">
+                  <svg xmlns="http://www.w3.org/2000/svg" class="text-secondary mr-1" width="16" height="16" viewBox="0 0 24 24" fill="none"
+                       stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                    <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
+                    <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                    <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
+                  </svg>
+                  <span>{ bundle.name }</span>
+                </div>
+              </td>
+              <td class="actions">
                 <a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
                 <button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
-              </div>
-            </div>
+              </td>
+            </tr>
             """
 
             self.assertInHTML(expected_list_item, html)
@@ -61,7 +61,7 @@ def test_renders_user_owned_bundles_only(self):
         self.assertEqual(response.status_code, 200)
         html = response.content.decode()
 
-        self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
+        self.assertInHTML(f"<span>{user_bundle.name}</span>", html)
         self.assertNotIn(other_user_bundle.name, html)
 
     def test_empty_state(self):
@@ -83,7 +83,7 @@ def test_add_new_button(self):
         html = response.content.decode()
 
         self.assertInHTML(
-            f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
+            f'<a href="{reverse("linkding:bundles.new")}" class="btn">Add bundle</a>',
             html,
         )
 
diff --git a/bookmarks/tests/test_tags_edit_view.py b/bookmarks/tests/test_tags_edit_view.py
new file mode 100644
index 000000000..36725c45e
--- /dev/null
+++ b/bookmarks/tests/test_tags_edit_view.py
@@ -0,0 +1,113 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class TagsEditViewTestCase(TestCase, BookmarkFactoryMixin):
+    def setUp(self) -> None:
+        self.user = self.get_or_create_test_user()
+        self.client.force_login(self.user)
+
+    def test_update_tag(self):
+        tag = self.setup_tag(name="old_name")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+
+        tag.refresh_from_db()
+        self.assertEqual(tag.name, "new_name")
+
+    def test_allow_case_changes(self):
+        tag = self.setup_tag(name="tag")
+
+        self.client.post(reverse("linkding:tags.edit", args=[tag.id]), {"name": "TAG"})
+
+        tag.refresh_from_db()
+        self.assertEqual(tag.name, "TAG")
+
+    def test_can_only_edit_own_tags(self):
+        other_user = self.setup_user()
+        tag = self.setup_tag(user=other_user)
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag.id]), {"name": "new_name"}
+        )
+
+        self.assertEqual(response.status_code, 404)
+        tag.refresh_from_db()
+        self.assertNotEqual(tag.name, "new_name")
+
+    def test_show_error_for_empty_name(self):
+        tag = self.setup_tag(name="tag1")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag.id]), {"name": ""}
+        )
+
+        self.assertContains(response, "This field is required", status_code=422)
+        tag.refresh_from_db()
+        self.assertEqual(tag.name, "tag1")
+
+    def test_show_error_for_duplicate_name(self):
+        tag1 = self.setup_tag(name="tag1")
+        self.setup_tag(name="tag2")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag1.id]), {"name": "tag2"}
+        )
+
+        self.assertContains(
+            response, "Tag &quot;tag2&quot; already exists", status_code=422
+        )
+        tag1.refresh_from_db()
+        self.assertEqual(tag1.name, "tag1")
+
+    def test_show_error_for_duplicate_name_different_casing(self):
+        tag1 = self.setup_tag(name="tag1")
+        self.setup_tag(name="tag2")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag1.id]), {"name": "TAG2"}
+        )
+
+        self.assertContains(
+            response, "Tag &quot;TAG2&quot; already exists", status_code=422
+        )
+        tag1.refresh_from_db()
+        self.assertEqual(tag1.name, "tag1")
+
+    def test_no_error_for_duplicate_name_different_user(self):
+        other_user = self.setup_user()
+        self.setup_tag(name="tag1", user=other_user)
+
+        tag2 = self.setup_tag(name="tag2")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag2.id]), {"name": "tag1"}
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+        tag2.refresh_from_db()
+        self.assertEqual(tag2.name, "tag1")
+
+    def test_update_shows_success_message(self):
+        tag = self.setup_tag(name="old_name")
+
+        response = self.client.post(
+            reverse("linkding:tags.edit", args=[tag.id]),
+            {"name": "new_name"},
+            follow=True,
+        )
+
+        self.assertInHTML(
+            """
+            <div class="toast toast-success" role="alert">
+                Tag "new_name" updated successfully.
+            </div>
+        """,
+            response.content.decode(),
+        )
diff --git a/bookmarks/tests/test_tags_index_view.py b/bookmarks/tests/test_tags_index_view.py
new file mode 100644
index 000000000..b9da9bf99
--- /dev/null
+++ b/bookmarks/tests/test_tags_index_view.py
@@ -0,0 +1,281 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import Tag
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+    def setUp(self) -> None:
+        self.user = self.get_or_create_test_user()
+        self.client.force_login(self.user)
+
+    def get_rows(self, response):
+        html = response.content.decode()
+        soup = self.make_soup(html)
+        return soup.select(".crud-table tbody tr")
+
+    def find_row(self, rows, tag):
+        for row in rows:
+            if tag.name in row.get_text():
+                return row
+        return None
+
+    def assertRows(self, response, tags):
+        rows = self.get_rows(response)
+        self.assertEqual(len(rows), len(tags))
+        for tag in tags:
+            row = self.find_row(rows, tag)
+            self.assertIsNotNone(row, f"Tag '{tag.name}' not found in table")
+
+    def assertOrderedRows(self, response, tags):
+        rows = self.get_rows(response)
+        self.assertEqual(len(rows), len(tags))
+        for index, tag in enumerate(tags):
+            row = rows[index]
+            self.assertIn(
+                tag.name,
+                row.get_text(),
+                f"Tag '{tag.name}' not found at index {index}",
+            )
+
+    def test_list_tags(self):
+        tag1 = self.setup_tag()
+        tag2 = self.setup_tag()
+        tag3 = self.setup_tag()
+
+        response = self.client.get(reverse("linkding:tags.index"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertRows(response, [tag1, tag2, tag3])
+
+    def test_show_user_owned_tags(self):
+        tag1 = self.setup_tag()
+        tag2 = self.setup_tag()
+        tag3 = self.setup_tag()
+
+        other_user = self.setup_user()
+        self.setup_tag(user=other_user)
+        self.setup_tag(user=other_user)
+        self.setup_tag(user=other_user)
+
+        response = self.client.get(reverse("linkding:tags.index"))
+
+        self.assertRows(response, [tag1, tag2, tag3])
+
+    def test_search_tags(self):
+        tag1 = self.setup_tag(name="programming")
+        self.setup_tag(name="python")
+        self.setup_tag(name="django")
+        self.setup_tag(name="design")
+
+        response = self.client.get(reverse("linkding:tags.index") + "?search=prog")
+
+        self.assertRows(response, [tag1])
+
+    def test_filter_unused_tags(self):
+        tag1 = self.setup_tag()
+        tag2 = self.setup_tag()
+        tag3 = self.setup_tag()
+
+        self.setup_bookmark(tags=[tag1])
+        self.setup_bookmark(tags=[tag3])
+
+        response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
+
+        self.assertRows(response, [tag2])
+
+    def test_rows_have_links_to_filtered_bookmarks(self):
+        tag1 = self.setup_tag(name="python")
+        tag2 = self.setup_tag(name="django-framework")
+
+        self.setup_bookmark(tags=[tag1])
+        self.setup_bookmark(tags=[tag1, tag2])
+
+        response = self.client.get(reverse("linkding:tags.index"))
+
+        rows = self.get_rows(response)
+
+        tag1_row = self.find_row(rows, tag1)
+        view_link = tag1_row.find("a", string=lambda s: s and s.strip() == "2")
+        expected_url = reverse("linkding:bookmarks.index") + "?q=%23python"
+        self.assertEqual(view_link["href"], expected_url)
+
+        tag2_row = self.find_row(rows, tag2)
+        view_link = tag2_row.find("a", string=lambda s: s and s.strip() == "1")
+        expected_url = reverse("linkding:bookmarks.index") + "?q=%23django-framework"
+        self.assertEqual(view_link["href"], expected_url)
+
+    def test_shows_tag_total(self):
+        tag1 = self.setup_tag(name="python")
+        tag2 = self.setup_tag(name="javascript")
+        tag3 = self.setup_tag(name="design")
+        self.setup_tag(name="unused-tag")
+
+        self.setup_bookmark(tags=[tag1])
+        self.setup_bookmark(tags=[tag2])
+        self.setup_bookmark(tags=[tag3])
+
+        response = self.client.get(reverse("linkding:tags.index"))
+        self.assertContains(response, "4 tags total")
+
+        response = self.client.get(reverse("linkding:tags.index") + "?search=python")
+        self.assertContains(response, "Showing 1 of 4 tags")
+
+        response = self.client.get(reverse("linkding:tags.index") + "?unused=true")
+        self.assertContains(response, "Showing 1 of 4 tags")
+
+        response = self.client.get(
+            reverse("linkding:tags.index") + "?search=nonexistent"
+        )
+        self.assertContains(response, "Showing 0 of 4 tags")
+
+    def test_pagination(self):
+        tags = []
+        for i in range(75):
+            tags.append(self.setup_tag())
+
+        response = self.client.get(reverse("linkding:tags.index"))
+        rows = self.get_rows(response)
+        self.assertEqual(len(rows), 50)
+
+        response = self.client.get(reverse("linkding:tags.index") + "?page=2")
+        rows = self.get_rows(response)
+        self.assertEqual(len(rows), 25)
+
+    def test_delete_action(self):
+        tag = self.setup_tag(name="tag_to_delete")
+
+        response = self.client.post(
+            reverse("linkding:tags.index"), {"delete_tag": tag.id}
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+        self.assertFalse(Tag.objects.filter(id=tag.id).exists())
+
+    def test_tag_delete_action_shows_success_message(self):
+        tag = self.setup_tag(name="tag_to_delete")
+
+        response = self.client.post(
+            reverse("linkding:tags.index"), {"delete_tag": tag.id}, follow=True
+        )
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertInHTML(
+            """
+            <div class="toast toast-success" role="alert">
+                Tag "tag_to_delete" deleted successfully.
+            </div>
+        """,
+            response.content.decode(),
+        )
+
+    def test_tag_delete_action_preserves_query_parameters(self):
+        tag = self.setup_tag(name="search_tag")
+
+        url = (
+            reverse("linkding:tags.index")
+            + "?search=search&unused=true&page=2&sort=name-desc"
+        )
+        response = self.client.post(url, {"delete_tag": tag.id})
+
+        self.assertRedirects(response, url)
+
+    def test_tag_delete_action_only_deletes_own_tags(self):
+        other_user = self.setup_user()
+        other_tag = self.setup_tag(user=other_user, name="other_user_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.index"), {"delete_tag": other_tag.id}, follow=True
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_sort_by_name_ascending(self):
+        tag_c = self.setup_tag(name="c_tag")
+        tag_a = self.setup_tag(name="a_tag")
+        tag_b = self.setup_tag(name="b_tag")
+
+        response = self.client.get(reverse("linkding:tags.index") + "?sort=name-asc")
+
+        self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
+
+    def test_sort_by_name_descending(self):
+        tag_c = self.setup_tag(name="c_tag")
+        tag_a = self.setup_tag(name="a_tag")
+        tag_b = self.setup_tag(name="b_tag")
+
+        response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
+
+        self.assertOrderedRows(response, [tag_c, tag_b, tag_a])
+
+    def test_sort_by_bookmark_count_ascending(self):
+        tag_few = self.setup_tag(name="few_bookmarks")
+        tag_many = self.setup_tag(name="many_bookmarks")
+        tag_none = self.setup_tag(name="no_bookmarks")
+
+        self.setup_bookmark(tags=[tag_few])
+        self.setup_bookmark(tags=[tag_many])
+        self.setup_bookmark(tags=[tag_many])
+        self.setup_bookmark(tags=[tag_many])
+
+        response = self.client.get(reverse("linkding:tags.index") + "?sort=count-asc")
+
+        self.assertOrderedRows(response, [tag_none, tag_few, tag_many])
+
+    def test_sort_by_bookmark_count_descending(self):
+        tag_few = self.setup_tag(name="few_bookmarks")
+        tag_many = self.setup_tag(name="many_bookmarks")
+        tag_none = self.setup_tag(name="no_bookmarks")
+
+        self.setup_bookmark(tags=[tag_few])
+        self.setup_bookmark(tags=[tag_many])
+        self.setup_bookmark(tags=[tag_many])
+        self.setup_bookmark(tags=[tag_many])
+
+        response = self.client.get(reverse("linkding:tags.index") + "?sort=count-desc")
+
+        self.assertOrderedRows(response, [tag_many, tag_few, tag_none])
+
+    def test_default_sort_is_name_ascending(self):
+        tag_c = self.setup_tag(name="c_tag")
+        tag_a = self.setup_tag(name="a_tag")
+        tag_b = self.setup_tag(name="b_tag")
+
+        response = self.client.get(reverse("linkding:tags.index"))
+
+        self.assertOrderedRows(response, [tag_a, tag_b, tag_c])
+
+    def test_sort_select_has_correct_options_and_selection(self):
+        self.setup_tag()
+
+        response = self.client.get(reverse("linkding:tags.index"))
+        html = response.content.decode()
+
+        self.assertInHTML(
+            """
+            <select id="sort" name="sort" class="form-select" ld-auto-submit>
+              <option value="name-asc" selected>Name A-Z</option>
+              <option value="name-desc">Name Z-A</option>
+              <option value="count-asc">Fewest bookmarks</option>
+              <option value="count-desc">Most bookmarks</option>
+            </select>        
+        """,
+            html,
+        )
+
+        response = self.client.get(reverse("linkding:tags.index") + "?sort=name-desc")
+        html = response.content.decode()
+
+        self.assertInHTML(
+            """
+            <select id="sort" name="sort" class="form-select" ld-auto-submit>
+              <option value="name-asc">Name A-Z</option>
+              <option value="name-desc" selected>Name Z-A</option>
+              <option value="count-asc">Fewest bookmarks</option>
+              <option value="count-desc">Most bookmarks</option>
+            </select>        
+        """,
+            html,
+        )
diff --git a/bookmarks/tests/test_tags_merge_view.py b/bookmarks/tests/test_tags_merge_view.py
new file mode 100644
index 000000000..54d40d61c
--- /dev/null
+++ b/bookmarks/tests/test_tags_merge_view.py
@@ -0,0 +1,219 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import Bookmark, Tag
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+    def setUp(self) -> None:
+        self.user = self.get_or_create_test_user()
+        self.client.force_login(self.user)
+
+    def get_form_group(self, response, input_name):
+        soup = self.make_soup(response.content.decode())
+        input_element = soup.find("input", {"name": input_name})
+        if input_element:
+            return input_element.find_parent("div", class_="form-group")
+        return None
+
+    def test_merge_tags(self):
+        target_tag = self.setup_tag(name="target_tag")
+        merge_tag1 = self.setup_tag(name="merge_tag1")
+        merge_tag2 = self.setup_tag(name="merge_tag2")
+
+        bookmark1 = self.setup_bookmark(tags=[merge_tag1])
+        bookmark2 = self.setup_bookmark(tags=[merge_tag2])
+        bookmark3 = self.setup_bookmark(tags=[target_tag])
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+
+        self.assertEqual(Tag.objects.count(), 1)
+        self.assertFalse(Tag.objects.filter(id=merge_tag1.id).exists())
+        self.assertFalse(Tag.objects.filter(id=merge_tag2.id).exists())
+
+        self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
+
+    def test_merge_tags_complex(self):
+        target_tag = self.setup_tag(name="target_tag")
+        merge_tag1 = self.setup_tag(name="merge_tag1")
+        merge_tag2 = self.setup_tag(name="merge_tag2")
+        other_tag = self.setup_tag(name="other_tag")
+
+        bookmark1 = self.setup_bookmark(tags=[merge_tag1])
+        bookmark2 = self.setup_bookmark(tags=[merge_tag2])
+        bookmark3 = self.setup_bookmark(tags=[target_tag])
+        bookmark4 = self.setup_bookmark(
+            tags=[merge_tag1, merge_tag2]
+        )  # both merge tags
+        bookmark5 = self.setup_bookmark(
+            tags=[merge_tag2, target_tag]
+        )  # already has target tag
+        bookmark6 = self.setup_bookmark(
+            tags=[merge_tag1, merge_tag2, target_tag]
+        )  # both merge tags and target
+        bookmark7 = self.setup_bookmark(tags=[other_tag])  # unrelated tag
+        bookmark8 = self.setup_bookmark(
+            tags=[other_tag, merge_tag1]
+        )  # merge and unrelated tag
+        bookmark9 = self.setup_bookmark(
+            tags=[other_tag, target_tag]
+        )  # merge and target tag
+        bookmark10 = self.setup_bookmark(tags=[])  # no tags
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+
+        self.assertEqual(Bookmark.objects.count(), 10)
+        self.assertEqual(Tag.objects.count(), 2)
+        self.assertEqual(Bookmark.tags.through.objects.count(), 11)
+
+        self.assertCountEqual(list(Tag.objects.all()), [target_tag, other_tag])
+
+        self.assertCountEqual(list(bookmark1.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark2.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark3.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark4.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark5.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark6.tags.all()), [target_tag])
+        self.assertCountEqual(list(bookmark7.tags.all()), [other_tag])
+        self.assertCountEqual(list(bookmark8.tags.all()), [other_tag, target_tag])
+        self.assertCountEqual(list(bookmark9.tags.all()), [other_tag, target_tag])
+        self.assertCountEqual(list(bookmark10.tags.all()), [])
+
+    def test_can_only_merge_own_tags(self):
+        other_user = self.setup_user()
+        self.setup_tag(name="target_tag", user=other_user)
+        self.setup_tag(name="merge_tag", user=other_user)
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "merge_tag"},
+        )
+        self.assertEqual(response.status_code, 422)
+
+        target_tag_group = self.get_form_group(response, "target_tag")
+        self.assertIn('Tag "target_tag" does not exist', target_tag_group.get_text())
+
+        merge_tags_group = self.get_form_group(response, "merge_tags")
+        self.assertIn('Tag "merge_tag" does not exist', merge_tags_group.get_text())
+
+    def test_validate_missing_target_tag(self):
+        merge_tag = self.setup_tag(name="merge_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "", "merge_tags": "merge_tag"},
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+        target_tag_group = self.get_form_group(response, "target_tag")
+        self.assertIn("This field is required", target_tag_group.get_text())
+        self.assertTrue(Tag.objects.filter(id=merge_tag.id).exists())
+
+    def test_validate_missing_merge_tags(self):
+        self.setup_tag(name="target_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": ""},
+        )
+
+        self.assertEqual(response.status_code, 422)
+        merge_tags_group = self.get_form_group(response, "merge_tags")
+        self.assertIn("This field is required", merge_tags_group.get_text())
+
+    def test_validate_nonexistent_target_tag(self):
+        merge_tag = self.setup_tag(name="merge_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "nonexistent_tag", "merge_tags": "merge_tag"},
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+        target_tag_group = self.get_form_group(response, "target_tag")
+        self.assertIn(
+            'Tag "nonexistent_tag" does not exist', target_tag_group.get_text()
+        )
+
+    def test_validate_nonexistent_merge_tag(self):
+        self.setup_tag(name="target_tag")
+        self.setup_tag(name="merge_tag1")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "merge_tag1 nonexistent_tag"},
+        )
+
+        self.assertEqual(response.status_code, 422)
+        merge_tags_group = self.get_form_group(response, "merge_tags")
+        self.assertIn(
+            'Tag "nonexistent_tag" does not exist', merge_tags_group.get_text()
+        )
+
+    def test_validate_multiple_target_tags(self):
+        self.setup_tag(name="target_tag1")
+        self.setup_tag(name="target_tag2")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag1 target_tag2", "merge_tags": "some_tag"},
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+        target_tag_group = self.get_form_group(response, "target_tag")
+        self.assertIn(
+            "Please enter only one tag name for the target tag",
+            target_tag_group.get_text(),
+        )
+
+    def test_validate_target_tag_in_merge_list(self):
+        self.setup_tag(name="target_tag")
+        self.setup_tag(name="merge_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "target_tag merge_tag"},
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+        merge_tags_group = self.get_form_group(response, "merge_tags")
+        self.assertIn(
+            "The target tag cannot be selected for merging", merge_tags_group.get_text()
+        )
+
+    def test_merge_shows_success_message(self):
+        self.setup_tag(name="target_tag")
+        self.setup_tag(name="merge_tag1")
+        self.setup_tag(name="merge_tag2")
+
+        response = self.client.post(
+            reverse("linkding:tags.merge"),
+            {"target_tag": "target_tag", "merge_tags": "merge_tag1 merge_tag2"},
+            follow=True,
+        )
+
+        self.assertInHTML(
+            """
+                <div class="toast toast-success" role="alert">
+                    Successfully merged 2 tags (merge_tag1, merge_tag2) into "target_tag".
+                </div>
+            """,
+            response.content.decode(),
+        )
diff --git a/bookmarks/tests/test_tags_new_view.py b/bookmarks/tests/test_tags_new_view.py
new file mode 100644
index 000000000..5a889b8a7
--- /dev/null
+++ b/bookmarks/tests/test_tags_new_view.py
@@ -0,0 +1,79 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import Tag
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class TagsNewViewTestCase(TestCase, BookmarkFactoryMixin):
+    def setUp(self) -> None:
+        self.user = self.get_or_create_test_user()
+        self.client.force_login(self.user)
+
+    def test_create_tag(self):
+        response = self.client.post(reverse("linkding:tags.new"), {"name": "new_tag"})
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+        self.assertEqual(Tag.objects.count(), 1)
+        self.assertTrue(Tag.objects.filter(name="new_tag", owner=self.user).exists())
+
+    def test_show_error_for_empty_name(self):
+        response = self.client.post(reverse("linkding:tags.new"), {"name": ""})
+
+        self.assertContains(response, "This field is required", status_code=422)
+        self.assertEqual(Tag.objects.count(), 0)
+
+    def test_show_error_for_duplicate_name(self):
+        self.setup_tag(name="existing_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.new"), {"name": "existing_tag"}
+        )
+
+        self.assertContains(
+            response, "Tag &quot;existing_tag&quot; already exists", status_code=422
+        )
+        self.assertEqual(Tag.objects.count(), 1)
+
+    def test_show_error_for_duplicate_name_different_casing(self):
+        self.setup_tag(name="existing_tag")
+
+        response = self.client.post(
+            reverse("linkding:tags.new"), {"name": "existing_TAG"}
+        )
+
+        self.assertContains(
+            response, "Tag &quot;existing_TAG&quot; already exists", status_code=422
+        )
+        self.assertEqual(Tag.objects.count(), 1)
+
+    def test_no_error_for_duplicate_name_different_user(self):
+        other_user = self.setup_user()
+        self.setup_tag(name="existing_tag", user=other_user)
+
+        response = self.client.post(
+            reverse("linkding:tags.new"), {"name": "existing_tag"}
+        )
+
+        self.assertRedirects(response, reverse("linkding:tags.index"))
+        self.assertEqual(Tag.objects.count(), 2)
+        self.assertEqual(
+            Tag.objects.filter(name="existing_tag", owner=self.user).count(), 1
+        )
+        self.assertEqual(
+            Tag.objects.filter(name="existing_tag", owner=other_user).count(), 1
+        )
+
+    def test_create_shows_success_message(self):
+        response = self.client.post(
+            reverse("linkding:tags.new"), {"name": "new_tag"}, follow=True
+        )
+
+        self.assertInHTML(
+            """
+            <div class="toast toast-success" role="alert">
+                Tag "new_tag" created successfully.
+            </div>
+        """,
+            response.content.decode(),
+        )