diff --git a/WordPress/Classes/Services/PostRepository.swift b/WordPress/Classes/Services/PostRepository.swift
index c09d99d12b20..05f5cde3c7fc 100644
--- a/WordPress/Classes/Services/PostRepository.swift
+++ b/WordPress/Classes/Services/PostRepository.swift
@@ -228,3 +228,205 @@ final class PostRepository {
     }
 
 }
+
+// MARK: - Posts/Pages List
+
+private final class PostRepositoryPostsSerivceRemoteOptions: NSObject, PostServiceRemoteOptions {
+    struct Options {
+        var statuses: [String]?
+        var number: Int = 100
+        var offset: Int = 0
+        var order: PostServiceResultsOrder = .descending
+        var orderBy: PostServiceResultsOrdering = .byDate
+        var authorID: NSNumber?
+        var search: String?
+        var meta: String? = "autosave"
+    }
+
+    var options: Options
+
+    init(options: Options) {
+        self.options = options
+    }
+
+    func statuses() -> [String]? {
+        options.statuses
+    }
+
+    func number() -> NSNumber {
+        NSNumber(value: options.number)
+    }
+
+    func offset() -> NSNumber {
+        NSNumber(value: options.offset)
+    }
+
+    func order() -> PostServiceResultsOrder {
+        options.order
+    }
+
+    func orderBy() -> PostServiceResultsOrdering {
+        options.orderBy
+    }
+
+    func authorID() -> NSNumber? {
+        options.authorID
+    }
+
+    func search() -> String? {
+        options.search
+    }
+
+    func meta() -> String? {
+        options.meta
+    }
+}
+
+private extension PostServiceRemote {
+
+    func getPosts(ofType type: String, options: PostRepositoryPostsSerivceRemoteOptions) async throws -> [RemotePost] {
+        try await withCheckedThrowingContinuation { continuation in
+            self.getPostsOfType(type, options: self.dictionary(with: options), success: {
+                continuation.resume(returning: $0 ?? [])
+            }, failure: {
+                continuation.resume(throwing: $0!)
+            })
+        }
+    }
+}
+
+extension PostRepository {
+
+    /// Fetch posts or pages from the given site page by page. All fetched posts are saved to the local database.
+    ///
+    /// - Parameters:
+    ///   - type: `Post.self` and `Page.self` are the only acceptable types.
+    ///   - statuses: Filter posts or pages with given status.
+    ///   - authorUserID: Filter posts or pages that are authored by given user.
+    ///   - offset: The position of the paginated request. Pass 0 for the first page and count of already fetched results for following pages.
+    ///   - number: Number of posts or pages should be fetched.
+    ///   - blogID: The blog from which to fetch posts or pages
+    /// - Returns: Object identifiers of the fetched posts.
+    /// - SeeAlso: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/
+    func paginate<P: AbstractPost>(
+        type: P.Type = P.self,
+        statuses: [BasePost.Status],
+        authorUserID: NSNumber? = nil,
+        offset: Int,
+        number: Int,
+        in blogID: TaggedManagedObjectID<Blog>
+    ) async throws -> [TaggedManagedObjectID<P>] {
+        try await fetch(
+            type: type,
+            statuses: statuses,
+            authorUserID: authorUserID,
+            range: offset..<(offset + max(number, 0)),
+            orderBy: .byDate,
+            descending: true,
+            // Only delete other local posts if the current call is the first pagination request.
+            deleteOtherLocalPosts: offset == 0,
+            in: blogID
+        )
+    }
+
+    /// Search posts or pages in the given site. All fetched posts are saved to the local database.
+    ///
+    /// - Parameters:
+    ///   - type: `Post.self` and `Page.self` are the only acceptable types.
+    ///   - input: The text input from user. Or `nil` for searching all posts or pages.
+    ///   - statuses: Filter posts or pages with given status.
+    ///   - authorUserID: Filter posts or pages that are authored by given user.
+    ///   - limit: Number of posts or pages should be fetched.
+    ///   - orderBy: The property by which to sort posts or pages.
+    ///   - descending: Whether to sort the results in descending order.
+    ///   - blogID: The blog from which to search posts or pages
+    /// - Returns: Object identifiers of the search result.
+    /// - SeeAlso: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/posts/
+    func search<P: AbstractPost>(
+        type: P.Type = P.self,
+        input: String?,
+        statuses: [BasePost.Status],
+        authorUserID: NSNumber? = nil,
+        limit: Int,
+        orderBy: PostServiceResultsOrdering,
+        descending: Bool,
+        in blogID: TaggedManagedObjectID<Blog>
+    ) async throws -> [TaggedManagedObjectID<P>] {
+        try await fetch(
+            type: type,
+            searchInput: input,
+            statuses: statuses,
+            authorUserID: authorUserID,
+            range: 0..<max(limit, 0),
+            orderBy: orderBy,
+            descending: descending,
+            deleteOtherLocalPosts: false,
+            in: blogID
+        )
+    }
+
+    private func fetch<P: AbstractPost>(
+        type: P.Type,
+        searchInput: String? = nil,
+        statuses: [BasePost.Status]?,
+        authorUserID: NSNumber?,
+        range: Range<Int>,
+        orderBy: PostServiceResultsOrdering = .byDate,
+        descending: Bool = true,
+        deleteOtherLocalPosts: Bool,
+        in blogID: TaggedManagedObjectID<Blog>
+    ) async throws -> [TaggedManagedObjectID<P>] {
+        assert(type == Post.self || type == Page.self, "Only support fetching Post or Page")
+        assert(range.lowerBound >= 0)
+
+        let postType: String
+        if type == Post.self {
+            postType = "post"
+        } else if type == Page.self {
+            postType = "page"
+        } else {
+            // There is an assertion above to ensure the app doesn't fall into this case.
+            return []
+        }
+
+        let remote = try await coreDataStack.performQuery { [remoteFactory] context in
+            let blog = try context.existingObject(with: blogID)
+            return remoteFactory.forBlog(blog)
+        }
+        guard let remote else {
+            throw PostRepository.Error.remoteAPIUnavailable
+        }
+
+        let options = PostRepositoryPostsSerivceRemoteOptions(options: .init(
+            statuses: statuses?.strings,
+            number: range.count,
+            offset: range.lowerBound,
+            order: descending ? .descending : .ascending,
+            orderBy: orderBy,
+            authorID: authorUserID,
+            search: searchInput
+        ))
+        let remotePosts = try await remote.getPosts(ofType: postType, options: options)
+
+        let updatedPosts = try await coreDataStack.performAndSave { context in
+            let updatedPosts = PostHelper.merge(
+                remotePosts,
+                ofType: postType,
+                withStatuses: statuses?.strings,
+                byAuthor: authorUserID,
+                for: try context.existingObject(with: blogID),
+                purgeExisting: deleteOtherLocalPosts,
+                in: context
+            )
+            return updatedPosts.map {
+                guard let post = $0 as? P else {
+                    fatalError("Expecting a \(postType) as \(type), but got \($0)")
+                }
+                return TaggedManagedObjectID(post)
+            }
+        }
+
+        return updatedPosts
+    }
+
+}
diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj
index 1a43ec89c33b..046c3c38ae71 100644
--- a/WordPress/WordPress.xcodeproj/project.pbxproj
+++ b/WordPress/WordPress.xcodeproj/project.pbxproj
@@ -1267,6 +1267,7 @@
 		4AA33F022999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */; };
 		4AA33F04299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */; };
 		4AA33F05299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */; };
