Custom CDN

By default, files are uploaded to Stream's CDN, but you can also use your own CDN. The following components are available to customize the CDN behavior:

  • CDN Storage: Provide a custom CDNStorage to upload files to your own CDN or storage service.
  • CDN Requester: Provide a custom CDNRequester to intercept cdn reads (signed URLs, auth headers, custom hosts).

CDN Storage

To use a custom CDN storage service (such as AWS S3, Firebase Storage, or your own backend), implement the CDNStorage protocol and configure it on ChatClientConfig.

The CDNStorage protocol defines the contract for uploading and deleting files:

public protocol CDNStorage: Sendable {
    func uploadAttachment(
        _ attachment: AnyChatMessageAttachment,
        options: AttachmentUploadOptions,
        completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
    )

    func uploadAttachment(
        localUrl: URL,
        options: AttachmentUploadOptions,
        completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
    )

    func deleteAttachment(
        remoteUrl: URL,
        options: AttachmentDeleteOptions,
        completion: @escaping @Sendable (Error?) -> Void
    )
}
  • uploadAttachment(_:options:completion:) is called when uploading message attachments. Upload progress is reported through options.progress.
  • uploadAttachment(localUrl:options:completion:) is used for standalone file uploads (not tied to a message).
  • deleteAttachment(remoteUrl:options:completion:) deletes a previously uploaded file from the CDN.

Custom CDNStorage

In order to create a custom CDNStorage, you need to implement the CDNStorage protocol, here is an example of a custom CDNStorage implementation:

final class CustomStorage: CDNStorage {
    let uploadApi: YourUploadApi

    init(uploadApi: YourUploadApi) {
        self.uploadApi = uploadApi
    }

    func uploadAttachment(
        _ attachment: AnyChatMessageAttachment,
        options: AttachmentUploadOptions,
        completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
    ) {
        guard let uploadingState = attachment.uploadingState,
              let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
            completion(.failure(ClientError("Failed to upload")))
            return
        }

        uploadApi.upload(data: fileData, progress: options.progress) { result in
            switch result {
            case let .success(response):
                completion(.success(UploadedFile(
                    fileURL: response.url,
                    thumbnailURL: response.thumbnailURL
                )))
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }

    func uploadAttachment(
        localUrl: URL,
        options: AttachmentUploadOptions,
        completion: @escaping @Sendable (Result<UploadedFile, Error>) -> Void
    ) {
        guard let fileData = try? Data(contentsOf: localUrl) else {
            completion(.failure(ClientError("Failed to read file")))
            return
        }

        uploadApi.upload(data: fileData, progress: options.progress) { result in
            completion(result.map { UploadedFile(fileURL: $0.url, thumbnailURL: $0.thumbnailURL) })
        }
    }

    func deleteAttachment(
        remoteUrl: URL,
        options: AttachmentDeleteOptions,
        completion: @escaping @Sendable (Error?) -> Void
    ) {
        uploadApi.delete(url: remoteUrl, completion: completion)
    }
}

Then, you need to create an instance of your custom storage and pass it to ChatClientConfig:

var config = ChatClientConfig(apiKeyString: apiKeyString)
config.cdnStorage = CustomStorage(uploadApi: YourUploadApi())

CDN Requester

If your CDN requires signed URLs, authentication tokens, or custom headers when loading files and images, implement the CDNRequester protocol. This provides a single configuration point that covers all media loading paths in the SDK:

  • Images loaded via the MediaLoader (avatars, image attachments, link previews)
  • Video previews generated by the MediaLoader (video attachment thumbnails)
  • Videos played via AVPlayer (video attachments)
  • File downloads and document previews

The CDNRequester protocol defines two methods, each returning a CDNRequest:

public protocol CDNRequester: Sendable {
    func imageRequest(
        for url: URL,
        options: ImageRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    )

