Repository: sissbruecker/linkding
Pull request #1096 (2025-06-19 03:57:34)

Bump urllib3 from 2.2.3 to 2.5.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.3 to 2.5.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/urllib3/urllib3/releases">urllib3's releases</a>.</em></p>
<blockquote>
<h2>2.5.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3 is raising ~$40,000 USD</a> to release HTTP/2 support and ensure long-term sustainable maintenance of the project after a sharp decline in financial support. If your company or organization uses Python and would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and thousands of other projects <a href="https://opencollective.com/urllib3">please consider contributing financially</a> to ensure HTTP/2 support is developed sustainably and maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h1>Security issues</h1>
<p>urllib3 2.5.0 fixes two moderate security issues:</p>
<ul>
<li>Pool managers now properly control redirects when <code>retries</code> is passed — CVE-2025-50181 reported by <a href="https://github.com/sandumjacob"><code>@​sandumjacob</code></a> (5.3 Medium, GHSA-pq67-6m6q-mj2v)</li>
<li>Redirects are now controlled by urllib3 in the Node.js runtime — CVE-2025-50182 (5.3 Medium, GHSA-48p4-8xcf-vxj5)</li>
</ul>
<h1>Features</h1>
<ul>
<li>Added support for the <code>compression.zstd</code> module that is new in Python 3.14. See <a href="https://peps.python.org/pep-0784/">PEP 784</a> for more information. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3610">#3610</a>)</li>
<li>Added support for version 0.5 of <code>hatch-vcs</code> (<a href="https://redirect.github.com/urllib3/urllib3/issues/3612">#3612</a>)</li>
</ul>
<h1>Bugfixes</h1>
<ul>
<li>Raised exception for <code>HTTPResponse.shutdown</code> on a connection already released to the pool. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3581">#3581</a>)</li>
<li>Fixed incorrect <code>CONNECT</code> statement when using an IPv6 proxy with <code>connection_from_host</code>. Previously would not be wrapped in <code>[]</code>. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3615">#3615</a>)</li>
</ul>
<h2>2.4.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3 is raising ~$40,000 USD</a> to release HTTP/2 support and ensure long-term sustainable maintenance of the project after a sharp decline in financial support. If your company or organization uses Python and would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and thousands of other projects <a href="https://opencollective.com/urllib3">please consider contributing financially</a> to ensure HTTP/2 support is developed sustainably and maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h1>Features</h1>
<ul>
<li>Applied PEP 639 by specifying the license fields in pyproject.toml. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3522">#3522</a>)</li>
<li>Updated exceptions to save and restore more properties during the pickle/serialization process. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3567">#3567</a>)</li>
<li>Added <code>verify_flags</code> option to <code>create_urllib3_context</code> with a default of <code>VERIFY_X509_PARTIAL_CHAIN</code> and <code>VERIFY_X509_STRICT</code> for Python 3.13+. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3571">#3571</a>)</li>
</ul>
<h1>Bugfixes</h1>
<ul>
<li>Fixed a bug with partial reads of streaming data in Emscripten. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3555">#3555</a>)</li>
</ul>
<h1>Misc</h1>
<ul>
<li>Switched to uv for installing development dependecies. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3550">#3550</a>)</li>
<li>Removed the <code>multiple.intoto.jsonl</code> asset from GitHub releases. Attestation of release files since v2.3.0 can be found on PyPI. (<a href="https://redirect.github.com/urllib3/urllib3/issues/3566">#3566</a>)</li>
</ul>
<h2>2.3.0</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's changelog</a>.</em></p>
<blockquote>
<h1>2.5.0 (2025-06-18)</h1>
<h2>Features</h2>
<ul>
<li>Added support for the <code>compression.zstd</code> module that is new in Python 3.14.
See <code>PEP 784 &lt;https://peps.python.org/pep-0784/&gt;</code>_ for more information. (<code>[#3610](https://github.com/urllib3/urllib3/issues/3610) &lt;https://github.com/urllib3/urllib3/issues/3610&gt;</code>__)</li>
<li>Added support for version 0.5 of <code>hatch-vcs</code> (<code>[#3612](https://github.com/urllib3/urllib3/issues/3612) &lt;https://github.com/urllib3/urllib3/issues/3612&gt;</code>__)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>Fixed a security issue where restricting the maximum number of followed
redirects at the <code>urllib3.PoolManager</code> level via the <code>retries</code> parameter
did not work.</li>
<li>Made the Node.js runtime respect redirect parameters such as <code>retries</code>
and <code>redirects</code>.</li>
<li>Raised exception for <code>HTTPResponse.shutdown</code> on a connection already released to the pool. (<code>[#3581](https://github.com/urllib3/urllib3/issues/3581) &lt;https://github.com/urllib3/urllib3/issues/3581&gt;</code>__)</li>
<li>Fixed incorrect <code>CONNECT</code> statement when using an IPv6 proxy with <code>connection_from_host</code>. Previously would not be wrapped in <code>[]</code>. (<code>[#3615](https://github.com/urllib3/urllib3/issues/3615) &lt;https://github.com/urllib3/urllib3/issues/3615&gt;</code>__)</li>
</ul>
<h1>2.4.0 (2025-04-10)</h1>
<h2>Features</h2>
<ul>
<li>Applied PEP 639 by specifying the license fields in pyproject.toml. (<code>[#3522](https://github.com/urllib3/urllib3/issues/3522) &lt;https://github.com/urllib3/urllib3/issues/3522&gt;</code>__)</li>
<li>Updated exceptions to save and restore more properties during the pickle/serialization process. (<code>[#3567](https://github.com/urllib3/urllib3/issues/3567) &lt;https://github.com/urllib3/urllib3/issues/3567&gt;</code>__)</li>
<li>Added <code>verify_flags</code> option to <code>create_urllib3_context</code> with a default of <code>VERIFY_X509_PARTIAL_CHAIN</code> and <code>VERIFY_X509_STRICT</code> for Python 3.13+. (<code>[#3571](https://github.com/urllib3/urllib3/issues/3571) &lt;https://github.com/urllib3/urllib3/issues/3571&gt;</code>__)</li>
</ul>
<h2>Bugfixes</h2>
<ul>
<li>Fixed a bug with partial reads of streaming data in Emscripten. (<code>[#3555](https://github.com/urllib3/urllib3/issues/3555) &lt;https://github.com/urllib3/urllib3/issues/3555&gt;</code>__)</li>
</ul>
<h2>Misc</h2>
<ul>
<li>Switched to uv for installing development dependecies. (<code>[#3550](https://github.com/urllib3/urllib3/issues/3550) &lt;https://github.com/urllib3/urllib3/issues/3550&gt;</code>__)</li>
<li>Removed the <code>multiple.intoto.jsonl</code> asset from GitHub releases. Attestation of release files since v2.3.0 can be found on PyPI. (<code>[#3566](https://github.com/urllib3/urllib3/issues/3566) &lt;https://github.com/urllib3/urllib3/issues/3566&gt;</code>__)</li>
</ul>
<h1>2.3.0 (2024-12-22)</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/urllib3/urllib3/commit/aaab4eccc10c965897540b21e15f11859d0b62e7"><code>aaab4ec</code></a> Release 2.5.0</li>
<li><a href="https://github.com/urllib3/urllib3/commit/7eb4a2aafe49a279c29b6d1f0ed0f42e9736194f"><code>7eb4a2a</code></a> Merge commit from fork</li>
<li><a href="https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857"><code>f05b132</code></a> Merge commit from fork</li>
<li><a href="https://github.com/urllib3/urllib3/commit/d03fe327a71d09728512217149f269763671f296"><code>d03fe32</code></a> Fix HTTP tunneling with IPv6 in older Python versions</li>
<li><a href="https://github.com/urllib3/urllib3/commit/11661e9bb4278e43d081f47a516e287a928c2206"><code>11661e9</code></a> Bump github/codeql-action from 3.28.0 to 3.29.0 (<a href="https://redirect.github.com/urllib3/urllib3/issues/3624">#3624</a>)</li>
<li><a href="https://github.com/urllib3/urllib3/commit/6a0ecc6b16fe30f721021b44a81d19615098c71e"><code>6a0ecc6</code></a> Update v2 migration guide to 2.4.0 (<a href="https://redirect.github.com/urllib3/urllib3/issues/3621">#3621</a>)</li>
<li><a href="https://github.com/urllib3/urllib3/commit/8e32e60d9024c05bc6f7adda08bdf6c539d0b0d4"><code>8e32e60</code></a> Raise exception for shutdown on a connection already released to the pool (<a href="https://redirect.github.com/urllib3/urllib3/issues/3">#3</a>...</li>
<li><a href="https://github.com/urllib3/urllib3/commit/9996e0fbf90b77083ad3c73737a6c6395703faa9"><code>9996e0f</code></a> Fix emscripten CI for Chrome 137+ (<a href="https://redirect.github.com/urllib3/urllib3/issues/3599">#3599</a>)</li>
<li><a href="https://github.com/urllib3/urllib3/commit/4fd1a99a59725faf0efc946ce3b6bc9a194420af"><code>4fd1a99</code></a> Bump RECENT_DATE (<a href="https://redirect.github.com/urllib3/urllib3/issues/3617">#3617</a>)</li>
<li><a href="https://github.com/urllib3/urllib3/commit/c4b5917e911a90c8bf279448df8952a682294135"><code>c4b5917</code></a> Add support for the new <code>compression.zstd</code> module in Python 3.14 (<a href="https://redirect.github.com/urllib3/urllib3/issues/3611">#3611</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/urllib3/urllib3/compare/2.2.3...2.5.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=urllib3&package-manager=pip&previous-version=2.2.3&new-version=2.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/sissbruecker/linkding/network/alerts).

</details>


Patch:
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index eac5b5d..c63d15d 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -50,7 +50,7 @@ class BookmarkViewSet(
     def get_queryset(self):
         # Provide filtered queryset for list actions
         user = self.request.user
-        search = BookmarkSearch.from_request(self.request.GET)
+        search = BookmarkSearch.from_request(self.request, self.request.GET)
         if self.action == "list":
             return queries.query_bookmarks(user, user.profile, search)
         elif self.action == "archived":
diff --git a/bookmarks/frontend/components/TagAutocomplete.svelte b/bookmarks/frontend/components/TagAutocomplete.svelte
index 09553b4..8214eca 100644
--- a/bookmarks/frontend/components/TagAutocomplete.svelte
+++ b/bookmarks/frontend/components/TagAutocomplete.svelte
@@ -77,6 +77,7 @@
     const bounds = getCurrentWordBounds(input);
     const value = input.value;
     input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
+    input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
 
     close();
   }
@@ -128,41 +129,41 @@
 </div>
 
 <style>
-    .menu {
-        display: none;
-        max-height: 200px;
-        overflow: auto;
-    }
+  .menu {
+    display: none;
+    max-height: 200px;
+    overflow: auto;
+  }
 
-    .menu.open {
-        display: block;
-    }
+  .menu.open {
+    display: block;
+  }
 
-    .form-autocomplete-input {
-        box-sizing: border-box;
-        height: var(--control-size);
-        min-height: var(--control-size);
-        padding: 0;
-    }
+  .form-autocomplete-input {
+    box-sizing: border-box;
+    height: var(--control-size);
+    min-height: var(--control-size);
+    padding: 0;
+  }
 
-    .form-autocomplete-input input {
-        width: 100%;
-        height: 100%;
-        border: none;
-        margin: 0;
-    }
+  .form-autocomplete-input input {
+    width: 100%;
+    height: 100%;
+    border: none;
+    margin: 0;
+  }
 
-    .form-autocomplete.small .form-autocomplete-input {
-        height: var(--control-size-sm);
-        min-height: var(--control-size-sm);
-    }
+  .form-autocomplete.small .form-autocomplete-input {
+    height: var(--control-size-sm);
+    min-height: var(--control-size-sm);
+  }
 
-    .form-autocomplete.small .form-autocomplete-input input {
-        padding: 0.05rem 0.3rem;
-        font-size: var(--font-size-sm);
-    }
+  .form-autocomplete.small .form-autocomplete-input input {
+    padding: 0.05rem 0.3rem;
+    font-size: var(--font-size-sm);
+  }
 
-    .form-autocomplete.small .menu .menu-item {
-        font-size: var(--font-size-sm);
-    }
+  .form-autocomplete.small .menu .menu-item {
+    font-size: var(--font-size-sm);
+  }
 </style>
diff --git a/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py
new file mode 100644
index 0000000..7be682f
--- /dev/null
+++ b/bookmarks/migrations/0045_userprofile_hide_bundles_bookmarkbundle.py
@@ -0,0 +1,50 @@
+# Generated by Django 5.1.9 on 2025-06-19 08:48
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0044_bookmark_latest_snapshot"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="hide_bundles",
+            field=models.BooleanField(default=False),
+        ),
+        migrations.CreateModel(
+            name="BookmarkBundle",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=256)),
+                ("search", models.CharField(blank=True, max_length=256)),
+                ("any_tags", models.CharField(blank=True, max_length=1024)),
+                ("all_tags", models.CharField(blank=True, max_length=1024)),
+                ("excluded_tags", models.CharField(blank=True, max_length=1024)),
+                ("order", models.IntegerField(default=0)),
+                ("date_created", models.DateTimeField(auto_now_add=True)),
+                ("date_modified", models.DateTimeField(auto_now=True)),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+        ),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index d411c9a..375c0cd 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -2,6 +2,7 @@ import binascii
 import hashlib
 import logging
 import os
+from functools import cached_property
 from typing import List
 
 from django import forms
@@ -157,6 +158,27 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
                 logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
 
 
+class BookmarkBundle(models.Model):
+    name = models.CharField(max_length=256, blank=False)
+    search = models.CharField(max_length=256, blank=True)
+    any_tags = models.CharField(max_length=1024, blank=True)
+    all_tags = models.CharField(max_length=1024, blank=True)
+    excluded_tags = models.CharField(max_length=1024, blank=True)
+    order = models.IntegerField(null=False, default=0)
+    date_created = models.DateTimeField(auto_now_add=True, null=False)
+    date_modified = models.DateTimeField(auto_now=True, null=False)
+    owner = models.ForeignKey(User, on_delete=models.CASCADE)
+
+    def __str__(self):
+        return self.name
+
+
+class BookmarkBundleForm(forms.ModelForm):
+    class Meta:
+        model = BookmarkBundle
+        fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
+
+
 class BookmarkSearch:
     SORT_ADDED_ASC = "added_asc"
     SORT_ADDED_DESC = "added_desc"
@@ -171,11 +193,21 @@ class BookmarkSearch:
     FILTER_UNREAD_YES = "yes"
     FILTER_UNREAD_NO = "no"
 
-    params = ["q", "user", "sort", "shared", "unread", "modified_since", "added_since"]
+    params = [
+        "q",
+        "user",
+        "bundle",
+        "sort",
+        "shared",
+        "unread",
+        "modified_since",
+        "added_since",
+    ]
     preferences = ["sort", "shared", "unread"]
     defaults = {
         "q": "",
         "user": "",
+        "bundle": None,
         "sort": SORT_ADDED_DESC,
         "shared": FILTER_SHARED_OFF,
         "unread": FILTER_UNREAD_OFF,
@@ -187,19 +219,23 @@ class BookmarkSearch:
         self,
         q: str = None,
         user: str = None,
+        bundle: BookmarkBundle = None,
         sort: str = None,
         shared: str = None,
         unread: str = None,
         modified_since: str = None,
         added_since: str = None,
         preferences: dict = None,
+        request: any = None,
     ):
         if not preferences:
             preferences = {}
         self.defaults = {**BookmarkSearch.defaults, **preferences}
+        self.request = request
 
         self.q = q or self.defaults["q"]
         self.user = user or self.defaults["user"]
+        self.bundle = bundle or self.defaults["bundle"]
         self.sort = sort or self.defaults["sort"]
         self.shared = shared or self.defaults["shared"]
         self.unread = unread or self.defaults["unread"]
@@ -232,7 +268,14 @@ class BookmarkSearch:
 
     @property
     def query_params(self):
-        return {param: self.__dict__[param] for param in self.modified_params}
+        query_params = {}
+        for param in self.modified_params:
+            value = self.__dict__[param]
+            if isinstance(value, models.Model):
+                query_params[param] = value.id
+            else:
+                query_params[param] = value
+        return query_params
 
     @property
     def preferences_dict(self):
@@ -241,14 +284,21 @@ class BookmarkSearch:
         }
 
     @staticmethod
-    def from_request(query_dict: QueryDict, preferences: dict = None):
+    def from_request(request: any, query_dict: QueryDict, preferences: dict = None):
         initial_values = {}
         for param in BookmarkSearch.params:
             value = query_dict.get(param)
             if value:
-                initial_values[param] = value
+                if param == "bundle":
+                    initial_values[param] = BookmarkBundle.objects.filter(
+                        owner=request.user, pk=value
+                    ).first()
+                else:
+                    initial_values[param] = value
 
-        return BookmarkSearch(**initial_values, preferences=preferences)
+        return BookmarkSearch(
+            **initial_values, preferences=preferences, request=request
+        )
 
 
 class BookmarkSearchForm(forms.Form):
@@ -271,6 +321,7 @@ class BookmarkSearchForm(forms.Form):
 
     q = forms.CharField()
     user = forms.ChoiceField(required=False)
+    bundle = forms.CharField(required=False)
     sort = forms.ChoiceField(choices=SORT_CHOICES)
     shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
     unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
@@ -295,7 +346,11 @@ class BookmarkSearchForm(forms.Form):
 
         for param in search.params:
             # set initial values for modified params
-            self.fields[param].initial = search.__dict__[param]
+            value = search.__dict__.get(param)
+            if isinstance(value, models.Model):
+                self.fields[param].initial = value.id
+            else:
+                self.fields[param].initial = value
 
             # Mark non-editable modified fields as hidden. That way, templates
             # rendering a form can just loop over hidden_fields to ensure that
@@ -416,6 +471,7 @@ class UserProfile(models.Model):
     )
     sticky_pagination = models.BooleanField(default=False, null=False)
     collapse_side_panel = models.BooleanField(default=False, null=False)
+    hide_bundles = models.BooleanField(default=False, null=False)
 
     def save(self, *args, **kwargs):
         if self.custom_css:
@@ -456,6 +512,7 @@ class UserProfileForm(forms.ModelForm):
             "items_per_page",
             "sticky_pagination",
             "collapse_side_panel",
+            "hide_bundles",
         ]
 
 
diff --git a/bookmarks/queries.py b/bookmarks/queries.py
index f661f9f..5337b0b 100644
--- a/bookmarks/queries.py
+++ b/bookmarks/queries.py
@@ -7,12 +7,21 @@ from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharFiel
 from django.db.models.expressions import RawSQL
 from django.db.models.functions import Lower
 
-from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
+from bookmarks.models import (
+    Bookmark,
+    BookmarkBundle,
+    BookmarkSearch,
+    Tag,
+    UserProfile,
+    parse_tag_string,
+)
 from bookmarks.utils import unique
 
 
 def query_bookmarks(
-    user: User, profile: UserProfile, search: BookmarkSearch
+    user: User,
+    profile: UserProfile,
+    search: BookmarkSearch,
 ) -> QuerySet:
     return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
 
@@ -36,8 +45,51 @@ def query_shared_bookmarks(
     return _base_bookmarks_query(user, profile, search).filter(conditions)
 
 
+def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
+    # Search terms
+    search_terms = parse_query_string(bundle.search)["search_terms"]
+    for term in search_terms:
+        conditions = (
+            Q(title__icontains=term)
+            | Q(description__icontains=term)
+            | Q(notes__icontains=term)
+            | Q(url__icontains=term)
+        )
+        query_set = query_set.filter(conditions)
+
+    # Any tags - at least one tag must match
+    any_tags = parse_tag_string(bundle.any_tags, " ")
+    if len(any_tags) > 0:
+        tag_conditions = Q()
+        for tag in any_tags:
+            tag_conditions |= Q(tags__name__iexact=tag)
+
+        query_set = query_set.filter(
+            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
+        )
+
+    # All tags - all tags must match
+    all_tags = parse_tag_string(bundle.all_tags, " ")
+    for tag in all_tags:
+        query_set = query_set.filter(tags__name__iexact=tag)
+
+    # Excluded tags - no tags must match
+    exclude_tags = parse_tag_string(bundle.excluded_tags, " ")
+    if len(exclude_tags) > 0:
+        tag_conditions = Q()
+        for tag in exclude_tags:
+            tag_conditions |= Q(tags__name__iexact=tag)
+        query_set = query_set.exclude(
+            Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
+        )
+
+    return query_set
+
+
 def _base_bookmarks_query(
-    user: Optional[User], profile: UserProfile, search: BookmarkSearch
+    user: Optional[User],
+    profile: UserProfile,
+    search: BookmarkSearch,
 ) -> QuerySet:
     query_set = Bookmark.objects
 
@@ -102,6 +154,10 @@ def _base_bookmarks_query(
     elif search.shared == BookmarkSearch.FILTER_SHARED_UNSHARED:
         query_set = query_set.filter(shared=False)
 
+    # Filter by bundle
+    if search.bundle:
+        query_set = _filter_bundle(query_set, search.bundle)
+
     # Sort
     if (
         search.sort == BookmarkSearch.SORT_TITLE_ASC
diff --git a/bookmarks/styles/bookmark-details.css b/bookmarks/styles/bookmark-details.css
index 5aa0215..0dad87d 100644
--- a/bookmarks/styles/bookmark-details.css
+++ b/bookmarks/styles/bookmark-details.css
@@ -49,50 +49,9 @@
   & .assets {
     margin-top: var(--unit-2);
 
-    & .asset {
-      display: flex;
-      align-items: center;
-      gap: var(--unit-2);
-      padding: var(--unit-2) 0;
-      border-top: var(--unit-o) solid var(--secondary-border-color);
-    }
-
-    & .asset:last-child {
-      border-bottom: var(--unit-o) solid var(--secondary-border-color);
-    }
-
-    & .asset-icon {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-    }
-
-    & .asset-text {
-      flex: 1 1 0;
-      gap: var(--unit-2);
-      min-width: 0;
-      display: flex;
-    }
-
-    & .asset-text .truncate {
-      flex-shrink: 1;
-    }
-
-    & .asset-text .filesize {
+    & .filesize {
       color: var(--tertiary-text-color);
     }
-
-    & .asset-actions {
-      display: flex;
-      gap: var(--unit-4);
-      align-items: center;
-
-      & .btn.btn-link {
-        height: unset;
-        padding: 0;
-        border: none;
-      }
-    }
   }
 
   & .assets-actions {
diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css
index f3f5d7d..e5e332a 100644
--- a/bookmarks/styles/bookmark-page.css
+++ b/bookmarks/styles/bookmark-page.css
@@ -379,6 +379,26 @@ li[ld-bookmark-item] {
   }
 }
 
+.bundle-menu {
+  list-style-type: none;
+  margin: 0 0 var(--unit-6);
+
+  .bundle-menu-item {
+    margin: 0;
+    margin-bottom: var(--unit-2);
+  }
+
+  .bundle-menu-item a {
+    padding: var(--unit-1) var(--unit-2);
+    border-radius: var(--border-radius);
+  }
+
+  .bundle-menu-item.selected a {
+    background: var(--primary-color);
+    color: var(--contrast-text-color);
+  }
+}
+
 .tag-cloud {
   /* Increase line-height for better separation within / between items */
   line-height: 1.1rem;
diff --git a/bookmarks/styles/bundles.css b/bookmarks/styles/bundles.css
new file mode 100644
index 0000000..97c24bc
--- /dev/null
+++ b/bookmarks/styles/bundles.css
@@ -0,0 +1,34 @@
+.bundles-page {
+  h1 {
+    font-size: var(--font-size-lg);
+    margin-bottom: var(--unit-6);
+  }
+
+  .item-list {
+    .list-item .list-item-icon {
+      cursor: grab;
+    }
+
+    .list-item.drag-start {
+      --secondary-border-color: transparent;
+    }
+
+    .list-item.dragging > * {
+      visibility: hidden;
+    }
+  }
+}
+
+.bundles-editor-page {
+  &.grid {
+    gap: var(--unit-9);
+  }
+
+  .form-footer {
+    position: sticky;
+    bottom: 0;
+    border-top: solid 1px var(--secondary-border-color);
+    background: var(--body-color);
+    padding: var(--unit-3) 0;
+  }
+}
diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css
index 9f4c021..3904a37 100644
--- a/bookmarks/styles/components.css
+++ b/bookmarks/styles/components.css
@@ -60,3 +60,60 @@ span.confirmation {
 .turbo-progress-bar {
   background-color: var(--primary-color);
 }
+
+/* Messages */
+.message-list {
+  margin: var(--unit-4) 0;
+
+  .toast {
+    margin-bottom: var(--unit-2);
+  }
+
+  .toast a.btn-clear:visited {
+    color: currentColor;
+  }
+}
+
+/* Item list */
+.item-list {
+  & .list-item {
+    display: flex;
+    align-items: center;
+    gap: var(--unit-2);
+    padding: var(--unit-2) 0;
+    border-top: var(--unit-o) solid var(--secondary-border-color);
+  }
+
+  & .list-item:last-child {
+    border-bottom: var(--unit-o) solid var(--secondary-border-color);
+  }
+
+  & .list-item-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  & .list-item-text {
+    flex: 1 1 0;
+    gap: var(--unit-2);
+    min-width: 0;
+    display: flex;
+  }
+
+  & .list-item-text .truncate {
+    flex-shrink: 1;
+  }
+
+  & .list-item-actions {
+    display: flex;
+    gap: var(--unit-4);
+    align-items: center;
+
+    & .btn.btn-link {
+      height: unset;
+      padding: 0;
+      border: none;
+    }
+  }
+}
diff --git a/bookmarks/styles/layout.css b/bookmarks/styles/layout.css
index bc951d1..4e02435 100644
--- a/bookmarks/styles/layout.css
+++ b/bookmarks/styles/layout.css
@@ -27,15 +27,3 @@ header {
     line-height: 1.2;
   }
 }
-
-header .toasts {
-  margin-bottom: 20px;
-
-  .toast {
-    margin-bottom: 0.4rem;
-  }
-
-  .toast a.btn-clear:visited {
-    color: currentColor;
-  }
-}
diff --git a/bookmarks/styles/theme-light.css b/bookmarks/styles/theme-light.css
index 57a5cd9..ac68d39 100644
--- a/bookmarks/styles/theme-light.css
+++ b/bookmarks/styles/theme-light.css
@@ -28,3 +28,4 @@
 @import "markdown.css";
 @import "reader-mode.css";
 @import "settings.css";
+@import "bundles.css";
diff --git a/bookmarks/styles/theme/utilities.css b/bookmarks/styles/theme/utilities.css
index bbdd0d2..91437f4 100644
--- a/bookmarks/styles/theme/utilities.css
+++ b/bookmarks/styles/theme/utilities.css
@@ -242,6 +242,14 @@
   margin-top: var(--unit-4) !important;
 }
 
+.ml-auto {
+  margin-left: auto;
+}
+
+.mr-auto {
+  margin-right: auto;
+}
+
 .mx-auto {
   margin-left: auto;
   margin-right: auto;
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index 96e6465..5e699d5 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -30,16 +30,10 @@
       </form>
     </main>
 
-    {# Tag cloud #}
+    {# Filters #}
     <div class="side-panel col-1 hide-md">
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+      {% include 'bookmarks/bundle_section.html' %}
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index d23e649..2e38354 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -77,72 +77,76 @@
                 {% else %}
                   <span>{{ bookmark_item.display_date }}</span>
                 {% endif %}
-                <span>|</span>
-              {% endif %}
-              {# View link is visible for both owned and shared bookmarks #}
-              {% if bookmark_list.show_view_action %}
-                <a href="{{ bookmark_item.details_url }}" class="view-action"
-                   data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
-              {% endif %}
-              {% if bookmark_item.is_editable %}
-                {# Bookmark owner actions #}
-                {% if bookmark_list.show_edit_action %}
-                  <a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
-                {% endif %}
-                {% if bookmark_list.show_archive_action %}
-                  {% if bookmark_item.is_archived %}
-                    <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm">Unarchive
-                    </button>
-                  {% else %}
-                    <button type="submit" name="archive" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm">Archive
-                    </button>
-                  {% endif %}
+                {% if not bookmark_list.is_preview %}
+                  <span>|</span>
                 {% endif %}
-                {% if bookmark_list.show_remove_action %}
-                  <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
-                          class="btn btn-link btn-sm">Remove
-                  </button>
-                {% endif %}
-              {% else %}
-                {# Shared bookmark actions #}
-                <span>Shared by
-                <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
-              </span>
               {% endif %}
-              {% if bookmark_item.has_extra_actions %}
-                <div class="extra-actions">
-                  <span class="hide-sm">|</span>
-                  {% if bookmark_item.show_mark_as_read %}
-                    <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm btn-icon"
-                            ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-unread"></use>
-                      </svg>
-                      Unread
-                    </button>
+              {% if not bookmark_list.is_preview %}
+                {# View link is visible for both owned and shared bookmarks #}
+                {% if bookmark_list.show_view_action %}
+                  <a href="{{ bookmark_item.details_url }}" class="view-action"
+                    data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
+                {% endif %}
+                {% if bookmark_item.is_editable %}
+                  {# Bookmark owner actions #}
+                  {% if bookmark_list.show_edit_action %}
+                    <a href="{% url 'linkding:bookmarks.edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
                   {% endif %}
-                  {% if bookmark_item.show_unshare %}
-                    <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
-                            class="btn btn-link btn-sm btn-icon"
-                            ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-share"></use>
-                      </svg>
-                      Shared
-                    </button>
+                  {% if bookmark_list.show_archive_action %}
+                    {% if bookmark_item.is_archived %}
+                      <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm">Unarchive
+                      </button>
+                    {% else %}
+                      <button type="submit" name="archive" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm">Archive
+                      </button>
+                    {% endif %}
                   {% endif %}
-                  {% if bookmark_item.show_notes_button %}
-                    <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
-                      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
-                        <use xlink:href="#ld-icon-note"></use>
-                      </svg>
-                      Notes
+                  {% if bookmark_list.show_remove_action %}
+                    <button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
+                            class="btn btn-link btn-sm">Remove
                     </button>
                   {% endif %}
-                </div>
+                {% else %}
+                  {# Shared bookmark actions #}
+                  <span>Shared by
+                  <a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
+                </span>
+                {% endif %}
+                {% if bookmark_item.has_extra_actions %}
+                  <div class="extra-actions">
+                    <span class="hide-sm">|</span>
+                    {% if bookmark_item.show_mark_as_read %}
+                      <button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm btn-icon"
+                              ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-unread"></use>
+                        </svg>
+                        Unread
+                      </button>
+                    {% endif %}
+                    {% if bookmark_item.show_unshare %}
+                      <button type="submit" name="unshare" value="{{ bookmark_item.id }}"
+                              class="btn btn-link btn-sm btn-icon"
+                              ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-share"></use>
+                        </svg>
+                        Shared
+                      </button>
+                    {% endif %}
+                    {% if bookmark_item.show_notes_button %}
+                      <button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+                          <use xlink:href="#ld-icon-note"></use>
+                        </svg>
+                        Notes
+                      </button>
+                    {% endif %}
+                  </div>
+                {% endif %}
               {% endif %}
             </div>
           </div>
diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html
new file mode 100644
index 0000000..bd707f4
--- /dev/null
+++ b/bookmarks/templates/bookmarks/bundle_section.html
@@ -0,0 +1,23 @@
+{% if not request.user_profile.hide_bundles %}
+  <section aria-labelledby="bundles-heading">
+    <div class="section-header">
+      <h2 id="bundles-heading">Bundles</h2>
+      <a href="{% url 'linkding:bundles.index' %}" class="btn ml-auto" aria-label="Manage bundles">
+        <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="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
+          <path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
+        </svg>
+      </a>
+    </div>
+    <ul class="bundle-menu">
+      {% for bundle in bundles.bundles %}
+        <li class="bundle-menu-item {% if bundle.id == bundles.selected_bundle.id %}selected{% endif %}">
+          <a href="?bundle={{ bundle.id }}">{{ bundle.name }}</a>
+        </li>
+      {% endfor %}
+    </ul>
+  </section>
+{% endif %}
diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html
index a6a120a..3212e49 100644
--- a/bookmarks/templates/bookmarks/details/assets.html
+++ b/bookmarks/templates/bookmarks/details/assets.html
@@ -1,12 +1,12 @@
 <div>
   {% if details.assets %}
-    <div class="assets">
+    <div class="item-list assets">
       {% for asset in details.assets %}
-        <div class="asset" data-asset-id="{{ asset.id }}">
-          <div class="asset-icon {{ asset.icon_classes }}">
+        <div class="list-item" data-asset-id="{{ asset.id }}">
+          <div class="list-item-icon {{ asset.icon_classes }}">
             {% include 'bookmarks/details/asset_icon.html' %}
           </div>
-          <div class="asset-text {{ asset.text_classes }}">
+          <div class="list-item-text {{ asset.text_classes }}">
           <span class="truncate">
             {{ asset.display_name }}
             {% if asset.status == 'pending' %}(queued){% endif %}
@@ -16,7 +16,7 @@
               <span class="filesize">{{ asset.file_size|filesizeformat }}</span>
             {% endif %}
           </div>
-          <div class="asset-actions">
+          <div class="list-item-actions">
             {% if asset.file %}
               <a class="btn btn-link" href="{% url 'linkding:assets.view' asset.id %}" target="_blank">View</a>
             {% endif %}
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html
index 849aa26..c8cbd16 100644
--- a/bookmarks/templates/bookmarks/index.html
+++ b/bookmarks/templates/bookmarks/index.html
@@ -32,16 +32,10 @@
       </form>
     </main>
 
-    {# Tag cloud #}
+    {# Filters #}
     <div class="side-panel col-1 hide-md">
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+      {% include 'bookmarks/bundle_section.html' %}
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index 9508cf6..41ff112 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -67,7 +67,7 @@
 
 <header class="container">
   {% if has_toasts %}
-    <div class="toasts">
+    <div class="message-list">
       <form action="{% url 'linkding:toasts.acknowledge' %}?return_url={{ request.path | urlencode }}" method="post">
         {% csrf_token %}
         {% for toast in toast_messages %}
diff --git a/bookmarks/templates/bookmarks/pagination.html b/bookmarks/templates/bookmarks/pagination.html
index 62a6394..6af81cb 100644
--- a/bookmarks/templates/bookmarks/pagination.html
+++ b/bookmarks/templates/bookmarks/pagination.html
@@ -3,7 +3,7 @@
 <ul class="pagination">
   {% if prev_link %}
     <li class="page-item">
-      <a href="?{{ prev_link }}" tabindex="-1">Previous</a>
+      <a href="{{ prev_link }}" tabindex="-1">Previous</a>
     </li>
   {% else %}
     <li class="page-item disabled">
@@ -14,7 +14,7 @@
   {% for page_link in page_links %}
     {% if page_link %}
       <li class="page-item {% if page_link.active %}active{% endif %}">
-        <a href="?{{ page_link.link }}">{{ page_link.number }}</a>
+        <a href="{{ page_link.link }}">{{ page_link.number }}</a>
       </li>
     {% else %}
       <li class="page-item">
@@ -25,7 +25,7 @@
 
   {% if next_link %}
     <li class="page-item">
-      <a href="?{{ next_link }}" tabindex="-1">Next</a>
+      <a href="{{ next_link }}" tabindex="-1">Next</a>
     </li>
   {% else %}
     <li class="page-item disabled">
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
index 88eab9e..c1d7764 100644
--- a/bookmarks/templates/bookmarks/shared.html
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -38,14 +38,7 @@
           <br>
         </div>
       </section>
-      <section aria-labelledby="tags-heading">
-        <div class="section-header">
-          <h2 id="tags-heading">Tags</h2>
-        </div>
-        <div id="tag-cloud-container">
-          {% include 'bookmarks/tag_cloud.html' %}
-        </div>
-      </section>
+      {% include 'bookmarks/tag_section.html' %}
     </div>
   </div>
 {% endblock %}
diff --git a/bookmarks/templates/bookmarks/tag_section.html b/bookmarks/templates/bookmarks/tag_section.html
new file mode 100644
index 0000000..78558e0
--- /dev/null
+++ b/bookmarks/templates/bookmarks/tag_section.html
@@ -0,0 +1,8 @@
+<section aria-labelledby="tags-heading">
+  <div class="section-header">
+    <h2 id="tags-heading">Tags</h2>
+  </div>
+  <div id="tag-cloud-container">
+    {% include 'bookmarks/tag_cloud.html' %}
+  </div>
+</section>
diff --git a/bookmarks/templates/bundles/edit.html b/bookmarks/templates/bundles/edit.html
new file mode 100644
index 0000000..a67f855
--- /dev/null
+++ b/bookmarks/templates/bundles/edit.html
@@ -0,0 +1,33 @@
+{% extends 'bookmarks/layout.html' %}
+{% load widget_tweaks %}
+
+{% block head %}
+  {% with page_title="Edit bundle - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="bundles-editor-page grid columns-md-1">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">Edit bundle</h1>
+      </div>
+
+      {% include 'shared/messages.html' %}
+
+      <form id="bundle-form" action="{% url 'linkding:bundles.edit' bundle.id %}" method="post" novalidate>
+        {% csrf_token %}
+        {% include 'bundles/form.html' %}
+      </form>
+    </main>
+
+    <aside class="col-2" aria-labelledby="preview-heading">
+      <div class="section-header">
+        <h2 id="preview-heading">Preview</h2>
+      </div>
+
+      {% include 'bundles/preview.html' %}
+    </aside>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/form.html b/bookmarks/templates/bundles/form.html
new file mode 100644
index 0000000..bb119cd
--- /dev/null
+++ b/bookmarks/templates/bundles/form.html
@@ -0,0 +1,91 @@
+{% load widget_tweaks %}
+
+<div class="form-group {% if form.name.errors %}has-error{% endif %}">
+  <label for="{{ form.name.id_for_label }}" class="form-label">Name</label>
+  {{ form.name|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
+  {% if form.name.errors %}
+    <div class="form-input-hint">
+      {{ form.name.errors }}
+    </div>
+  {% endif %}
+</div>
+
+<div class="form-group {% if form.search.errors %}has-error{% endif %}">
+  <label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
+  {{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
+  {% if form.search.errors %}
+    <div class="form-input-hint">
+      {{ form.search.errors }}
+    </div>
+  {% endif %}
+  <div class="form-input-hint">
+    Search terms to match bookmarks in this bundle.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.any_tags.id_for_label }}" class="form-label">Tags</label>
+  {{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    At least one of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.all_tags.id_for_label }}" class="form-label">Required tags</label>
+  {{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    All of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-group" ld-tag-autocomplete>
+  <label for="{{ form.excluded_tags.id_for_label }}" class="form-label">Excluded tags</label>
+  {{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
+  <div class="form-input-hint">
+    None of these tags must be present in a bookmark to match.
+  </div>
+</div>
+
+<div class="form-footer d-flex mt-4">
+  <input type="submit" name="save" value="Save" class="btn btn-primary btn-wide">
+  <a href="{% url 'linkding:bundles.index' %}" class="btn btn-wide ml-auto">Cancel</a>
+  <a href="{% url 'linkding:bundles.preview' %}" data-turbo-frame="preview" class="d-none" id="preview-link"></a>
+</div>
+
+<script>
+  (function init() {
+    const bundleForm = document.getElementById('bundle-form');
+    const previewLink = document.getElementById('preview-link');
+
+    let pendingUpdate;
+
+    function scheduleUpdate() {
+      if (pendingUpdate) {
+        clearTimeout(pendingUpdate);
+      }
+      pendingUpdate = setTimeout(() => {
+        // Ignore if link has been removed (e.g. form submit or navigation)
+        if (!previewLink.isConnected) {
+          return;
+        }
+
+        const baseUrl = previewLink.href.split('?')[0];
+        const params = new URLSearchParams();
+        const inputs = bundleForm.querySelectorAll('input[type="text"]:not([name="csrfmiddlewaretoken"]), textarea, select');
+
+        inputs.forEach(input => {
+          if (input.name && input.value.trim()) {
+            params.set(input.name, input.value.trim());
+          }
+        });
+
+        previewLink.href = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+        previewLink.click();
+      }, 500)
+    }
+
+    bundleForm.addEventListener('input', scheduleUpdate);
+    bundleForm.addEventListener('change', scheduleUpdate);
+  })();
+</script>
diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html
new file mode 100644
index 0000000..a8d1b5f
--- /dev/null
+++ b/bookmarks/templates/bundles/index.html
@@ -0,0 +1,124 @@
+{% extends "bookmarks/layout.html" %}
+
+{% block head %}
+  {% with page_title="Bundles - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <main class="bundles-page" aria-labelledby="main-heading">
+    <h1 id="main-heading">Bundles</h1>
+
+    {% include 'shared/messages.html' %}
+
+    {% if bundles %}
+      <form action="{% url 'linkding:bundles.action' %}" method="post">
+        {% csrf_token %}
+        <div class="item-list bundles">
+          {% 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">
+                <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>
+          {% endfor %}
+        </div>
+        <input type="submit" name="move_bundle" value="" class="d-none">
+        <input type="hidden" name="move_position" value="">
+      </form>
+    {% else %}
+      <div class="empty">
+        <p class="empty-title h5">You have no bundles yet</p>
+        <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;
+
+      let draggedElement = null;
+
+      const listItems = bundlesList.querySelectorAll('.list-item');
+      listItems.forEach((item) => {
+        item.addEventListener('dragstart', handleDragStart);
+        item.addEventListener('dragend', handleDragEnd);
+        item.addEventListener('dragover', handleDragOver);
+        item.addEventListener('dragenter', handleDragEnter);
+      });
+
+      function handleDragStart(e) {
+        draggedElement = this;
+
+        e.dataTransfer.effectAllowed = 'move';
+        e.dataTransfer.dropEffect = 'move';
+
+        this.classList.add('drag-start');
+        setTimeout(() => {
+          this.classList.remove('drag-start');
+          this.classList.add('dragging');
+        }, 0);
+      }
+
+      function handleDragEnd() {
+        this.classList.remove('dragging');
+
+        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);
+
+        const form = this.closest('form');
+        form.requestSubmit(moveBundleInput);
+
+        draggedElement = null;
+      }
+
+      function handleDragOver(e) {
+        if (e.preventDefault) {
+          e.preventDefault();
+        }
+        return false;
+      }
+
+      function handleDragEnter() {
+        if (this !== draggedElement) {
+          const listItems = Array.from(bundlesList.children);
+          const draggedIndex = listItems.indexOf(draggedElement);
+          const currentIndex = listItems.indexOf(this);
+
+          if (draggedIndex < currentIndex) {
+            this.insertAdjacentElement('afterend', draggedElement);
+          } else {
+            this.insertAdjacentElement('beforebegin', draggedElement);
+          }
+        }
+      }
+    })();
+  </script>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/new.html b/bookmarks/templates/bundles/new.html
new file mode 100644
index 0000000..a73b71c
--- /dev/null
+++ b/bookmarks/templates/bundles/new.html
@@ -0,0 +1,33 @@
+{% extends 'bookmarks/layout.html' %}
+{% load widget_tweaks %}
+
+{% block head %}
+  {% with page_title="New bundle - Linkding" %}
+    {{ block.super }}
+  {% endwith %}
+{% endblock %}
+
+{% block content %}
+  <div class="bundles-editor-page grid columns-md-1">
+    <main aria-labelledby="main-heading">
+      <div class="section-header">
+        <h1 id="main-heading">New bundle</h1>
+      </div>
+
+      {% include 'shared/messages.html' %}
+
+      <form id="bundle-form" action="{% url 'linkding:bundles.new' %}" method="post" novalidate>
+        {% csrf_token %}
+        {% include 'bundles/form.html' %}
+      </form>
+    </main>
+
+    <aside class="col-2" aria-labelledby="preview-heading">
+      <div class="section-header">
+        <h2 id="preview-heading">Preview</h2>
+      </div>
+
+      {% include 'bundles/preview.html' %}
+    </aside>
+  </div>
+{% endblock %}
diff --git a/bookmarks/templates/bundles/preview.html b/bookmarks/templates/bundles/preview.html
new file mode 100644
index 0000000..a7973fb
--- /dev/null
+++ b/bookmarks/templates/bundles/preview.html
@@ -0,0 +1,12 @@
+<turbo-frame id="preview">
+  {% if bookmark_list.is_empty %}
+    <div>
+      No bookmarks match the current bundle.
+    </div>
+  {% else %}
+    <div class="mb-4">
+      Found {{ bookmark_list.bookmarks_total }} bookmarks matching this bundle.
+    </div>
+    {% include 'bookmarks/bookmark_list.html' %}
+  {% endif %}
+</turbo-frame>
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index c8cfc6d..16800bf 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -139,6 +139,15 @@
             Instead, the tags are shown in an expandable drawer.
           </div>
         </div>
+        <div class="form-group">
+          <label for="{{ form.hide_bundles.id_for_label }}" class="form-checkbox">
+            {{ form.hide_bundles }}
+            <i class="form-icon"></i> Hide bundles
+          </label>
+          <div class="form-input-hint">
+            Allows to hide the bundles in the side panel if you don't intend to use them.
+          </div>
+        </div>
         <div class="form-group">
           <label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
           {{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
diff --git a/bookmarks/templates/shared/messages.html b/bookmarks/templates/shared/messages.html
new file mode 100644
index 0000000..fce07fc
--- /dev/null
+++ b/bookmarks/templates/shared/messages.html
@@ -0,0 +1,9 @@
+{% if messages %}
+  <div class="message-list">
+    {% for message in messages %}
+      <div class="toast toast-{{ message.tags }}" role="alert">
+        {{ message }}
+      </div>
+    {% endfor %}
+  </div>
+{% endif %}
diff --git a/bookmarks/templatetags/pagination.py b/bookmarks/templatetags/pagination.py
index bb46e1e..9f0245e 100644
--- a/bookmarks/templatetags/pagination.py
+++ b/bookmarks/templatetags/pagination.py
@@ -13,18 +13,21 @@ register = template.Library()
     "bookmarks/pagination.html", name="pagination", takes_context=True
 )
 def pagination(context, page: Page):
+    request = context["request"]
+    base_url = request.build_absolute_uri(request.path)
+
     # remove page number and details from query parameters
-    query_params = context["request"].GET.copy()
+    query_params = request.GET.copy()
     query_params.pop("page", None)
     query_params.pop("details", None)
 
     prev_link = (
-        _generate_link(query_params, page.previous_page_number())
+        _generate_link(base_url, query_params, page.previous_page_number())
         if page.has_previous()
         else None
     )
     next_link = (
-        _generate_link(query_params, page.next_page_number())
+        _generate_link(base_url, query_params, page.next_page_number())
         if page.has_next()
         else None
     )
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
         if page_number == -1:
             page_links.append(None)
         else:
-            link = _generate_link(query_params, page_number)
+            link = _generate_link(base_url, query_params, page_number)
             page_links.append(
                 {
                     "active": page_number == page.number,
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
     return reduce(append_page, visible_pages, [])
 
 
-def _generate_link(query_params: QueryDict, page_number: int) -> str:
+def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
+    query_params = query_params.copy()
     query_params["page"] = page_number
-    return query_params.urlencode()
+    return f"{base_url}?{query_params.urlencode()}"
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index be0273a..ff963c9 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -43,6 +43,12 @@ urlpatterns = [
         views.assets.read,
         name="assets.read",
     ),
+    # Bundles
+    path("bundles", views.bundles.index, name="bundles.index"),
+    path("bundles/action", views.bundles.action, name="bundles.action"),
+    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"),
     # 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 802057b..397cda5 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -1,6 +1,7 @@
 from .assets import *
 from .auth import *
 from .bookmarks import *
+from . import bundles
 from .settings import *
 from .toasts import *
 from .health import health
diff --git a/bookmarks/views/access.py b/bookmarks/views/access.py
index a22070e..0228d4a 100644
--- a/bookmarks/views/access.py
+++ b/bookmarks/views/access.py
@@ -1,6 +1,6 @@
 from django.http import Http404
 
-from bookmarks.models import Bookmark, BookmarkAsset, Toast
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
 from bookmarks.type_defs import HttpRequest
 
 
@@ -32,6 +32,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
         raise Http404("Bookmark does not exist")
 
 
+def bundle_write(request: HttpRequest, bundle_id: int | str):
+    try:
+        return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
+    except BookmarkBundle.DoesNotExist:
+        raise Http404("Bundle does not exist")
+
+
 def asset_read(request: HttpRequest, asset_id: int | str):
     try:
         asset = BookmarkAsset.objects.get(pk=asset_id)
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index b19411f..7b4556c 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -42,8 +42,12 @@ def index(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.ActiveBookmarkListContext(request)
-    tag_cloud = contexts.ActiveTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+    bundles = contexts.BundlesContext(request)
+    tag_cloud = contexts.ActiveTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.ActiveBookmarkDetailsContext
     )
@@ -54,6 +58,7 @@ def index(request: HttpRequest):
         {
             "page_title": "Bookmarks - Linkding",
             "bookmark_list": bookmark_list,
+            "bundles": bundles,
             "tag_cloud": tag_cloud,
             "details": bookmark_details,
         },
@@ -65,8 +70,12 @@ def archived(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.ArchivedBookmarkListContext(request)
-    tag_cloud = contexts.ArchivedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+    bundles = contexts.BundlesContext(request)
+    tag_cloud = contexts.ArchivedTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.ArchivedBookmarkDetailsContext
     )
@@ -77,6 +86,7 @@ def archived(request: HttpRequest):
         {
             "page_title": "Archived bookmarks - Linkding",
             "bookmark_list": bookmark_list,
+            "bundles": bundles,
             "tag_cloud": tag_cloud,
             "details": bookmark_details,
         },
@@ -87,8 +97,11 @@ def shared(request: HttpRequest):
     if request.method == "POST":
         return search_action(request)
 
-    bookmark_list = contexts.SharedBookmarkListContext(request)
-    tag_cloud = contexts.SharedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.SharedBookmarkListContext(request, search)
+    tag_cloud = contexts.SharedTagCloudContext(request, search)
     bookmark_details = contexts.get_details_context(
         request, contexts.SharedBookmarkDetailsContext
     )
@@ -132,13 +145,13 @@ def search_action(request: HttpRequest):
     if "save" in request.POST:
         if not request.user.is_authenticated:
             return HttpResponseForbidden()
-        search = BookmarkSearch.from_request(request.POST)
+        search = BookmarkSearch.from_request(request, request.POST)
         request.user_profile.search_preferences = search.preferences_dict
         request.user_profile.save()
 
     # redirect to base url including new query params
     search = BookmarkSearch.from_request(
-        request.POST, request.user_profile.search_preferences
+        request, request.POST, request.user_profile.search_preferences
     )
     base_url = request.path
     query_params = search.query_params
@@ -248,7 +261,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
 
 @login_required
 def index_action(request: HttpRequest):
-    search = BookmarkSearch.from_request(request.GET)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
     query = queries.query_bookmarks(request.user, request.user_profile, search)
 
     response = handle_action(request, query)
@@ -263,7 +278,9 @@ def index_action(request: HttpRequest):
 
 @login_required
 def archived_action(request: HttpRequest):
-    search = BookmarkSearch.from_request(request.GET)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
     query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
 
     response = handle_action(request, query)
diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py
new file mode 100644
index 0000000..062db8d
--- /dev/null
+++ b/bookmarks/views/bundles.py
@@ -0,0 +1,109 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.db.models import Max
+from django.http import HttpRequest, HttpResponseRedirect
+from django.shortcuts import render
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
+from bookmarks.views import access
+from bookmarks.views.contexts import ActiveBookmarkListContext
+
+
+@login_required
+def index(request: HttpRequest):
+    bundles = BookmarkBundle.objects.filter(owner=request.user).order_by("order")
+    context = {"bundles": bundles}
+    return render(request, "bundles/index.html", context)
+
+
+@login_required
+def action(request: HttpRequest):
+    if "remove_bundle" in request.POST:
+        remove_bundle_id = request.POST.get("remove_bundle")
+        bundle = access.bundle_write(request, remove_bundle_id)
+        bundle_name = bundle.name
+        bundle.delete()
+        messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
+
+    elif "move_bundle" in request.POST:
+        bundle_id = request.POST.get("move_bundle")
+        move_position = int(request.POST.get("move_position"))
+        bundle_to_move = access.bundle_write(request, bundle_id)
+        user_bundles = list(
+            BookmarkBundle.objects.filter(owner=request.user).order_by("order")
+        )
+
+        if move_position != user_bundles.index(bundle_to_move):
+            user_bundles.remove(bundle_to_move)
+            user_bundles.insert(move_position, bundle_to_move)
+            for bundle_index, bundle in enumerate(user_bundles):
+                bundle.order = bundle_index
+
+            BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
+
+    return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+
+def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
+    form_data = request.POST if request.method == "POST" else None
+    form = BookmarkBundleForm(form_data, instance=bundle)
+
+    if request.method == "POST":
+        if form.is_valid():
+            instance = form.save(commit=False)
+            instance.owner = request.user
+
+            if bundle is None:  # New bundle
+                max_order_result = BookmarkBundle.objects.filter(
+                    owner=request.user
+                ).aggregate(Max("order", default=-1))
+                instance.order = max_order_result["order__max"] + 1
+
+            instance.save()
+            messages.success(request, "Bundle saved successfully.")
+            return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+    status = 422 if request.method == "POST" and not form.is_valid() else 200
+    bookmark_list = _get_bookmark_list_preview(request, bundle)
+    context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list}
+
+    return render(request, template, context, status=status)
+
+
+@login_required
+def new(request: HttpRequest):
+    return _handle_edit(request, "bundles/new.html")
+
+
+@login_required
+def edit(request: HttpRequest, bundle_id: int):
+    bundle = access.bundle_write(request, bundle_id)
+
+    return _handle_edit(request, "bundles/edit.html", bundle)
+
+
+@login_required
+def preview(request: HttpRequest):
+    bookmark_list = _get_bookmark_list_preview(request)
+    context = {"bookmark_list": bookmark_list}
+    return render(request, "bundles/preview.html", context)
+
+
+def _get_bookmark_list_preview(
+    request: HttpRequest, bundle: BookmarkBundle | None = None
+):
+    if request.method == "GET" and bundle:
+        preview_bundle = bundle
+    else:
+        form_data = (
+            request.POST.copy() if request.method == "POST" else request.GET.copy()
+        )
+        form_data["name"] = "Preview Bundle"  # Set dummy name for form validation
+        form = BookmarkBundleForm(form_data)
+        preview_bundle = form.save(commit=False)
+
+    search = BookmarkSearch(bundle=preview_bundle)
+    bookmark_list = ActiveBookmarkListContext(request, search)
+    bookmark_list.is_preview = True
+    return bookmark_list
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index 86aec9e..cace23f 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -13,6 +13,7 @@ from bookmarks import utils
 from bookmarks.models import (
     Bookmark,
     BookmarkAsset,
+    BookmarkBundle,
     BookmarkSearch,
     User,
     UserProfile,
@@ -178,15 +179,13 @@ class BookmarkItem:
 class BookmarkListContext:
     request_context = RequestContext
 
-    def __init__(self, request: HttpRequest) -> None:
+    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
         request_context = self.request_context(request)
         user = request.user
         user_profile = request.user_profile
 
         self.request = request
-        self.search = BookmarkSearch.from_request(
-            self.request.GET, user_profile.search_preferences
-        )
+        self.search = search
 
         query_set = request_context.get_bookmark_query_set(self.search)
         page_number = request.GET.get("page")
@@ -219,6 +218,7 @@ class BookmarkListContext:
         self.show_preview_images = user_profile.enable_preview_images
         self.show_notes = user_profile.permanent_notes
         self.collapse_side_panel = user_profile.collapse_side_panel
+        self.is_preview = False
 
     @staticmethod
     def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -315,14 +315,12 @@ class TagGroup:
 class TagCloudContext:
     request_context = RequestContext
 
-    def __init__(self, request: HttpRequest) -> None:
+    def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
         request_context = self.request_context(request)
         user_profile = request.user_profile
 
         self.request = request
-        self.search = BookmarkSearch.from_request(
-            self.request.GET, user_profile.search_preferences
-        )
+        self.search = search
 
         query_set = request_context.get_tag_query_set(self.search)
         tags = list(query_set)
@@ -461,3 +459,23 @@ def get_details_context(
         return None
 
     return context_type(request, bookmark)
+
+
+class BundlesContext:
+    def __init__(self, request: HttpRequest) -> None:
+        self.request = request
+        self.user = request.user
+        self.user_profile = request.user_profile
+
+        self.bundles = (
+            BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
+        )
+        self.is_empty = len(self.bundles) == 0
+
+        selected_bundle_id = (
+            int(request.GET.get("bundle")) if request.GET.get("bundle") else None
+        )
+        self.selected_bundle = next(
+            (bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
+            None,
+        )
diff --git a/bookmarks/views/partials.py b/bookmarks/views/partials.py
index 8930a66..34a4eed 100644
--- a/bookmarks/views/partials.py
+++ b/bookmarks/views/partials.py
@@ -1,3 +1,4 @@
+from bookmarks.models import BookmarkSearch
 from bookmarks.views import contexts, turbo
 
 
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
 
 
 def active_bookmark_update(request):
-    bookmark_list = contexts.ActiveBookmarkListContext(request)
-    tag_cloud = contexts.ActiveTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+    tag_cloud = contexts.ActiveTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.ActiveBookmarkDetailsContext
     )
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
 
 
 def archived_bookmark_update(request):
-    bookmark_list = contexts.ArchivedBookmarkListContext(request)
-    tag_cloud = contexts.ArchivedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+    tag_cloud = contexts.ArchivedTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.ArchivedBookmarkDetailsContext
     )
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
 
 
 def shared_bookmark_update(request):
-    bookmark_list = contexts.SharedBookmarkListContext(request)
-    tag_cloud = contexts.SharedTagCloudContext(request)
+    search = BookmarkSearch.from_request(
+        request, request.GET, request.user_profile.search_preferences
+    )
+    bookmark_list = contexts.SharedBookmarkListContext(request, search)
+    tag_cloud = contexts.SharedTagCloudContext(request, search)
     details = contexts.get_details_context(
         request, contexts.SharedBookmarkDetailsContext
     )
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 7685add..36aec62 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -5584,9 +5584,9 @@
       }
     },
     "node_modules/prebuild-install/node_modules/tar-fs": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
-      "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
+      "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
       "license": "MIT",
       "dependencies": {
         "chownr": "^1.1.1",
@@ -6511,9 +6511,9 @@
       }
     },
     "node_modules/tar-fs": {
-      "version": "3.0.8",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
-      "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz",
+      "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==",
       "license": "MIT",
       "dependencies": {
         "pump": "^3.0.0",
diff --git a/requirements.dev.txt b/requirements.dev.txt
index e480949..74f1c29 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -12,7 +12,7 @@ click==8.1.7
     # via black
 coverage==7.6.1
     # via -r requirements.dev.in
-django==5.1.9
+django==5.1.10
     # via django-debug-toolbar
 django-debug-toolbar==4.4.6
     # via -r requirements.dev.in
diff --git a/requirements.txt b/requirements.txt
index f11c319..9379244 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,7 +27,7 @@ cryptography==43.0.1
     #   josepy
     #   mozilla-django-oidc
     #   pyopenssl
-django==5.1.9
+django==5.1.10
     # via
     #   -r requirements.in
     #   django-registration
@@ -57,7 +57,7 @@ pyopenssl==24.2.1
     # via josepy
 python-dateutil==2.9.0.post0
     # via -r requirements.in
-requests==2.32.3
+requests==2.32.4
     # via
     #   -r requirements.in
     #   mozilla-django-oidc
@@ -72,7 +72,7 @@ sqlparse==0.5.1
     # via django
 supervisor==4.2.5
     # via -r requirements.in
-urllib3==2.2.3
+urllib3==2.5.0
     # via
     #   requests
     #   waybackpy


Test patch:
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 5607287..7db175b 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -17,7 +17,7 @@ from rest_framework import status
 from rest_framework.authtoken.models import Token
 from rest_framework.test import APITestCase
 
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
 
 
 class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
     def get_numbered_bookmark(self, title: str):
         return Bookmark.objects.get(title=title)
 
+    def setup_bundle(
+        self,
+        user: User = None,
+        name: str = None,
+        search: str = "",
+        any_tags: str = "",
+        all_tags: str = "",
+        excluded_tags: str = "",
+        order: int = 0,
+    ):
+        if user is None:
+            user = self.get_or_create_test_user()
+        if not name:
+            name = get_random_string(length=32)
+        bundle = BookmarkBundle(
+            name=name,
+            owner=user,
+            date_created=timezone.now(),
+            search=search,
+            any_tags=any_tags,
+            all_tags=all_tags,
+            excluded_tags=excluded_tags,
+            order=order,
+        )
+        bundle.save()
+        return bundle
+
     def setup_asset(
         self,
         bookmark: Bookmark,
@@ -239,7 +266,7 @@ class BookmarkFactoryMixin:
         user.profile.save()
         return user
 
-    def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
+    def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
         all_tags = []
         for bookmark in bookmarks:
             all_tags = all_tags + list(bookmark.tags.all())
diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py
index ef523f7..29fd3a0 100644
--- a/bookmarks/tests/test_bookmark_action_view.py
+++ b/bookmarks/tests/test_bookmark_action_view.py
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
         self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
         self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
 
+    def test_index_action_bulk_select_across_respects_bundle(self):
+        self.setup_numbered_bookmarks(3, prefix="foo")
+        self.setup_numbered_bookmarks(3, prefix="bar")
+
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+        bundle = self.setup_bundle(search="foo")
+
+        self.client.post(
+            reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
+            {
+                "bulk_action": ["bulk_delete"],
+                "bulk_execute": [""],
+                "bulk_select_across": ["on"],
+            },
+        )
+
+        self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
     def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
         self.setup_bulk_edit_scope_test_data()
 
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
         self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
         self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
 
+    def test_archived_action_bulk_select_across_respects_bundle(self):
+        self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
+        self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
+
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+        bundle = self.setup_bundle(search="foo")
+
+        self.client.post(
+            reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
+            {
+                "bulk_action": ["bulk_delete"],
+                "bulk_execute": [""],
+                "bulk_select_across": ["on"],
+            },
+        )
+
+        self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+        self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
     def test_shared_action_bulk_select_across_not_supported(self):
         self.setup_bulk_edit_scope_test_data()
 
diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py
index c871d8b..00b9c7b 100644
--- a/bookmarks/tests/test_bookmark_archived_view.py
+++ b/bookmarks/tests/test_bookmark_archived_view.py
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
     BookmarkFactoryMixin,
     BookmarkListTestMixin,
     TagCloudTestMixin,
-    collapse_whitespace,
 )
 
 
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
         )
 
         response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
-        html = collapse_whitespace(response.content.decode())
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+    def test_should_list_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, prefix="foo", archived=True
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, prefix="bar", archived=True
+        )
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+        )
 
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
+        )
+
+        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_bookmarks_and_tags_for_search_preferences(self):
         user_profile = self.user.profile
         user_profile.search_preferences = {
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
 
         feed = soup.select_one('head link[type="application/rss+xml"]')
         self.assertIsNone(feed)
+
+    def test_hide_bundles_when_enabled_in_profile(self):
+        # visible by default
+        response = self.client.get(reverse("linkding:bookmarks.archived"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
+
+        # hidden when disabled in profile
+        user_profile = self.get_or_create_test_user().profile
+        user_profile.hide_bundles = True
+        user_profile.save()
+
+        response = self.client.get(reverse("linkding:bookmarks.archived"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index c99c58d..8c69876 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
             asset_item = self.find_asset(asset_list, asset)
             self.assertIsNotNone(asset_item)
 
-            asset_icon = asset_item.select_one(".asset-icon svg")
+            asset_icon = asset_item.select_one(".list-item-icon svg")
             self.assertIsNotNone(asset_icon)
 
-            asset_text = asset_item.select_one(".asset-text span")
+            asset_text = asset_item.select_one(".list-item-text span")
             self.assertIsNotNone(asset_text)
             self.assertIn(asset.display_name, asset_text.text)
 
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         soup = self.get_index_details_modal(bookmark)
 
         asset_item = self.find_asset(soup, pending_asset)
-        asset_text = asset_item.select_one(".asset-text span")
+        asset_text = asset_item.select_one(".list-item-text span")
         self.assertIn("(queued)", asset_text.text)
 
         asset_item = self.find_asset(soup, failed_asset)
-        asset_text = asset_item.select_one(".asset-text span")
+        asset_text = asset_item.select_one(".list-item-text span")
         self.assertIn("(failed)", asset_text.text)
 
     def test_asset_file_size(self):
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         soup = self.get_index_details_modal(bookmark)
 
         asset_item = self.find_asset(soup, asset1)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertEqual(asset_text.text.strip(), asset1.display_name)
 
         asset_item = self.find_asset(soup, asset2)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertIn("53.4\xa0KB", asset_text.text)
 
         asset_item = self.find_asset(soup, asset3)
-        asset_text = asset_item.select_one(".asset-text")
+        asset_text = asset_item.select_one(".list-item-text")
         self.assertIn("11.0\xa0MB", asset_text.text)
 
     def test_asset_actions_visibility(self):
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index 8884e03..a82d014 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
         self.assertIsNotNone(form)
         self.assertEqual(form.attrs["action"], url)
 
+    def assertVisibleBundles(self, soup, bundles):
+        bundle_list = soup.select_one("ul.bundle-menu")
+        self.assertIsNotNone(bundle_list)
+
+        list_items = bundle_list.select("li.bundle-menu-item")
+        self.assertEqual(len(list_items), len(bundles))
+
+        for index, list_item in enumerate(list_items):
+            bundle = bundles[index]
+            link = list_item.select_one("a")
+            href = link.attrs["href"]
+
+            self.assertEqual(bundle.name, list_item.text.strip())
+            self.assertEqual(f"?bundle={bundle.id}", href)
+
     def test_should_list_unarchived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
 
+    def test_should_list_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
+        invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
     def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
         other_user = User.objects.create_user(
             "otheruser", "otheruser@example.com", "password123"
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, prefix="foo", tag_prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(
+            3, with_tags=True, prefix="bar", tag_prefix="bar"
+        )
+
+        visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+        invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_bookmarks_and_tags_for_search_preferences(self):
         user_profile = self.user.profile
         user_profile.search_preferences = {
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
 
         feed = soup.select_one('head link[type="application/rss+xml"]')
         self.assertIsNone(feed)
+
+    def test_list_bundles(self):
+        books = self.setup_bundle(name="Books bundle", order=3)
+        music = self.setup_bundle(name="Music bundle", order=1)
+        tools = self.setup_bundle(name="Tools bundle", order=2)
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        self.assertVisibleBundles(soup, [music, tools, books])
+
+    def test_list_bundles_only_shows_user_owned_bundles(self):
+        user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
+        other_user = self.setup_user()
+        self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        self.assertVisibleBundles(soup, user_bundles)
+
+    def test_hide_bundles_when_enabled_in_profile(self):
+        # visible by default
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
+
+        # hidden when disabled in profile
+        user_profile = self.get_or_create_test_user().profile
+        user_profile.hide_bundles = True
+        user_profile.save()
+
+        response = self.client.get(reverse("linkding:bookmarks.index"))
+        html = response.content.decode()
+
+        self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_search_form.py b/bookmarks/tests/test_bookmark_search_form.py
index b516b6e..2143617 100644
--- a/bookmarks/tests/test_bookmark_search_form.py
+++ b/bookmarks/tests/test_bookmark_search_form.py
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
         form = BookmarkSearchForm(search)
         self.assertEqual(form["q"].initial, "")
         self.assertEqual(form["user"].initial, "")
+        self.assertEqual(form["bundle"].initial, None)
         self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
         self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
         self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
 
         # with params
+        bundle = self.setup_bundle()
         search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         form = BookmarkSearchForm(search)
         self.assertEqual(form["q"].initial, "search query")
         self.assertEqual(form["user"].initial, "user123")
+        self.assertEqual(form["bundle"].initial, bundle.id)
         self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
         self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
         self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
         self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
 
         # all modified params
+        bundle = self.setup_bundle()
         search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         form = BookmarkSearchForm(search)
         self.assertCountEqual(
             form.hidden_fields(),
-            [form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
+            [
+                form["q"],
+                form["sort"],
+                form["user"],
+                form["bundle"],
+                form["shared"],
+                form["unread"],
+            ],
         )
 
         # some modified params are editable fields
diff --git a/bookmarks/tests/test_bookmark_search_model.py b/bookmarks/tests/test_bookmark_search_model.py
index 69caddf..a2bb8c7 100644
--- a/bookmarks/tests/test_bookmark_search_model.py
+++ b/bookmarks/tests/test_bookmark_search_model.py
@@ -2,16 +2,23 @@ from django.http import QueryDict
 from django.test import TestCase
 
 from bookmarks.models import BookmarkSearch
+from bookmarks.tests.helpers import BookmarkFactoryMixin
 
 
-class BookmarkSearchModelTest(TestCase):
+class MockRequest:
+    def __init__(self, user):
+        self.user = user
+
+
+class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
     def test_from_request(self):
         # no params
         query_dict = QueryDict()
 
-        search = BookmarkSearch.from_request(query_dict)
+        search = BookmarkSearch.from_request(None, query_dict)
         self.assertEqual(search.q, "")
         self.assertEqual(search.user, "")
+        self.assertEqual(search.bundle, None)
         self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
         # some params
         query_dict = QueryDict("q=search query&user=user123")
 
-        bookmark_search = BookmarkSearch.from_request(query_dict)
+        bookmark_search = BookmarkSearch.from_request(None, query_dict)
         self.assertEqual(bookmark_search.q, "search query")
         self.assertEqual(bookmark_search.user, "user123")
         self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
 
         # all params
+        bundle = self.setup_bundle()
+        request = MockRequest(self.get_or_create_test_user())
         query_dict = QueryDict(
-            "q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
+            f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
         )
 
-        search = BookmarkSearch.from_request(query_dict)
+        search = BookmarkSearch.from_request(request, query_dict)
         self.assertEqual(search.q, "search query")
         self.assertEqual(search.user, "user123")
+        self.assertEqual(search.bundle, bundle)
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
         }
         query_dict = QueryDict("q=search query")
 
-        search = BookmarkSearch.from_request(query_dict, preferences)
+        search = BookmarkSearch.from_request(None, query_dict, preferences)
         self.assertEqual(search.q, "search query")
         self.assertEqual(search.user, "")
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
         }
         query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
 
-        search = BookmarkSearch.from_request(query_dict, preferences)
+        search = BookmarkSearch.from_request(None, query_dict, preferences)
         self.assertEqual(search.q, "")
         self.assertEqual(search.user, "")
         self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
         self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
         self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
 
+    def test_from_request_ignores_invalid_bundle_param(self):
+        self.setup_bundle()
+
+        # bundle does not exist
+        request = MockRequest(self.get_or_create_test_user())
+        query_dict = QueryDict("bundle=99999")
+        search = BookmarkSearch.from_request(request, query_dict)
+        self.assertIsNone(search.bundle)
+
+        # bundle belongs to another user
+        other_user = self.setup_user()
+        bundle = self.setup_bundle(user=other_user)
+        query_dict = QueryDict(f"bundle={bundle.id}")
+        search = BookmarkSearch.from_request(request, query_dict)
+        self.assertIsNone(search.bundle)
+
+    def test_query_params(self):
+        # no params
+        search = BookmarkSearch()
+        self.assertEqual(search.query_params, {})
+
+        # params are default values
+        search = BookmarkSearch(
+            q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
+        )
+        self.assertEqual(search.query_params, {})
+
+        # some modified params
+        search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
+        self.assertEqual(
+            search.query_params,
+            {"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
+        )
+
+        # all modified params
+        bundle = self.setup_bundle()
+        search = BookmarkSearch(
+            q="search query",
+            sort=BookmarkSearch.SORT_ADDED_ASC,
+            user="user123",
+            bundle=bundle,
+            shared=BookmarkSearch.FILTER_SHARED_SHARED,
+            unread=BookmarkSearch.FILTER_UNREAD_YES,
+        )
+        self.assertEqual(
+            search.query_params,
+            {
+                "q": "search query",
+                "sort": BookmarkSearch.SORT_ADDED_ASC,
+                "user": "user123",
+                "bundle": bundle.id,
+                "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+                "unread": BookmarkSearch.FILTER_UNREAD_YES,
+            },
+        )
+
+        # preferences are not query params if they match default
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(preferences=preferences)
+        self.assertEqual(search.query_params, {})
+
+        # param is not a query param if it matches the preference
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(
+            sort=BookmarkSearch.SORT_TITLE_ASC,
+            unread=BookmarkSearch.FILTER_UNREAD_YES,
+            preferences=preferences,
+        )
+        self.assertEqual(search.query_params, {})
+
+        # overriding preferences is a query param
+        preferences = {
+            "sort": BookmarkSearch.SORT_TITLE_ASC,
+            "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+            "unread": BookmarkSearch.FILTER_UNREAD_YES,
+        }
+        search = BookmarkSearch(
+            sort=BookmarkSearch.SORT_TITLE_DESC,
+            shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
+            unread=BookmarkSearch.FILTER_UNREAD_OFF,
+            preferences=preferences,
+        )
+        self.assertEqual(
+            search.query_params,
+            {
+                "sort": BookmarkSearch.SORT_TITLE_DESC,
+                "shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
+                "unread": BookmarkSearch.FILTER_UNREAD_OFF,
+            },
+        )
+
     def test_modified_params(self):
         # no params
         bookmark_search = BookmarkSearch()
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
         self.assertCountEqual(modified_params, ["q", "sort"])
 
         # all modified params
+        bundle = self.setup_bundle()
         bookmark_search = BookmarkSearch(
             q="search query",
             sort=BookmarkSearch.SORT_ADDED_ASC,
             user="user123",
+            bundle=bundle,
             shared=BookmarkSearch.FILTER_SHARED_SHARED,
             unread=BookmarkSearch.FILTER_UNREAD_YES,
         )
         modified_params = bookmark_search.modified_params
         self.assertCountEqual(
-            modified_params, ["q", "sort", "user", "shared", "unread"]
+            modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
         )
 
         # preferences are not modified params
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
         )
 
         # only returns preferences
-        bookmark_search = BookmarkSearch(q="search query", user="user123")
+        bundle = self.setup_bundle()
+        bookmark_search = BookmarkSearch(
+            q="search query", user="user123", bundle=bundle
+        )
         self.assertEqual(
             bookmark_search.preferences_dict,
             {
diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py
index eb77675..3ab8a9a 100644
--- a/bookmarks/tests/test_bookmark_search_tag.py
+++ b/bookmarks/tests/test_bookmark_search_tag.py
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         request = rf.get(url)
         request.user = self.get_or_create_test_user()
         request.user_profile = self.get_or_create_test_user().profile
-        search = BookmarkSearch.from_request(request.GET)
+        search = BookmarkSearch.from_request(request, request.GET)
         context = RequestContext(
             request,
             {
diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py
index e7746c6..f512ccd 100644
--- a/bookmarks/tests/test_bookmark_shared_view.py
+++ b/bookmarks/tests/test_bookmark_shared_view.py
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
         self.assertVisibleBookmarks(response, visible_bookmarks)
         self.assertInvisibleBookmarks(response, invisible_bookmarks)
 
+    def test_should_list_bookmarks_matching_bundle(self):
+        self.authenticate()
+        user = self.setup_user(enable_sharing=True)
+
+        visible_bookmarks = self.setup_numbered_bookmarks(
+            3, shared=True, user=user, prefix="foo"
+        )
+        invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
+
+        bundle = self.setup_bundle(search="foo")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleBookmarks(response, visible_bookmarks)
+        self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
     def test_should_list_only_publicly_shared_bookmarks_without_login(self):
         user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
         user2 = self.setup_user(enable_sharing=True)
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
         self.assertVisibleTags(response, visible_tags)
         self.assertInvisibleTags(response, invisible_tags)
 
+    def test_should_list_tags_for_bookmarks_matching_bundle(self):
+        self.authenticate()
+        user1 = self.setup_user(enable_sharing=True)
+        user2 = self.setup_user(enable_sharing=True)
+        user3 = self.setup_user(enable_sharing=True)
+        visible_tags = [
+            self.setup_tag(user=user1),
+            self.setup_tag(user=user2),
+            self.setup_tag(user=user3),
+        ]
+        invisible_tags = [
+            self.setup_tag(user=user1),
+            self.setup_tag(user=user2),
+            self.setup_tag(user=user3),
+        ]
+
+        self.setup_bookmark(
+            shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
+        )
+        self.setup_bookmark(
+            shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
+        )
+        self.setup_bookmark(
+            shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
+        )
+
+        self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
+        self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
+        self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
+
+        bundle = self.setup_bundle(search="searchvalue")
+
+        response = self.client.get(
+            reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+        )
+
+        self.assertVisibleTags(response, visible_tags)
+        self.assertInvisibleTags(response, invisible_tags)
+
     def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
         user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
         user2 = self.setup_user(enable_sharing=True)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 68a4510..5f22d03 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         )
         self.assertBookmarkListEqual(response.data["results"], bookmarks)
 
+    def test_list_bookmarks_should_filter_by_bundle(self):
+        self.authenticate()
+        search_value = self.get_random_string()
+        bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
+        self.setup_numbered_bookmarks(5)
+        bundle = self.setup_bundle(search=search_value)
+
+        response = self.get(
+            reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], bookmarks)
+
     def test_list_bookmarks_filter_unread(self):
         self.authenticate()
         unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         )
         self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
 
+    def test_list_archived_bookmarks_should_filter_by_bundle(self):
+        self.authenticate()
+        search_value = self.get_random_string()
+        archived_bookmarks = self.setup_numbered_bookmarks(
+            5, archived=True, prefix=search_value
+        )
+        self.setup_numbered_bookmarks(5, archived=True)
+        bundle = self.setup_bundle(search=search_value)
+
+        response = self.get(
+            reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
+            expected_status_code=status.HTTP_200_OK,
+        )
+        self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
+
     def test_list_archived_bookmarks_should_respect_sort(self):
         self.authenticate()
         bookmarks = self.setup_numbered_bookmarks(5, archived=True)
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 802ae45..cf5d60d 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -10,7 +10,7 @@ from django.urls import reverse
 from django.utils import timezone, formats
 
 from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import Bookmark, UserProfile, User
+from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
 from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 from bookmarks.views import contexts
 
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
            title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
             {label_content}
         </a>
-        <span>|</span>
         """,
             html,
         )
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
             contexts.BookmarkListContext
         ] = contexts.ActiveBookmarkListContext,
         user: User | AnonymousUser = None,
+        is_preview: bool = False,
     ) -> str:
         rf = RequestFactory()
         request = rf.get(url)
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         middleware = LinkdingMiddleware(lambda r: HttpResponse())
         middleware(request)
 
-        bookmark_list_context = context_type(request)
+        search = BookmarkSearch.from_request(request, request.GET)
+        bookmark_list_context = context_type(request, search)
+        if is_preview:
+            bookmark_list_context.is_preview = True
         context = RequestContext(request, {"bookmark_list": bookmark_list_context})
 
         template = Template("{% include 'bookmarks/bookmark_list.html' %}")
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         soup = self.make_soup(html)
         bookmarks = soup.select("li[ld-bookmark-item]")
         self.assertEqual(10, len(bookmarks))
+
+    def test_no_actions_rendered_when_is_preview(self):
+        bookmark = self.setup_bookmark()
+        bookmark.date_added = timezone.now() - relativedelta(days=8)
+        bookmark.web_archive_snapshot_url = "https://example.com"
+        bookmark.save()
+
+        html = self.render_template(is_preview=True)
+
+        # Verify no actions are rendered
+        self.assertNoViewLink(html, bookmark)
+        self.assertNoBookmarkActions(html, bookmark)
+        self.assertMarkAsReadButton(html, bookmark, count=0)
+        self.assertUnshareButton(html, bookmark, count=0)
+        self.assertNotesToggle(html, count=0)
+
+        # But date should still be rendered
+        self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
diff --git a/bookmarks/tests/test_bundles_edit_view.py b/bookmarks/tests/test_bundles_edit_view.py
new file mode 100644
index 0000000..45e5e8a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_edit_view.py
@@ -0,0 +1,122 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def create_form_data(self, overrides=None):
+        if overrides is None:
+            overrides = {}
+        form_data = {
+            "name": "Test Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+        return {**form_data, **overrides}
+
+    def test_should_edit_bundle(self):
+        bundle = self.setup_bundle()
+
+        updated_data = self.create_form_data()
+
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
+        )
+
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, updated_data["any_tags"])
+        self.assertEqual(bundle.all_tags, updated_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
+
+    def test_should_render_edit_form_with_prefilled_fields(self):
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search terms",
+            any_tags="tag1 tag2 tag3",
+            all_tags="required-tag all-tag",
+            excluded_tags="excluded-tag banned-tag",
+        )
+
+        response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(
+            f'<input type="text" name="name" value="{bundle.name}" '
+            'autocomplete="off" placeholder=" " class="form-input" '
+            'maxlength="256" required id="id_name">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="search" value="{bundle.search}" '
+            'autocomplete="off" placeholder=" " class="form-input" '
+            'maxlength="256" id="id_search">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_any_tags">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_all_tags">',
+            html,
+        )
+
+        self.assertInHTML(
+            f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
+            'autocomplete="off" autocapitalize="off" class="form-input" '
+            'maxlength="1024" id="id_excluded_tags">',
+            html,
+        )
+
+    def test_should_return_422_with_invalid_form(self):
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search",
+            any_tags="tag1 tag2",
+            all_tags="required-tag",
+            excluded_tags="excluded-tag",
+        )
+
+        invalid_data = self.create_form_data({"name": ""})
+
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
+        )
+
+        self.assertEqual(response.status_code, 422)
+
+    def test_should_not_allow_editing_other_users_bundles(self):
+        other_user = self.setup_user(name="otheruser")
+        other_users_bundle = self.setup_bundle(user=other_user)
+
+        response = self.client.get(
+            reverse("linkding:bundles.edit", args=[other_users_bundle.id])
+        )
+        self.assertEqual(response.status_code, 404)
+
+        updated_data = self.create_form_data()
+        response = self.client.post(
+            reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
+        )
+        self.assertEqual(response.status_code, 404)
diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py
new file mode 100644
index 0000000..57d19f3
--- /dev/null
+++ b/bookmarks/tests/test_bundles_index_view.py
@@ -0,0 +1,198 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def test_render_bundle_list(self):
+        bundles = [
+            self.setup_bundle(name="Bundle 1"),
+            self.setup_bundle(name="Bundle 2"),
+            self.setup_bundle(name="Bundle 3"),
+        ]
+
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        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">
+                <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>
+            """
+
+            self.assertInHTML(expected_list_item, html)
+
+    def test_renders_user_owned_bundles_only(self):
+        user_bundle = self.setup_bundle(name="User Bundle")
+
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
+        self.assertNotIn(other_user_bundle.name, html)
+
+    def test_empty_state(self):
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML('<p class="empty-title h5">You have no bundles yet</p>', html)
+        self.assertInHTML(
+            '<p class="empty-subtitle">Create your first bundle to get started</p>',
+            html,
+        )
+
+    def test_add_new_button(self):
+        response = self.client.get(reverse("linkding:bundles.index"))
+
+        self.assertEqual(response.status_code, 200)
+        html = response.content.decode()
+
+        self.assertInHTML(
+            f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
+            html,
+        )
+
+    def test_remove_bundle(self):
+        bundle = self.setup_bundle(name="Test Bundle")
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(bundle.id)},
+        )
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_remove_other_user_bundle(self):
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(other_user_bundle.id)},
+        )
+
+        self.assertEqual(response.status_code, 404)
+        self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
+
+    def assertBundleOrder(self, expected_bundles, user=None):
+        if user is None:
+            user = self.user
+        actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
+        self.assertEqual(len(actual_bundles), len(expected_bundles))
+        for i, bundle in enumerate(expected_bundles):
+            self.assertEqual(actual_bundles[i].id, bundle.id)
+            self.assertEqual(actual_bundles[i].order, i)
+
+    def move_bundle(self, bundle: BookmarkBundle, position: int):
+        return self.client.post(
+            reverse("linkding:bundles.action"),
+            {"move_bundle": str(bundle.id), "move_position": position},
+        )
+
+    def test_move_bundle(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+        bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+        self.move_bundle(bundle1, 1)
+        self.assertBundleOrder([bundle2, bundle1, bundle3])
+
+        self.move_bundle(bundle1, 0)
+        self.assertBundleOrder([bundle1, bundle2, bundle3])
+
+        self.move_bundle(bundle1, 2)
+        self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+        self.move_bundle(bundle1, 2)
+        self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+    def test_move_bundle_response(self):
+        bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+        self.setup_bundle(name="Bundle 2", order=1)
+
+        response = self.move_bundle(bundle1, 1)
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+    def test_can_only_move_user_owned_bundles(self):
+        other_user = self.setup_user()
+        other_user_bundle1 = self.setup_bundle(user=other_user)
+        self.setup_bundle(user=other_user)
+
+        response = self.move_bundle(other_user_bundle1, 1)
+        self.assertEqual(response.status_code, 404)
+
+    def test_move_bundle_only_affects_own_bundles(self):
+        user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
+        user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
+
+        other_user = self.setup_user(name="otheruser")
+        other_user_bundle = self.setup_bundle(
+            name="Other User Bundle", user=other_user, order=0
+        )
+
+        # Move user bundle
+        self.move_bundle(user_bundle1, 1)
+        self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
+
+        # Check that other user's bundle is unaffected
+        self.assertBundleOrder([other_user_bundle], user=other_user)
+
+    def test_remove_non_existing_bundle(self):
+        non_existent_id = 99999
+
+        response = self.client.post(
+            reverse("linkding:bundles.action"),
+            {"remove_bundle": str(non_existent_id)},
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_post_without_action(self):
+        bundle = self.setup_bundle(name="Test Bundle")
+
+        response = self.client.post(reverse("linkding:bundles.action"), {})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py
new file mode 100644
index 0000000..db39963
--- /dev/null
+++ b/bookmarks/tests/test_bundles_new_view.py
@@ -0,0 +1,77 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def create_form_data(self, overrides=None):
+        if overrides is None:
+            overrides = {}
+        form_data = {
+            "name": "Test Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+        return {**form_data, **overrides}
+
+    def test_should_create_new_bundle(self):
+        form_data = self.create_form_data()
+
+        response = self.client.post(reverse("linkding:bundles.new"), form_data)
+
+        self.assertEqual(BookmarkBundle.objects.count(), 1)
+
+        bundle = BookmarkBundle.objects.first()
+        self.assertEqual(bundle.owner, self.user)
+        self.assertEqual(bundle.name, form_data["name"])
+        self.assertEqual(bundle.search, form_data["search"])
+        self.assertEqual(bundle.any_tags, form_data["any_tags"])
+        self.assertEqual(bundle.all_tags, form_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
+
+        self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+    def test_should_increment_order_for_subsequent_bundles(self):
+        # Create first bundle
+        form_data_1 = self.create_form_data({"name": "Bundle 1"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_1)
+        bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+        self.assertEqual(bundle1.order, 0)
+
+        # Create second bundle
+        form_data_2 = self.create_form_data({"name": "Bundle 2"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_2)
+        bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
+        self.assertEqual(bundle2.order, 1)
+
+        # Create another bundle with a higher order
+        self.setup_bundle(order=5)
+
+        # Create third bundle
+        form_data_3 = self.create_form_data({"name": "Bundle 3"})
+        self.client.post(reverse("linkding:bundles.new"), form_data_3)
+        bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
+        self.assertEqual(bundle3.order, 6)
+
+    def test_incrementing_order_ignores_other_user_bookmark(self):
+        other_user = self.setup_user()
+        self.setup_bundle(user=other_user, order=10)
+
+        form_data = self.create_form_data({"name": "Bundle 1"})
+        self.client.post(reverse("linkding:bundles.new"), form_data)
+        bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+        self.assertEqual(bundle1.order, 0)
+
+    def test_should_return_422_with_invalid_form(self):
+        form_data = self.create_form_data({"name": ""})
+        response = self.client.post(reverse("linkding:bundles.new"), form_data)
+        self.assertEqual(response.status_code, 422)
diff --git a/bookmarks/tests/test_bundles_preview_view.py b/bookmarks/tests/test_bundles_preview_view.py
new file mode 100644
index 0000000..3eb6a0a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_preview_view.py
@@ -0,0 +1,116 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def test_preview_empty_bundle(self):
+        bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
+        bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 2 bookmarks matching this bundle")
+        self.assertContains(response, bookmark1.title)
+        self.assertContains(response, bookmark2.title)
+        self.assertNotContains(response, "No bookmarks match the current bundle")
+
+    def test_preview_with_search_terms(self):
+        bookmark1 = self.setup_bookmark(title="Python Programming")
+        bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
+        bookmark3 = self.setup_bookmark(title="Django Framework")
+
+        response = self.client.get(
+            reverse("linkding:bundles.preview"), {"search": "python"}
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, bookmark1.title)
+        self.assertNotContains(response, bookmark2.title)
+        self.assertNotContains(response, bookmark3.title)
+
+    def test_preview_no_matching_bookmarks(self):
+        bookmark = self.setup_bookmark(title="Python Guide")
+
+        response = self.client.get(
+            reverse("linkding:bundles.preview"), {"search": "nonexistent"}
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "No bookmarks match the current bundle")
+        self.assertNotContains(response, bookmark.title)
+
+    def test_preview_renders_bookmark(self):
+        tag = self.setup_tag(name="test-tag")
+        bookmark = self.setup_bookmark(
+            title="Test Bookmark",
+            description="Test description",
+            url="https://example.com/test",
+            tags=[tag],
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, bookmark.title)
+        self.assertContains(response, bookmark.description)
+        self.assertContains(response, bookmark.url)
+        self.assertContains(response, "#test-tag")
+
+    def test_preview_renders_bookmark_in_preview_mode(self):
+        tag = self.setup_tag(name="test-tag")
+        self.setup_bookmark(
+            title="Test Bookmark",
+            description="Test description",
+            url="https://example.com/test",
+            tags=[tag],
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+        soup = self.make_soup(response.content.decode())
+
+        list_item = soup.select_one("li[ld-bookmark-item]")
+        actions = list_item.select(".actions > *")
+        self.assertEqual(len(actions), 1)
+
+    def test_preview_ignores_archived_bookmarks(self):
+        active_bookmark = self.setup_bookmark(title="Active Bookmark")
+        archived_bookmark = self.setup_bookmark(
+            title="Archived Bookmark", is_archived=True
+        )
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, active_bookmark.title)
+        self.assertNotContains(response, archived_bookmark.title)
+
+    def test_preview_requires_authentication(self):
+        self.client.logout()
+
+        response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
+
+        self.assertRedirects(
+            response, f"/login/?next={reverse('linkding:bundles.preview')}"
+        )
+
+    def test_preview_only_shows_user_bookmarks(self):
+        other_user = self.setup_user()
+        own_bookmark = self.setup_bookmark(title="Own Bookmark")
+        other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
+
+        response = self.client.get(reverse("linkding:bundles.preview"))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Found 1 bookmarks matching this bundle")
+        self.assertContains(response, own_bookmark.title)
+        self.assertNotContains(response, other_bookmark.title)
diff --git a/bookmarks/tests/test_pagination_tag.py b/bookmarks/tests/test_pagination_tag.py
index 19ec728..ce4530d 100644
--- a/bookmarks/tests/test_pagination_tag.py
+++ b/bookmarks/tests/test_pagination_tag.py
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         )
 
     def assertPrevLink(self, html: str, page_number: int, href: str = None):
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item">
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         )
 
     def assertNextLink(self, html: str, page_number: int, href: str = None):
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item">
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         href: str = None,
     ):
         active_class = "active" if active else ""
-        href = href if href else "?page={0}".format(page_number)
+        href = href if href else "http://testserver/test?page={0}".format(page_number)
         self.assertInHTML(
             """
             <li class="page-item {1}">
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
         rendered_template = self.render_template(
             100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
         )
-        self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
+        self.assertPrevLink(
+            rendered_template,
+            1,
+            href="http://testserver/test?q=cake&sort=title_asc&page=1",
+        )
         self.assertPageLink(
-            rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
+            rendered_template,
+            1,
+            False,
+            href="http://testserver/test?q=cake&sort=title_asc&page=1",
         )
         self.assertPageLink(
-            rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
+            rendered_template,
+            2,
+            True,
+            href="http://testserver/test?q=cake&sort=title_asc&page=2",
+        )
+        self.assertNextLink(
+            rendered_template,
+            3,
+            href="http://testserver/test?q=cake&sort=title_asc&page=3",
         )
-        self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
 
     def test_removes_details_parameter(self):
         rendered_template = self.render_template(
             100, 10, 2, url="/test?details=1&page=2"
         )
-        self.assertPrevLink(rendered_template, 1, href="?page=1")
-        self.assertPageLink(rendered_template, 1, False, href="?page=1")
-        self.assertPageLink(rendered_template, 2, True, href="?page=2")
-        self.assertNextLink(rendered_template, 3, href="?page=3")
+        self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
+        self.assertPageLink(
+            rendered_template, 1, False, href="http://testserver/test?page=1"
+        )
+        self.assertPageLink(
+            rendered_template, 2, True, href="http://testserver/test?page=2"
+        )
+        self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index 01e2d0c..7516aa7 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
             self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
         ]
 
-    def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
+    def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
         expected_items = []
         for item_list in item_lists:
             expected_items = expected_items + item_list
@@ -1287,3 +1287,267 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
         search = BookmarkSearch(added_since="invalid-date")
         query = queries.query_bookmarks(self.user, self.profile, search)
         self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+    def test_query_bookmarks_with_bundle_search_terms(self):
+        bundle = self.setup_bundle(search="search_term_A search_term_B")
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                title="search_term_A content", description="search_term_B also here"
+            ),
+            self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(title="search_term_A only")
+        self.setup_bookmark(description="search_term_B only")
+        self.setup_bookmark(title="unrelated content")
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_and_bundle_search_terms(self):
+        bundle = self.setup_bundle(search="bundle_term_B")
+        search = BookmarkSearch(q="search_term_A", bundle=bundle)
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                title="search_term_A content", description="bundle_term_B also here"
+            )
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(title="search_term_A only")
+        self.setup_bookmark(description="bundle_term_B only")
+        self.setup_bookmark(title="unrelated content")
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_any_tags(self):
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[tag1]),
+            self.setup_bookmark(tags=[tag2]),
+            self.setup_bookmark(tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
+        bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
+        search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+        search_tag1 = self.setup_tag(name="searchTag1")
+        search_tag2 = self.setup_tag(name="searchTag2")
+        bundle_tag_a = self.setup_tag(name="bundleTagA")
+        bundle_tag_b = self.setup_tag(name="bundleTagB")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
+            self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
+            self.setup_bookmark(
+                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+            ),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
+        self.setup_bookmark(tags=[search_tag1, search_tag2])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+        self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
+        self.setup_bookmark(tags=[bundle_tag_a])
+        self.setup_bookmark(tags=[bundle_tag_b])
+        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_all_tags(self):
+        bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[tag1])
+        self.setup_bookmark(tags=[tag2])
+        self.setup_bookmark(tags=[tag1, other_tag])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
+        bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
+        search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+        search_tag1 = self.setup_tag(name="searchTag1")
+        search_tag2 = self.setup_tag(name="searchTag2")
+        bundle_tag_a = self.setup_tag(name="bundleTagA")
+        bundle_tag_b = self.setup_tag(name="bundleTagB")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(
+                tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+            )
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[search_tag1, search_tag2])
+        self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+        self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(self.user, self.profile, search)
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_excluded_tags(self):
+        bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
+
+        exclude_tag1 = self.setup_tag(name="excludeTag1")
+        exclude_tag2 = self.setup_tag(name="excludeTag2")
+        keep_tag = self.setup_tag(name="keepTag")
+        keep_other_tag = self.setup_tag(name="keepOtherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[keep_tag]),
+            self.setup_bookmark(tags=[keep_other_tag]),
+            self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
+            self.setup_bookmark(),
+        ]
+
+        # Bookmarks that should not be returned
+        self.setup_bookmark(tags=[exclude_tag1])
+        self.setup_bookmark(tags=[exclude_tag2])
+        self.setup_bookmark(tags=[exclude_tag1, keep_tag])
+        self.setup_bookmark(tags=[exclude_tag2, keep_tag])
+        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
+        self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_bookmarks_with_bundle_combined_tags(self):
+        bundle = self.setup_bundle(
+            any_tags="anyTagA anyTagB",
+            all_tags="allTag1 allTag2",
+            excluded_tags="excludedTag",
+        )
+
+        any_tag_a = self.setup_tag(name="anyTagA")
+        any_tag_b = self.setup_tag(name="anyTagB")
+        all_tag_1 = self.setup_tag(name="allTag1")
+        all_tag_2 = self.setup_tag(name="allTag2")
+        other_tag = self.setup_tag(name="otherTag")
+        excluded_tag = self.setup_tag(name="excludedTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
+            self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
+            self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(tags=[any_tag_a, all_tag_1])
+        self.setup_bookmark(tags=[any_tag_b, all_tag_2])
+        self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
+        self.setup_bookmark(tags=[all_tag_1, all_tag_2])
+        self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
+        self.setup_bookmark(tags=[any_tag_a])
+        self.setup_bookmark(tags=[any_tag_b])
+        self.setup_bookmark(tags=[all_tag_1])
+        self.setup_bookmark(tags=[all_tag_2])
+        self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
+        self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
+        self.setup_bookmark(tags=[other_tag])
+        self.setup_bookmark()
+
+        query = queries.query_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_archived_bookmarks_with_bundle(self):
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(is_archived=True, tags=[tag1]),
+            self.setup_bookmark(is_archived=True, tags=[tag2]),
+            self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(is_archived=True, tags=[other_tag])
+        self.setup_bookmark(is_archived=True)
+        self.setup_bookmark(tags=[tag1]),
+        self.setup_bookmark(tags=[tag2]),
+        self.setup_bookmark(tags=[tag1, tag2]),
+
+        query = queries.query_archived_bookmarks(
+            self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
+
+    def test_query_shared_bookmarks_with_bundle(self):
+        user1 = self.setup_user(enable_sharing=True)
+        user2 = self.setup_user(enable_sharing=True)
+
+        bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+        tag1 = self.setup_tag(name="bundleTag1")
+        tag2 = self.setup_tag(name="bundleTag2")
+        other_tag = self.setup_tag(name="otherTag")
+
+        matching_bookmarks = [
+            self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
+            self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
+            self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
+        ]
+
+        # Bookmarks that should not match
+        self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
+        self.setup_bookmark(user=user2, shared=True)
+        self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
+        self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
+        self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
+
+        query = queries.query_shared_bookmarks(
+            None, self.profile, BookmarkSearch(q="", bundle=bundle), False
+        )
+        self.assertQueryResult(query, [matching_bookmarks])
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index a99a04a..ad250bb 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "items_per_page": "30",
             "sticky_pagination": False,
             "collapse_side_panel": False,
+            "hide_bundles": False,
         }
 
         return {**form_data, **overrides}
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
             "items_per_page": "10",
             "sticky_pagination": True,
             "collapse_side_panel": True,
+            "hide_bundles": True,
         }
         response = self.client.post(
             reverse("linkding:settings.update"), form_data, follow=True
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(
             self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
         )
+        self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
 
         self.assertSuccessMessage(html, "Profile updated")
 
diff --git a/bookmarks/tests/test_tag_cloud_template.py b/bookmarks/tests/test_tag_cloud_template.py
index 04cad07..5736cc6 100644
--- a/bookmarks/tests/test_tag_cloud_template.py
+++ b/bookmarks/tests/test_tag_cloud_template.py
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
 from django.test import TestCase, RequestFactory
 
 from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import UserProfile
+from bookmarks.models import BookmarkSearch, UserProfile
 from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 from bookmarks.views import contexts
 
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         middleware = LinkdingMiddleware(lambda r: HttpResponse())
         middleware(request)
 
-        tag_cloud_context = context_type(request)
+        search = BookmarkSearch.from_request(
+            request, request.GET, request.user_profile.search_preferences
+        )
+        tag_cloud_context = context_type(request, search)
         context = RequestContext(request, {"tag_cloud": tag_cloud_context})
         template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
         return template_to_render.render(context)
diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py
index 9d4d93a..4144c8d 100644
--- a/bookmarks/tests/test_toasts_view.py
+++ b/bookmarks/tests/test_toasts_view.py
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should render toasts container
-        self.assertContains(response, '<div class="toasts">')
+        self.assertContains(response, '<div class="message-list">')
         # Should render two toasts
         self.assertContains(response, '<div class="toast d-flex">', count=2)
 
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should not render toasts container
-        self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
+        self.assertContains(response, '<div class="message-list">', count=0)
         # Should not render toasts
         self.assertContains(response, '<div class="toast">', count=0)
 
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
         response = self.client.get(reverse("linkding:bookmarks.index"))
 
         # Should not render toasts container
-        self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
+        self.assertContains(response, '<div class="message-list">', count=0)
         # Should not render toasts
         self.assertContains(response, '<div class="toast">', count=0)
 
diff --git a/bookmarks/tests/test_user_select_tag.py b/bookmarks/tests/test_user_select_tag.py
index ed2980f..94b09c8 100644
--- a/bookmarks/tests/test_user_select_tag.py
+++ b/bookmarks/tests/test_user_select_tag.py
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
         request = rf.get(url)
         request.user = self.get_or_create_test_user()
         request.user_profile = self.get_or_create_test_user().profile
-        search = BookmarkSearch.from_request(request.GET)
+        search = BookmarkSearch.from_request(request, request.GET)
         context = RequestContext(
             request,
             {
diff --git a/bookmarks/tests_e2e/e2e_test_bundle_preview.py b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
new file mode 100644
index 0000000..44e5b6e
--- /dev/null
+++ b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
@@ -0,0 +1,50 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect
+
+from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
+
+
+class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
+    def test_update_preview_on_filter_changes(self):
+        group1 = self.setup_numbered_bookmarks(3, prefix="foo")
+        group2 = self.setup_numbered_bookmarks(3, prefix="bar")
+
+        with sync_playwright() as p:
+            # shows all bookmarks initially
+            page = self.open(reverse("linkding:bundles.new"), p)
+
+            expect(
+                page.get_by_text(f"Found 6 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group1 + group2)
+
+            # filter by group1
+            search = page.get_by_label("Search")
+            search.fill("foo")
+
+            expect(
+                page.get_by_text(f"Found 3 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group1)
+
+            # filter by group2
+            search.fill("bar")
+
+            expect(
+                page.get_by_text(f"Found 3 bookmarks matching this bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks(group2)
+
+            # filter by invalid group
+            search.fill("invalid")
+
+            expect(
+                page.get_by_text(f"No bookmarks match the current bundle")
+            ).to_be_visible()
+            self.assertVisibleBookmarks([])
+
+    def assertVisibleBookmarks(self, bookmarks):
+        self.assertEqual(len(bookmarks), self.count_bookmarks())
+
+        for bookmark in bookmarks:
+            expect(self.locate_bookmark(bookmark.title)).to_be_visible()
diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py
index 48ce248..cdfce86 100644
--- a/bookmarks/tests_e2e/helpers.py
+++ b/bookmarks/tests_e2e/helpers.py
@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
         bookmark_tags = self.page.locator("li[ld-bookmark-item]")
         return bookmark_tags.filter(has_text=title)
 
+    def count_bookmarks(self):
+        bookmark_tags = self.page.locator("li[ld-bookmark-item]")
+        return bookmark_tags.count()
+
     def locate_details_modal(self):
         return self.page.locator(".modal.bookmark-details")