+		4AA7EE0F2ADF7367007D261D /* PostRepositoryPostsListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA7EE0E2ADF7367007D261D /* PostRepositoryPostsListTests.swift */; };
 		4AAD69082A6F68A5007FE77E /* MediaRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AAD69072A6F68A5007FE77E /* MediaRepositoryTests.swift */; };
 		4AD5656C28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */; };
 		4AD5656D28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */; };
@@ -6869,6 +6870,7 @@
 		4AA33EFA2999AE3B005B6E23 /* ReaderListTopic+Creation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderListTopic+Creation.swift"; sourceTree = "<group>"; };
 		4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderSiteTopic+Lookup.swift"; sourceTree = "<group>"; };
 		4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTagTopic+Lookup.swift"; sourceTree = "<group>"; };
+		4AA7EE0E2ADF7367007D261D /* PostRepositoryPostsListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepositoryPostsListTests.swift; sourceTree = "<group>"; };
 		4AAD69072A6F68A5007FE77E /* MediaRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaRepositoryTests.swift; sourceTree = "<group>"; };
 		4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderPost+Helper.swift"; sourceTree = "<group>"; };
 		4AD5656E28E413160054C676 /* Blog+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+History.swift"; sourceTree = "<group>"; };
@@ -15478,6 +15480,7 @@
 				570B037622F1FFF6009D8411 /* PostCoordinatorFailedPostsFetcherTests.swift */,
 				57569CF1230485680052EE14 /* PostAutoUploadInteractorTests.swift */,
 				4A2C73F62A9585B000ACE79E /* PostRepositoryTests.swift */,
