diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts
new file mode 100644
index 0000000000..0a26ccef0e
--- /dev/null
+++ b/e2e/src/api/specs/tag.e2e-spec.ts
@@ -0,0 +1,559 @@
+import {
+  AssetMediaResponseDto,
+  LoginResponseDto,
+  Permission,
+  TagCreateDto,
+  createTag,
+  getAllTags,
+  tagAssets,
+  upsertTags,
+} from '@immich/sdk';
+import { createUserDto, uuidDto } from 'src/fixtures';
+import { errorDto } from 'src/responses';
+import { app, asBearerAuth, utils } from 'src/utils';
+import request from 'supertest';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+const create = (accessToken: string, dto: TagCreateDto) =>
+  createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) });
+
+const upsert = (accessToken: string, tags: string[]) =>
+  upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) });
+
+describe('/tags', () => {
+  let admin: LoginResponseDto;
+  let user: LoginResponseDto;
+  let userAsset: AssetMediaResponseDto;
+
+  beforeAll(async () => {
+    await utils.resetDatabase();
+
+    admin = await utils.adminSetup();
+    user = await utils.userSetup(admin.accessToken, createUserDto.user1);
+    userAsset = await utils.createAsset(user.accessToken);
+  });
+
+  beforeEach(async () => {
+    //  tagging assets eventually triggers metadata extraction which can impact other tests
+    await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+    await utils.resetDatabase(['tags']);
+  });
+
+  describe('POST /tags', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).post('/tags').send({ name: 'TagA' });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.create'));
+    });
+
+    it('should work with tag.create', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]);
+      const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
+      expect(body).toEqual({
+        id: expect.any(String),
+        name: 'TagA',
+        value: 'TagA',
+        createdAt: expect.any(String),
+        updatedAt: expect.any(String),
+      });
+      expect(status).toBe(201);
+    });
+
+    it('should create a tag', async () => {
+      const { status, body } = await request(app)
+        .post('/tags')
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send({ name: 'TagA' });
+      expect(body).toEqual({
+        id: expect.any(String),
+        name: 'TagA',
+        value: 'TagA',
+        createdAt: expect.any(String),
+        updatedAt: expect.any(String),
+      });
+      expect(status).toBe(201);
+    });
+
+    it('should create a nested tag', async () => {
+      const parent = await create(admin.accessToken, { name: 'TagA' });
+
+      const { status, body } = await request(app)
+        .post('/tags')
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send({ name: 'TagB', parentId: parent.id });
+      expect(body).toEqual({
+        id: expect.any(String),
+        name: 'TagB',
+        value: 'TagA/TagB',
+        createdAt: expect.any(String),
+        updatedAt: expect.any(String),
+      });
+      expect(status).toBe(201);
+    });
+  });
+
+  describe('GET /tags', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).get('/tags');
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.read'));
+    });
+
+    it('should start off empty', async () => {
+      const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toEqual([]);
+      expect(status).toEqual(200);
+    });
+
+    it('should return a list of tags', async () => {
+      const [tagA, tagB, tagC] = await Promise.all([
+        create(admin.accessToken, { name: 'TagA' }),
+        create(admin.accessToken, { name: 'TagB' }),
+        create(admin.accessToken, { name: 'TagC' }),
+      ]);
+      const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toHaveLength(3);
+      expect(body).toEqual([tagA, tagB, tagC]);
+      expect(status).toEqual(200);
+    });
+
+    it('should return a nested tags', async () => {
+      await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']);
+      const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(body).toHaveLength(4);
+      expect(body).toEqual([
+        expect.objectContaining({ name: 'TagA', value: 'TagA' }),
+        expect.objectContaining({ name: 'TagB', value: 'TagA/TagB' }),
+        expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC' }),
+        expect.objectContaining({ name: 'TagD', value: 'TagD' }),
+      ]);
+      expect(status).toEqual(200);
+    });
+  });
+
+  describe('PUT /tags', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.create'));
+    });
+
+    it('should upsert tags', async () => {
+      const { status, body } = await request(app)
+        .put(`/tags`)
+        .send({ tags: ['TagA/TagB/TagC/TagD'] })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]);
+    });
+  });
+
+  describe('PUT /tags/assets', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app)
+        .put('/tags/assets')
+        .set('x-api-key', secret)
+        .send({ assetIds: [], tagIds: [] });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.asset'));
+    });
+
+    it('should skip assets that are not owned by the user', async () => {
+      const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
+        create(user.accessToken, { name: 'TagA' }),
+        create(user.accessToken, { name: 'TagB' }),
+        create(user.accessToken, { name: 'TagC' }),
+        utils.createAsset(user.accessToken),
+        utils.createAsset(admin.accessToken),
+      ]);
+      const { status, body } = await request(app)
+        .put(`/tags/assets`)
+        .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual({ count: 3 });
+    });
+
+    it('should skip tags that are not owned by the user', async () => {
+      const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
+        create(user.accessToken, { name: 'TagA' }),
+        create(user.accessToken, { name: 'TagB' }),
+        create(admin.accessToken, { name: 'TagC' }),
+        utils.createAsset(user.accessToken),
+        utils.createAsset(user.accessToken),
+      ]);
+      const { status, body } = await request(app)
+        .put(`/tags/assets`)
+        .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual({ count: 4 });
+    });
+
+    it('should bulk tag assets', async () => {
+      const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
+        create(user.accessToken, { name: 'TagA' }),
+        create(user.accessToken, { name: 'TagB' }),
+        create(user.accessToken, { name: 'TagC' }),
+        utils.createAsset(user.accessToken),
+        utils.createAsset(user.accessToken),
+      ]);
+      const { status, body } = await request(app)
+        .put(`/tags/assets`)
+        .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual({ count: 6 });
+    });
+  });
+
+  describe('GET /tags/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`);
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .get(`/tags/${tag.id}`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.noPermission);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app)
+        .get(`/tags/${uuidDto.notFound}`)
+        .set('x-api-key', secret)
+        .send({ assetIds: [], tagIds: [] });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.read'));
+    });
+
+    it('should require a valid uuid', async () => {
+      const { status, body } = await request(app)
+        .get(`/tags/${uuidDto.invalid}`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
+    });
+
+    it('should get tag details', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .get(`/tags/${tag.id}`)
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        id: expect.any(String),
+        name: 'TagA',
+        value: 'TagA',
+        createdAt: expect.any(String),
+        updatedAt: expect.any(String),
+      });
+    });
+
+    it('should get nested tag details', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
+      const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id });
+      const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id });
+
+      const { status, body } = await request(app)
+        .get(`/tags/${tagD.id}`)
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual({
+        id: expect.any(String),
+        name: 'TagD',
+        value: 'TagA/TagB/TagC/TagD',
+        createdAt: expect.any(String),
+        updatedAt: expect.any(String),
+      });
+    });
+  });
+
+  describe('PUT /tags/:id', () => {
+    it('should require authentication', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization', async () => {
+      const tag = await create(admin.accessToken, { name: 'tagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}`)
+        .send({ color: '#000000' })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.noPermission);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}`)
+        .set('x-api-key', secret)
+        .send({ color: '#000000' });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.update'));
+    });
+
+    it('should update a tag', async () => {
+      const tag = await create(user.accessToken, { name: 'tagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}`)
+        .send({ color: '#000000' })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
+    });
+
+    it('should update a tag color without a # prefix', async () => {
+      const tag = await create(user.accessToken, { name: 'tagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}`)
+        .send({ color: '000000' })
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(200);
+      expect(body).toEqual(expect.objectContaining({ color: `#000000` }));
+    });
+  });
+
+  describe('DELETE /tags/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`);
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .delete(`/tags/${tag.id}`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.noPermission);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret);
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.delete'));
+    });
+
+    it('should require a valid uuid', async () => {
+      const { status, body } = await request(app)
+        .delete(`/tags/${uuidDto.invalid}`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
+    });
+
+    it('should delete a tag', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status } = await request(app)
+        .delete(`/tags/${tag.id}`)
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(204);
+    });
+
+    it('should delete a nested tag (root)', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
+      const { status } = await request(app)
+        .delete(`/tags/${tagA.id}`)
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(204);
+      const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
+      expect(tags.length).toBe(0);
+    });
+
+    it('should delete a nested tag (leaf)', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id });
+      const { status } = await request(app)
+        .delete(`/tags/${tagB.id}`)
+        .set('Authorization', `Bearer ${user.accessToken}`);
+      expect(status).toBe(204);
+      const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) });
+      expect(tags.length).toBe(1);
+      expect(tags[0]).toEqual(tagA);
+    });
+  });
+
+  describe('PUT /tags/:id/assets', () => {
+    it('should require authentication', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tagA.id}/assets`)
+        .send({ ids: [userAsset.id] });
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}/assets`)
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send({ ids: [userAsset.id] });
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.noPermission);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app)
+        .put(`/tags/${tag.id}/assets`)
+        .set('x-api-key', secret)
+        .send({ ids: [userAsset.id] });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.asset'));
+    });
+
+    it('should be able to tag own asset', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${user.accessToken}`)
+        .send({ ids: [userAsset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
+    });
+
+    it("should not be able to add assets to another user's tag", async () => {
+      const tagA = await create(admin.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${user.accessToken}`)
+        .send({ ids: [userAsset.id] });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access'));
+    });
+
+    it('should add duplicate assets only once', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .put(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${user.accessToken}`)
+        .send({ ids: [userAsset.id, userAsset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([
+        expect.objectContaining({ id: userAsset.id, success: true }),
+        expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }),
+      ]);
+    });
+  });
+
+  describe('DELETE /tags/:id/assets', () => {
+    it('should require authentication', async () => {
+      const tagA = await create(admin.accessToken, { name: 'TagA' });
+      const { status, body } = await request(app)
+        .delete(`/tags/${tagA}/assets`)
+        .send({ ids: [userAsset.id] });
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should require authorization', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      await tagAssets(
+        { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
+        { headers: asBearerAuth(user.accessToken) },
+      );
+      const { status, body } = await request(app)
+        .delete(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send({ ids: [userAsset.id] });
+
+      expect(status).toBe(400);
+      expect(body).toEqual(errorDto.noPermission);
+    });
+
+    it('should require authorization (api key)', async () => {
+      const tag = await create(user.accessToken, { name: 'TagA' });
+      const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
+      const { status, body } = await request(app)
+        .delete(`/tags/${tag.id}/assets`)
+        .set('x-api-key', secret)
+        .send({ ids: [userAsset.id] });
+      expect(status).toBe(403);
+      expect(body).toEqual(errorDto.missingPermission('tag.asset'));
+    });
+
+    it('should be able to remove own asset from own tag', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      await tagAssets(
+        { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
+        { headers: asBearerAuth(user.accessToken) },
+      );
+      const { status, body } = await request(app)
+        .delete(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${user.accessToken}`)
+        .send({ ids: [userAsset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]);
+    });
+
+    it('should remove duplicate assets only once', async () => {
+      const tagA = await create(user.accessToken, { name: 'TagA' });
+      await tagAssets(
+        { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
+        { headers: asBearerAuth(user.accessToken) },
+      );
+      const { status, body } = await request(app)
+        .delete(`/tags/${tagA.id}/assets`)
+        .set('Authorization', `Bearer ${user.accessToken}`)
+        .send({ ids: [userAsset.id, userAsset.id] });
+
+      expect(status).toBe(200);
+      expect(body).toEqual([
+        expect.objectContaining({ id: userAsset.id, success: true }),
+        expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }),
+      ]);
+    });
+  });
+});
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 30e2497b51..a53a3ddd25 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -148,6 +148,7 @@ export const utils = {
         'sessions',
         'users',
         'system_metadata',
+        'tags',
       ];
 
       const sql: string[] = [];
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 1da4463a12..1f8958dd95 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 05a43c8af7..532d7e22cd 100644
Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ
diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart
index e5d1e9c650..87c9001a3c 100644
Binary files a/mobile/openapi/lib/api/tags_api.dart and b/mobile/openapi/lib/api/tags_api.dart differ
diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart
index 4acb98bdf2..8c94e09bf5 100644
Binary files a/mobile/openapi/lib/api/timeline_api.dart and b/mobile/openapi/lib/api/timeline_api.dart differ
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index c9ed2a508d..54873a5955 100644
Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ
diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart
index 7f46e145b1..a486551cc5 100644
Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ
diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart
index 3f89c9826d..1244a434b6 100644
Binary files a/mobile/openapi/lib/model/permission.dart and b/mobile/openapi/lib/model/permission.dart differ
diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart
new file mode 100644
index 0000000000..c11cb66ce0
Binary files /dev/null and b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart differ
diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart
new file mode 100644
index 0000000000..d4dcb91d8c
Binary files /dev/null and b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart
similarity index 56%
rename from mobile/openapi/lib/model/update_tag_dto.dart
rename to mobile/openapi/lib/model/tag_create_dto.dart
index dfa9b8cfc0..dd7e537a0a 100644
Binary files a/mobile/openapi/lib/model/update_tag_dto.dart and b/mobile/openapi/lib/model/tag_create_dto.dart differ
diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart
index d371bd1c04..4f0a62a8b9 100644
Binary files a/mobile/openapi/lib/model/tag_response_dto.dart and b/mobile/openapi/lib/model/tag_response_dto.dart differ
diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart
deleted file mode 100644
index 3f2e723796..0000000000
Binary files a/mobile/openapi/lib/model/tag_type_enum.dart and /dev/null differ
diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart
similarity index 57%
rename from mobile/openapi/lib/model/create_tag_dto.dart
rename to mobile/openapi/lib/model/tag_update_dto.dart
index 31b194993d..661f65896e 100644
Binary files a/mobile/openapi/lib/model/create_tag_dto.dart and b/mobile/openapi/lib/model/tag_update_dto.dart differ
diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart
new file mode 100644
index 0000000000..941d25b6ae
Binary files /dev/null and b/mobile/openapi/lib/model/tag_upsert_dto.dart differ
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 2137bf7b11..4d80353177 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -6169,7 +6169,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/CreateTagDto"
+                "$ref": "#/components/schemas/TagCreateDto"
               }
             }
           },
@@ -6201,6 +6201,91 @@
         "tags": [
           "Tags"
         ]