    func fileRequest(
        for url: URL,
        options: FileRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    )
}
  • imageRequest is called for every image URL (avatar loading, image attachment download, link previews). The options parameter carries optional CDNImageResize parameters for server-side resizing.
  • fileRequest is called for non-image files (video/audio playback, file download).

Both methods use completion handlers to support asynchronous operations such as fetching pre-signed URLs from a remote server.

CDNRequest

CDNRequest holds the (potentially rewritten) URL, optional headers, and an optional caching key:

public struct CDNRequest: Sendable {
    public var url: URL
    public var headers: [String: String]?
    public var cachingKey: String?
}

The cachingKey controls cache behaviour. For example, a CDN with signed URLs can strip the token from the key so the same image is cached regardless of token rotation.

Pre-Signed URLs

If your CDN serves files behind pre-signed URLs that must be fetched from a server, implement CDNRequester directly:

final class PreSignedCDNRequester: CDNRequester {
    let signingService: SigningService

    func imageRequest(
        for url: URL,
        options: ImageRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        signingService.fetchSignedURL(for: url) { result in
            switch result {
            case let .success(signedURL):
                completion(.success(CDNRequest(
                    url: signedURL,
                    headers: ["Authorization": "Bearer \(self.signingService.token)"],
                    cachingKey: url.absoluteString
                )))
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }

    func fileRequest(
        for url: URL,
        options: FileRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        signingService.fetchSignedURL(for: url) { result in
            completion(result.map { CDNRequest(url: $0) })
        }
    }
}

Custom Authentication Headers

If you only need to add headers without URL rewriting, implement CDNRequester and delegate to a StreamCDNRequester instance to preserve the default resize and caching behaviour:

final class AuthenticatedCDNRequester: CDNRequester {
    let streamRequester = StreamCDNRequester()
    let tokenProvider: () -> String

    init(tokenProvider: @escaping () -> String) {
        self.tokenProvider = tokenProvider
    }

    func imageRequest(
        for url: URL,
        options: ImageRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        streamRequester.imageRequest(for: url, options: options) { result in
            guard var request = try? result.get() else {
                completion(result)
                return
            }
            request.headers = ["Authorization": "Bearer \(self.tokenProvider())"]
            completion(.success(request))
        }
    }

    func fileRequest(
        for url: URL,
        options: FileRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        streamRequester.fileRequest(for: url, options: options) { result in
            guard var request = try? result.get() else {
                completion(result)
                return
            }
            request.headers = ["Authorization": "Bearer \(self.tokenProvider())"]
            completion(.success(request))
        }
    }
}

Custom Image Resizing

If your CDN supports server-side image resizing, override imageRequest to append your CDN's resize query parameters:

final class CustomResizeCDNRequester: CDNRequester {
    private let cdnHost = "your-cdn.com"

    func imageRequest(
        for url: URL,
        options: ImageRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host, host.contains(cdnHost),
              let resize = options.resize else {
            completion(.success(CDNRequest(url: url)))
            return
        }

        let scale = UIScreen.main.scale
        var queryItems = components.queryItems ?? []
        queryItems.append(URLQueryItem(
            name: "width",
            value: String(format: "%.0f", resize.width * scale)
        ))
        queryItems.append(URLQueryItem(
            name: "height",
            value: String(format: "%.0f", resize.height * scale)
        ))

        components.queryItems = queryItems
        let cachingKey = buildCachingKey(for: url)
        completion(.success(CDNRequest(
            url: components.url ?? url,
            cachingKey: cachingKey
        )))
    }

    func fileRequest(
        for url: URL,
        options: FileRequestOptions,
        completion: @escaping (Result<CDNRequest, Error>) -> Void
    ) {
        completion(.success(CDNRequest(url: url)))
    }

    private func buildCachingKey(for url: URL) -> String {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return url.absoluteString
        }

        let persistedParameters = ["width", "height"]
        let filtered = components.queryItems?.filter {
            persistedParameters.contains($0.name)
        }
        components.queryItems = filtered?.isEmpty == true ? nil : filtered
        return components.string ?? url.absoluteString
    }
}

Configuration

The CDNRequester is configured as a dependency of StreamMediaLoader. This ensures it's set in a single place and automatically applies to all media loading, file downloads, and URL resolution throughout the SDK.

UIKit:

let customRequester = CustomCDNRequester(signingService: myService)
Components.default.mediaLoader = StreamMediaLoader(downloader: StreamImageDownloader(), cdnRequester: customRequester)

SwiftUI:

let customRequester = CustomCDNRequester(signingService: myService)
let loader = StreamMediaLoader(downloader: StreamImageDownloader(), cdnRequester: customRequester)
let utils = Utils(mediaLoader: loader)
let streamChat = StreamChat(chatClient: chatClient, utils: utils)

Custom MediaLoader

If you need full control over how images and video previews are loaded (for example, to use a different image loading library or custom caching strategy), you can provide a custom MediaLoader.

The MediaLoader protocol defines the contract for loading images, video content, and resolving file URLs:

public protocol MediaLoader: AnyObject, Sendable {
    func loadImage(
        url: URL?,
        options: ImageLoadOptions,
        completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void
    )

