Repository: vivid-planet/comet
Pull request #3624: Redirects target URL search and filter
Author: login: johnnyomair
Created: 2025-03-11T12:48:38Z | Merged: 2025-03-26T09:11:33Z

## Description

We need to support searching for redirects by the target. This can't be accomplished easily since the redirect target is a block and doesn't store the target URL in the database. This change adds client-side (API) searching and filtering to the `paginatedRedirects` query, where the target URL is resolved on demand. The URL is resolved using the newly added `RedirectTargetUrlService`, which can be overridden in the application to support scope-prefixed paths or absolute URLs.

### Performance

I tested the performance of the client-side searching/filtering using [AutoCannon](https://www.npmjs.com/package/autocannon). I changed the redirects fixtures to generate 7000 redirects for 1000 pages, which is roughly the size of a large customer project of ours (also, this allows caching the URL resolution).

For 100 consecutive runs the average response time is 323.8 ms:

```
npx autocannon -a 100 -c 1 -m POST -H Content-Type=application/json --b '{"query":"query {\n  paginatedRedirects(\n    scope: { domain: \"secondary\" }\n\t\tsearch: \"/page-42\"\n    limit: 100\n  ) {\n    nodes {\n      id\n      active\n      sourceType\n      source\n      target\n      comment\n      generationType\n    }\n    totalCount\n  }\n}\n"}' 'http://localhost:4000/graphql'
Running 100 requests test @ http://localhost:4000/graphql
1 connections


┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │ Max    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 284 ms │ 324 ms │ 394 ms │ 407 ms │ 321.23 ms │ 25.27 ms │ 417 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬───────┬───────┬────────┬────────┬────────┬─────────┬───────┐
│ Stat      │ 1%    │ 2.5%  │ 50%    │ 97.5%  │ Avg    │ Stdev   │ Min   │
├───────────┼───────┼───────┼────────┼────────┼────────┼─────────┼───────┤
│ Req/Sec   │ 1     │ 1     │ 3      │ 4      │ 3.04   │ 0.53    │ 1     │
├───────────┼───────┼───────┼────────┼────────┼────────┼─────────┼───────┤
│ Bytes/Sec │ 45 kB │ 45 kB │ 135 kB │ 180 kB │ 136 kB │ 23.5 kB │ 45 kB │
└───────────┴───────┴───────┴────────┴────────┴────────┴─────────┴───────┘

Req/Bytes counts sampled once per second.
# of samples: 33

100 requests in 33.04s, 4.5 MB read
```

I then added a cache for the URL resolution (which is okay since the service is request-scoped). This reduced the average response time to 213.38 ms:

```
npx autocannon -a 100 -c 1 -m POST -H Content-Type=application/json --b '{"query":"query {\n  paginatedRedirects(\n    scope: { domain: \"secondary\" }\n\t\tsearch: \"/page-42\"\n    limit: 100\n  ) {\n    nodes {\n      id\n      active\n      sourceType\n      source\n      target\n      comment\n      generationType\n    }\n    totalCount\n  }\n}\n"}' 'http://localhost:4000/graphql'
Running 100 requests test @ http://localhost:4000/graphql
1 connections


┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │ Max    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 193 ms │ 206 ms │ 247 ms │ 249 ms │ 213.38 ms │ 18.54 ms │ 279 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬───────┬───────┬────────┬────────┬────────┬─────────┬───────┐
│ Stat      │ 1%    │ 2.5%  │ 50%    │ 97.5%  │ Avg    │ Stdev   │ Min   │
├───────────┼───────┼───────┼────────┼────────┼────────┼─────────┼───────┤
│ Req/Sec   │ 2     │ 2     │ 5      │ 5      │ 4.55   │ 0.73    │ 2     │
├───────────┼───────┼───────┼────────┼────────┼────────┼─────────┼───────┤
│ Bytes/Sec │ 90 kB │ 90 kB │ 225 kB │ 225 kB │ 205 kB │ 32.4 kB │ 90 kB │
└───────────┴───────┴───────┴────────┴────────┴────────┴─────────┴───────┘

Req/Bytes counts sampled once per second.
# of samples: 22

100 requests in 22.03s, 4.5 MB read
```

**However, when using the default settings of AutoCannon (10 parallel requests, 15k requests in 15 s), the API went out-of-memory. We should therefore discuss the number of parallel requests we want to support.**

## Screenshots/screencasts


https://github.com/user-attachments/assets/fa582c44-0ac4-4e65-95dd-7f47d7fe2a22

## Open TODOs/questions

- [x] Merge parent PR https://github.com/vivid-planet/comet/pull/3623
- [x] Decide if the performance is sufficient
- [x] Decide if the search for paths should use includes or startsWith
- [x] Add changeset
- [x] Address open TODOs
- [x] Docs

## Further information

-   Task: https://vivid-planet.atlassian.net/browse/COM-1204



Diff:
diff --git a/.changeset/warm-jeans-cheer.md b/.changeset/warm-jeans-cheer.md
new file mode 100644
index 00000000000..1f5cd6e7f40
--- /dev/null
+++ b/.changeset/warm-jeans-cheer.md
@@ -0,0 +1,28 @@
+---
+"@comet/cms-admin": minor
+"@comet/cms-api": minor
+---
+
+Add support for searching/filtering redirects by target
+
+Add a custom target URL service to resolve the URLs of custom redirect targets:
+
+```ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        // Your custom logic here
+    }
+}
+```
+
+```diff
+RedirectsModule.register({
+    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
+    customTargets: { news: NewsLinkBlock },
+    Scope: RedirectScope,
++   TargetUrlService: MyRedirectTargetUrlService,
+}),
+```
diff --git a/demo/api/schema.gql b/demo/api/schema.gql
index 9cf67d17eaf..747ede954b9 100644
--- a/demo/api/schema.gql
+++ b/demo/api/schema.gql
@@ -432,17 +432,6 @@ A date string, such as 2007-12-03, compliant with the `full-date` format outline
 """
 scalar Date
 
-type Link implements DocumentInterface {
-  id: ID!
-  updatedAt: DateTime!
-  content: LinkBlockData!
-  createdAt: DateTime!
-  pageTreeNode: PageTreeNode
-}
-
-"""Link root block data"""
-scalar LinkBlockData @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
-
 type PageTreeNodeScope {
   domain: String!
   language: String!
@@ -489,6 +478,17 @@ enum UserGroup {
 
 union PageContentUnion = Page | Link | PredefinedPage
 
+type Link implements DocumentInterface {
+  id: ID!
+  updatedAt: DateTime!
+  content: LinkBlockData!
+  createdAt: DateTime!
+  pageTreeNode: PageTreeNode
+}
+
+"""Link root block data"""
+scalar LinkBlockData @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
+
 type PredefinedPage implements DocumentInterface {
   id: ID!
   updatedAt: DateTime!
@@ -872,6 +872,7 @@ enum SlugAvailability {
 input RedirectFilter {
   generationType: StringFilter
   source: StringFilter
+  target: StringFilter
   active: BooleanFilter
   createdAt: DateTimeFilter
   updatedAt: DateTimeFilter
diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts
index 19ce34eb725..b0e7050aeb0 100644
--- a/demo/api/src/app.module.ts
+++ b/demo/api/src/app.module.ts
@@ -17,6 +17,7 @@ import {
     SentryModule,
     UserPermissionsModule,
 } from "@comet/cms-api";
+import { MikroOrmModule } from "@mikro-orm/nestjs";
 import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
 import { DynamicModule, Module } from "@nestjs/common";
 import { ModuleRef } from "@nestjs/core";
@@ -43,6 +44,7 @@ import { PredefinedPagesModule } from "./documents/predefined-pages/predefined-p
 import { FooterModule } from "./footer/footer.module";
 import { MenusModule } from "./menus/menus.module";
 import { NewsLinkBlock } from "./news/blocks/news-link.block";
+import { News } from "./news/entities/news.entity";
 import { NewsModule } from "./news/news.module";
 import { OpenTelemetryModule } from "./open-telemetry/open-telemetry.module";
 import { PageTreeNodeCreateInput, PageTreeNodeUpdateInput } from "./page-tree/dto/page-tree-node.input";
@@ -50,6 +52,7 @@ import { PageTreeNodeScope } from "./page-tree/dto/page-tree-node-scope";
 import { PageTreeNode } from "./page-tree/entities/page-tree-node.entity";
 import { ProductsModule } from "./products/products.module";
 import { RedirectScope } from "./redirects/dto/redirect-scope";
+import { RedirectTargetUrlService } from "./redirects/redirect-target-url.service";
 
 @Module({})
 export class AppModule {
@@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
+                    customTargets: { news: NewsLinkBlock },
+                    Scope: RedirectScope,
+                    TargetUrlService: RedirectTargetUrlService,
+                }),
                 BlobStorageModule.register({
                     backend: config.blob.storage,
                 }),
diff --git a/demo/api/src/db/fixtures/fixtures.module.ts b/demo/api/src/db/fixtures/fixtures.module.ts
index ba7e48be31d..cd0ec97a018 100644
--- a/demo/api/src/db/fixtures/fixtures.module.ts
+++ b/demo/api/src/db/fixtures/fixtures.module.ts
@@ -1,4 +1,4 @@
-import { DependenciesModule } from "@comet/cms-api";
+import { AttachedDocument, DependenciesModule } from "@comet/cms-api";
 import { MikroOrmModule } from "@mikro-orm/nestjs";
 import { Module } from "@nestjs/common";
 import { ConfigModule } from "@src/config/config.module";
@@ -8,6 +8,7 @@ import { Link } from "@src/documents/links/entities/link.entity";
 import { LinksModule } from "@src/documents/links/links.module";
 import { Page } from "@src/documents/pages/entities/page.entity";
 import { PagesModule } from "@src/documents/pages/pages.module";
+import { PageTreeNode } from "@src/page-tree/entities/page-tree-node.entity";
 import { Manufacturer } from "@src/products/entities/manufacturer.entity";
 import { Product } from "@src/products/entities/product.entity";
 import { ConsoleModule } from "nestjs-console";
@@ -63,7 +64,7 @@ import { VideoFixtureService } from "./generators/video-fixture.service";
         PagesModule,
         LinksModule,
         DependenciesModule,
-        MikroOrmModule.forFeature([DamFile, Page, Link, Product, Manufacturer]),
+        MikroOrmModule.forFeature([DamFile, Page, Link, Product, Manufacturer, PageTreeNode, AttachedDocument]),
     ],
     providers: [
         AccordionBlockFixtureService,
diff --git a/demo/api/src/db/fixtures/generators/redirects-fixture.service.ts b/demo/api/src/db/fixtures/generators/redirects-fixture.service.ts
index e5822d2fdb4..d92d5109fd9 100644
--- a/demo/api/src/db/fixtures/generators/redirects-fixture.service.ts
+++ b/demo/api/src/db/fixtures/generators/redirects-fixture.service.ts
@@ -1,18 +1,82 @@
-import { RedirectGenerationType, RedirectInterface, REDIRECTS_LINK_BLOCK, RedirectsLinkBlock, RedirectSourceTypeValues } from "@comet/cms-api";
+import {
+    AttachedDocument,
+    PageTreeNodeVisibility,
+    RedirectGenerationType,
+    RedirectInterface,
+    REDIRECTS_LINK_BLOCK,
+    RedirectsLinkBlock,
+    RedirectSourceTypeValues,
+} from "@comet/cms-api";
+import { faker } from "@faker-js/faker";
 import { InjectRepository } from "@mikro-orm/nestjs";
-import { EntityRepository } from "@mikro-orm/postgresql";
+import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
 import { Inject, Injectable } from "@nestjs/common";
+import { PageContentBlock } from "@src/documents/pages/blocks/page-content.block";
+import { SeoBlock } from "@src/documents/pages/blocks/seo.block";
+import { StageBlock } from "@src/documents/pages/blocks/stage.block";
+import { Page } from "@src/documents/pages/entities/page.entity";
+import { PageTreeNode } from "@src/page-tree/entities/page-tree-node.entity";
+import { PageTreeNodeCategory } from "@src/page-tree/page-tree-node-category";
+import { UserGroup } from "@src/user-groups/user-group";
+
+import { SeoBlockFixtureService } from "./seo-block-fixture.service";
 
 @Injectable()
 export class RedirectsFixtureService {
     constructor(
         @Inject(REDIRECTS_LINK_BLOCK) private readonly redirectsLinkBlock: RedirectsLinkBlock,
-        @InjectRepository("Redirect") private readonly repository: EntityRepository<RedirectInterface>,
+        @InjectRepository("Redirect") private readonly redirectsRepository: EntityRepository<RedirectInterface>,
+        @InjectRepository(PageTreeNode) private readonly pageTreeNodesRepository: EntityRepository<PageTreeNode>,
+        @InjectRepository(AttachedDocument) private readonly attachedDocumentsRepository: EntityRepository<AttachedDocument>,
+        @InjectRepository(Page) private readonly pagesRepository: EntityRepository<Page>,
+        private readonly seoBlockFixtureService: SeoBlockFixtureService,
+        private readonly entityManager: EntityManager,
     ) {}
 
     async generateRedirects(): Promise<void> {
+        const pages = [];
+        const pageTreeNodeIds = [];
+
+        for (let level = 0; level < 10; level++) {
+            const pagesForLevel = [];
+
+            for (let i = 0; i < 100; i++) {
+                const pageTreeNodeId = faker.string.uuid();
+                const pageId = faker.string.uuid();
+
+                this.entityManager.persist(
+                    this.pagesRepository.create({
+                        id: pageId,
+                        content: PageContentBlock.blockInputFactory({ blocks: [] }).transformToBlockData(),
+                        stage: StageBlock.blockInputFactory({ blocks: [] }).transformToBlockData(),
+                        seo: SeoBlock.blockDataFactory(await this.seoBlockFixtureService.generateBlockInput()),
+                    }),
+                );
+                this.entityManager.persist(
+                    this.pageTreeNodesRepository.create({
+                        id: pageTreeNodeId,
+                        parentId: level > 0 ? faker.helpers.arrayElement(pages[level - 1]) : null,
+                        name: `Page ${i}`,
+                        slug: `page-${i}`,
+                        documentType: "Page",
+                        visibility: PageTreeNodeVisibility.Published,
+                        scope: { domain: "secondary", language: "en" },
+                        userGroup: UserGroup.All,
+                        pos: i,
+                        category: PageTreeNodeCategory.MainNavigation,
+                    }),
+                );
+                this.entityManager.persist(this.attachedDocumentsRepository.create({ pageTreeNodeId, documentId: pageId, type: "Page" }));
+
+                pagesForLevel.push(pageTreeNodeId);
+            }
+
+            pages.push(pagesForLevel);
+            pageTreeNodeIds.push(...pagesForLevel);
+        }
+
         for (let i = 0; i < 7000; i++) {
-            this.repository.create({
+            this.redirectsRepository.create({
                 generationType: RedirectGenerationType.manual,
                 source: `/redirect-${i}`,
                 target: this.redirectsLinkBlock
@@ -21,7 +85,7 @@ export class RedirectsFixtureService {
                             {
                                 type: "internal",
                                 props: {
-                                    targetPageId: "aaa585d3-eca1-47c9-8852-9370817b49ac",
+                                    targetPageId: faker.helpers.arrayElement(pageTreeNodeIds),
                                 },
                             },
                         ],
@@ -30,12 +94,12 @@ export class RedirectsFixtureService {
                     .transformToBlockData(),
                 active: true,
                 scope: {
-                    domain: "main",
-                    language: "en",
+                    domain: "secondary",
                 },
                 sourceType: RedirectSourceTypeValues.path,
             });
         }
-        await this.repository.getEntityManager().flush();
+
+        await this.entityManager.flush();
     }
 }
diff --git a/demo/api/src/documents/predefined-pages/predefined-pages.module.ts b/demo/api/src/documents/predefined-pages/predefined-pages.module.ts
index 55fcdd2b8e8..a187c8b34bc 100644
--- a/demo/api/src/documents/predefined-pages/predefined-pages.module.ts
+++ b/demo/api/src/documents/predefined-pages/predefined-pages.module.ts
@@ -3,9 +3,11 @@ import { Module } from "@nestjs/common";
 
 import { PredefinedPage } from "./entities/predefined-page.entity";
 import { PredefinedPagesResolver } from "./predefined-pages.resolver";
+import { PredefinedPagesService } from "./predefined-pages.service";
 
 @Module({
     imports: [MikroOrmModule.forFeature([PredefinedPage])],
-    providers: [PredefinedPagesResolver],
+    providers: [PredefinedPagesResolver, PredefinedPagesService],
+    exports: [PredefinedPagesService],
 })
 export class PredefinedPagesModule {}
diff --git a/demo/api/src/documents/predefined-pages/predefined-pages.service.ts b/demo/api/src/documents/predefined-pages/predefined-pages.service.ts
new file mode 100644
index 00000000000..38f45a63b16
--- /dev/null
+++ b/demo/api/src/documents/predefined-pages/predefined-pages.service.ts
@@ -0,0 +1,50 @@
+import { PageTreeNodeInterface, PageTreeReadApiService, PageTreeService } from "@comet/cms-api";
+import { Injectable } from "@nestjs/common";
+import { PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+import { PredefinedPage, PredefinedPageType } from "./entities/predefined-page.entity";
+
+@Injectable()
+export class PredefinedPagesService {
+    constructor(private readonly pageTreeService: PageTreeService, private readonly pageTreeReadApi: PageTreeReadApiService) {}
+
+    async predefinedPagePath(type: PredefinedPageType, scope: PageTreeNodeScope): Promise<string> {
+        const pageTreeNode = await this.pageTreeNodeForPredefinedPage(type, scope);
+
+        if (!pageTreeNode) {
+            switch (type) {
+                case PredefinedPageType.News:
+                    return "/news";
+            }
+        }
+
+        return this.pageTreeReadApi.nodePath(pageTreeNode);
+    }
+
+    private async pageTreeNodeForPredefinedPage(type: PredefinedPageType, scope: PageTreeNodeScope): Promise<PageTreeNodeInterface | null> {
+        const predefinedPageNodes = await this.pageTreeReadApi.getNodes({ scope, documentType: "PredefinedPage" });
+
+        for (const node of predefinedPageNodes) {
+            const attachedDocument = await this.pageTreeService.getActiveAttachedDocument(node.id, node.documentType);
+
+            if (!attachedDocument) {
+                continue;
+            }
+
+            const document = (await this.pageTreeService.resolveDocument(
+                attachedDocument.type,
+                attachedDocument.documentId,
+            )) as PredefinedPage | null;
+
+            if (!document) {
+                continue;
+            }
+
+            if (document.type === type) {
+                return node;
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/demo/api/src/redirects/redirect-target-url.service.ts b/demo/api/src/redirects/redirect-target-url.service.ts
new file mode 100644
index 00000000000..9bbe280895e
--- /dev/null
+++ b/demo/api/src/redirects/redirect-target-url.service.ts
@@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
+        } else {
+            const newsId = (target.props as ExtractBlockData<typeof NewsLinkBlock>).id;
+            if (newsId) {
+                const news = await this.newsRepository.findOne(newsId);
+
+                if (!news) {
+                    return undefined;
+                }
+
+                const scope = news.scope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = `${await this.predefinedPagesService.predefinedPagePath(PredefinedPageType.News, scope)}/${news.slug}`;
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        }
+    }
+
+    private getSiteUrl(scope: { domain: string }): string {
+        const siteConfig = this.config.siteConfigs.find((siteConfig) => siteConfig.scope.domain === scope.domain);
+
+        if (!siteConfig) {
+            throw new Error(`Site config not found for domain: ${scope.domain}`);
+        }
+
+        return siteConfig.url;
+    }
+}
diff --git a/docs/docs/3-features-modules/10-redirects/index.md b/docs/docs/3-features-modules/10-redirects/index.md
new file mode 100644
index 00000000000..ac71a84fb9a
--- /dev/null
+++ b/docs/docs/3-features-modules/10-redirects/index.md
@@ -0,0 +1,134 @@
+---
+title: Redirects
+---
+
+## Custom redirect targets
+
+Custom redirect targets (e.g., a news site) are supported via the `customTargets`-option.
+The option expects a block:
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+```tsx title=App.tsx
+const RedirectsPage = createRedirectsPage({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+A custom target URL service is required to resolve the target URLs:
+
+```ts title=my-redirect-target-url.service.ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(
+        target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number],
+    ): Promise<string | undefined> {
+        // Build the target URL here
+    }
+}
+```
+
+<details>
+    <summary>Full example</summary>
+
+```ts
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import {
+    InternalLinkBlock,
+    PageTreeReadApiService,
+    RedirectsLinkBlock,
+    RedirectTargetUrlServiceInterface,
+} from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(
+        target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number],
+    ): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>)
+                .targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
+        } else {
+            const newsId = (target.props as ExtractBlockData<typeof NewsLinkBlock>).id;
+            if (newsId) {
+                const news = await this.newsRepository.findOne(newsId);
+
+                if (!news) {
+                    return undefined;
+                }
+
+                const scope = news.scope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = `${await this.predefinedPagesService.predefinedPagePath(
+                    PredefinedPageType.News,
+                    scope,
+                )}/${news.slug}`;
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        }
+    }
+
+    private getSiteUrl(scope: { domain: string }): string {
+        const siteConfig = this.config.siteConfigs.find(
+            (siteConfig) => siteConfig.scope.domain === scope.domain,
+        );
+
+        if (!siteConfig) {
+            throw new Error(`Site config not found for domain: ${scope.domain}`);
+        }
+
+        return siteConfig.url;
+    }
+}
+```
+
+</details>
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    customTargets: { news: NewsLinkBlock },
+    // highlight-next-line
+    TargetUrlService: MyRedirectTargetUrlService,
+}),
+```
diff --git a/packages/admin/cms-admin/src/redirects/RedirectsGrid.tsx b/packages/admin/cms-admin/src/redirects/RedirectsGrid.tsx
index c61f583045e..6d0e450ecfc 100644
--- a/packages/admin/cms-admin/src/redirects/RedirectsGrid.tsx
+++ b/packages/admin/cms-admin/src/redirects/RedirectsGrid.tsx
@@ -92,7 +92,6 @@ export function RedirectsGrid({ linkBlock, scope }: Props): JSX.Element {
             },
             sortable: false,
             flex: 2,
-            filterable: false,
         },
         {
             field: "comment",
diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql
index f9beafe3439..c09e6a5dde4 100644
--- a/packages/api/cms-api/schema.gql
+++ b/packages/api/cms-api/schema.gql
@@ -406,6 +406,7 @@ enum SortDirection {
 input RedirectFilter {
   generationType: StringFilter
   source: StringFilter
+  target: StringFilter
   active: BooleanFilter
   createdAt: DateTimeFilter
   updatedAt: DateTimeFilter
diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts
index 7b0471b6b00..5508824bb2b 100644
--- a/packages/api/cms-api/src/index.ts
+++ b/packages/api/cms-api/src/index.ts
@@ -158,6 +158,7 @@ export { PageTreeReadApiService } from "./page-tree/page-tree-read-api.service";
 export { PageTreeNodeCategory, PageTreeNodeInterface, PageTreeNodeVisibility, ScopeInterface } from "./page-tree/types";
 export { PageExists, PageExistsConstraint } from "./page-tree/validators/page-exists.validator";
 export { RedirectInterface } from "./redirects/entities/redirect-entity.factory";
+export { RedirectTargetUrlServiceInterface } from "./redirects/redirect-target-url.service";
 export { REDIRECTS_LINK_BLOCK } from "./redirects/redirects.constants";
 export { RedirectGenerationType, RedirectSourceTypeValues } from "./redirects/redirects.enum";
 export { RedirectsLinkBlock, RedirectsModule } from "./redirects/redirects.module";
diff --git a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts
index 868c3514bae..a8f4a169b88 100644
--- a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts
+++ b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts
@@ -18,6 +18,11 @@ export class RedirectFilter {
     @Type(() => StringFilter)
     source?: StringFilter;
 
+    @Field(() => StringFilter, { nullable: true })
+    @IsOptional()
+    @Type(() => StringFilter)
+    target?: StringFilter;
+
     @Field(() => BooleanFilter, { nullable: true })
     @ValidateNested()
     @Type(() => BooleanFilter)
diff --git a/packages/api/cms-api/src/redirects/redirect-target-url.service.ts b/packages/api/cms-api/src/redirects/redirect-target-url.service.ts
new file mode 100644
index 00000000000..eba3c02d88d
--- /dev/null
+++ b/packages/api/cms-api/src/redirects/redirect-target-url.service.ts
@@ -0,0 +1,38 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { Injectable, Scope } from "@nestjs/common";
+
+import { InternalLinkBlock } from "../page-tree/blocks/internal-link.block";
+import { PageTreeReadApiService } from "../page-tree/page-tree-read-api.service";
+import { RedirectsLinkBlock } from "./redirects.module";
+
+export type RedirectTargetUrlServiceInterface = {
+    resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined>;
+};
+
+@Injectable({ scope: Scope.REQUEST })
+export class DefaultRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(private readonly pageTreeReadApi: PageTreeReadApiService) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                return this.pageTreeReadApi.nodePath(targetPageNode);
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
+        } else {
+            if (process.env.NODE_ENV === "development") {
+                throw new Error(`Unsupported custom redirect target: ${target.type}. You need to implement a custom target URL service`);
+            }
+
+            return undefined;
+        }
+    }
+}
diff --git a/packages/api/cms-api/src/redirects/redirects.constants.ts b/packages/api/cms-api/src/redirects/redirects.constants.ts
index 408a0dd5ebd..a46e5985454 100644
--- a/packages/api/cms-api/src/redirects/redirects.constants.ts
+++ b/packages/api/cms-api/src/redirects/redirects.constants.ts
@@ -1 +1,2 @@
 export const REDIRECTS_LINK_BLOCK = "REDIRECTS_LINK_BLOCK";
+export const REDIRECTS_TARGET_URL_SERVICE = "REDIRECTS_TARGET_URL_SERVICE";
diff --git a/packages/api/cms-api/src/redirects/redirects.module.ts b/packages/api/cms-api/src/redirects/redirects.module.ts
index 36d92a37ef7..fed0f4555a8 100644
--- a/packages/api/cms-api/src/redirects/redirects.module.ts
+++ b/packages/api/cms-api/src/redirects/redirects.module.ts
@@ -1,13 +1,14 @@
 import { Block, createOneOfBlock, ExternalLinkBlock, OneOfBlock } from "@comet/blocks-api";
 import { MikroOrmModule } from "@mikro-orm/nestjs";
-import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common";
+import { ClassProvider, DynamicModule, Global, Module, ModuleMetadata, Type, ValueProvider } from "@nestjs/common";
 
 import { DependenciesResolverFactory } from "../dependencies/dependencies.resolver.factory";
 import { InternalLinkBlock, InternalLinkBlockData, InternalLinkBlockInput } from "../page-tree/blocks/internal-link.block";
 import { RedirectInputFactory } from "./dto/redirect-input.factory";
 import { RedirectEntityFactory } from "./entities/redirect-entity.factory";
 import { ImportRedirectsConsole } from "./import-redirects.console";
-import { REDIRECTS_LINK_BLOCK } from "./redirects.constants";
+import { DefaultRedirectTargetUrlService, RedirectTargetUrlServiceInterface } from "./redirect-target-url.service";
+import { REDIRECTS_LINK_BLOCK, REDIRECTS_TARGET_URL_SERVICE } from "./redirects.constants";
 import { createRedirectsResolver } from "./redirects.resolver";
 import { RedirectsService } from "./redirects.service";
 import { RedirectScopeInterface } from "./types";
@@ -18,14 +19,15 @@ export type RedirectsLinkBlock = OneOfBlock<
     CustomTargets & { internal: Block<InternalLinkBlockData, InternalLinkBlockInput>; external: typeof ExternalLinkBlock }
 >;
 
-interface Config {
+interface Config extends Pick<ModuleMetadata, "imports"> {
     customTargets?: CustomTargets;
     Scope?: Type<RedirectScopeInterface>;
+    TargetUrlService?: Type<RedirectTargetUrlServiceInterface>;
 }
 @Global()
 @Module({})
 export class RedirectsModule {
-    static register({ customTargets, Scope }: Config = {}): DynamicModule {
+    static register({ customTargets, Scope, TargetUrlService = DefaultRedirectTargetUrlService, imports }: Config = {}): DynamicModule {
         const linkBlock = createOneOfBlock(
             {
                 supportedBlocks: { internal: InternalLinkBlock, external: ExternalLinkBlock, ...customTargets },
@@ -44,12 +46,24 @@ export class RedirectsModule {
             useValue: linkBlock,
         };
 
+        const targetUrlServiceProvider: ClassProvider<RedirectTargetUrlServiceInterface> = {
+            provide: REDIRECTS_TARGET_URL_SERVICE,
+            useClass: TargetUrlService,
+        };
+
         const mikroOrmModule = MikroOrmModule.forFeature([Redirect]);
 
         return {
             module: RedirectsModule,
-            imports: [mikroOrmModule],
-            providers: [RedirectsResolver, RedirectsDependenciesResolver, RedirectsService, linkBlockProvider, ImportRedirectsConsole],
+            imports: [...(imports ?? []), mikroOrmModule],
+            providers: [
+                RedirectsResolver,
+                RedirectsDependenciesResolver,
+                RedirectsService,
+                linkBlockProvider,
+                ImportRedirectsConsole,
+                targetUrlServiceProvider,
+            ],
             exports: [RedirectsService, REDIRECTS_LINK_BLOCK, mikroOrmModule],
         };
     }
diff --git a/packages/api/cms-api/src/redirects/redirects.resolver.ts b/packages/api/cms-api/src/redirects/redirects.resolver.ts
index fda033f68ab..2d49cb4abcf 100644
--- a/packages/api/cms-api/src/redirects/redirects.resolver.ts
+++ b/packages/api/cms-api/src/redirects/redirects.resolver.ts
@@ -1,7 +1,8 @@
+import { ExtractBlockData } from "@comet/blocks-api";
 import { FilterQuery, FindOptions, wrap } from "@mikro-orm/core";
 import { InjectRepository } from "@mikro-orm/nestjs";
 import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
-import { Type } from "@nestjs/common";
+import { Inject, Type } from "@nestjs/common";
 import { Args, ArgsType, ID, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql";
 
 import { CometValidationException } from "../common/errors/validation.exception";
@@ -17,8 +18,12 @@ import { RedirectInputInterface } from "./dto/redirect-input.factory";
 import { RedirectUpdateActivenessInput } from "./dto/redirect-update-activeness.input";
 import { RedirectsArgsFactory } from "./dto/redirects-args.factory";
 import { RedirectInterface } from "./entities/redirect-entity.factory";
+import { RedirectTargetUrlServiceInterface } from "./redirect-target-url.service";
+import { REDIRECTS_TARGET_URL_SERVICE } from "./redirects.constants";
 import { RedirectSourceTypeValues } from "./redirects.enum";
+import { RedirectsLinkBlock } from "./redirects.module";
 import { RedirectsService } from "./redirects.service";
+import { isEmptyFilter, redirectMatchesFilter } from "./redirects.util";
 import { RedirectScopeInterface } from "./types";
 
 export function createRedirectsResolver({
@@ -59,6 +64,7 @@ export function createRedirectsResolver({
             @InjectRepository("Redirect") private readonly repository: EntityRepository<RedirectInterface>,
             private readonly pageTreeReadApi: PageTreeReadApiService,
             private readonly entityManager: EntityManager,
+            @Inject(REDIRECTS_TARGET_URL_SERVICE) private readonly targetUrlService: RedirectTargetUrlServiceInterface,
         ) {}
 
         @Query(() => [Redirect], { deprecationReason: "Use paginatedRedirects instead. Will be removed in the next version." })
@@ -81,6 +87,77 @@ export function createRedirectsResolver({
 
         @Query(() => PaginatedRedirects)
         async paginatedRedirects(@Args() { scope, search, filter, sort, offset, limit }: PaginatedRedirectsArgs): Promise<PaginatedRedirects> {
+            const needsInMemoryFiltering = search || (filter && !isEmptyFilter(filter));
+
+            if (needsInMemoryFiltering) {
+                const where = {};
+                if (hasNonEmptyScope) {
+                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                    (where as any).scope = scope;
+                }
+
+                const options: FindOptions<RedirectInterface> = {};
+
+                if (sort) {
+                    options.orderBy = sort.map((sortItem) => {
+                        return {
+                            [sortItem.field]: sortItem.direction,
+                        };
+                    });
+                }
+
+                await this.pageTreeReadApi.preloadNodes(scope);
+
+                const allRedirects = await this.repository.find(where, options);
+                const redirects = [];
+
+                const targetUrlCache = new Map<string, string | undefined>();
+
+                for (const redirect of allRedirects) {
+                    let targetUrl: string | undefined;
+
+                    const target = redirect.target as ExtractBlockData<RedirectsLinkBlock>;
+
+                    if (target.activeType) {
+                        const targetBlock = target.attachedBlocks.find((block) => block.type === target.activeType);
+
+                        if (!targetBlock) {
+                            throw new Error(`Can't find target block for redirect '${redirect.id}'`);
+                        }
+
+                        const targetBlockKey = JSON.stringify(targetBlock);
+
+                        if (targetUrlCache.has(targetBlockKey)) {
+                            targetUrl = targetUrlCache.get(targetBlockKey);
+                        } else {
+                            targetUrl = await this.targetUrlService.resolveTargetUrl(targetBlock);
+                            targetUrlCache.set(targetBlockKey, targetUrl);
+                        }
+
+                        if (!targetUrl) {
+                            continue;
+                        }
+                    }
+
+                    let searchMatches = true;
+                    let filterMatches = true;
+
+                    if (search) {
+                        searchMatches = redirect.source.includes(search) || (targetUrl?.includes(search) ?? false);
+                    }
+
+                    if (filter) {
+                        filterMatches = redirectMatchesFilter({ ...redirect, target: targetUrl }, filter);
+                    }
+
+                    if (searchMatches && filterMatches) {
+                        redirects.push(redirect);
+                    }
+                }
+
+                return new PaginatedRedirects(redirects.slice(offset, offset + limit), redirects.length);
+            }
+
             const where = this.redirectService.getFindConditionPaginatedRedirects({ search, filter });
             if (hasNonEmptyScope) {
                 // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/packages/api/cms-api/src/redirects/redirects.util.test.ts b/packages/api/cms-api/src/redirects/redirects.util.test.ts
new file mode 100644
index 00000000000..622a545b765
--- /dev/null
+++ b/packages/api/cms-api/src/redirects/redirects.util.test.ts
@@ -0,0 +1,192 @@
+import { addDays, subDays } from "date-fns";
+
+import { RedirectGenerationType } from "./redirects.enum";
+import { FilterableRedirect, isEmptyFilter, redirectMatchesFilter } from "./redirects.util";
+
+describe("redirectMatchesFilter", () => {
+    it("should match for empty filter", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, {})).toBe(true);
+    });
+
+    it("should match for string filter", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { contains: "arg" } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { source: { startsWith: "/sou" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { startsWith: "/arg" } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { source: { endsWith: "rce" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { endsWith: "get" } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { source: { equal: "/source" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { equal: "/target" } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { source: { notEqual: "/source" } })).toBe(false);
+        expect(redirectMatchesFilter(redirect, { source: { notEqual: "/target" } })).toBe(true);
+
+        expect(redirectMatchesFilter(redirect, { source: { isAnyOf: ["/source", "/target"] } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { isAnyOf: ["/target"] } })).toBe(false);
+    });
+
+    it("should match for boolean filter", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { active: { equal: true } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { active: { equal: false } })).toBe(false);
+    });
+
+    it("should match for date time filter", () => {
+        const today = new Date();
+        const yesterday = subDays(today, 1);
+        const tomorrow = addDays(today, 1);
+
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: yesterday,
+            updatedAt: today,
+        };
+
+        expect(redirectMatchesFilter(redirect, { createdAt: { equal: yesterday } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { createdAt: { equal: today } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { createdAt: { notEqual: today } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { createdAt: { notEqual: yesterday } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { updatedAt: { greaterThan: yesterday } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { updatedAt: { greaterThan: today } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { updatedAt: { greaterThanEqual: today } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { updatedAt: { greaterThanEqual: tomorrow } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { updatedAt: { lowerThan: tomorrow } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { updatedAt: { lowerThan: today } })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { updatedAt: { lowerThanEqual: today } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { updatedAt: { lowerThanEqual: yesterday } })).toBe(false);
+    });
+
+    it("should match for multiple filters", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, active: { equal: true } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, active: { equal: false } })).toBe(false);
+    });
+
+    it("should match for and filters", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, and: [{ active: { equal: true } }] })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, and: [{ active: { equal: false } }] })).toBe(false);
+
+        expect(redirectMatchesFilter(redirect, { and: [] })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { and: [{ source: { contains: "our" } }, { active: { equal: true } }] })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { and: [{ source: { contains: "our" } }, { active: { equal: false } }] })).toBe(false);
+    });
+
+    it("should match for or filters", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, or: [{ active: { equal: true } }] })).toBe(true); // Both match
+        expect(redirectMatchesFilter(redirect, { source: { contains: "our" }, or: [{ active: { equal: false } }] })).toBe(true); // First matches
+        expect(redirectMatchesFilter(redirect, { source: { equal: "/target" }, or: [{ active: { equal: true } }] })).toBe(true); // Second matches
+        expect(redirectMatchesFilter(redirect, { source: { equal: "/target" }, or: [{ active: { equal: false } }] })).toBe(false); // None match
+
+        expect(redirectMatchesFilter(redirect, { or: [] })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { or: [{ source: { contains: "our" } }, { active: { equal: true } }] })).toBe(true); // Both match
+        expect(redirectMatchesFilter(redirect, { or: [{ source: { contains: "our" } }, { active: { equal: false } }] })).toBe(true); // First matches
+        expect(redirectMatchesFilter(redirect, { or: [{ source: { equal: "/target" } }, { active: { equal: true } }] })).toBe(true); // Second matches
+        expect(redirectMatchesFilter(redirect, { or: [{ source: { equal: "/target" } }, { active: { equal: false } }] })).toBe(false); // None match
+    });
+
+    it("should match target URL and pathname", () => {
+        const redirect: FilterableRedirect = {
+            generationType: RedirectGenerationType.manual,
+            source: "/source",
+            target: "https://example.com/target",
+            active: true,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+        };
+
+        expect(redirectMatchesFilter(redirect, { target: { equal: "/target" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { target: { equal: "https://example.com/target" } })).toBe(true);
+
+        expect(redirectMatchesFilter(redirect, { target: { startsWith: "/target" } })).toBe(true);
+        expect(redirectMatchesFilter(redirect, { target: { startsWith: "https://example.com/target" } })).toBe(true);
+    });
+});
+
+describe("isEmptyFilter", () => {
+    it("should return true for empty filter", () => {
+        expect(isEmptyFilter({})).toBe(true);
+    });
+
+    it("should return false for non-empty filter", () => {
+        expect(isEmptyFilter({ source: { contains: "our" } })).toBe(false);
+    });
+
+    it("should return true for empty and-filter", () => {
+        expect(isEmptyFilter({ and: [] })).toBe(true);
+    });
+
+    it("should return false for non-empty and-filter", () => {
+        expect(isEmptyFilter({ and: [{ source: { contains: "our" } }] })).toBe(false);
+    });
+
+    it("should return true for empty or-filter", () => {
+        expect(isEmptyFilter({ or: [] })).toBe(true);
+    });
+
+    it("should return false for non-empty or-filter", () => {
+        expect(isEmptyFilter({ or: [{ source: { contains: "our" } }] })).toBe(false);
+    });
+});
diff --git a/packages/api/cms-api/src/redirects/redirects.util.ts b/packages/api/cms-api/src/redirects/redirects.util.ts
new file mode 100644
index 00000000000..51fef210ece
--- /dev/null
+++ b/packages/api/cms-api/src/redirects/redirects.util.ts
@@ -0,0 +1,124 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        if (redirect.target) {
+            const isAbsoluteUrl = redirect.target.startsWith("http://") || redirect.target.startsWith("https://");
+
+            if (isAbsoluteUrl) {
+                const url = new URL(redirect.target);
+                matches = stringMatchesFilter(redirect.target, filter.target) || stringMatchesFilter(url.pathname, filter.target);
+            } else {
+                matches = stringMatchesFilter(redirect.target, filter.target);
+            }
+        } else {
+            matches = false;
+        }
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    } else if (filter.isAnyOf && filter.isAnyOf.includes(string)) {
+        return true;
+    }
+
+    return false;
+}
+
+function booleanMatchesFilter(boolean: boolean, filter: BooleanFilter) {
+    if (filter.equal && boolean === filter.equal) {
+        return true;
+    }
+
+    return false;
+}
+
+function dateTimeMatchesFilter(date: Date, filter: DateTimeFilter) {
+    if (filter.equal && date.getTime() === filter.equal.getTime()) {
+        return true;
+    } else if (filter.lowerThan && date.getTime() < filter.lowerThan.getTime()) {
+        return true;
+    } else if (filter.greaterThan && date.getTime() > filter.greaterThan.getTime()) {
+        return true;
+    } else if (filter.lowerThanEqual && date.getTime() <= filter.lowerThanEqual.getTime()) {
+        return true;
+    } else if (filter.greaterThanEqual && date.getTime() >= filter.greaterThanEqual.getTime()) {
+        return true;
+    } else if (filter.notEqual && date.getTime() !== filter.notEqual.getTime()) {
+        return true;
+    }
+
+    return false;
+}
+
+export function isEmptyFilter(filter: RedirectFilter): boolean {
+    const filters = Object.keys(filter);
+
+    return (
+        filters.length === 0 ||
+        (filters.length === 1 && filter.and !== undefined && filter.and.length === 0) ||
+        (filters.length === 1 && filter.or !== undefined && filter.or.length === 0)
+    );
+}


Reviews:
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T10:06:05Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T10:07:13Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T10:07:49Z
user: login: nsams
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-18T10:25:51Z
user: login: thomasdax98
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:30:43Z
user: login: nsams
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:45:38Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T14:49:34Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T14:50:19Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T07:40:18Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T07:52:59Z
user: login: fraxachun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T08:26:37Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T10:29:15Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T13:19:34Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T13:20:52Z
user: login: thomasdax98
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T13:40:27Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T13:45:02Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T14:05:58Z
user: login: johnnyomair
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T14:13:29Z
user: login: thomasdax98
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-25T14:23:45Z
user: login: thomasdax98
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T14:27:09Z
user: login: nsams
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T14:52:26Z
user: login: thomasdax98
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T16:21:37Z
user: login: fraxachun
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:09:19Z

Review comments:
diff_hunk: @@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
path: demo/api/src/app.module.ts
user: login: johnnyomair
body: Here the module imports the dependencies of the target URL service. This doesn't seem correct to me. @fraxachun you did this differently for the user permissions module, could you share some insights?
created_at: 2025-03-18T10:06:05Z
author_association: COLLABORATOR
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: johnnyomair
body: @thomasdax98 where should we place the docs? As separate feature, or as part of the page tree?
created_at: 2025-03-18T10:07:13Z
author_association: COLLABORATOR
diff_hunk: @@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
path: demo/api/src/app.module.ts
user: login: johnnyomair
body: See change here: https://github.com/vivid-planet/comet/pull/3624/files#diff-b35306ba1fd418ff77fbeaf05bd6c6546bdb3f0dd965b9c70bf5813cfb9df007R22.
created_at: 2025-03-18T10:07:49Z
author_association: COLLABORATOR
diff_hunk: @@ -18,14 +19,16 @@ export type RedirectsLinkBlock = OneOfBlock<
     CustomTargets & { internal: Block<InternalLinkBlockData, InternalLinkBlockInput>; external: typeof ExternalLinkBlock }
 >;
 
-interface Config {
+// TODO implement registerAsync instead of imports option?
+interface Config extends Pick<ModuleMetadata, "imports"> {
     customTargets?: CustomTargets;
     Scope?: Type<RedirectScopeInterface>;
+    targetUrlService?: Type<RedirectTargetUrlServiceInterface>;
path: packages/api/cms-api/src/redirects/redirects.module.ts
user: login: thomasdax98
body: Shouldn't this be uppercase since it's a class (like Scope directly above)
created_at: 2025-03-18T14:02:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        matches = redirect.target ? stringMatchesFilter(redirect.target, filter.target) : false;
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    }
path: packages/api/cms-api/src/redirects/redirects.util.ts
user: login: thomasdax98
body: I just tested the filters. It seems like the "equal" and "startsWith" filters only work correctly for absolute URLs but are broken for relative URLs:

Absolute:

https://github.com/user-attachments/assets/a358b75d-a131-42a7-ad72-9f54072b0732


Relative:


https://github.com/user-attachments/assets/b0a0f586-f8de-4c61-bba5-fd3571020653

Also, isEmpty seems to return all redirects but it probably should return none since every redirect needs a target (though it doesn't really make sense for the redirect anyway). And isAnyOf is completely broken and crashes the admin
created_at: 2025-03-18T14:15:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+---
+title: Redirects
+---
+
+## Custom redirect targets
+
+Custom redirect targets (e.g., a news site) are supported via the `customTargets`-option.
+The option expects a block:
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    // highlight-next-line
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: thomasdax98
body: That's cool, I didn't know that
created_at: 2025-03-18T14:19:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: thomasdax98
body: We should add this to the starter later on
created_at: 2025-03-18T14:20:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+---
+title: Redirects
+---
+
+## Custom redirect targets
+
+Custom redirect targets (e.g., a news site) are supported via the `customTargets`-option.
+The option expects a block:
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+```tsx title=App.tsx
+const RedirectsPage = createRedirectsPage({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+A custom target URL service is required to resolve the target URLs:
+
+```ts title=my-redirect-target-url.service.ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(
+        target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number],
+    ): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>)
+                .targetPageId;
+
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                return this.pageTreeReadApi.nodePath(targetPageNode);
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: thomasdax98
body: Shouldn't this example also include the baseUrl to support absolute URLs?
created_at: 2025-03-18T14:21:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: nsams
body: for starter the default service is fine. The custom service is only needed for custom application specific targets **AND** when someone complains that the search isn't working.
created_at: 2025-03-18T14:45:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        matches = redirect.target ? stringMatchesFilter(redirect.target, filter.target) : false;
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    }
path: packages/api/cms-api/src/redirects/redirects.util.ts
user: login: johnnyomair
body: > I just tested the filters. It seems like the "equal" and "startsWith" filters only work correctly for absolute URLs but are broken for relative URLs

That's because the target url _is_ absolute due to the custom redirect target url service. Should we add support for both absolute and relative urls to the target filter?
created_at: 2025-03-18T14:49:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+---
+title: Redirects
+---
+
+## Custom redirect targets
+
+Custom redirect targets (e.g., a news site) are supported via the `customTargets`-option.
+The option expects a block:
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+```tsx title=App.tsx
+const RedirectsPage = createRedirectsPage({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+A custom target URL service is required to resolve the target URLs:
+
+```ts title=my-redirect-target-url.service.ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(
+        target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number],
+    ): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>)
+                .targetPageId;
+
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                return this.pageTreeReadApi.nodePath(targetPageNode);
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: johnnyomair
body: I wanted to keep the example in the docs as slim as possible, but I can add that.
created_at: 2025-03-18T14:50:19Z
author_association: COLLABORATOR
diff_hunk: @@ -18,14 +19,16 @@ export type RedirectsLinkBlock = OneOfBlock<
     CustomTargets & { internal: Block<InternalLinkBlockData, InternalLinkBlockInput>; external: typeof ExternalLinkBlock }
 >;
 
-interface Config {
+// TODO implement registerAsync instead of imports option?
+interface Config extends Pick<ModuleMetadata, "imports"> {
     customTargets?: CustomTargets;
     Scope?: Type<RedirectScopeInterface>;
+    targetUrlService?: Type<RedirectTargetUrlServiceInterface>;
path: packages/api/cms-api/src/redirects/redirects.module.ts
user: login: johnnyomair
body: We're inconsistent here: for the ContentGenerationModule it's uppercase, for the UserPermissionsModule it's lowercase. We should harmonize that later. Which casing do you prefer?
created_at: 2025-03-19T07:40:18Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,72 @@
+---
+title: Redirects
+---
+
+## Custom redirect targets
+
+Custom redirect targets (e.g., a news site) are supported via the `customTargets`-option.
+The option expects a block:
+
+```ts title=app.module.ts
+RedirectsModule.register({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+```tsx title=App.tsx
+const RedirectsPage = createRedirectsPage({
+    // highlight-next-line
+    customTargets: { news: NewsLinkBlock },
+});
+```
+
+A custom target URL service is required to resolve the target URLs:
+
+```ts title=my-redirect-target-url.service.ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(
+        target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number],
+    ): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>)
+                .targetPageId;
+
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                return this.pageTreeReadApi.nodePath(targetPageNode);
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: johnnyomair
body: Added a full example in a collapsible: 10ed0e428333c991a1c8990c5a9c8002b91eedc3
created_at: 2025-03-19T07:52:59Z
author_association: COLLABORATOR
diff_hunk: @@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
path: demo/api/src/app.module.ts
user: login: fraxachun
body: Importing News shouldn't be necessary. [useFactory](https://docs.nestjs.com/fundamentals/custom-providers#factory-providers-usefactory) might be the correct approach here as it provides an `inject` property which should resolve the dependency injection. Not sure though if this involves a breaking change - I'm really not an expert for this topic.
created_at: 2025-03-19T08:26:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
path: demo/api/src/app.module.ts
user: login: johnnyomair
body: I tried this here: 5b9f995a20968e9b2fd7dd6abf3d94a52c337f23. Now it requires an additional module that exports the service, which doesn't seem right. Maybe we can discuss this today.
created_at: 2025-03-19T10:29:15Z
author_association: COLLABORATOR
diff_hunk: @@ -125,7 +128,12 @@ export class AppModule {
                     sitePreviewSecret: config.sitePreviewSecret,
                 }),
 
-                RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
+                RedirectsModule.register({
+                    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
path: demo/api/src/app.module.ts
user: login: johnnyomair
body: Discussed this and decided that it's okay that the redirects module provides the service. [Reverted the change](https://github.com/vivid-planet/comet/pull/3624/commits/5a4ec2fa8b1d1dbc3d7b8a6678bccf42dd8cd06f) and [removed the TODO](https://github.com/vivid-planet/comet/pull/3624/commits/4acbdcc09ecbd1b39e61cc4c89a3918c1b0db9e8).
created_at: 2025-03-25T13:19:34Z
author_association: COLLABORATOR
diff_hunk: @@ -18,14 +19,16 @@ export type RedirectsLinkBlock = OneOfBlock<
     CustomTargets & { internal: Block<InternalLinkBlockData, InternalLinkBlockInput>; external: typeof ExternalLinkBlock }
 >;
 
-interface Config {
+// TODO implement registerAsync instead of imports option?
+interface Config extends Pick<ModuleMetadata, "imports"> {
     customTargets?: CustomTargets;
     Scope?: Type<RedirectScopeInterface>;
+    targetUrlService?: Type<RedirectTargetUrlServiceInterface>;
path: packages/api/cms-api/src/redirects/redirects.module.ts
user: login: johnnyomair
body: Discussed this and changed it to uppercase: https://github.com/vivid-planet/comet/pull/3624/commits/6cd7b4c66302d1e2e112a923320bc7e4b8ae3a07.
created_at: 2025-03-25T13:20:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: thomasdax98
body: > The custom service is only needed for custom application specific targets

Which we have in basically every project

> **AND** when someone complains that the search isn't working.

The starter has the language prefix, meaning the default service also won't work correctly in starter. And the default service has no out-of-the-box support for absolute URLs. 

From what I see the default service will realistically never be enough. The only purpose of the default is to ensure backwards compatibility.
created_at: 2025-03-25T13:32:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        matches = redirect.target ? stringMatchesFilter(redirect.target, filter.target) : false;
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    }
path: packages/api/cms-api/src/redirects/redirects.util.ts
user: login: thomasdax98
body: In my opinion supporting absolute URLs is only a benefit if absolute _and_ relative are supported
created_at: 2025-03-25T13:33:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+---
+"@comet/cms-admin": minor
+"@comet/cms-api": minor
+---
+
+Add support for searching/filtering redirects by target
+
+Add a custom target URL service to resolve the URLs of custom redirect targets:
+
+```ts
+@Injectable({ scope: Scope.REQUEST })
+export class MyRedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor() {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        // Your custom logic here
+    }
+}
+```
+
+```diff
+RedirectsModule.register({
+    imports: [MikroOrmModule.forFeature([News]), PredefinedPagesModule],
+    customTargets: { news: NewsLinkBlock },
+    Scope: RedirectScope,
++   targetUrlService: MyRedirectTargetUrlService,
path: .changeset/warm-jeans-cheer.md
user: login: thomasdax98
body: ```suggestion
+   TargetUrlService: MyRedirectTargetUrlService,
```
created_at: 2025-03-25T13:34:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        matches = redirect.target ? stringMatchesFilter(redirect.target, filter.target) : false;
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    }
path: packages/api/cms-api/src/redirects/redirects.util.ts
user: login: thomasdax98
body: Can we support both by allowing to return a string or an array? (in a follow-up PR)
created_at: 2025-03-25T13:36:49Z
author_association: MEMBER
path: docs/docs/3-features-modules/10-redirects/index.md
user: login: thomasdax98
body: I think it's fine here
created_at: 2025-03-25T13:37:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: johnnyomair
body: > From what I see the default service will realistically never be enough. The only purpose of the default is to ensure backwards compatibility.

The we probably should drop it in next.
created_at: 2025-03-25T13:45:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,111 @@
+import { BooleanFilter } from "../common/filter/boolean.filter";
+import { DateTimeFilter } from "../common/filter/date-time.filter";
+import { StringFilter } from "../common/filter/string.filter";
+import { RedirectFilter } from "./dto/redirects.filter";
+import { RedirectInterface } from "./entities/redirect-entity.factory";
+
+export type FilterableRedirect = Pick<RedirectInterface, "generationType" | "source" | "active" | "createdAt" | "updatedAt"> & { target?: string };
+
+export function redirectMatchesFilter(redirect: FilterableRedirect, filter: RedirectFilter): boolean {
+    let matches: boolean | undefined;
+
+    if (isEmptyFilter(filter)) {
+        matches = true;
+    }
+
+    if (filter.generationType) {
+        matches = stringMatchesFilter(redirect.generationType, filter.generationType);
+    }
+
+    if (filter.source) {
+        matches = stringMatchesFilter(redirect.source, filter.source);
+    }
+
+    if (filter.target) {
+        matches = redirect.target ? stringMatchesFilter(redirect.target, filter.target) : false;
+    }
+
+    if (filter.active) {
+        matches = booleanMatchesFilter(redirect.active, filter.active);
+    }
+
+    if (filter.createdAt) {
+        matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt);
+    }
+
+    if (filter.updatedAt) {
+        matches = dateTimeMatchesFilter(redirect.updatedAt, filter.updatedAt);
+    }
+
+    if (filter.and) {
+        if (matches === undefined) {
+            matches = filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        } else {
+            matches = matches && filter.and.every((subFilter) => redirectMatchesFilter(redirect, subFilter));
+        }
+    }
+
+    if (filter.or) {
+        if (matches !== true) {
+            if (filter.or.length > 0) {
+                matches = filter.or.some((subFilter) => redirectMatchesFilter(redirect, subFilter));
+            } else {
+                matches = true;
+            }
+        }
+    }
+
+    return matches ?? false;
+}
+
+function stringMatchesFilter(string: string, filter: StringFilter) {
+    if (filter.contains && string.includes(filter.contains)) {
+        return true;
+    } else if (filter.startsWith && string.startsWith(filter.startsWith)) {
+        return true;
+    } else if (filter.endsWith && string.endsWith(filter.endsWith)) {
+        return true;
+    } else if (filter.equal && string === filter.equal) {
+        return true;
+    } else if (filter.notEqual && string !== filter.notEqual) {
+        return true;
+    }
path: packages/api/cms-api/src/redirects/redirects.util.ts
user: login: johnnyomair
body: Added support here: https://github.com/vivid-planet/comet/pull/3624/commits/fa47c9393ab2973e4f9a749723316782959aec13
created_at: 2025-03-25T14:05:57Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: johnnyomair
body: > Which we have in basically every project

We have ~40 projects with redirects. 8 of them use custom targets.

> The starter has the language prefix, meaning the default service also won't work correctly in starter. And the default service has no out-of-the-box support for absolute URLs.

It's still possible to search for the node path though, which will presumably be sufficient for most of our customers.
created_at: 2025-03-25T14:13:29Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,68 @@
+import { ExternalLinkBlock, ExtractBlockData } from "@comet/blocks-api";
+import { InternalLinkBlock, PageTreeReadApiService, RedirectsLinkBlock, RedirectTargetUrlServiceInterface } from "@comet/cms-api";
+import { InjectRepository } from "@mikro-orm/nestjs";
+import { EntityRepository } from "@mikro-orm/postgresql";
+import { Inject, Injectable, Scope } from "@nestjs/common";
+import { Config } from "@src/config/config";
+import { CONFIG } from "@src/config/config.module";
+import { PredefinedPageType } from "@src/documents/predefined-pages/entities/predefined-page.entity";
+import { PredefinedPagesService } from "@src/documents/predefined-pages/predefined-pages.service";
+import { NewsLinkBlock } from "@src/news/blocks/news-link.block";
+import { News } from "@src/news/entities/news.entity";
+import { type PageTreeNodeScope } from "@src/page-tree/dto/page-tree-node-scope";
+
+@Injectable({ scope: Scope.REQUEST })
+export class RedirectTargetUrlService implements RedirectTargetUrlServiceInterface {
+    constructor(
+        private readonly pageTreeReadApi: PageTreeReadApiService,
+        @Inject(CONFIG) private readonly config: Config,
+        @InjectRepository(News) private readonly newsRepository: EntityRepository<News>,
+        private readonly predefinedPagesService: PredefinedPagesService,
+    ) {}
+
+    async resolveTargetUrl(target: ExtractBlockData<RedirectsLinkBlock>["attachedBlocks"][number]): Promise<string | undefined> {
+        if (target.type === "internal") {
+            const targetPageId = (target.props as ExtractBlockData<typeof InternalLinkBlock>).targetPageId;
+            if (targetPageId) {
+                const targetPageNode = await this.pageTreeReadApi.getNode(targetPageId);
+
+                if (!targetPageNode) {
+                    return undefined;
+                }
+
+                const scope = targetPageNode.scope as PageTreeNodeScope;
+                const baseUrl = this.getSiteUrl(scope);
+                const path = await this.pageTreeReadApi.nodePath(targetPageNode);
+
+                return `${baseUrl}/${scope.language}${path}`;
+            }
+        } else if (target.type === "external") {
+            return (target.props as ExtractBlockData<typeof ExternalLinkBlock>).targetUrl;
path: demo/api/src/redirects/redirect-target-url.service.ts
user: login: thomasdax98
body: Okay. Then let's leave it for now
created_at: 2025-03-25T14:27:09Z
author_association: MEMBER

PR comments:
user: login: johnnyomair
created_at: 2025-03-11T12:52:09Z
body: The quick search for relative paths currently matches all targets that include the path somewhere:

<img width="858" alt="quick search" src="https://github.com/user-attachments/assets/2e4abd5f-2405-4ffb-85c7-d3838cc722dd" />

Is this the desired behavior? Otherwise we need to change this to `startsWith` if search starts with a "/".
author_association: COLLABORATOR
user: login: johnnyomair
created_at: 2025-03-17T07:46:02Z
body: @nsams as discussed, I checked the results of my cache once more: I removed the cache and checked if all page tree node operations are using preloaded nodes, which they are. Still, without the cache, the average response time increased by ~100ms. I therefore decided to keep the cache and moved it to the resolver: 2a1e053b5b42a2e602aa9fa286c132e4c7c00d8c.
author_association: COLLABORATOR