+      },
+      "put": {
+        "operationId": "upsertTags",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/TagUpsertDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/TagResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Tags"
+        ]
+      }
+    },
+    "/tags/assets": {
+      "put": {
+        "operationId": "bulkTagAssets",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/TagBulkAssetsDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/TagBulkAssetsResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Tags"
+        ]
       }
     },
     "/tags/{id}": {
@@ -6218,7 +6303,7 @@
           }
         ],
         "responses": {
-          "200": {
+          "204": {
             "description": ""
           }
         },
@@ -6277,7 +6362,7 @@
           "Tags"
         ]
       },
-      "patch": {
+      "put": {
         "operationId": "updateTag",
         "parameters": [
           {
@@ -6294,7 +6379,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/UpdateTagDto"
+                "$ref": "#/components/schemas/TagUpdateDto"
               }
             }
           },
@@ -6346,7 +6431,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/AssetIdsDto"
+                "$ref": "#/components/schemas/BulkIdsDto"
               }
             }
           },
@@ -6358,50 +6443,7 @@
               "application/json": {
                 "schema": {
                   "items": {
-                    "$ref": "#/components/schemas/AssetIdsResponseDto"
-                  },
-                  "type": "array"
-                }
-              }
-            },
-            "description": ""
-          }
-        },
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ],
-        "tags": [
-          "Tags"
-        ]
-      },
-      "get": {
-        "operationId": "getTagAssets",
-        "parameters": [
-          {
-            "name": "id",
-            "required": true,
-            "in": "path",
-            "schema": {
-              "format": "uuid",
-              "type": "string"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "content": {
-              "application/json": {
-                "schema": {
-                  "items": {
-                    "$ref": "#/components/schemas/AssetResponseDto"
+                    "$ref": "#/components/schemas/BulkIdResponseDto"
                   },
                   "type": "array"
                 }
@@ -6442,7 +6484,7 @@
           "content": {
             "application/json": {
               "schema": {
-                "$ref": "#/components/schemas/AssetIdsDto"
+                "$ref": "#/components/schemas/BulkIdsDto"
               }
             }
           },
@@ -6454,7 +6496,7 @@
               "application/json": {
                 "schema": {
                   "items": {
-                    "$ref": "#/components/schemas/AssetIdsResponseDto"
+                    "$ref": "#/components/schemas/BulkIdResponseDto"
                   },
                   "type": "array"
                 }
@@ -6549,6 +6591,15 @@
               "$ref": "#/components/schemas/TimeBucketSize"
             }
           },
+          {
+            "name": "tagId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
           {
             "name": "timeBucket",
             "required": true,
@@ -6684,6 +6735,15 @@
               "$ref": "#/components/schemas/TimeBucketSize"
             }
           },
+          {
+            "name": "tagId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
           {
             "name": "userId",
             "required": false,
@@ -8685,21 +8745,6 @@
         ],
         "type": "object"
       },
-      "CreateTagDto": {
-        "properties": {
-          "name": {
-            "type": "string"
-          },
-          "type": {
-            "$ref": "#/components/schemas/TagTypeEnum"
-          }
-        },
-        "required": [
-          "name",
-          "type"
-        ],
-        "type": "object"
-      },
       "DownloadArchiveInfo": {
         "properties": {
           "assetIds": {
@@ -10053,6 +10098,7 @@
           "tag.read",
           "tag.update",
           "tag.delete",
+          "tag.asset",
           "admin.user.create",
           "admin.user.read",
           "admin.user.update",
@@ -11848,36 +11894,113 @@
         ],
         "type": "object"
       },
+      "TagBulkAssetsDto": {
+        "properties": {
+          "assetIds": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "tagIds": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "assetIds",
+          "tagIds"
+        ],
+        "type": "object"
+      },
+      "TagBulkAssetsResponseDto": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          }
+        },
+        "required": [
+          "count"
+        ],
+        "type": "object"
+      },
+      "TagCreateDto": {
+        "properties": {
+          "color": {
+            "type": "string"
+          },
+          "name": {
+            "type": "string"
+          },
+          "parentId": {
+            "format": "uuid",
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "required": [
+          "name"
+        ],
+        "type": "object"
+      },
       "TagResponseDto": {
         "properties": {
+          "color": {
+            "type": "string"
+          },
+          "createdAt": {
+            "format": "date-time",
+            "type": "string"
+          },
           "id": {
             "type": "string"
           },
           "name": {
             "type": "string"
           },
-          "type": {
-            "$ref": "#/components/schemas/TagTypeEnum"
+          "updatedAt": {
+            "format": "date-time",
+            "type": "string"
           },
-          "userId": {
+          "value": {
             "type": "string"
           }
         },
         "required": [
+          "createdAt",
           "id",
           "name",
-          "type",
-          "userId"
+          "updatedAt",
+          "value"
         ],
         "type": "object"
       },
-      "TagTypeEnum": {
-        "enum": [
-          "OBJECT",
-          "FACE",
-          "CUSTOM"
+      "TagUpdateDto": {
+        "properties": {
+          "color": {
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "TagUpsertDto": {
+        "properties": {
+          "tags": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "tags"
         ],
-        "type": "string"
+        "type": "object"
       },
       "TimeBucketResponseDto": {
         "properties": {
@@ -12021,14 +12144,6 @@
         ],
         "type": "object"
       },
-      "UpdateTagDto": {
-        "properties": {
-          "name": {
-            "type": "string"
-          }
-        },
-        "type": "object"
-      },
       "UsageByUserDto": {
         "properties": {
           "photos": {
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index bf0c63c2b8..3fdcf33757 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -198,10 +198,12 @@ export type AssetStackResponseDto = {
     primaryAssetId: string;
 };
 export type TagResponseDto = {
+    color?: string;
+    createdAt: string;
     id: string;
     name: string;
-    "type": TagTypeEnum;
-    userId: string;
+    updatedAt: string;
+    value: string;
 };
 export type AssetResponseDto = {
     /** base64 encoded sha1 hash */
@@ -1171,12 +1173,23 @@ export type ReverseGeocodingStateResponseDto = {
     lastImportFileName: string | null;
     lastUpdate: string | null;
 };
-export type CreateTagDto = {
+export type TagCreateDto = {
+    color?: string;
     name: string;
-    "type": TagTypeEnum;
+    parentId?: string | null;
 };
-export type UpdateTagDto = {
-    name?: string;
+export type TagUpsertDto = {
+    tags: string[];
+};
+export type TagBulkAssetsDto = {
+    assetIds: string[];
+    tagIds: string[];
+};
+export type TagBulkAssetsResponseDto = {
+    count: number;
+};
+export type TagUpdateDto = {
+    color?: string | null;
 };
 export type TimeBucketResponseDto = {
     count: number;
@@ -2835,8 +2848,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) {
         ...opts
     }));
 }
-export function createTag({ createTagDto }: {
-    createTagDto: CreateTagDto;
+export function createTag({ tagCreateDto }: {
+    tagCreateDto: TagCreateDto;
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 201;
@@ -2844,7 +2857,31 @@ export function createTag({ createTagDto }: {
     }>("/tags", oazapfts.json({
         ...opts,
         method: "POST",
-        body: createTagDto
+        body: tagCreateDto
+    })));
+}
+export function upsertTags({ tagUpsertDto }: {
+    tagUpsertDto: TagUpsertDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: TagResponseDto[];
+    }>("/tags", oazapfts.json({
+        ...opts,
+        method: "PUT",
+        body: tagUpsertDto
+    })));
+}
+export function bulkTagAssets({ tagBulkAssetsDto }: {
+    tagBulkAssetsDto: TagBulkAssetsDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: TagBulkAssetsResponseDto;
+    }>("/tags/assets", oazapfts.json({
+        ...opts,
+        method: "PUT",
+        body: tagBulkAssetsDto
     })));
 }
 export function deleteTag({ id }: {
@@ -2865,56 +2902,46 @@ export function getTagById({ id }: {
         ...opts
     }));
 }
-export function updateTag({ id, updateTagDto }: {
+export function updateTag({ id, tagUpdateDto }: {
     id: string;
-    updateTagDto: UpdateTagDto;
+    tagUpdateDto: TagUpdateDto;
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
         data: TagResponseDto;
     }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({
         ...opts,
-        method: "PATCH",
-        body: updateTagDto
+        method: "PUT",
+        body: tagUpdateDto
     })));
 }
-export function untagAssets({ id, assetIdsDto }: {
+export function untagAssets({ id, bulkIdsDto }: {
     id: string;
-    assetIdsDto: AssetIdsDto;
+    bulkIdsDto: BulkIdsDto;
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
-        data: AssetIdsResponseDto[];
+        data: BulkIdResponseDto[];
     }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
         ...opts,
         method: "DELETE",
-        body: assetIdsDto
+        body: bulkIdsDto
     })));
 }
-export function getTagAssets({ id }: {
+export function tagAssets({ id, bulkIdsDto }: {
     id: string;
+    bulkIdsDto: BulkIdsDto;
 }, opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
-        data: AssetResponseDto[];
-    }>(`/tags/${encodeURIComponent(id)}/assets`, {
-        ...opts
-    }));
-}
-export function tagAssets({ id, assetIdsDto }: {
-    id: string;
-    assetIdsDto: AssetIdsDto;
-}, opts?: Oazapfts.RequestOpts) {
-    return oazapfts.ok(oazapfts.fetchJson<{
-        status: 200;
-        data: AssetIdsResponseDto[];
+        data: BulkIdResponseDto[];
     }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
         ...opts,
         method: "PUT",
-        body: assetIdsDto
+        body: bulkIdsDto
     })));
 }
-export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
+export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: {
     albumId?: string;
     isArchived?: boolean;
     isFavorite?: boolean;
@@ -2923,6 +2950,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
     order?: AssetOrder;
     personId?: string;
     size: TimeBucketSize;
+    tagId?: string;
     timeBucket: string;
     userId?: string;
     withPartners?: boolean;
@@ -2940,6 +2968,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
         order,
         personId,
         size,
+        tagId,
         timeBucket,
         userId,
         withPartners,
@@ -2948,7 +2977,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
         ...opts
     }));
 }
-export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
+export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: {
     albumId?: string;
     isArchived?: boolean;
     isFavorite?: boolean;
@@ -2957,6 +2986,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
     order?: AssetOrder;
     personId?: string;
     size: TimeBucketSize;
+    tagId?: string;
     userId?: string;
     withPartners?: boolean;
     withStacked?: boolean;
@@ -2973,6 +3003,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
         order,
         personId,
         size,
+        tagId,
         userId,
         withPartners,
         withStacked
@@ -3162,11 +3193,6 @@ export enum AlbumUserRole {
     Editor = "editor",
     Viewer = "viewer"
 }
-export enum TagTypeEnum {
-    Object = "OBJECT",
-    Face = "FACE",
-    Custom = "CUSTOM"
-}
 export enum AssetTypeEnum {
     Image = "IMAGE",
     Video = "VIDEO",
@@ -3257,6 +3283,7 @@ export enum Permission {
     TagRead = "tag.read",
     TagUpdate = "tag.update",
     TagDelete = "tag.delete",
+    TagAsset = "tag.asset",
     AdminUserCreate = "admin.user.create",
     AdminUserRead = "admin.user.read",
     AdminUserUpdate = "admin.user.update",
diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts
index 8b646400cc..cf6b8ac695 100644
--- a/server/src/controllers/tag.controller.ts
+++ b/server/src/controllers/tag.controller.ts
@@ -1,10 +1,15 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
+import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
-import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
-import { AssetResponseDto } from 'src/dtos/asset-response.dto';
-import { AssetIdsDto } from 'src/dtos/asset.dto';
+import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
+import {
+  TagBulkAssetsDto,
+  TagBulkAssetsResponseDto,
+  TagCreateDto,
+  TagResponseDto,
+  TagUpdateDto,
+  TagUpsertDto,
+} from 'src/dtos/tag.dto';
 import { Permission } from 'src/enum';
 import { Auth, Authenticated } from 'src/middleware/auth.guard';
 import { TagService } from 'src/services/tag.service';
@@ -17,7 +22,7 @@ export class TagController {
 
   @Post()
   @Authenticated({ permission: Permission.TAG_CREATE })
-  createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
+  createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise<TagResponseDto> {
     return this.service.create(auth, dto);
   }
 
@@ -27,47 +32,54 @@ export class TagController {
     return this.service.getAll(auth);
   }
 
+  @Put()
+  @Authenticated({ permission: Permission.TAG_CREATE })
+  upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise<TagResponseDto[]> {
+    return this.service.upsert(auth, dto);
+  }
+
+  @Put('assets')
+  @Authenticated({ permission: Permission.TAG_ASSET })
+  bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
+    return this.service.bulkTagAssets(auth, dto);
+  }
+
   @Get(':id')
   @Authenticated({ permission: Permission.TAG_READ })
   getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
-    return this.service.getById(auth, id);
+    return this.service.get(auth, id);
   }
 
-  @Patch(':id')
+  @Put(':id')
   @Authenticated({ permission: Permission.TAG_UPDATE })
-  updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
+  updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise<TagResponseDto> {
     return this.service.update(auth, id, dto);
   }
 
   @Delete(':id')
+  @HttpCode(HttpStatus.NO_CONTENT)
   @Authenticated({ permission: Permission.TAG_DELETE })
   deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
     return this.service.remove(auth, id);
   }
 
-  @Get(':id/assets')
-  @Authenticated()
-  getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
-    return this.service.getAssets(auth, id);
-  }
-
   @Put(':id/assets')
-  @Authenticated()
+  @Authenticated({ permission: Permission.TAG_ASSET })
   tagAssets(
     @Auth() auth: AuthDto,
     @Param() { id }: UUIDParamDto,
-    @Body() dto: AssetIdsDto,
-  ): Promise<AssetIdsResponseDto[]> {
+    @Body() dto: BulkIdsDto,
+  ): Promise<BulkIdResponseDto[]> {
     return this.service.addAssets(auth, id, dto);
   }
 
   @Delete(':id/assets')
-  @Authenticated()
+  @Authenticated({ permission: Permission.TAG_ASSET })
   untagAssets(
     @Auth() auth: AuthDto,
-    @Body() dto: AssetIdsDto,
+    @Body() dto: BulkIdsDto,
     @Param() { id }: UUIDParamDto,
-  ): Promise<AssetIdsResponseDto[]> {
+  ): Promise<BulkIdResponseDto[]> {
     return this.service.removeAssets(auth, id, dto);
   }
 }
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index caeae2971a..463ab119a6 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -140,7 +140,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
     exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
-    tags: entity.tags?.map(mapTag),
+    tags: entity.tags?.map((tag) => mapTag(tag)),
     people: peopleWithFaces(entity.faces),
     unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
     checksum: entity.checksum.toString('base64'),
diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts
index 1094d70df3..40c5b176ff 100644
--- a/server/src/dtos/tag.dto.ts
+++ b/server/src/dtos/tag.dto.ts
@@ -1,38 +1,64 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
-import { TagEntity, TagType } from 'src/entities/tag.entity';
-import { Optional } from 'src/validation';
+import { Transform } from 'class-transformer';
+import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
+import { TagEntity } from 'src/entities/tag.entity';
+import { Optional, ValidateUUID } from 'src/validation';
 
-export class CreateTagDto {
+export class TagCreateDto {
   @IsString()
   @IsNotEmpty()
   name!: string;
 
-  @IsEnum(TagType)
-  @IsNotEmpty()
-  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
-  type!: TagType;
+  @ValidateUUID({ optional: true, nullable: true })
+  parentId?: string | null;
+
+  @IsHexColor()
+  @Optional({ nullable: true, emptyToNull: true })
+  color?: string;
 }
 
-export class UpdateTagDto {
-  @IsString()
-  @Optional()
-  name?: string;
+export class TagUpdateDto {
+  @Optional({ nullable: true, emptyToNull: true })
+  @IsHexColor()
+  @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
+  color?: string | null;
+}
+
+export class TagUpsertDto {
+  @IsString({ each: true })
+  @IsNotEmpty({ each: true })
+  tags!: string[];
+}
+
+export class TagBulkAssetsDto {
+  @ValidateUUID({ each: true })
+  tagIds!: string[];
+
+  @ValidateUUID({ each: true })
+  assetIds!: string[];
+}
+
+export class TagBulkAssetsResponseDto {
+  @ApiProperty({ type: 'integer' })
+  count!: number;
 }
 
 export class TagResponseDto {
   id!: string;
-  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
-  type!: string;
   name!: string;
-  userId!: string;
+  value!: string;
+  createdAt!: Date;
+  updatedAt!: Date;
+  color?: string;
 }
 
 export function mapTag(entity: TagEntity): TagResponseDto {
   return {
     id: entity.id,
-    type: entity.type,
-    name: entity.name,
-    userId: entity.userId,
+    name: entity.value.split('/').at(-1) as string,
+    value: entity.value,
+    createdAt: entity.createdAt,
+    updatedAt: entity.updatedAt,
+    color: entity.color ?? undefined,
   };
 }
diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts
index 8803f24fc4..dd7a01df35 100644
--- a/server/src/dtos/time-bucket.dto.ts
+++ b/server/src/dtos/time-bucket.dto.ts
@@ -19,6 +19,9 @@ export class TimeBucketDto {
   @ValidateUUID({ optional: true })
   personId?: string;
 
+  @ValidateUUID({ optional: true })
+  tagId?: string;
+
   @ValidateBoolean({ optional: true })
   isArchived?: boolean;
 
diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts
index 93edcb0555..940b446aea 100644
--- a/server/src/entities/tag.entity.ts
+++ b/server/src/entities/tag.entity.ts
@@ -1,45 +1,48 @@
 import { AssetEntity } from 'src/entities/asset.entity';
 import { UserEntity } from 'src/entities/user.entity';
-import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  ManyToMany,
+  ManyToOne,
+  PrimaryGeneratedColumn,
+  Tree,
+  TreeChildren,
+  TreeParent,
+  UpdateDateColumn,
+} from 'typeorm';
 
 @Entity('tags')
-@Unique('UQ_tag_name_userId', ['name', 'userId'])
+@Tree('closure-table')
 export class TagEntity {
   @PrimaryGeneratedColumn('uuid')
   id!: string;
 
-  @Column()
-  type!: TagType;
+  @Column({ unique: true })
+  value!: string;
 
-  @Column()
-  name!: string;
+  @CreateDateColumn({ type: 'timestamptz' })
+  createdAt!: Date;
 
-  @ManyToOne(() => UserEntity, (user) => user.tags)
-  user!: UserEntity;
+  @UpdateDateColumn({ type: 'timestamptz' })
+  updatedAt!: Date;
+
+  @Column({ type: 'varchar', nullable: true, default: null })
+  color!: string | null;
+
+  @TreeParent({ onDelete: 'CASCADE' })
+  parent?: TagEntity;
+
+  @TreeChildren()
+  children?: TagEntity[];
+
+  @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
+  user?: UserEntity;
 
   @Column()
   userId!: string;
 
-  @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
-  renameTagId!: string | null;
-
-  @ManyToMany(() => AssetEntity, (asset) => asset.tags)
-  assets!: AssetEntity[];
-}
-
-export enum TagType {
-  /**
-   * Tag that is detected by the ML model for object detection will use this type
-   */
-  OBJECT = 'OBJECT',
-
-  /**
-   * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type
-   */
-  FACE = 'FACE',
-
-  /**
-   * Tag that is created by the user will use this type
-   */
-  CUSTOM = 'CUSTOM',
+  @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
+  assets?: AssetEntity[];
 }
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 25ccbf961e..9cd5c189e8 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -130,6 +130,7 @@ export enum Permission {
   TAG_READ = 'tag.read',
   TAG_UPDATE = 'tag.update',
   TAG_DELETE = 'tag.delete',
+  TAG_ASSET = 'tag.asset',
 
   ADMIN_USER_CREATE = 'admin.user.create',
   ADMIN_USER_READ = 'admin.user.read',
diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts
index 2dcf9d6b94..d8d7b4e807 100644
--- a/server/src/interfaces/access.interface.ts
+++ b/server/src/interfaces/access.interface.ts
@@ -46,4 +46,8 @@ export interface IAccessRepository {
   stack: {
     checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
   };
+
+  tag: {
+    checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>>;
+  };
 }
diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts
index 9f9218a3e3..e323d98640 100644
--- a/server/src/interfaces/asset.interface.ts
+++ b/server/src/interfaces/asset.interface.ts
@@ -51,6 +51,7 @@ export interface AssetBuilderOptions {
   isTrashed?: boolean;
   isDuplicate?: boolean;
   albumId?: string;
+  tagId?: string;
   personId?: string;
   userIds?: string[];
   withStacked?: boolean;
diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts
index 609f42cc32..bb2b0d9ab4 100644
--- a/server/src/interfaces/event.interface.ts
+++ b/server/src/interfaces/event.interface.ts
@@ -17,6 +17,10 @@ type EmitEventMap = {
   'album.update': [{ id: string; updatedBy: string }];
   'album.invite': [{ id: string; userId: string }];
 
+  // tag events
+  'asset.tag': [{ assetId: string }];
+  'asset.untag': [{ assetId: string }];
+
   // user events
   'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
 };
diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts
index b2ac5ec6f1..bc780398ea 100644
--- a/server/src/interfaces/job.interface.ts
+++ b/server/src/interfaces/job.interface.ts
@@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob {
   latitude?: number;
   longitude?: number;
   rating?: number;
+  tags?: true;
 }
 
 export interface IDeferrableJob extends IEntityJob {
diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts
index 8071461dfc..f9f3784f06 100644
--- a/server/src/interfaces/tag.interface.ts
+++ b/server/src/interfaces/tag.interface.ts
@@ -1,17 +1,19 @@
-import { AssetEntity } from 'src/entities/asset.entity';
 import { TagEntity } from 'src/entities/tag.entity';
+import { IBulkAsset } from 'src/utils/asset.util';
 
 export const ITagRepository = 'ITagRepository';
 
-export interface ITagRepository {
-  getById(userId: string, tagId: string): Promise<TagEntity | null>;
+export type AssetTagItem = { assetId: string; tagId: string };
+
+export interface ITagRepository extends IBulkAsset {
   getAll(userId: string): Promise<TagEntity[]>;
+  getByValue(userId: string, value: string): Promise<TagEntity | null>;
+
   create(tag: Partial<TagEntity>): Promise<TagEntity>;
-  update(tag: Partial<TagEntity>): Promise<TagEntity>;
-  remove(tag: TagEntity): Promise<void>;
-  hasName(userId: string, name: string): Promise<boolean>;
-  hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean>;
-  getAssets(userId: string, tagId: string): Promise<AssetEntity[]>;
-  addAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
-  removeAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
+  get(id: string): Promise<TagEntity | null>;
+  update(tag: { id: string } & Partial<TagEntity>): Promise<TagEntity>;
+  delete(id: string): Promise<void>;
+
+  upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
+  upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
 }
diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts
new file mode 100644
index 0000000000..dfda9a6d7a
--- /dev/null
+++ b/server/src/migrations/1724790460210-NestedTagTable.ts
@@ -0,0 +1,57 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class NestedTagTable1724790460210 implements MigrationInterface {
+    name = 'NestedTagTable1724790460210'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query('TRUNCATE TABLE "tags" CASCADE');
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`);
+        await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `);
+        await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+        await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`);
+        await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`);
+        await queryRunner.query(`DROP TABLE "tags_closure"`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+    }
+
+}
diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql
index 48a93f546b..ad57eac0ad 100644
--- a/server/src/queries/access.repository.sql
+++ b/server/src/queries/access.repository.sql
@@ -259,6 +259,17 @@ WHERE
     AND ("StackEntity"."ownerId" = $2)
   )
 
+-- AccessRepository.tag.checkOwnerAccess
+SELECT
+  "TagEntity"."id" AS "TagEntity_id"
+FROM
+  "tags" "TagEntity"
+WHERE
+  (
+    ("TagEntity"."id" IN ($1))
+    AND ("TagEntity"."userId" = $2)
+  )
+
 -- AccessRepository.timeline.checkPartnerAccess
 SELECT
   "partner"."sharedById" AS "partner_sharedById",
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index b08130b183..ba52f7d148 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -184,10 +184,12 @@ SELECT
   "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
   "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
   "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
-  "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type",
-  "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name",
+  "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value",
+  "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt",
+  "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt",
+  "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color",
   "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId",
-  "AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId",
+  "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId",
   "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
   "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
   "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql
new file mode 100644
index 0000000000..ba1aac82b3
--- /dev/null
+++ b/server/src/queries/tag.repository.sql
@@ -0,0 +1,30 @@
+-- NOTE: This file is auto generated by ./sql-generator
+
+-- TagRepository.getAssetIds
+SELECT
+  "tag_asset"."assetsId" AS "assetId"
+FROM
+  "tag_asset" "tag_asset"
+WHERE
+  "tag_asset"."tagsId" = $1
+  AND "tag_asset"."assetsId" IN ($2)
+
+-- TagRepository.addAssetIds
+INSERT INTO
+  "tag_asset" ("assetsId", "tagsId")
+VALUES
+  ($1, $2)
+
+-- TagRepository.removeAssetIds
+DELETE FROM "tag_asset"
+WHERE
+  (
+    "tagsId" = $1
+    AND "assetsId" IN ($2)
+  )
+
+-- TagRepository.upsertAssetIds
+INSERT INTO
+  "tag_asset" ("assetsId", "tagsId")
+VALUES
+  ($1, $2)
diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts
index 6dd6d47a46..f6921ffe27 100644
--- a/server/src/repositories/access.repository.ts
+++ b/server/src/repositories/access.repository.ts
@@ -12,6 +12,7 @@ import { PersonEntity } from 'src/entities/person.entity';
 import { SessionEntity } from 'src/entities/session.entity';
 import { SharedLinkEntity } from 'src/entities/shared-link.entity';
 import { StackEntity } from 'src/entities/stack.entity';
+import { TagEntity } from 'src/entities/tag.entity';
 import { AlbumUserRole } from 'src/enum';
 import { IAccessRepository } from 'src/interfaces/access.interface';
 import { Instrumentation } from 'src/utils/instrumentation';
@@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory'];
 type IPersonAccess = IAccessRepository['person'];
 type IPartnerAccess = IAccessRepository['partner'];
 type IStackAccess = IAccessRepository['stack'];
+type ITagAccess = IAccessRepository['tag'];
 type ITimelineAccess = IAccessRepository['timeline'];
 
 @Instrumentation()
@@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess {
   }
 }
 
+class TagAccess implements ITagAccess {
+  constructor(private tagRepository: Repository<TagEntity>) {}
+
+  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
+  @ChunkedSet({ paramIndex: 1 })
+  async checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>> {
+    if (tagIds.size === 0) {
+      return new Set();
+    }
+
+    return this.tagRepository
+      .find({
+        select: { id: true },
+        where: {
+          id: In([...tagIds]),
+          userId,
+        },
+      })
+      .then((tags) => new Set(tags.map((tag) => tag.id)));
+  }
+}
+
 export class AccessRepository implements IAccessRepository {
   activity: IActivityAccess;
   album: IAlbumAccess;
@@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository {
   person: IPersonAccess;
   partner: IPartnerAccess;
   stack: IStackAccess;
+  tag: ITagAccess;
   timeline: ITimelineAccess;
 
   constructor(
@@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository {
     @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
     @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
     @InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
+    @InjectRepository(TagEntity) tagRepository: Repository<TagEntity>,
   ) {
     this.activity = new ActivityAccess(activityRepository, albumRepository);
     this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
@@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository {
     this.person = new PersonAccess(assetFaceRepository, personRepository);
     this.partner = new PartnerAccess(partnerRepository);
     this.stack = new StackAccess(stackRepository);
+    this.tag = new TagAccess(tagRepository);
     this.timeline = new TimelineAccess(partnerRepository);
   }
 }
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index 1a2a0474a1..dd526dd664 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository {
       builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
     }
 
+    if (options.tagId) {
+      builder.innerJoin(
+        'asset.tags',
+        'asset_tags',
+        'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
+        { tagId: options.tagId },
+      );
+    }
+
     let stackJoined = false;
 
     if (options.exifInfo !== false) {
diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts
index 788b976357..7699d5897a 100644
--- a/server/src/repositories/tag.repository.ts
+++ b/server/src/repositories/tag.repository.ts
@@ -1,33 +1,36 @@
 import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { AssetEntity } from 'src/entities/asset.entity';
+import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
+import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
 import { TagEntity } from 'src/entities/tag.entity';
-import { ITagRepository } from 'src/interfaces/tag.interface';
+import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
 import { Instrumentation } from 'src/utils/instrumentation';
-import { Repository } from 'typeorm';
+import { DataSource, In, Repository } from 'typeorm';
 
 @Instrumentation()
 @Injectable()
 export class TagRepository implements ITagRepository {
   constructor(
-    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
+    @InjectDataSource() private dataSource: DataSource,
     @InjectRepository(TagEntity) private repository: Repository<TagEntity>,
   ) {}
 
-  getById(userId: string, id: string): Promise<TagEntity | null> {
-    return this.repository.findOne({
-      where: {
-        id,
-        userId,
-      },
-      relations: {
-        user: true,
-      },
-    });
+  get(id: string): Promise<TagEntity | null> {
+    return this.repository.findOne({ where: { id } });
   }
 
-  getAll(userId: string): Promise<TagEntity[]> {
-    return this.repository.find({ where: { userId } });
+  getByValue(userId: string, value: string): Promise<TagEntity | null> {
+    return this.repository.findOne({ where: { userId, value } });
+  }
+
+  async getAll(userId: string): Promise<TagEntity[]> {
+    const tags = await this.repository.find({
+      where: { userId },
+      order: {
+        value: 'ASC',
+      },
+    });
+
+    return tags;
   }
 
   create(tag: Partial<TagEntity>): Promise<TagEntity> {
@@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository {
     return this.save(tag);
   }
 
-  async remove(tag: TagEntity): Promise<void> {
-    await this.repository.remove(tag);
+  async delete(id: string): Promise<void> {
+    await this.repository.delete(id);
   }
 
-  async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
-    return this.assetRepository.find({
-      where: {
-        tags: {
-          userId,
-          id: tagId,
-        },
-      },
-      relations: {
-        exifInfo: true,
-        tags: true,
-        faces: {
-          person: true,
-        },
-      },
-      order: {
-        createdAt: 'ASC',
-      },
-    });
-  }
-
-  async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
-    for (const assetId of assetIds) {
-      const asset = await this.assetRepository.findOneOrFail({
-        where: {
-          ownerId: userId,
-          id: assetId,
-        },
-        relations: {
-          tags: true,
-        },
-      });
-      asset.tags.push({ id } as TagEntity);
-      await this.assetRepository.save(asset);
+  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
+  @ChunkedSet({ paramIndex: 1 })
+  async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
+    if (assetIds.length === 0) {
+      return new Set();
     }
+
+    const results = await this.dataSource
+      .createQueryBuilder()
+      .select('tag_asset.assetsId', 'assetId')
+      .from('tag_asset', 'tag_asset')
+      .where('"tag_asset"."tagsId" = :tagId', { tagId })
+      .andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
+      .getRawMany<{ assetId: string }>();
+
+    return new Set(results.map(({ assetId }) => assetId));
   }
 
-  async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
-    for (const assetId of assetIds) {
-      const asset = await this.assetRepository.findOneOrFail({
-        where: {
-          ownerId: userId,
-          id: assetId,
-        },
-        relations: {
-          tags: true,
-        },
-      });
-      asset.tags = asset.tags.filter((tag) => tag.id !== id);
-      await this.assetRepository.save(asset);
+  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
+  async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
+    if (assetIds.length === 0) {
+      return;
     }
+
+    await this.dataSource.manager
+      .createQueryBuilder()
+      .insert()
+      .into('tag_asset', ['tagsId', 'assetsId'])
+      .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
+      .execute();
   }
 
-  hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
-    return this.repository.exists({
-      where: {
-        id: tagId,
-        userId,
-        assets: {
-          id: assetId,
-        },
-      },
-      relations: {
-        assets: true,
-      },
+  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
+  @Chunked({ paramIndex: 1 })
+  async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
+    if (assetIds.length === 0) {
+      return;
+    }
+
+    await this.dataSource
+      .createQueryBuilder()
+      .delete()
+      .from('tag_asset')
+      .where({
+        tagsId: tagId,
+        assetsId: In(assetIds),
+      })
+      .execute();
+  }
+
+  @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
+  @Chunked()
+  async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
+    if (items.length === 0) {
+      return [];
+    }
+
+    const { identifiers } = await this.dataSource
+      .createQueryBuilder()
+      .insert()
+      .into('tag_asset', ['assetsId', 'tagsId'])
+      .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
+      .execute();
+
+    return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
+      assetId: assetsId,
+      tagId: tagsId,
+    }));
+  }
+
+  async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
+    await this.dataSource.transaction(async (manager) => {
+      await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
+
+      if (tagIds.length === 0) {
+        return;
+      }
+
+      await manager
+        .createQueryBuilder()
+        .insert()
+        .into('tag_asset', ['tagsId', 'assetsId'])
+        .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
+        .execute();
     });
   }
 
-  hasName(userId: string, name: string): Promise<boolean> {
-    return this.repository.exists({
-      where: {
-        name,
-        userId,
-      },
-    });
-  }
-
-  private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
-    const { id } = await this.repository.save(tag);
-    return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
+  private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
+    const { id } = await this.repository.save(partial);
+    return this.repository.findOneOrFail({ where: { id } });
   }
 }
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 6585b8c2ee..cb89de184a 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
 import { IPersonRepository } from 'src/interfaces/person.interface';
 import { IStorageRepository } from 'src/interfaces/storage.interface';
 import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
+import { ITagRepository } from 'src/interfaces/tag.interface';
 import { IUserRepository } from 'src/interfaces/user.interface';
 import { MetadataService, Orientation } from 'src/services/metadata.service';
 import { assetStub } from 'test/fixtures/asset.stub';
 import { fileStub } from 'test/fixtures/file.stub';
 import { probeStub } from 'test/fixtures/media.stub';
+import { tagStub } from 'test/fixtures/tag.stub';
 import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
 import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
 import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
@@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
 import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
 import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
 import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
+import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
 import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
 import { Mocked } from 'vitest';
 
@@ -56,6 +59,7 @@ describe(MetadataService.name, () => {
   let databaseMock: Mocked<IDatabaseRepository>;
   let userMock: Mocked<IUserRepository>;
   let loggerMock: Mocked<ILoggerRepository>;
+  let tagMock: Mocked<ITagRepository>;
   let sut: MetadataService;
 
   beforeEach(() => {
@@ -74,6 +78,7 @@ describe(MetadataService.name, () => {
     databaseMock = newDatabaseRepositoryMock();
     userMock = newUserRepositoryMock();
     loggerMock = newLoggerRepositoryMock();
+    tagMock = newTagRepositoryMock();
 
     sut = new MetadataService(
       albumMock,
@@ -89,6 +94,7 @@ describe(MetadataService.name, () => {
       personMock,
       storageMock,
       systemMock,
+      tagMock,
       userMock,
       loggerMock,
     );
@@ -356,6 +362,72 @@ describe(MetadataService.name, () => {
       expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
     });
 
+    it('should extract tags from TagsList', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] });
+      tagMock.getByValue.mockResolvedValue(null);
+      tagMock.create.mockResolvedValue(tagStub.parent);
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+      expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
+    });
+
+    it('should extract hierarchy from TagsList', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] });
+      tagMock.getByValue.mockResolvedValue(null);
+      tagMock.create.mockResolvedValueOnce(tagStub.parent);
+      tagMock.create.mockResolvedValueOnce(tagStub.child);
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+      expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
+      expect(tagMock.create).toHaveBeenNthCalledWith(2, {
+        userId: 'user-id',
+        value: 'Parent/Child',
+        parent: tagStub.parent,
+      });
+    });
+
+    it('should extract tags from Keywords as a string', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' });
+      tagMock.getByValue.mockResolvedValue(null);
+      tagMock.create.mockResolvedValue(tagStub.parent);
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+      expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
+    });
+
+    it('should extract tags from Keywords as a list', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] });
+      tagMock.getByValue.mockResolvedValue(null);
+      tagMock.create.mockResolvedValue(tagStub.parent);
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+      expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
+    });
+
+    it('should extract hierarchal tags from Keywords', async () => {
+      assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' });
+      tagMock.getByValue.mockResolvedValue(null);
+      tagMock.create.mockResolvedValue(tagStub.parent);
+
+      await sut.handleMetadataExtraction({ id: assetStub.image.id });
+
+      expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
+      expect(tagMock.create).toHaveBeenNthCalledWith(2, {
+        userId: 'user-id',
+        value: 'Parent/Child',
+        parent: tagStub.parent,
+      });
+    });
+
     it('should not apply motion photos if asset is video', async () => {
       assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
       mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index 3c938a4e59..875414d84d 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -22,8 +22,8 @@ import {
   IEntityJob,
   IJobRepository,
   ISidecarWriteJob,
-  JOBS_ASSET_PAGINATION_SIZE,
   JobName,
+  JOBS_ASSET_PAGINATION_SIZE,
   JobStatus,
   QueueName,
 } from 'src/interfaces/job.interface';
@@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
 import { IPersonRepository } from 'src/interfaces/person.interface';
 import { IStorageRepository } from 'src/interfaces/storage.interface';
 import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
+import { ITagRepository } from 'src/interfaces/tag.interface';
 import { IUserRepository } from 'src/interfaces/user.interface';
 import { usePagination } from 'src/utils/pagination';
+import { upsertTags } from 'src/utils/tag';
 
 /** look for a date from these tags (in order) */
 const EXIF_DATE_TAGS: Array<keyof Tags> = [
@@ -105,6 +107,7 @@ export class MetadataService {
     @Inject(IPersonRepository) personRepository: IPersonRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
+    @Inject(ITagRepository) private tagRepository: ITagRepository,
     @Inject(IUserRepository) private userRepository: IUserRepository,
     @Inject(ILoggerRepository) private logger: ILoggerRepository,
   ) {
@@ -217,24 +220,27 @@ export class MetadataService {
       return JobStatus.FAILED;
     }
 
-    const { exifData, tags } = await this.exifData(asset);
+    const { exifData, exifTags } = await this.exifData(asset);
 
     if (asset.type === AssetType.VIDEO) {
       await this.applyVideoMetadata(asset, exifData);
     }
 
-    await this.applyMotionPhotos(asset, tags);
+    await this.applyMotionPhotos(asset, exifTags);
     await this.applyReverseGeocoding(asset, exifData);
+    await this.applyTagList(asset, exifTags);
+
     await this.assetRepository.upsertExif(exifData);
 
     const dateTimeOriginal = exifData.dateTimeOriginal;
     let localDateTime = dateTimeOriginal ?? undefined;
 
-    const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
+    const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
 
     if (dateTimeOriginal && timeZoneOffset) {
       localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
     }
+
     await this.assetRepository.update({
       id: asset.id,
       duration: asset.duration,
@@ -278,22 +284,35 @@ export class MetadataService {
     return this.processSidecar(id, false);
   }
 
+  @OnEmit({ event: 'asset.tag' })
+  async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
+    await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
+  }
+
+  @OnEmit({ event: 'asset.untag' })
+  async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
+    await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
+  }
+
   async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
-    const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
-    const [asset] = await this.assetRepository.getByIds([id]);
+    const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
+    const [asset] = await this.assetRepository.getByIds([id], { tags: true });
     if (!asset) {
       return JobStatus.FAILED;
     }
 
+    const tagsList = (asset.tags || []).map((tag) => tag.value);
+
     const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
-    const exif = _.omitBy<Tags>(
-      {
+    const exif = _.omitBy(
+      <Tags>{
         Description: description,
         ImageDescription: description,
         DateTimeOriginal: dateTimeOriginal,
         GPSLatitude: latitude,
         GPSLongitude: longitude,
         Rating: rating,
+        TagsList: tags ? tagsList : undefined,
       },
       _.isUndefined,
     );
@@ -332,6 +351,28 @@ export class MetadataService {
     }
   }
 
+  private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
+    const tags: string[] = [];
+
+    if (exifTags.TagsList) {
+      tags.push(...exifTags.TagsList);
+    }
+
+    if (exifTags.Keywords) {
+      let keywords = exifTags.Keywords;
+      if (typeof keywords === 'string') {
+        keywords = [keywords];
+      }
+      tags.push(...keywords);
+    }
+
+    if (tags.length > 0) {
+      const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
+      const tagIds = results.map((tag) => tag.id);
+      await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds });
+    }
+  }
+
   private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
     if (asset.type !== AssetType.IMAGE) {
       return;
@@ -466,7 +507,7 @@ export class MetadataService {
 
   private async exifData(
     asset: AssetEntity,
-  ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
+  ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
     const stats = await this.storageRepository.stat(asset.originalPath);
     const mediaTags = await this.repository.readTags(asset.originalPath);
     const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
@@ -479,38 +520,38 @@ export class MetadataService {
       }
     }
 
-    const tags = { ...mediaTags, ...sidecarTags };
+    const exifTags = { ...mediaTags, ...sidecarTags };
 
-    this.logger.verbose('Exif Tags', tags);
+    this.logger.verbose('Exif Tags', exifTags);
 
     const exifData = {
       // altitude: tags.GPSAltitude ?? null,
       assetId: asset.id,
-      bitsPerSample: this.getBitsPerSample(tags),
-      colorspace: tags.ColorSpace ?? null,
-      dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
-      description: String(tags.ImageDescription || tags.Description || '').trim(),
-      exifImageHeight: validate(tags.ImageHeight),
-      exifImageWidth: validate(tags.ImageWidth),
-      exposureTime: tags.ExposureTime ?? null,
+      bitsPerSample: this.getBitsPerSample(exifTags),
+      colorspace: exifTags.ColorSpace ?? null,
+      dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt,
+      description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
+      exifImageHeight: validate(exifTags.ImageHeight),
+      exifImageWidth: validate(exifTags.ImageWidth),
+      exposureTime: exifTags.ExposureTime ?? null,
       fileSizeInByte: stats.size,
-      fNumber: validate(tags.FNumber),
-      focalLength: validate(tags.FocalLength),
-      fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
-      iso: validate(tags.ISO),
-      latitude: validate(tags.GPSLatitude),
-      lensModel: tags.LensModel ?? null,
-      livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
-      autoStackId: this.getAutoStackId(tags),
-      longitude: validate(tags.GPSLongitude),
-      make: tags.Make ?? null,
-      model: tags.Model ?? null,
-      modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
-      orientation: validate(tags.Orientation)?.toString() ?? null,
-      profileDescription: tags.ProfileDescription || null,
-      projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
-      timeZone: tags.tz ?? null,
-      rating: tags.Rating ?? null,
+      fNumber: validate(exifTags.FNumber),
+      focalLength: validate(exifTags.FocalLength),
+      fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
+      iso: validate(exifTags.ISO),
+      latitude: validate(exifTags.GPSLatitude),
+      lensModel: exifTags.LensModel ?? null,
+      livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
+      autoStackId: this.getAutoStackId(exifTags),
+      longitude: validate(exifTags.GPSLongitude),
+      make: exifTags.Make ?? null,
+      model: exifTags.Model ?? null,
+      modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
+      orientation: validate(exifTags.Orientation)?.toString() ?? null,
+      profileDescription: exifTags.ProfileDescription || null,
+      projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
+      timeZone: exifTags.tz ?? null,
+      rating: exifTags.Rating ?? null,
     };
 
     if (exifData.latitude === 0 && exifData.longitude === 0) {
@@ -519,7 +560,7 @@ export class MetadataService {
       exifData.longitude = null;
     }
 
-    return { exifData, tags };
+    return { exifData, exifTags };
   }
 
   private getAutoStackId(tags: ImmichTags | null): string | null {
diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts
index 4323c061e1..ffa7895cb4 100644
--- a/server/src/services/tag.service.spec.ts
+++ b/server/src/services/tag.service.spec.ts
@@ -1,21 +1,28 @@
 import { BadRequestException } from '@nestjs/common';
-import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
-import { TagType } from 'src/entities/tag.entity';
+import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
+import { IEventRepository } from 'src/interfaces/event.interface';
 import { ITagRepository } from 'src/interfaces/tag.interface';
 import { TagService } from 'src/services/tag.service';
-import { assetStub } from 'test/fixtures/asset.stub';
 import { authStub } from 'test/fixtures/auth.stub';
 import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
+import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
+import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
 import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
 import { Mocked } from 'vitest';
 
 describe(TagService.name, () => {
   let sut: TagService;
+  let accessMock: IAccessRepositoryMock;
+  let eventMock: Mocked<IEventRepository>;
   let tagMock: Mocked<ITagRepository>;
 
   beforeEach(() => {
+    accessMock = newAccessRepositoryMock();
+    eventMock = newEventRepositoryMock();
     tagMock = newTagRepositoryMock();
-    sut = new TagService(tagMock);
+    sut = new TagService(accessMock, eventMock, tagMock);
+
+    accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
   });
 
   it('should work', () => {
@@ -30,148 +37,216 @@ describe(TagService.name, () => {
     });
   });
 
-  describe('getById', () => {
+  describe('get', () => {
     it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
-      await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
+      tagMock.get.mockResolvedValue(null);
+      await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
+      expect(tagMock.get).toHaveBeenCalledWith('tag-1');
     });
 
     it('should return a tag for a user', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
-      await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
+      tagMock.get.mockResolvedValue(tagStub.tag1);
+      await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
+      expect(tagMock.get).toHaveBeenCalledWith('tag-1');
+    });
+  });
+
+  describe('create', () => {
+    it('should throw an error for no parent tag access', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
+      await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(tagMock.create).not.toHaveBeenCalled();
+    });
+
+    it('should create a tag with a parent', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
+      tagMock.create.mockResolvedValue(tagStub.tag1);
+      tagMock.get.mockResolvedValueOnce(tagStub.parent);
+      tagMock.get.mockResolvedValueOnce(tagStub.child);
+      await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
+      expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
+    });
+
+    it('should handle invalid parent ids', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
+      await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(tagMock.create).not.toHaveBeenCalled();
     });
   });
 
   describe('create', () => {
     it('should throw an error for a duplicate tag', async () => {
-      tagMock.hasName.mockResolvedValue(true);
-      await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
+      tagMock.getByValue.mockResolvedValue(tagStub.tag1);
+      await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
+      expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
       expect(tagMock.create).not.toHaveBeenCalled();
     });
 
     it('should create a new tag', async () => {
       tagMock.create.mockResolvedValue(tagStub.tag1);
-      await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual(
-        tagResponseStub.tag1,
-      );
+      await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
       expect(tagMock.create).toHaveBeenCalledWith({
         userId: authStub.admin.user.id,
-        name: 'tag-1',
-        type: TagType.CUSTOM,
+        value: 'tag-1',
       });
     });
   });
 
   describe('update', () => {
-    it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
-      await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.remove).not.toHaveBeenCalled();
+    it('should throw an error for no update permission', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
+      await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(tagMock.update).not.toHaveBeenCalled();
     });
 
     it('should update a tag', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
-      tagMock.update.mockResolvedValue(tagStub.tag1);
-      await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
+      tagMock.update.mockResolvedValue(tagStub.color1);
+      await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
+      expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
+    });
+  });
+
+  describe('upsert', () => {
+    it('should upsert a new tag', async () => {
+      tagMock.create.mockResolvedValue(tagStub.parent);
+      await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
+      expect(tagMock.create).toHaveBeenCalledWith({
+        value: 'Parent',
+        userId: 'admin_id',
+        parentId: undefined,
+      });
+    });
+
+    it('should upsert a nested tag', async () => {
+      tagMock.getByValue.mockResolvedValueOnce(null);
+      tagMock.create.mockResolvedValueOnce(tagStub.parent);
+      tagMock.create.mockResolvedValueOnce(tagStub.child);
+      await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
+      expect(tagMock.create).toHaveBeenNthCalledWith(1, {
+        value: 'Parent',
+        userId: 'admin_id',
+        parentId: undefined,
+      });
+      expect(tagMock.create).toHaveBeenNthCalledWith(2, {
+        value: 'Parent/Child',
+        userId: 'admin_id',
+        parent: expect.objectContaining({ id: 'tag-parent' }),
+      });
     });
   });
 
   describe('remove', () => {
     it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
       await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.remove).not.toHaveBeenCalled();
+      expect(tagMock.delete).not.toHaveBeenCalled();
     });
 
     it('should remove a tag', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
+      tagMock.get.mockResolvedValue(tagStub.tag1);
       await sut.remove(authStub.admin, 'tag-1');
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
+      expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
     });
   });
 
-  describe('getAssets', () => {
-    it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
-      await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.remove).not.toHaveBeenCalled();
+  describe('bulkTagAssets', () => {
+    it('should handle invalid requests', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
+      tagMock.upsertAssetIds.mockResolvedValue([]);
+      await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
+        count: 0,
+      });
+      expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
     });
 
-    it('should get the assets for a tag', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
-      tagMock.getAssets.mockResolvedValue([assetStub.image]);
-      await sut.getAssets(authStub.admin, 'tag-1');
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
+    it('should upsert records', async () => {
+      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
+      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
+      tagMock.upsertAssetIds.mockResolvedValue([
+        { tagId: 'tag-1', assetId: 'asset-1' },
+        { tagId: 'tag-1', assetId: 'asset-2' },
+        { tagId: 'tag-1', assetId: 'asset-3' },
+        { tagId: 'tag-2', assetId: 'asset-1' },
+        { tagId: 'tag-2', assetId: 'asset-2' },
+        { tagId: 'tag-2', assetId: 'asset-3' },
+      ]);
+      await expect(
+        sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
+      ).resolves.toEqual({
+        count: 6,
+      });
+      expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([
+        { tagId: 'tag-1', assetId: 'asset-1' },
+        { tagId: 'tag-1', assetId: 'asset-2' },
+        { tagId: 'tag-1', assetId: 'asset-3' },
+        { tagId: 'tag-2', assetId: 'asset-1' },
+        { tagId: 'tag-2', assetId: 'asset-2' },
+        { tagId: 'tag-2', assetId: 'asset-3' },
+      ]);
     });
   });
 
   describe('addAssets', () => {
-    it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
-      await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.addAssets).not.toHaveBeenCalled();
+    it('should handle invalid ids', async () => {
+      tagMock.get.mockResolvedValue(null);
+      tagMock.getAssetIds.mockResolvedValue(new Set([]));
+      await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
+        { id: 'asset-1', success: false, error: 'no_permission' },
+      ]);
+      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
+      expect(tagMock.addAssetIds).not.toHaveBeenCalled();
     });
 