    func loadVideoAsset(
        at url: URL,
        options: VideoLoadOptions,
        completion: @escaping @MainActor (Result<MediaLoaderVideoAsset, Error>) -> Void
    )

    func loadVideoPreview(
        with attachment: ChatMessageVideoAttachment,
        options: VideoLoadOptions,
        completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
    )

    func loadVideoPreview(
        at url: URL,
        options: VideoLoadOptions,
        completion: @escaping @MainActor (Result<MediaLoaderVideoPreview, Error>) -> Void
    )

    func loadFileRequest(
        for url: URL,
        options: DownloadFileRequestOptions,
        completion: @escaping @MainActor (Result<MediaLoaderFileRequest, Error>) -> Void
    )
}

All methods are required when creating a custom loader from scratch. The CDN requester is a constructor dependency of StreamMediaLoader, so options structs like ImageLoadOptions and VideoLoadOptions no longer carry a CDN requester. The loadVideoPreview(with:) method receives the full video attachment, allowing implementations to use the attachment's thumbnailURL when available and fall back to generating a preview frame from the video URL. The loadFileRequest method resolves a file URL through the CDN and returns a ready-to-use URLRequest with any required HTTP headers, which can be passed to downloadAttachment or loaded directly in a web view.

Subclassing StreamMediaLoader

If you only need to customize part of the media loading pipeline, you can subclass StreamMediaLoader and override just the methods you need. All other methods continue to use the default implementation:

class AnalyticsMediaLoader: StreamMediaLoader {
    let analytics: AnalyticsService

    init(analytics: AnalyticsService, cdnRequester: CDNRequester = StreamCDNRequester()) {
        self.analytics = analytics
        super.init(cdnRequester: cdnRequester, downloader: StreamImageDownloader())
    }

    override func loadImage(
        url: URL?,
        options: ImageLoadOptions,
        completion: @escaping @MainActor (Result<MediaLoaderImage, Error>) -> Void
    ) {
        let startTime = CFAbsoluteTimeGetCurrent()
        super.loadImage(url: url, options: options) { [analytics] result in
            let duration = CFAbsoluteTimeGetCurrent() - startTime
            analytics.track("image_load", properties: [
                "url": url?.absoluteString ?? "nil",
                "duration": duration,
                "success": (try? result.get()) != nil
            ])
            completion(result)
        }
    }
}

Configuration

To use a custom MediaLoader:

// UIKit
Components.default.mediaLoader = CustomMediaLoader()

// SwiftUI
let utils = Utils(mediaLoader: CustomMediaLoader())
let streamChat = StreamChat(chatClient: chatClient, utils: utils)

Migration from v4

v4 Typev5 Replacement
CDNClientCDNStorage
AttachmentUploaderCDNStorage + UploadedAttachmentPostProcessor
ChatClientConfig.customCDNClientChatClientConfig.cdnStorage
ChatClientConfig.customAttachmentUploaderChatClientConfig.cdnStorage
ImageCDNCDNRequester
StreamImageCDNStreamCDNRequester
FileCDNCDNRequester (via fileRequest)
ImageLoading / NukeImageLoaderMediaLoader / StreamMediaLoader
VideoLoading / VideoPreviewLoaderMediaLoader (via loadVideoPreview)
StreamVideoLoader / DefaultVideoPreviewLoaderStreamMediaLoader
Components.imageCDNStreamMediaLoader(cdnRequester:)
Components.imageLoader + Components.videoLoaderComponents.mediaLoader
Utils.imageCDN / Utils.fileCDNStreamMediaLoader(cdnRequester:)
Utils.imageLoader + Utils.videoLoaderUtils.mediaLoader