+				4AA7EE0E2ADF7367007D261D /* PostRepositoryPostsListTests.swift */,
 				57D66B9C234BB78B005A2D74 /* PostServiceWPComTests.swift */,
 				57240223234E5BE200227067 /* PostServiceSelfHostedTests.swift */,
 				575802122357C41200E4C63C /* MediaCoordinatorTests.swift */,
@@ -23563,6 +23566,7 @@
 				3FFE3C0828FE00D10021BB96 /* StatsSegmentedControlDataTests.swift in Sources */,
 				015BA4EB29A788A300920F4B /* StatsTotalInsightsCellTests.swift in Sources */,
 				D81C2F5820F86CEA002AE1F1 /* NetworkStatus.swift in Sources */,
+				4AA7EE0F2ADF7367007D261D /* PostRepositoryPostsListTests.swift in Sources */,
 				E1C545801C6C79BB001CEB0E /* MediaSettingsTests.swift in Sources */,
 				806BA11C2A492B0F00052422 /* BlazeCampaignDetailsWebViewModelTests.swift in Sources */,
 				7E987F5A2108122A00CAFB88 /* NotificationUtility.swift in Sources */,
diff --git a/WordPress/WordPressTest/PostRepositoryPostsListTests.swift b/WordPress/WordPressTest/PostRepositoryPostsListTests.swift
new file mode 100644
index 000000000000..504a5c6197c5
--- /dev/null
+++ b/WordPress/WordPressTest/PostRepositoryPostsListTests.swift
@@ -0,0 +1,191 @@
+import Foundation
+import XCTest
+import OHHTTPStubs
+
+@testable import WordPress
+
+class PostRepositoryPostsListTests: CoreDataTestCase {
+
+    var repository: PostRepository!
+    var blogID: TaggedManagedObjectID<Blog>!
+
+    override func setUp() async throws {
+        repository = PostRepository(coreDataStack: contextManager)
+
+        let loggedIn = try await signIn()
+        blogID = try await contextManager.performAndSave {
+            let blog = try BlogBuilder($0)
+                .with(dotComID: 42)
+                .withAccount(id: loggedIn)
+                .build()
+            return TaggedManagedObjectID(blog)
+        }
+    }
+
+    override func tearDown() async throws {
+        HTTPStubs.removeAllStubs()
+    }
+
+    func testPagination() async throws {
+        // Given there are 15 published posts on the site
+        try await preparePostsList(type: "post", total: 15)
+
+        // When fetching all of the posts
+        let firstPage = try await repository.paginate(type: Post.self, statuses: [.publish], offset: 0, number: 10, in: blogID)
+        let secondPage = try await repository.paginate(type: Post.self, statuses: [.publish], offset: 10, number: 10, in: blogID)
+
+        XCTAssertEqual(firstPage.count, 10)
+        XCTAssertEqual(secondPage.count, 5)
+
+        // All of the posts are saved
+        let total = await contextManager.performQuery { $0.countObjects(ofType: Post.self) }
+        XCTAssertEqual(total, 15)
+    }
+
+    func testSearching() async throws {
+        // Given there are 15 published posts on the site
+        try await preparePostsList(type: "post", total: 15)
+
+        // When fetching all of the posts
+        let _ = try await repository.paginate(type: Post.self, statuses: [.publish], offset: 0, number: 15, in: blogID)
+
+        // There should 15 posts saved locally before performing search
+        var total = await contextManager.performQuery { $0.countObjects(ofType: Post.self) }
+        XCTAssertEqual(total, 15)
+
+        // Perform search
+        let postIDs: [TaggedManagedObjectID<Post>] = try await repository.search(input: "1", statuses: [.publish], limit: 1, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(postIDs.count, 1)
+
+        // There should still be 15 posts after the search: no local posts should be deleted
+        total = await contextManager.performQuery { $0.countObjects(ofType: Post.self) }
+        XCTAssertEqual(total, 15)
+    }
+
+}
+
+// MARK: - Tests that ensure the `preparePostsList` works as expected
+
+extension PostRepositoryPostsListTests {
+
+    func testPostsListStubReturnPostsAsRequested() async throws {
+        try await preparePostsList(type: "post", total: 20)
+
+        var result = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 10, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result.count, 10)
+
+        result = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 20, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result.count, 20)
+
+        result = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 30, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result.count, 20)
+    }
+
+    func testPostsListStubReturnPostsAtCorrectPosition() async throws {
+        try await preparePostsList(type: "post", total: 20)
+
+        let all = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 30, orderBy: .byDate, descending: true, in: blogID)
+
+        var result = try await repository.paginate(type: Post.self, statuses: [], offset: 0, number: 5, in: blogID)
+        XCTAssertEqual(result, Array(all[0..<5]))
+
+        result = try await repository.paginate(type: Post.self, statuses: [], offset: 3, number: 2, in: blogID)
+        XCTAssertEqual(result, [all[3], all[4]])
+    }
+
+    func testPostsListStubReturnPostsSearch() async throws {
+        try await preparePostsList(type: "post", total: 10)
+
+        let all = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 30, orderBy: .byDate, descending: true, in: blogID)
+
+        var result = try await repository.search(type: Post.self, input: "1", statuses: [], limit: 1, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result, [all[0]])
+
+        result = try await repository.search(type: Post.self, input: "2", statuses: [], limit: 1, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result, [all[1]])
+    }
+
+    func testPostsListStubReturnDefaultNumberOfPosts() async throws {
+        try await preparePostsList(type: "post", total: 100)
+
+        let result = try await repository.search(type: Post.self, input: nil, statuses: [], limit: 0, orderBy: .byDate, descending: true, in: blogID)
+        XCTAssertEqual(result.count, 20)
+    }
+
+}
+
+private extension PostRepositoryPostsListTests {
+    /// This is a helper function to create HTTP stubs for fetching posts(GET /sites/%s/posts) requests.
+    ///
+    /// The returned fake posts have only basic properties. The stubs ensure post id is unique and starts from 1.
+    /// But it does not promise the returned posts match the request filters (like status).
+    ///
+    /// You can use the `update` closure to update the returned posts if needed.
+    ///
+    /// Here are the supported features:
+    /// - Pagination. The stubs simulates `total` number of posts in the site, to handle paginated request accordingly.
+    /// - Search, but limited. Search is based on title. All fake posts have a title like "Random Post - [post-id]", where post id starts from 1. So, search "1" returns the posts whose id has "1" in it (1, 1x, x1, and so on).
+    ///
+    /// Here are unsupported features:
+    /// - Order. The sorting related arguments are ignored.
+    /// - Filter by status. The status argument is ignored.
+    func preparePostsList(type: String, total: Int, update: ((inout [String: Any]) -> Void)? = nil) async throws {
+        let allPosts = (1...total).map { id -> [String: Any] in
+            [
+                "ID": id,
+                "title": "Random Post - \(id)",
+                "content": "This is a test.",
+                "status": BasePost.Status.publish.rawValue,
+                "type": type
+            ]
+        }
+
+        let siteID = try await contextManager.performQuery { try XCTUnwrap($0.existingObject(with: self.blogID).dotComID) }
+        stub(condition: isMethodGET() && pathMatches("/sites/\(siteID)/posts", options: [])) { request in
+            let queryItems = URLComponents(url: request.url!, resolvingAgainstBaseURL: true)?.queryItems ?? []
+
+            var result = allPosts
+
+            if let search = queryItems.first(where: { $0.name == "search" })?.value {
+                result = result.filter {
+                    let title = $0["title"] as? String
+                    return title?.contains(search) == true
+                }
+            }
+
+            var number = (queryItems.first { $0.name == "number" }?.value.flatMap { Int($0) }) ?? 0
+            number = number == 0 ? 20 : number // The REST API uses the default value 20 when number is 0.
+            let offset = (queryItems.first { $0.name == "offset" }?.value.flatMap { Int($0) }) ?? 0
+            let upperBound = number == 0 ? result.endIndex : max(offset, offset + number - 1)
+            let allowed = 0..<result.count
+            let range = (offset..<(upperBound + 1)).clamped(to: allowed)
+
+            let response: [String: Any] = [
+                "found": result.count,
+                "posts": result[range].map { post in
+                    var json = post
+                    update?(&json)
+                    return json
+                }
+            ]
+
+            return HTTPStubsResponse(jsonObject: response, statusCode: 200, headers: nil)
+        }
+    }
+}
+
+extension CoreDataTestCase {
+
+    func signIn() async throws -> NSManagedObjectID {
+        let loggedIn = await contextManager.performQuery {
+            try? WPAccount.lookupDefaultWordPressComAccount(in: $0)?.objectID
+        }
+        if let loggedIn {
+            return loggedIn
+        }
+
+        let service = AccountService(coreDataStack: contextManager)
+        return service.createOrUpdateAccount(withUsername: "test-user", authToken: "test-token")
+    }
+
+}