-    it('should reject duplicate asset ids and accept new ones', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
-      tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
+    it('should accept accept ids that are new and reject the rest', async () => {
+      tagMock.get.mockResolvedValue(tagStub.tag1);
+      tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
+      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
 
       await expect(
         sut.addAssets(authStub.admin, 'tag-1', {
-          assetIds: ['asset-1', 'asset-2'],
+          ids: ['asset-1', 'asset-2'],
         }),
       ).resolves.toEqual([
-        { assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
-        { assetId: 'asset-2', success: true },
+        { id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE },
+        { id: 'asset-2', success: true },
       ]);
 
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
-      expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']);
+      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
+      expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
     });
   });
 
   describe('removeAssets', () => {
     it('should throw an error for an invalid id', async () => {
-      tagMock.getById.mockResolvedValue(null);
-      await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.removeAssets).not.toHaveBeenCalled();
+      tagMock.get.mockResolvedValue(null);
+      tagMock.getAssetIds.mockResolvedValue(new Set());
+      await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
+        { id: 'asset-1', success: false, error: 'not_found' },
+      ]);
     });
 
     it('should accept accept ids that are tagged and reject the rest', async () => {
-      tagMock.getById.mockResolvedValue(tagStub.tag1);
-      tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
+      tagMock.get.mockResolvedValue(tagStub.tag1);
+      tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
 
       await expect(
         sut.removeAssets(authStub.admin, 'tag-1', {
-          assetIds: ['asset-1', 'asset-2'],
+          ids: ['asset-1', 'asset-2'],
         }),
       ).resolves.toEqual([
-        { assetId: 'asset-1', success: true },
-        { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
+        { id: 'asset-1', success: true },
+        { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
       ]);
 
-      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
-      expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
-      expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']);
+      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
+      expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
     });
   });
 });
diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts
index c04f9b14c4..97b0ef1be6 100644
--- a/server/src/services/tag.service.ts
+++ b/server/src/services/tag.service.ts
@@ -1,102 +1,145 @@
 import { BadRequestException, Inject, Injectable } from '@nestjs/common';
-import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
-import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
-import { AssetIdsDto } from 'src/dtos/asset.dto';
+import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto';
-import { ITagRepository } from 'src/interfaces/tag.interface';
+import {
+  TagBulkAssetsDto,
+  TagBulkAssetsResponseDto,
+  TagCreateDto,
+  TagResponseDto,
+  TagUpdateDto,
+  TagUpsertDto,
+  mapTag,
+} from 'src/dtos/tag.dto';
+import { TagEntity } from 'src/entities/tag.entity';
+import { Permission } from 'src/enum';
+import { IAccessRepository } from 'src/interfaces/access.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
+import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
+import { checkAccess, requireAccess } from 'src/utils/access';
+import { addAssets, removeAssets } from 'src/utils/asset.util';
+import { upsertTags } from 'src/utils/tag';
 
 @Injectable()
 export class TagService {
-  constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
+  constructor(
+    @Inject(IAccessRepository) private access: IAccessRepository,
+    @Inject(IEventRepository) private eventRepository: IEventRepository,
+    @Inject(ITagRepository) private repository: ITagRepository,
+  ) {}
 
-  getAll(auth: AuthDto) {
-    return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
+  async getAll(auth: AuthDto) {
+    const tags = await this.repository.getAll(auth.user.id);
+    return tags.map((tag) => mapTag(tag));
   }
 
-  async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
-    const tag = await this.findOrFail(auth, id);
+  async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
+    await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
+    const tag = await this.findOrFail(id);
     return mapTag(tag);
   }
 
-  async create(auth: AuthDto, dto: CreateTagDto) {
-    const duplicate = await this.repository.hasName(auth.user.id, dto.name);
+  async create(auth: AuthDto, dto: TagCreateDto) {
+    let parent: TagEntity | undefined;
+    if (dto.parentId) {
+      await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
+      parent = (await this.repository.get(dto.parentId)) || undefined;
+      if (!parent) {
+        throw new BadRequestException('Tag not found');
+      }
+    }
+
+    const userId = auth.user.id;
+    const value = parent ? `${parent.value}/${dto.name}` : dto.name;
+    const duplicate = await this.repository.getByValue(userId, value);
     if (duplicate) {
       throw new BadRequestException(`A tag with that name already exists`);
     }
 
-    const tag = await this.repository.create({
-      userId: auth.user.id,
-      name: dto.name,
-      type: dto.type,
-    });
+    const tag = await this.repository.create({ userId, value, parent });
 
     return mapTag(tag);
   }
 
-  async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
-    await this.findOrFail(auth, id);
-    const tag = await this.repository.update({ id, name: dto.name });
+  async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
+    await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
+
+    const { color } = dto;
+    const tag = await this.repository.update({ id, color });
     return mapTag(tag);
   }
 
+  async upsert(auth: AuthDto, dto: TagUpsertDto) {
+    const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
+    return tags.map((tag) => mapTag(tag));
+  }
+
   async remove(auth: AuthDto, id: string): Promise<void> {
-    const tag = await this.findOrFail(auth, id);
-    await this.repository.remove(tag);
+    await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
+
+    // TODO sync tag changes for affected assets
+
+    await this.repository.delete(id);
   }
 
-  async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
-    await this.findOrFail(auth, id);
-    const assets = await this.repository.getAssets(auth.user.id, id);
-    return assets.map((asset) => mapAsset(asset));
-  }
+  async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
+    const [tagIds, assetIds] = await Promise.all([
+      checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
+      checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
+    ]);
 
-  async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
-    await this.findOrFail(auth, id);
-
-    const results: AssetIdsResponseDto[] = [];
-    for (const assetId of dto.assetIds) {
-      const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
-      if (hasAsset) {
-        results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
-      } else {
-        results.push({ assetId, success: true });
+    const items: AssetTagItem[] = [];
+    for (const tagId of tagIds) {
+      for (const assetId of assetIds) {
+        items.push({ tagId, assetId });
       }
     }
 
-    await this.repository.addAssets(
-      auth.user.id,
-      id,
-      results.filter((result) => result.success).map((result) => result.assetId),
+    const results = await this.repository.upsertAssetIds(items);
+    for (const assetId of new Set(results.map((item) => item.assetId))) {
+      await this.eventRepository.emit('asset.tag', { assetId });
+    }
+
+    return { count: results.length };
+  }
+
+  async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
+    await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
+
+    const results = await addAssets(
+      auth,
+      { access: this.access, bulk: this.repository },
+      { parentId: id, assetIds: dto.ids },
     );
 
+    for (const { id: assetId, success } of results) {
+      if (success) {
+        await this.eventRepository.emit('asset.tag', { assetId });
+      }
+    }
+
     return results;
   }
 
-  async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
-    await this.findOrFail(auth, id);
+  async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
+    await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
 
-    const results: AssetIdsResponseDto[] = [];
-    for (const assetId of dto.assetIds) {
-      const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
-      if (hasAsset) {
-        results.push({ assetId, success: true });
-      } else {
-        results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
+    const results = await removeAssets(
+      auth,
+      { access: this.access, bulk: this.repository },
+      { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
+    );
+
+    for (const { id: assetId, success } of results) {
+      if (success) {
+        await this.eventRepository.emit('asset.untag', { assetId });
       }
     }
 
-    await this.repository.removeAssets(
-      auth.user.id,
-      id,
-      results.filter((result) => result.success).map((result) => result.assetId),
-    );
-
     return results;
   }
 
-  private async findOrFail(auth: AuthDto, id: string) {
-    const tag = await this.repository.getById(auth.user.id, id);
+  private async findOrFail(id: string) {
+    const tag = await this.repository.get(id);
     if (!tag) {
       throw new BadRequestException('Tag not found');
     }
diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts
index 052565fca9..bc08505b94 100644
--- a/server/src/services/timeline.service.ts
+++ b/server/src/services/timeline.service.ts
@@ -68,6 +68,10 @@ export class TimelineService {
       }
     }
 
+    if (dto.tagId) {
+      await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
+    }
+
     if (dto.withPartners) {
       const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
       const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts
index 45badeec73..d3219a1a6c 100644
--- a/server/src/utils/access.ts
+++ b/server/src/utils/access.ts
@@ -41,7 +41,10 @@ export const requireAccess = async (access: IAccessRepository, request: AccessRe
   }
 };
 
-export const checkAccess = async (access: IAccessRepository, { ids, auth, permission }: AccessRequest) => {
+export const checkAccess = async (
+  access: IAccessRepository,
+  { ids, auth, permission }: AccessRequest,
+): Promise<Set<string>> => {
   const idSet = Array.isArray(ids) ? new Set(ids) : ids;
   if (idSet.size === 0) {
     return new Set<string>();
@@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis
     : checkOtherAccess(access, { auth, permission, ids: idSet });
 };
 
-const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => {
+const checkSharedLinkAccess = async (
+  access: IAccessRepository,
+  request: SharedLinkAccessRequest,
+): Promise<Set<string>> => {
   const { sharedLink, permission, ids } = request;
   const sharedLinkId = sharedLink.id;
 
@@ -96,7 +102,7 @@ const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedL
   }
 };
 
-const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => {
+const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise<Set<string>> => {
   const { auth, permission, ids } = request;
 
   switch (permission) {
@@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR
       return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
     }
 
+    case Permission.TAG_ASSET:
+    case Permission.TAG_READ:
+    case Permission.TAG_UPDATE:
+    case Permission.TAG_DELETE: {
+      return await access.tag.checkOwnerAccess(auth.user.id, ids);
+    }
+
     case Permission.TIMELINE_READ: {
       const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
       const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts
index f6edb2f8b3..19d3cac661 100644
--- a/server/src/utils/request.ts
+++ b/server/src/utils/request.ts
@@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => {
   return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
 };
 
-export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param);
+export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts
new file mode 100644
index 0000000000..12c46d2440
--- /dev/null
+++ b/server/src/utils/tag.ts
@@ -0,0 +1,30 @@
+import { TagEntity } from 'src/entities/tag.entity';
+import { ITagRepository } from 'src/interfaces/tag.interface';
+
+type UpsertRequest = { userId: string; tags: string[] };
+export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => {
+  tags = [...new Set(tags)];
+
+  const results: TagEntity[] = [];
+
+  for (const tag of tags) {
+    const parts = tag.split('/');
+    let parent: TagEntity | undefined;
+
+    for (const part of parts) {
+      const value = parent ? `${parent.value}/${part}` : part;
+      let tag = await repository.getByValue(userId, value);
+      if (!tag) {
+        tag = await repository.create({ userId, value, parent });
+      }
+
+      parent = tag;
+    }
+
+    if (parent) {
+      results.push(parent);
+    }
+  }
+
+  return results;
+};
diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts
index 537c65db47..b245bfe9e5 100644
--- a/server/test/fixtures/tag.stub.ts
+++ b/server/test/fixtures/tag.stub.ts
@@ -1,24 +1,65 @@
 import { TagResponseDto } from 'src/dtos/tag.dto';
-import { TagEntity, TagType } from 'src/entities/tag.entity';
+import { TagEntity } from 'src/entities/tag.entity';
 import { userStub } from 'test/fixtures/user.stub';
 
+const parent = Object.freeze<TagEntity>({
+  id: 'tag-parent',
+  createdAt: new Date('2021-01-01T00:00:00Z'),
+  updatedAt: new Date('2021-01-01T00:00:00Z'),
+  value: 'Parent',
+  color: null,
+  userId: userStub.admin.id,
+  user: userStub.admin,
+});
+
+const child = Object.freeze<TagEntity>({
+  id: 'tag-child',
+  createdAt: new Date('2021-01-01T00:00:00Z'),
+  updatedAt: new Date('2021-01-01T00:00:00Z'),
+  value: 'Parent/Child',
+  color: null,
+  parent,
+  userId: userStub.admin.id,
+  user: userStub.admin,
+});
+
 export const tagStub = {
   tag1: Object.freeze<TagEntity>({
     id: 'tag-1',
-    name: 'Tag1',
-    type: TagType.CUSTOM,
+    createdAt: new Date('2021-01-01T00:00:00Z'),
+    updatedAt: new Date('2021-01-01T00:00:00Z'),
+    value: 'Tag1',
+    color: null,
+    userId: userStub.admin.id,
+    user: userStub.admin,
+  }),
+  parent,
+  child,
+  color1: Object.freeze<TagEntity>({
+    id: 'tag-1',
+    createdAt: new Date('2021-01-01T00:00:00Z'),
+    updatedAt: new Date('2021-01-01T00:00:00Z'),
+    value: 'Tag1',
+    color: '#000000',
     userId: userStub.admin.id,
     user: userStub.admin,
-    renameTagId: null,
-    assets: [],
   }),
 };
 
 export const tagResponseStub = {
   tag1: Object.freeze<TagResponseDto>({
     id: 'tag-1',
+    createdAt: new Date('2021-01-01T00:00:00Z'),
+    updatedAt: new Date('2021-01-01T00:00:00Z'),
     name: 'Tag1',
-    type: 'CUSTOM',
-    userId: 'admin_id',
+    value: 'Tag1',
+  }),
+  color1: Object.freeze<TagResponseDto>({
+    id: 'tag-1',
+    createdAt: new Date('2021-01-01T00:00:00Z'),
+    updatedAt: new Date('2021-01-01T00:00:00Z'),
+    color: '#000000',
+    name: 'Tag1',
+    value: 'Tag1',
   }),
 };
diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts
index c9db8cd76a..9e9bf5406b 100644
--- a/server/test/repositories/access.repository.mock.ts
+++ b/server/test/repositories/access.repository.mock.ts
@@ -11,6 +11,7 @@ export interface IAccessRepositoryMock {
   partner: Mocked<IAccessRepository['partner']>;
   stack: Mocked<IAccessRepository['stack']>;
   timeline: Mocked<IAccessRepository['timeline']>;
+  tag: Mocked<IAccessRepository['tag']>;
 }
 
 export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
@@ -58,5 +59,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
     timeline: {
       checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
     },
+
+    tag: {
+      checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
+    },
   };
 };
diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts
index a5123e0f36..35b3de1576 100644
--- a/server/test/repositories/tag.repository.mock.ts
+++ b/server/test/repositories/tag.repository.mock.ts
@@ -4,14 +4,17 @@ import { Mocked, vitest } from 'vitest';
 export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
   return {
     getAll: vitest.fn(),
-    getById: vitest.fn(),
+    getByValue: vitest.fn(),
+    upsertAssetTags: vitest.fn(),
+
+    get: vitest.fn(),
     create: vitest.fn(),
     update: vitest.fn(),
-    remove: vitest.fn(),
-    hasAsset: vitest.fn(),
-    hasName: vitest.fn(),
-    getAssets: vitest.fn(),
-    addAssets: vitest.fn(),
-    removeAssets: vitest.fn(),
+    delete: vitest.fn(),
+
+    getAssetIds: vitest.fn(),
+    addAssetIds: vitest.fn(),
+    removeAssetIds: vitest.fn(),
+    upsertAssetIds: vitest.fn(),
   };
 };
diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
new file mode 100644
index 0000000000..434682f73e
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
@@ -0,0 +1,80 @@
+<script lang="ts">
+  import Icon from '$lib/components/elements/icon.svelte';
+  import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
+  import { AppRoute } from '$lib/constants';
+  import { isSharedLink } from '$lib/utils';
+  import { removeTag, tagAssets } from '$lib/utils/asset-utils';
+  import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
+  import { mdiClose, mdiPlus } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  export let asset: AssetResponseDto;
+  export let isOwner: boolean;
+
+  $: tags = asset.tags || [];
+
+  let isOpen = false;
+
+  const handleAdd = () => (isOpen = true);
+
+  const handleCancel = () => (isOpen = false);
+
+  const handleTag = async (tagIds: string[]) => {
+    const ids = await tagAssets({ tagIds, assetIds: [asset.id], showNotification: false });
+    if (ids) {
+      isOpen = false;
+    }
+
+    asset = await getAssetInfo({ id: asset.id });
+  };
+
+  const handleRemove = async (tagId: string) => {
+    const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
+    if (ids) {
+      asset = await getAssetInfo({ id: asset.id });
+    }
+  };
+</script>
+
+{#if isOwner && !isSharedLink()}
+  <section class="px-4 mt-4">
+    <div class="flex h-10 w-full items-center justify-between text-sm">
+      <h2>{$t('tags').toUpperCase()}</h2>
+    </div>
+    <section class="flex flex-wrap pt-2 gap-1">
+      {#each tags as tag (tag.id)}
+        <div class="flex group transition-all">
+          <a
+            class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
+            href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
+          >
+            <p class="text-sm">
+              {tag.value}
+            </p>
+          </a>
+
+          <button
+            type="button"
+            class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
+            title="Remove tag"
+            on:click={() => handleRemove(tag.id)}
+          >
+            <Icon path={mdiClose} />
+          </button>
+        </div>
+      {/each}
+      <button
+        type="button"
+        class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
+        title="Add tag"
+        on:click={handleAdd}
+      >
+        <span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
+      </button>
+    </section>
+  </section>
+{/if}
+
+{#if isOpen}
+  <TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
+{/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 88417f248f..0a105430cc 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -43,6 +43,7 @@
   import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
   import { t } from 'svelte-i18n';
   import { goto } from '$app/navigation';
+  import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
 
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
@@ -157,7 +158,7 @@
   <DetailPanelRating {asset} {isOwner} />
 
   {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
-    <section class="px-4 py-4 text-sm">
+    <section class="px-4 pt-4 text-sm">
       <div class="flex h-10 w-full items-center justify-between">
         <h2>{$t('people').toUpperCase()}</h2>
         <div class="flex gap-2 items-center">
@@ -472,11 +473,11 @@
 {/if}
 
 {#if albums.length > 0}
-  <section class="p-6 dark:text-immich-dark-fg">
+  <section class="px-6 pt-6 dark:text-immich-dark-fg">
     <p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
     {#each albums as album}
       <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
-        <div class="flex gap-4 py-2 hover:cursor-pointer items-center">
+        <div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
           <div>
             <img
               alt={album.albumName}
@@ -501,6 +502,10 @@
   </section>
 {/if}
 
+<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
+  <DetailPanelTags {asset} {isOwner} />
+</section>
+
 {#if showEditFaces}
   <PersonSidePanel
     assetId={asset.id}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 2ac8402b5f..69f777f530 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -153,7 +153,7 @@
       return;
     }
     if (dateGroup && assetStore) {
-      assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
+      assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
     } else {
       intersecting = false;
     }
diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte
new file mode 100644
index 0000000000..306d25d3f1
--- /dev/null
+++ b/web/src/lib/components/forms/tag-asset-form.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+  import { mdiClose, mdiTag } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import Button from '../elements/buttons/button.svelte';
+  import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte';
+  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
+  import { onMount } from 'svelte';
+  import { getAllTags, type TagResponseDto } from '@immich/sdk';
+  import Icon from '$lib/components/elements/icon.svelte';
+
+  export let onTag: (tagIds: string[]) => void;
+  export let onCancel: () => void;
+
+  let allTags: TagResponseDto[] = [];
+  $: tagMap = Object.fromEntries(allTags.map((tag) => [tag.id, tag]));
+  let selectedIds = new Set<string>();
+  $: disabled = selectedIds.size === 0;
+
+  onMount(async () => {
+    allTags = await getAllTags();
+  });
+
+  const handleSubmit = () => onTag([...selectedIds]);
+
+  const handleSelect = (option?: ComboBoxOption) => {
+    if (!option) {
+      return;
+    }
+
+    selectedIds.add(option.value);
+    selectedIds = selectedIds;
+  };
+
+  const handleRemove = (tag: string) => {
+    selectedIds.delete(tag);
+    selectedIds = selectedIds;
+  };
+</script>
+
+<FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
+  <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
+    <div class="my-4 flex flex-col gap-2">
+      <Combobox
+        on:select={({ detail: option }) => handleSelect(option)}
+        label={$t('tag')}
+        options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
+        placeholder={$t('search_tags')}
+      />
+    </div>
+  </form>
+
+  <section class="flex flex-wrap pt-2 gap-1">
+    {#each selectedIds as tagId (tagId)}
+      {@const tag = tagMap[tagId]}
+      {#if tag}
+        <div class="flex group transition-all">
+          <span
+            class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
+          >
+            <p class="text-sm">
+              {tag.value}
+            </p>
+          </span>
+
+          <button
+            type="button"
+            class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
+            title="Remove tag"
+            on:click={() => handleRemove(tagId)}
+          >
+            <Icon path={mdiClose} />
+          </button>
+        </div>
+      {/if}
+    {/each}
+  </section>
+
+  <svelte:fragment slot="sticky-bottom">
+    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
+    <Button type="submit" fullwidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
+  </svelte:fragment>
+</FullScreenModal>
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte
index 8222007d57..6511a9deba 100644
--- a/web/src/lib/components/layouts/user-page-layout.svelte
+++ b/web/src/lib/components/layouts/user-page-layout.svelte
@@ -35,12 +35,16 @@
   </slot>
 
   <section class="relative">
-    {#if title}
+    {#if title || $$slots.title || $$slots.buttons}
       <div
         class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg"
       >
         <div class="flex gap-2 items-center">
-          <div class="font-medium">{title}</div>
+          <slot name="title">
+            {#if title}
+              <div class="font-medium">{title}</div>
+            {/if}
+          </slot>
           {#if description}
             <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
           {/if}
diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte
new file mode 100644
index 0000000000..77e91d7235
--- /dev/null
+++ b/web/src/lib/components/photos-page/actions/tag-action.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import { tagAssets } from '$lib/utils/asset-utils';
+  import { mdiTagMultipleOutline, mdiTimerSand } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
+  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
+
+  export let menuItem = false;
+
+  const text = $t('tag');
+  const icon = mdiTagMultipleOutline;
+
+  let loading = false;
+  let isOpen = false;
+
+  const { clearSelect, getOwnedAssets } = getAssetControlContext();
+
+  const handleOpen = () => (isOpen = true);
+  const handleCancel = () => (isOpen = false);
+  const handleTag = async (tagIds: string[]) => {
+    const assets = [...getOwnedAssets()];
+    loading = true;
+    const ids = await tagAssets({ tagIds, assetIds: assets.map((asset) => asset.id) });
+    if (ids) {
+      clearSelect();
+    }
+    loading = false;
+  };
+</script>
+
+{#if menuItem}
+  <MenuOption {text} {icon} onClick={handleOpen} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+  {:else}
+    <CircleIconButton title={text} {icon} on:click={handleOpen} />
+  {/if}
+{/if}
+
+{#if isOpen}
+  <TagAssetForm onTag={(tagIds) => handleTag(tagIds)} onCancel={handleCancel} />
+{/if}
diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte
index 5ca29967fe..5cbc2e7dca 100644
--- a/web/src/lib/components/photos-page/asset-date-group.svelte
+++ b/web/src/lib/components/photos-page/asset-date-group.svelte
@@ -109,7 +109,7 @@
           );
         },
         onSeparate: () => {
-          $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
+          $assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
             assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
           );
         },
@@ -186,9 +186,9 @@
               <div
                 use:intersectionObserver={{
                   onIntersect: () => onAssetInGrid?.(asset),
-                  top: `-${TITLE_HEIGHT}px`,
-                  bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
-                  right: `-${viewport.width - 1}px`,
+                  top: `${-TITLE_HEIGHT}px`,
+                  bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
+                  right: `${-(viewport.width - 1)}px`,
                   root: assetGridElement,
                 }}
                 data-asset-id={asset.id}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index db030ed14c..40dc79c4f2 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -498,21 +498,21 @@
     }
   };
 
-  function intersectedHandler(bucket: AssetBucket) {
+  function handleIntersect(bucket: AssetBucket) {
     updateLastIntersectedBucketDate();
-    const intersectedTask = () => {
+    const task = () => {
       $assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
       void $assetStore.loadBucket(bucket.bucketDate);
     };
-    $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
+    $assetStore.taskManager.intersectedBucket(componentId, bucket, task);
   }
 
-  function seperatedHandler(bucket: AssetBucket) {
-    const seperatedTask = () => {
+  function handleSeparate(bucket: AssetBucket) {
+    const task = () => {
       $assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
       bucket.cancel();
     };
-    $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
+    $assetStore.taskManager.separatedBucket(componentId, bucket, task);
   }
 
   const handlePrevious = async () => {
@@ -809,8 +809,8 @@
       <div
         id="bucket"
         use:intersectionObserver={{
-          onIntersect: () => intersectedHandler(bucket),
-          onSeparate: () => seperatedHandler(bucket),
+          onIntersect: () => handleIntersect(bucket),
+          onSeparate: () => handleSeparate(bucket),
           top: BUCKET_INTERSECTION_ROOT_TOP,
           bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
           root: element,
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index 64ec16fda6..d3e022a759 100644
--- a/web/src/lib/components/shared-components/combobox.svelte
+++ b/web/src/lib/components/shared-components/combobox.svelte
@@ -1,5 +1,6 @@
 <script lang="ts" context="module">
   export type ComboBoxOption = {
+    id?: string;
     label: string;
     value: string;
   };
@@ -32,7 +33,7 @@
   export let label: string;
   export let hideLabel = false;
   export let options: ComboBoxOption[] = [];
-  export let selectedOption: ComboBoxOption | undefined;
+  export let selectedOption: ComboBoxOption | undefined = undefined;
   export let placeholder = '';
 
   /**
@@ -237,7 +238,7 @@
           {$t('no_results')}
         </li>
       {/if}
-      {#each filteredOptions as option, index (option.label)}
+      {#each filteredOptions as option, index (option.id || option.label)}
         <!-- svelte-ignore a11y-click-events-have-key-events -->
         <li
           aria-selected={index === selectedIndex}
diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte
index 04bc72f3f6..0767ecf12f 100644
--- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte
@@ -4,6 +4,7 @@
     TEXT = 'text',
     NUMBER = 'number',
     PASSWORD = 'password',
+    COLOR = 'color',
   }
 </script>
 
@@ -13,6 +14,7 @@
   import { fly } from 'svelte/transition';
   import PasswordField from '../password-field.svelte';
   import { t } from 'svelte-i18n';
+  import { onMount, tick } from 'svelte';
 
   export let inputType: SettingInputFieldType;
   export let value: string | number;
@@ -25,8 +27,11 @@
   export let required = false;
   export let disabled = false;
   export let isEdited = false;
+  export let autofocus = false;
   export let passwordAutocomplete: string = 'current-password';
 
+  let input: HTMLInputElement;
+
   const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
     value = e.currentTarget.value;
 
@@ -41,6 +46,14 @@
       value = newValue;
     }
   };
+
+  onMount(() => {
+    if (autofocus) {
+      tick()
+        .then(() => input?.focus())
+        .catch((_) => {});
+    }
+  });
 </script>
 
 <div class="mb-4 w-full">
@@ -69,22 +82,46 @@
   {/if}
 
   {#if inputType !== SettingInputFieldType.PASSWORD}
-    <input
-      class="immich-form-input w-full pb-2"
-      aria-describedby={desc ? `${label}-desc` : undefined}
-      aria-labelledby="{label}-label"
-      id={label}
-      name={label}
-      type={inputType}
-      min={min.toString()}
-      max={max.toString()}
-      {step}
-      {required}
-      {value}
-      on:change={handleChange}
-      {disabled}
-      {title}
-    />
+    <div class="flex place-items-center place-content-center">
+      {#if inputType === SettingInputFieldType.COLOR}
+        <input
+          bind:this={input}
+          class="immich-form-input w-full pb-2 rounded-none mr-1"
+          aria-describedby={desc ? `${label}-desc` : undefined}
+          aria-labelledby="{label}-label"
+          id={label}
+          name={label}
+          type="text"
+          min={min.toString()}
+          max={max.toString()}
+          {step}
+          {required}
+          {value}
+          on:change={handleChange}
+          {disabled}
+          {title}
+        />
+      {/if}
+
+      <input
+        bind:this={input}
+        class="immich-form-input w-full pb-2"
+        class:color-picker={inputType === SettingInputFieldType.COLOR}
+        aria-describedby={desc ? `${label}-desc` : undefined}
+        aria-labelledby="{label}-label"
+        id={label}
+        name={label}
+        type={inputType}
+        min={min.toString()}
+        max={max.toString()}
+        {step}
+        {required}
+        {value}
+        on:change={handleChange}
+        {disabled}
+        {title}
+      />
+    </div>
   {:else}
     <PasswordField
       aria-describedby={desc ? `${label}-desc` : undefined}
@@ -100,3 +137,28 @@
     />
   {/if}
 </div>
+
+<style>
+  .color-picker {
+    -webkit-appearance: none;
+    -moz-appearance: none;
+    appearance: none;
+    width: 52px;
+    height: 52px;
+    background-color: transparent;
+    border: none;
+    cursor: pointer;
+    padding: 0;
+    margin: 0;
+  }
+
+  .color-picker::-webkit-color-swatch {
+    border-radius: 14px;
+    border: none;
+  }
+
+  .color-picker::-moz-color-swatch {
+    border-radius: 14px;
+    border: none;
+  }
+</style>
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
index 1985160b27..dd777d1259 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
@@ -21,6 +21,7 @@
     mdiToolbox,
     mdiToolboxOutline,
     mdiFolderOutline,
+    mdiTagMultipleOutline,
   } from '@mdi/js';
   import SideBarSection from './side-bar-section.svelte';
   import SideBarLink from './side-bar-link.svelte';
@@ -105,6 +106,8 @@
       </svelte:fragment>
     </SideBarLink>
 
+    <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
+
     <SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
 
     <SideBarLink
diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte
index bf04e6ae1f..4bdc95db9f 100644
--- a/web/src/lib/components/shared-components/tree/tree-items.svelte
+++ b/web/src/lib/components/shared-components/tree/tree-items.svelte
@@ -1,17 +1,23 @@
 <script lang="ts">
   import Tree from '$lib/components/shared-components/tree/tree.svelte';
-  import type { RecursiveObject } from '$lib/utils/tree-utils';
+  import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
 
   export let items: RecursiveObject;
   export let parent = '';
   export let active = '';
+  export let icons: { default: string; active: string };
   export let getLink: (path: string) => string;
+  export let getColor: (path: string) => string | undefined = () => undefined;
 </script>
 
 <ul class="list-none ml-2">
-  {#each Object.entries(items) as [path, tree], index (index)}
-    <li>
-      <Tree {parent} value={path} {tree} {active} {getLink} />
-    </li>
+  {#each Object.entries(items) as [path, tree]}
+    {@const value = normalizeTreePath(`${parent}/${path}`)}
+    {@const key = value + getColor(value)}
+    {#key key}
+      <li>
+        <Tree {parent} value={path} {tree} {icons} {active} {getLink} {getColor} />
+      </li>
+    {/key}
   {/each}
 </ul>
diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte
index 7975825c5e..99928f5bbd 100644
--- a/web/src/lib/components/shared-components/tree/tree.svelte
+++ b/web/src/lib/components/shared-components/tree/tree.svelte
@@ -2,18 +2,21 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
   import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
-  import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderOutline } from '@mdi/js';
+  import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
 
   export let tree: RecursiveObject;
   export let parent: string;
   export let value: string;
   export let active = '';
+  export let icons: { default: string; active: string };
   export let getLink: (path: string) => string;
+  export let getColor: (path: string) => string | undefined;
 
   $: path = normalizeTreePath(`${parent}/${value}`);
   $: isActive = active.startsWith(path);
   $: isOpen = isActive;
   $: isTarget = active === path;
+  $: color = getColor(path);
 </script>
 
 <a
@@ -21,13 +24,18 @@
   title={value}
   class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
 >
-  <button type="button" on:click|preventDefault={() => (isOpen = !isOpen)}>
+  <button
+    type="button"
+    on:click|preventDefault={() => (isOpen = !isOpen)}
+    class={Object.values(tree).length === 0 ? 'invisible' : ''}
+  >
     <Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
   </button>
   <div>
     <Icon
-      path={isActive ? mdiFolder : mdiFolderOutline}
+      path={isActive ? icons.active : icons.default}
       class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
+      {color}
       size={20}
     />
   </div>
@@ -35,5 +43,5 @@
 </a>
 
 {#if isOpen}
-  <TreeItems parent={path} items={tree} {active} {getLink} />
+  <TreeItems parent={path} items={tree} {icons} {active} {getLink} {getColor} />
 {/if}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index 34d6409848..ce5cefd815 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -47,6 +47,7 @@ export enum AppRoute {
   DUPLICATES = '/utilities/duplicates',
 
   FOLDERS = '/folders',
+  TAGS = '/tags',
 }
 
 export enum ProjectionType {
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index 8b1ec452d7..684cb0e319 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -440,6 +440,7 @@
   "close": "Close",
   "collapse": "Collapse",
   "collapse_all": "Collapse all",
+  "color": "Color",
   "color_theme": "Color theme",
   "comment_deleted": "Comment deleted",
   "comment_options": "Comment options",
@@ -473,6 +474,8 @@
   "create_new_person": "Create new person",
   "create_new_person_hint": "Assign selected assets to a new person",
   "create_new_user": "Create new user",
+  "create_tag": "Create tag",
+  "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
   "create_user": "Create user",
   "created": "Created",
   "current_device": "Current device",
@@ -496,6 +499,8 @@
   "delete_library": "Delete library",
   "delete_link": "Delete link",
   "delete_shared_link": "Delete shared link",
+  "delete_tag": "Delete tag",
+  "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
   "delete_user": "Delete user",
   "deleted_shared_link": "Deleted shared link",
   "description": "Description",
@@ -537,6 +542,7 @@
   "edit_location": "Edit location",
   "edit_name": "Edit name",
   "edit_people": "Edit people",
+  "edit_tag": "Edit tag",
   "edit_title": "Edit Title",
   "edit_user": "Edit user",
   "edited": "Edited",
@@ -1007,6 +1013,7 @@
   "removed_from_archive": "Removed from archive",
   "removed_from_favorites": "Removed from favorites",
   "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
+  "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
   "rename": "Rename",
   "repair": "Repair",
   "repair_no_results_message": "Untracked and missing files will show up here",
@@ -1055,6 +1062,7 @@
   "search_people": "Search people",
   "search_places": "Search places",
   "search_state": "Search state...",
+  "search_tags": "Search tags...",
   "search_timezone": "Search timezone...",
   "search_type": "Search type",
   "search_your_photos": "Search your photos",
@@ -1158,6 +1166,12 @@
   "sunrise_on_the_beach": "Sunrise on the beach",
   "swap_merge_direction": "Swap merge direction",
   "sync": "Sync",
+  "tag": "Tag",
+  "tag_assets": "Tag assets",
+  "tag_created": "Created tag: {tag}",
+  "tag_updated": "Updated tag: {tag}",
+  "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
+  "tags": "Tags",
   "template": "Template",
   "theme": "Theme",
   "theme_selection": "Theme selection",
@@ -1169,6 +1183,7 @@
   "to_change_password": "Change password",
   "to_favorite": "Favorite",
   "to_login": "Login",
+  "to_root": "To root",
   "to_trash": "Trash",
   "toggle_settings": "Toggle settings",
   "toggle_theme": "Toggle dark theme",
diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts
index 6ece1327c4..6ca4f057bd 100644
--- a/web/src/lib/utils/asset-store-task-manager.ts
+++ b/web/src/lib/utils/asset-store-task-manager.ts
@@ -256,9 +256,9 @@ export class AssetGridTaskManager {
     bucketTask.scheduleIntersected(componentId, task);
   }
 
-  seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
+  separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
     const bucketTask = this.getOrCreateBucketTask(bucket);
-    bucketTask.scheduleSeparated(componentId, seperated);
+    bucketTask.scheduleSeparated(componentId, separated);
   }
 
   intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
@@ -266,9 +266,9 @@ export class AssetGridTaskManager {
     bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
   }
 
-  seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
+  separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
     const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
-    bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
+    bucketTask.separatedDateGroup(componentId, dateGroup, separated);
   }
 
   intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
@@ -277,16 +277,16 @@ export class AssetGridTaskManager {
     dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
   }
 
-  seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
+  separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
     const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
     const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
-    dateGroupTask.separatedThumbnail(componentId, asset, seperated);
+    dateGroupTask.separatedThumbnail(componentId, asset, separated);
   }
 }
 
 class IntersectionTask {
   internalTaskManager: InternalTaskManager;
-  seperatedKey;
+  separatedKey;
   intersectedKey;
   priority;
 
@@ -295,7 +295,7 @@ class IntersectionTask {
 
   constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
     this.internalTaskManager = internalTaskManager;
-    this.seperatedKey = keyPrefix + ':s:' + key;
+    this.separatedKey = keyPrefix + ':s:' + key;
     this.intersectedKey = keyPrefix + ':i:' + key;
     this.priority = priority;
   }
@@ -325,14 +325,14 @@ class IntersectionTask {
     this.separated = execTask;
     const cleanup = () => {
       this.separated = undefined;
-      this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
+      this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
     };
     return { task: execTask, cleanup };
   }
 
   removePendingSeparated() {
     if (this.separated) {
-      this.internalTaskManager.removeSeparateTask(this.seperatedKey);
+      this.internalTaskManager.removeSeparateTask(this.separatedKey);
     }
   }
   removePendingIntersected() {
@@ -368,7 +368,7 @@ class IntersectionTask {
       task,
       cleanup,
       componentId: componentId,
-      taskId: this.seperatedKey,
+      taskId: this.separatedKey,
     });
   }
 }
@@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask {
     thumbnailTask.scheduleIntersected(componentId, intersected);
   }
 
-  separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
+  separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
     const thumbnailTask = this.getOrCreateThumbnailTask(asset);
-    thumbnailTask.scheduleSeparated(componentId, seperated);
+    thumbnailTask.scheduleSeparated(componentId, separated);
   }
 }
 class ThumbnailTask extends IntersectionTask {
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index 576b14b201..ce7944b9c9 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store';
 import { downloadRequest, getKey, withError } from '$lib/utils';
 import { createAlbum } from '$lib/utils/album-utils';
 import { getByteUnitString } from '$lib/utils/byte-units';
+import { getFormatter } from '$lib/utils/i18n';
 import {
   addAssetsToAlbum as addAssets,
   createStack,
@@ -18,6 +19,8 @@ import {
   getBaseUrl,
   getDownloadInfo,
   getStack,
+  tagAssets as tagAllAssets,
+  untagAssets,
   updateAsset,
   updateAssets,
   type AlbumResponseDto,
@@ -61,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
   }
 };
 
+export const tagAssets = async ({
+  assetIds,
+  tagIds,
+  showNotification = true,
+}: {
+  assetIds: string[];
+  tagIds: string[];
+  showNotification?: boolean;
+}) => {
+  for (const tagId of tagIds) {
+    await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
+  }
+
+  if (showNotification) {
+    const $t = await getFormatter();
+    notificationController.show({
+      message: $t('tagged_assets', { values: { count: assetIds.length } }),
+      type: NotificationType.Info,
+    });
+  }
+
+  return assetIds;
+};
+
+export const removeTag = async ({
+  assetIds,
+  tagIds,
+  showNotification = true,
+}: {
+  assetIds: string[];
+  tagIds: string[];
+  showNotification?: boolean;
+}) => {
+  for (const tagId of tagIds) {
+    await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
+  }
+
+  if (showNotification) {
+    const $t = await getFormatter();
+    notificationController.show({
+      message: $t('removed_tagged_assets', { values: { count: assetIds.length } }),
+      type: NotificationType.Info,
+    });
+  }
+
+  return assetIds;
+};
+
 export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
   const album = await createAlbum(albumName, assetIds);
   if (!album) {
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
index b530184342..a8b8602c02 100644
--- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -13,7 +13,7 @@
   import { foldersStore } from '$lib/stores/folders.store';
   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
   import { type AssetResponseDto } from '@immich/sdk';
-  import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js';
+  import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
@@ -60,7 +60,12 @@
     <section>
       <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
       <div class="h-full">
-        <TreeItems items={tree} active={currentPath} {getLink} />
+        <TreeItems
+          icons={{ default: mdiFolderOutline, active: mdiFolder }}
+          items={tree}
+          active={currentPath}
+          {getLink}
+        />
       </div>
     </section>
   </SideBarSection>
@@ -73,7 +78,7 @@
     <div
       class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
     >
-      <a href={`${AppRoute.FOLDERS}`} title="To root">
+      <a href={`${AppRoute.FOLDERS}`} title={$t('to_root')}>
         <Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
       </a>
       {#each pathSegments as segment, index}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 9bcbdbeea0..e15c20cbbe 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -25,6 +25,7 @@
   import { preferences, user } from '$lib/stores/user.store';
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
+  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 
   let { isViewing: showAssetViewer } = assetViewingStore;
   const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@@ -80,6 +81,7 @@
       <ChangeDate menuItem />
       <ChangeLocation menuItem />
       <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
+      <TagAction menuItem />
       <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
       <hr />
       <AssetJobActions />
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
new file mode 100644
index 0000000000..7335bf83c1
--- /dev/null
+++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -0,0 +1,251 @@
+<script lang="ts">
+  import { goto } from '$app/navigation';
+  import { page } from '$app/stores';
+  import Button from '$lib/components/elements/buttons/button.svelte';
+  import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
+  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
+  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import SettingInputField, {
+    SettingInputFieldType,
+  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
+  import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
+  import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
+  import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
+  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
+  import { AssetStore } from '$lib/stores/assets.store';
+  import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
+  import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
+  import { mdiChevronRight, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import type { PageData } from './$types';
+  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+
+  export let data: PageData;
+
+  $: pathSegments = data.path ? data.path.split('/') : [];
+  $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
+
+  const assetInteractionStore = createAssetInteractionStore();
+
+  const buildMap = (tags: TagResponseDto[]) => {
+    return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
+  };
+
+  $: tags = data.tags;
+  $: tagsMap = buildMap(tags);
+  $: tag = currentPath ? tagsMap[currentPath] : null;
+  $: tree = buildTree(tags.map((tag) => tag.value));
+
+  const handleNavigation = async (tag: string) => {
+    await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
+  };
+
+  const handleBreadcrumbNavigation = async (targetPath: string) => {
+    await navigateToView(targetPath);
+  };
+
+  const getLink = (path: string) => {
+    const url = new URL(AppRoute.TAGS, window.location.href);
+    url.searchParams.set(QueryParameter.PATH, path);
+    return url.href;
+  };
+
+  const getColor = (path: string) => tagsMap[path]?.color;
+
+  const navigateToView = (path: string) => goto(getLink(path));
+
+  let isNewOpen = false;
+  let newTagValue = '';
+  const handleCreate = () => {
+    newTagValue = tag ? tag.value + '/' : '';
+    isNewOpen = true;
+  };
+
+  let isEditOpen = false;
+  let newTagColor = '';
+  const handleEdit = () => {
+    newTagColor = tag?.color ?? '';
+    isEditOpen = true;
+  };
+
+  const handleCancel = () => {
+    isNewOpen = false;
+    isEditOpen = false;
+  };
+
+  const handleSubmit = async () => {
+    if (tag && isEditOpen && newTagColor) {
+      await updateTag({ id: tag.id, tagUpdateDto: { color: newTagColor } });
+
+      notificationController.show({
+        message: $t('tag_updated', { values: { tag: tag.value } }),
+        type: NotificationType.Info,
+      });
+
+      tags = await getAllTags();
+      isEditOpen = false;
+    }
+
+    if (isNewOpen && newTagValue) {
+      const [newTag] = await upsertTags({ tagUpsertDto: { tags: [newTagValue] } });
+
+      notificationController.show({
+        message: $t('tag_created', { values: { tag: newTag.value } }),
+        type: NotificationType.Info,
+      });
+
+      tags = await getAllTags();
+      isNewOpen = false;
+    }
+  };
+
+  const handleDelete = async () => {
+    if (!tag) {
+      return;
+    }
+
+    const isConfirm = await dialogController.show({
+      title: $t('delete_tag'),
+      prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
+      confirmText: $t('delete'),
+      cancelText: $t('cancel'),
+    });
+
+    if (!isConfirm) {
+      return;
+    }
+
+    await deleteTag({ id: tag.id });
+    tags = await getAllTags();
+
+    // navigate to parent
+    const parentPath = pathSegments.slice(0, -1).join('/');
+    await navigateToView(parentPath);
+  };
+</script>
+
+<UserPageLayout title={data.meta.title} scrollbar={false}>
+  <SideBarSection slot="sidebar">
+    <section>
+      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
+      <div class="h-full">
+        <TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} />
+      </div>
+    </section>
+  </SideBarSection>
+
+  <section slot="buttons">
+    <LinkButton on:click={handleCreate}>
+      <div class="flex place-items-center gap-2 text-sm">
+        <Icon path={mdiPlus} size="18" />
+        <p class="hidden md:block">{$t('create_tag')}</p>
+      </div>
+    </LinkButton>
+
+    <LinkButton on:click={handleEdit}>
+      <div class="flex place-items-center gap-2 text-sm">
+        <Icon path={mdiPencil} size="18" />
+        <p class="hidden md:block">{$t('edit_tag')}</p>
+      </div>
+    </LinkButton>
+
+    {#if pathSegments.length > 0 && tag}
+      <LinkButton on:click={handleDelete}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiTrashCanOutline} size="18" />
+          <p class="hidden md:block">{$t('delete_tag')}</p>
+        </div>
+      </LinkButton>
+    {/if}
+  </section>
+
+  <section
+    class="flex place-items-center gap-2 mt-2 text-immich-primary dark:text-immich-dark-primary rounded-2xl bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 border border-gray-100 dark:border-gray-900"
+  >
+    <a href={`${AppRoute.TAGS}`} title={$t('to_root')}>
+      <Icon path={mdiTagMultiple} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
+    </a>
+    {#each pathSegments as segment, index}
+      <button
+        class="text-sm font-mono underline hover:font-semibold"
+        on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
+        type="button"
+      >
+        {segment}
+      </button>
+      <p class="text-gray-500">
+        {#if index < pathSegments.length - 1}
+          <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
+        {/if}
+      </p>
+    {/each}
+  </section>
+
+  <section class="mt-2 h-full">
+    {#key $page.url.href}
+      {#if tag}
+        <AssetGrid
+          enableRouting={true}
+          assetStore={new AssetStore({ tagId: tag.id })}
+          {assetInteractionStore}
+          removeAction={AssetAction.UNARCHIVE}
+        >
+          <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" />
+        </AssetGrid>
+      {:else}
+        <TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
+      {/if}
+    {/key}
+  </section>
+</UserPageLayout>
+
+{#if isNewOpen}
+  <FullScreenModal title={$t('create_tag')} icon={mdiTag} onClose={handleCancel}>
+    <div class="text-immich-primary dark:text-immich-dark-primary">
+      <p class="text-sm dark:text-immich-dark-fg">
+        {$t('create_tag_description')}
+      </p>
+    </div>
+
+    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
+      <div class="my-4 flex flex-col gap-2">
+        <SettingInputField
+          inputType={SettingInputFieldType.TEXT}
+          label={$t('tag').toUpperCase()}
+          bind:value={newTagValue}
+          required={true}
+          autofocus={true}
+        />
+      </div>
+    </form>
+    <svelte:fragment slot="sticky-bottom">
+      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+      <Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button>
+    </svelte:fragment>
+  </FullScreenModal>
+{/if}
+
+{#if isEditOpen}
+  <FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}>
+    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form">
+      <div class="my-4 flex flex-col gap-2">
+        <SettingInputField
+          inputType={SettingInputFieldType.COLOR}
+          label={$t('color').toUpperCase()}
+          bind:value={newTagColor}
+        />
+      </div>
+    </form>
+    <svelte:fragment slot="sticky-bottom">
+      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+      <Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button>
+    </svelte:fragment>
+  </FullScreenModal>
+{/if}
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts
new file mode 100644
index 0000000000..23846e57c4
--- /dev/null
+++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts
@@ -0,0 +1,32 @@
+import { QueryParameter } from '$lib/constants';
+import { authenticate } from '$lib/utils/auth';
+import { getFormatter } from '$lib/utils/i18n';
+import { getAssetInfoFromParam } from '$lib/utils/navigation';
+import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
+import { getAllTags } from '@immich/sdk';
+import type { PageLoad } from './$types';
+
+export const load = (async ({ params, url }) => {
+  await authenticate();
+  const asset = await getAssetInfoFromParam(params);
+  const $t = await getFormatter();
+
+  const path = url.searchParams.get(QueryParameter.PATH);
+  const tags = await getAllTags();
+  const tree = buildTree(tags.map((tag) => tag.value));
+  let currentTree = tree;
+  const parts = normalizeTreePath(path || '').split('/');
+  for (const part of parts) {
+    currentTree = currentTree?.[part];
+  }
+
+  return {
+    tags,
+    asset,
+    path,
+    children: Object.keys(currentTree || {}),
+    meta: {
+      title: $t('tags'),
+    },
+  };
+}) satisfies PageLoad;