diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index 7051007..8bbae22 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -252,6 +252,7 @@ define([
     "js/spec/views/xblock_string_field_editor_spec",
     "js/spec/views/xblock_validation_spec",
     "js/spec/views/license_spec",
+    "js/spec/views/paging_spec",
 
     "js/spec/views/utils/view_utils_spec",
 
@@ -284,4 +285,3 @@ define([
     # isolation issues with Squire.js
     # "coffee/spec/views/assets_spec"
     ])
-
diff --git a/cms/static/js/collections/asset.js b/cms/static/js/collections/asset.js
index 580659b..04320b3 100644
--- a/cms/static/js/collections/asset.js
+++ b/cms/static/js/collections/asset.js
@@ -33,6 +33,44 @@ define(["backbone.paginator", "js/models/asset"], function(BackbonePaginator, As
             this.currentPage = currentPage;
             this.start = start;
             return response.assets;
+        },
+
+        setPage: function (page) {
+            var oldPage = this.currentPage,
+                self = this;
+            this.goTo(page - 1, {
+                reset: true,
+                success: function () {
+                    self.trigger('page_changed');
+                },
+                error: function () {
+                    self.currentPage = oldPage;
+                }
+            });
+        },
+
+        nextPage: function () {
+            if (this.currentPage < this.totalPages - 1) {
+                this.setPage(this.getPage() + 1);
+            }
+        },
+
+        previousPage: function () {
+            if (this.currentPage > 0) {
+                this.setPage(this.getPage() - 1);
+            }
+        },
+
+        getPage: function () {
+            return this.currentPage + 1;
+        },
+
+        hasPreviousPage: function () {
+            return this.currentPage > 0;
+        },
+
+        hasNextPage: function () {
+            return this.currentPage < this.totalPages - 1;
         }
     });
     return AssetCollection;
diff --git a/cms/static/js/spec/views/assets_spec.js b/cms/static/js/spec/views/assets_spec.js
index 463fbda..5031409 100644
--- a/cms/static/js/spec/views/assets_spec.js
+++ b/cms/static/js/spec/views/assets_spec.js
@@ -1,10 +1,10 @@
-define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset", "js/views/assets",
-    "js/models/asset", "js/collections/asset", "js/spec_helpers/view_helpers"],
-    function ($, AjaxHelpers, URI, AssetView, AssetsView, AssetModel, AssetCollection, ViewHelpers) {
+define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/assets",
+         "js/collections/asset", "js/spec_helpers/view_helpers"],
+    function ($, AjaxHelpers, URI, AssetsView, AssetCollection, ViewHelpers) {
 
         describe("Assets", function() {
             var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse, mockFileUpload,
-                assetLibraryTpl, assetTpl, pagingFooterTpl, pagingHeaderTpl, uploadModalTpl;
+                assetLibraryTpl, assetTpl, uploadModalTpl;
 
             assetLibraryTpl = readFixtures('asset-library.underscore');
             assetTpl = readFixtures('asset.underscore');
@@ -357,6 +357,96 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI", "js/views/asset
                     $(".upload-modal .file-chooser").fileupload('add', mockFileUpload);
                     expect(assetsView.largeFileErrorMsg).toBeNull();
                 });
+
+                describe('Paging footer', function () {
+                    var firstPageAssets = {
+                        sort: "uploadDate",
+                        end: 1,
+                        assets: [
+                            {
+                                "display_name": "test.jpg",
+                                "url": "/c4x/A/CS102/asset/test.jpg",
+                                "date_added": "Nov 07, 2014 at 17:47 UTC",
+                                "id": "/c4x/A/CS102/asset/test.jpg",
+                                "portable_url": "/static/test.jpg",
+                                "thumbnail": "/c4x/A/CS102/thumbnail/test.jpg",
+                                "locked": false,
+                                "external_url": "localhost:8000/c4x/A/CS102/asset/test.jpg"
+                            },
+                            {
+                                "display_name": "test.pdf",
+                                "url": "/c4x/A/CS102/asset/test.pdf",
+                                "date_added": "Oct 20, 2014 at 11:00 UTC",
+                                "id": "/c4x/A/CS102/asset/test.pdf",
+                                "portable_url": "/static/test.pdf",
+                                "thumbnail": null,
+                                "locked": false,
+                                "external_url": "localhost:8000/c4x/A/CS102/asset/test.pdf"
+                            }
+                        ],
+                        pageSize: 2,
+                        totalCount: 3,
+                        start: 0,
+                        page: 0
+                    }, secondPageAssets = {
+                        sort: "uploadDate",
+                        end: 2,
+                        assets: [
+                            {
+                                "display_name": "test.odt",
+                                "url": "/c4x/A/CS102/asset/test.odt",
+                                "date_added": "Oct 20, 2014 at 11:00 UTC",
+                                "id": "/c4x/A/CS102/asset/test.odt",
+                                "portable_url": "/static/test.odt",
+                                "thumbnail": null,
+                                "locked": false,
+                                "external_url": "localhost:8000/c4x/A/CS102/asset/test.odt"
+                            }
+                        ],
+                        pageSize: 2,
+                        totalCount: 3,
+                        start: 2,
+                        page: 1
+                    };
+
+                    it('can move forward a page using the next page button', function () {
+                        var requests = AjaxHelpers.requests(this);
+                        assetsView.pagingView.setPage(0);
+                        AjaxHelpers.respondWithJson(requests, firstPageAssets);
+                        expect(assetsView.pagingView.pagingFooter).toBeDefined();
+                        expect(assetsView.pagingView.pagingFooter.$('button.next-page-link'))
+                            .not.toHaveClass('is-disabled');
+                        assetsView.pagingView.pagingFooter.$('button.next-page-link').click();
+                        AjaxHelpers.respondWithJson(requests, secondPageAssets);
+                        expect(assetsView.pagingView.pagingFooter.$('button.next-page-link'))
+                            .toHaveClass('is-disabled');
+                    });
+
+                    it('can move back a page using the previous page button', function () {
+                        var requests = AjaxHelpers.requests(this);
+                        assetsView.pagingView.setPage(1);
+                        AjaxHelpers.respondWithJson(requests, secondPageAssets);
+                        expect(assetsView.pagingView.pagingFooter).toBeDefined();
+                        expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
+                            .not.toHaveClass('is-disabled');
+                        assetsView.pagingView.pagingFooter.$('button.previous-page-link').click();
+                        AjaxHelpers.respondWithJson(requests, firstPageAssets);
+                        expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
+                            .toHaveClass('is-disabled');
+                    });
+
+                    it('can set the current page using the page number input', function () {
+                        var requests = AjaxHelpers.requests(this);
+                        assetsView.pagingView.setPage(0);
+                        AjaxHelpers.respondWithJson(requests, firstPageAssets);
+                        assetsView.pagingView.pagingFooter.$('#page-number-input').val('2');
+                        assetsView.pagingView.pagingFooter.$('#page-number-input').trigger('change');
+                        AjaxHelpers.respondWithJson(requests, secondPageAssets);
+                        expect(assetsView.collection.currentPage).toBe(1);
+                        expect(assetsView.pagingView.pagingFooter.$('button.previous-page-link'))
+                            .not.toHaveClass('is-disabled');
+                    });
+                });
             });
         });
     });
diff --git a/cms/static/js/spec/views/paged_container_spec.js b/cms/static/js/spec/views/paged_container_spec.js
index 3eaf8ce..e601610 100644
--- a/cms/static/js/spec/views/paged_container_spec.js
+++ b/cms/static/js/spec/views/paged_container_spec.js
@@ -1,5 +1,5 @@
 define(["jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "URI", "js/models/xblock_info",
-        "js/views/paged_container", "common/js/components/views/paging_header",
+        "js/views/paged_container", "js/views/paging_header",
         "common/js/components/views/paging_footer", "js/views/xblock"],
     function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter, XBlockView) {
 
diff --git a/common/static/common/js/spec/components/paging_spec.js b/cms/static/js/spec/views/paging_spec.js
similarity index 63%
rename from common/static/common/js/spec/components/paging_spec.js
rename to cms/static/js/spec/views/paging_spec.js
index 8355ada..ff9c7a8 100644
--- a/common/static/common/js/spec/components/paging_spec.js
+++ b/cms/static/js/spec/views/paging_spec.js
@@ -1,7 +1,11 @@
-define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
-    "common/js/components/views/paging", "common/js/components/views/paging_header",
-    "common/js/components/views/paging_footer", "common/js/spec/components/paging_collection"],
-    function ($, AjaxHelpers, URI, PagingView, PagingHeader, PagingFooter, PagingCollection) {
+define([
+    "jquery",
+    "common/js/spec_helpers/ajax_helpers",
+    "URI",
+    "js/views/paging",
+    "js/views/paging_header",
+    "common/js/components/collections/paging_collection"
+], function ($, AjaxHelpers, URI, PagingView, PagingHeader, PagingCollection) {
 
         var createPageableItem = function(index) {
             var id = 'item_' + index;
@@ -13,34 +17,37 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
         };
 
         var mockFirstPage = {
-            items: [
+            results: [
                 createPageableItem(1),
                 createPageableItem(2),
                 createPageableItem(3)
             ],
-            pageSize: 3,
-            totalCount: 4,
+            num_pages: 2,
+            page_size: 3,
+            current_page: 0,
+            count: 4,
             page: 0,
-            start: 0,
-            end: 2
+            start: 0
         };
         var mockSecondPage = {
-            items: [
+            results: [
                 createPageableItem(4)
             ],
-            pageSize: 3,
-            totalCount: 4,
+            num_pages: 2,
+            page_size: 3,
+            current_page: 1,
+            count: 4,
             page: 1,
-            start: 3,
-            end: 4
+            start: 3
         };
         var mockEmptyPage = {
-            items: [],
-            pageSize: 3,
-            totalCount: 0,
+            results: [],
+            num_pages: 1,
+            page_size: 3,
+            current_page: 0,
+            count: 0,
             page: 0,
-            start: 0,
-            end: 0
+            start: 0
         };
 
         var respondWithMockItems = function(requests) {
@@ -66,26 +73,28 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
             var pagingView;
 
             beforeEach(function () {
-                pagingView = new MockPagingView({collection: new PagingCollection()});
+                var collection = new PagingCollection();
+                collection.isZeroIndexed = true;
+                pagingView = new MockPagingView({collection: collection});
             });
 
             describe("PagingView", function () {
                 describe("setPage", function () {
                     it('can set the current page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingView.collection.currentPage).toBe(0);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         expect(pagingView.collection.currentPage).toBe(1);
                     });
 
                     it('should not change page after a server error', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
                         pagingView.setPage(1);
+                        respondWithMockItems(requests);
+                        pagingView.setPage(2);
                         requests[1].respond(500);
                         expect(pagingView.collection.currentPage).toBe(0);
                     });
@@ -94,7 +103,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                 describe("nextPage", function () {
                     it('does not move forward after a server error', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         pagingView.nextPage();
                         requests[1].respond(500);
@@ -103,7 +112,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can move to the next page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         pagingView.nextPage();
                         respondWithMockItems(requests);
@@ -112,7 +121,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can not move forward from the final page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         pagingView.nextPage();
                         expect(requests.length).toBe(1);
@@ -123,7 +132,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can move back a page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         pagingView.previousPage();
                         respondWithMockItems(requests);
@@ -132,7 +141,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can not move back from the first page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         pagingView.previousPage();
                         expect(requests.length).toBe(1);
@@ -140,7 +149,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('does not move back after a server error', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         pagingView.previousPage();
                         requests[1].respond(500);
@@ -208,7 +217,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('does not move forward if a server error occurs', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         pagingHeader.$('.next-page-link').click();
                         requests[1].respond(500);
@@ -217,7 +226,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can move to the next page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         pagingHeader.$('.next-page-link').click();
                         respondWithMockItems(requests);
@@ -226,14 +235,14 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('should be enabled when there is at least one more page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.next-page-link')).not.toHaveClass('is-disabled');
                     });
 
                     it('should be disabled on the final page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
                     });
@@ -255,7 +264,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('does not move back if a server error occurs', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         pagingHeader.$('.previous-page-link').click();
                         requests[1].respond(500);
@@ -264,7 +273,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('can go back a page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         pagingHeader.$('.previous-page-link').click();
                         respondWithMockItems(requests);
@@ -273,21 +282,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
 
                     it('should be disabled on the first page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
                     });
 
                     it('should be enabled on the second page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.previous-page-link')).not.toHaveClass('is-disabled');
                     });
 
                     it('should be disabled for an empty page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         AjaxHelpers.respondWithJson(requests, mockEmptyPage);
                         expect(pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
                     });
@@ -297,7 +306,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                     it('shows the correct metadata for the current page', function () {
                         var requests = AjaxHelpers.requests(this),
                             message;
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         message = pagingHeader.$('.meta').html().trim();
                         expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
@@ -308,7 +317,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                     it('shows the correct metadata when sorted ascending', function () {
                         var requests = AjaxHelpers.requests(this),
                             message;
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         pagingView.toggleSortOrder('name-col');
                         respondWithMockItems(requests);
                         message = pagingHeader.$('.meta').html().trim();
@@ -321,21 +330,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                 describe("Item count label", function () {
                     it('should show correct count on first page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.count-current-shown')).toHaveHtml('1-3');
                     });
 
                     it('should show correct count on second page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.count-current-shown')).toHaveHtml('4-4');
                     });
 
                     it('should show correct count for an empty collection', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         AjaxHelpers.respondWithJson(requests, mockEmptyPage);
                         expect(pagingHeader.$('.count-current-shown')).toHaveHtml('0-0');
                     });
@@ -344,21 +353,21 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                 describe("Item total label", function () {
                     it('should show correct total on the first page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.count-total')).toHaveText('4 total');
                     });
 
                     it('should show correct total on the second page', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
+                        pagingView.setPage(2);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.count-total')).toHaveText('4 total');
                     });
 
                     it('should show zero total for an empty collection', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         AjaxHelpers.respondWithJson(requests, mockEmptyPage);
                         expect(pagingHeader.$('.count-total')).toHaveText('0 total');
                     });
@@ -367,7 +376,7 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                 describe("Sort order label", function () {
                     it('should show correct initial sort order', function () {
                         var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
+                        pagingView.setPage(1);
                         respondWithMockItems(requests);
                         expect(pagingHeader.$('.sort-order')).toHaveText('Date');
                     });
@@ -380,193 +389,5 @@ define([ "jquery", "common/js/spec_helpers/ajax_helpers", "URI",
                     });
                 });
             });
-
-            describe("PagingFooter", function () {
-                var pagingFooter;
-
-                beforeEach(function () {
-                    pagingFooter = new PagingFooter({view: pagingView});
-                });
-
-                describe("Next page button", function () {
-                    beforeEach(function () {
-                        // Render the page and header so that they can react to events
-                        pagingView.render();
-                        pagingFooter.render();
-                    });
-
-                    it('does not move forward if a server error occurs', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.next-page-link').click();
-                        requests[1].respond(500);
-                        expect(pagingView.collection.currentPage).toBe(0);
-                    });
-
-                    it('can move to the next page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.next-page-link').click();
-                        respondWithMockItems(requests);
-                        expect(pagingView.collection.currentPage).toBe(1);
-                    });
-
-                    it('should be enabled when there is at least one more page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.next-page-link')).not.toHaveClass('is-disabled');
-                    });
-
-                    it('should be disabled on the final page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
-                    });
-
-                    it('should be disabled on an empty page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        AjaxHelpers.respondWithJson(requests, mockEmptyPage);
-                        expect(pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
-                    });
-                });
-
-                describe("Previous page button", function () {
-                    beforeEach(function () {
-                        // Render the page and header so that they can react to events
-                        pagingView.render();
-                        pagingFooter.render();
-                    });
-
-                    it('does not move back if a server error occurs', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.previous-page-link').click();
-                        requests[1].respond(500);
-                        expect(pagingView.collection.currentPage).toBe(1);
-                    });
-
-                    it('can go back a page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.previous-page-link').click();
-                        respondWithMockItems(requests);
-                        expect(pagingView.collection.currentPage).toBe(0);
-                    });
-
-                    it('should be disabled on the first page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
-                    });
-
-                    it('should be enabled on the second page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.previous-page-link')).not.toHaveClass('is-disabled');
-                    });
-
-                    it('should be disabled for an empty page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        AjaxHelpers.respondWithJson(requests, mockEmptyPage);
-                        expect(pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
-                    });
-                });
-
-                describe("Current page label", function () {
-                    it('should show 1 on the first page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.current-page')).toHaveText('1');
-                    });
-
-                    it('should show 2 on the second page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(1);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.current-page')).toHaveText('2');
-                    });
-
-                    it('should show 1 for an empty collection', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        AjaxHelpers.respondWithJson(requests, mockEmptyPage);
-                        expect(pagingFooter.$('.current-page')).toHaveText('1');
-                    });
-                });
-
-                describe("Page total label", function () {
-                    it('should show the correct value with more than one page', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.total-pages')).toHaveText('2');
-                    });
-
-                    it('should show page 1 when there are no pageable items', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        AjaxHelpers.respondWithJson(requests, mockEmptyPage);
-                        expect(pagingFooter.$('.total-pages')).toHaveText('1');
-                    });
-                });
-
-                describe("Page input field", function () {
-                    var input;
-
-                    beforeEach(function () {
-                        pagingFooter.render();
-                    });
-
-                    it('should initially have a blank page input', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        expect(pagingFooter.$('.page-number-input')).toHaveValue('');
-                    });
-
-                    it('should handle invalid page requests', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.page-number-input').val('abc');
-                        pagingFooter.$('.page-number-input').trigger('change');
-                        expect(pagingView.collection.currentPage).toBe(0);
-                        expect(pagingFooter.$('.page-number-input')).toHaveValue('');
-                    });
-
-                    it('should switch pages via the input field', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.page-number-input').val('2');
-                        pagingFooter.$('.page-number-input').trigger('change');
-                        AjaxHelpers.respondWithJson(requests, mockSecondPage);
-                        expect(pagingView.collection.currentPage).toBe(1);
-                        expect(pagingFooter.$('.page-number-input')).toHaveValue('');
-                    });
-
-                    it('should handle AJAX failures when switching pages via the input field', function () {
-                        var requests = AjaxHelpers.requests(this);
-                        pagingView.setPage(0);
-                        respondWithMockItems(requests);
-                        pagingFooter.$('.page-number-input').val('2');
-                        pagingFooter.$('.page-number-input').trigger('change');
-                        requests[1].respond(500);
-                        expect(pagingView.collection.currentPage).toBe(0);
-                        expect(pagingFooter.$('.page-number-input')).toHaveValue('');
-                    });
-                });
-            });
         });
     });
diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js
index ecb3f7b..c336ce1 100644
--- a/cms/static/js/views/assets.js
+++ b/cms/static/js/views/assets.js
@@ -1,5 +1,5 @@
-define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "common/js/components/views/paging",
-        "js/views/asset", "common/js/components/views/paging_header", "common/js/components/views/paging_footer",
+define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset", "js/views/paging",
+        "js/views/asset", "js/views/paging_header", "common/js/components/views/paging_footer",
         "js/utils/modal", "js/views/utils/view_utils", "js/views/feedback_notification",
         "text!templates/asset-library.underscore",
         "jquery.fileupload-process", "jquery.fileupload-validate"],
@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset
                         tableBody = this.$('#asset-table-body');
                         this.tableBody = tableBody;
                         this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
-                        this.pagingFooter = new PagingFooter({view: this, el: $('#asset-paging-footer')});
+                        this.pagingFooter = new PagingFooter({collection: this.collection, el: $('#asset-paging-footer')});
                         this.pagingHeader.render();
                         this.pagingFooter.render();
 
diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js
index cac10ce..c9fa504 100644
--- a/cms/static/js/views/paged_container.js
+++ b/cms/static/js/views/paged_container.js
@@ -1,8 +1,7 @@
 define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container", "js/utils/module", "gettext",
-        "js/views/feedback_notification", "common/js/components/views/paging_header",
-        "common/js/components/views/paging_footer", "common/js/components/views/paging_mixin"],
-    function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) {
-        var PagedContainerView = ContainerView.extend(PagingMixin).extend({
+        "js/views/feedback_notification", "js/views/paging_header", "common/js/components/views/paging_footer"],
+    function ($, _, ViewUtils, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
+        var PagedContainerView = ContainerView.extend({
             initialize: function(options){
                 var self = this;
                 ContainerView.prototype.initialize.call(this);
@@ -27,7 +26,33 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
                     // of paginator, on the current page.
                     size: function() { return self.collection._size; },
                     // Toggles the functionality for showing and hiding child previews.
-                    showChildrenPreviews: true
+                    showChildrenPreviews: true,
+
+                    // PagingFooter expects to be able to control paging through the collection instead of the view,
+                    // so we just make these functions act as pass-throughs
+                    setPage: function (page) {
+                        self.setPage(page - 1);
+                    },
+
+                    nextPage: function () {
+                        self.nextPage();
+                    },
+
+                    previousPage: function() {
+                        self.previousPage();
+                    },
+
+                    getPage: function () {
+                        return self.collection.currentPage + 1;
+                    },
+
+                    hasPreviousPage: function () {
+                        return self.collection.currentPage > 0;
+                    },
+
+                    hasNextPage: function () {
+                        return self.collection.currentPage < self.collection.totalPages - 1;
+                    }
                 };
             },
 
@@ -87,6 +112,23 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
                 this.render(options);
             },
 
+            nextPage: function() {
+                var collection = this.collection,
+                    currentPage = collection.currentPage,
+                    lastPage = collection.totalPages - 1;
+                if (currentPage < lastPage) {
+                    this.setPage(currentPage + 1);
+                }
+            },
+
+            previousPage: function() {
+                var collection = this.collection,
+                    currentPage = collection.currentPage;
+                if (currentPage > 0) {
+                    this.setPage(currentPage - 1);
+                }
+            },
+
             processPaging: function(options){
                 // We have the Django template sneak us the pagination information,
                 // and we load it from a div here.
@@ -119,7 +161,7 @@ define(["jquery", "underscore", "js/views/utils/view_utils", "js/views/container
                     el: this.$el.find('.container-paging-header')
                 });
                 this.pagingFooter = new PagingFooter({
-                    view: this,
+                    collection: this.collection,
                     el: this.$el.find('.container-paging-footer')
                 });
 
diff --git a/common/static/common/js/components/views/paging.js b/cms/static/js/views/paging.js
similarity index 83%
rename from common/static/common/js/components/views/paging.js
rename to cms/static/js/views/paging.js
index 07ac68f..86972c7 100644
--- a/common/static/common/js/components/views/paging.js
+++ b/cms/static/js/views/paging.js
@@ -1,142 +1,143 @@
-define(["underscore", "backbone", "gettext", "common/js/components/views/paging_mixin"],
-    function(_, Backbone, gettext, PagingMixin) {
-
-        var PagingView = Backbone.View.extend(PagingMixin).extend({
-            // takes a Backbone Paginator as a model
-
-            sortableColumns: {},
-
-            filterableColumns: {},
-
-            filterColumn: '',
-
-            initialize: function() {
-                Backbone.View.prototype.initialize.call(this);
-                var collection = this.collection;
-                collection.bind('add', _.bind(this.onPageRefresh, this));
-                collection.bind('remove', _.bind(this.onPageRefresh, this));
-                collection.bind('reset', _.bind(this.onPageRefresh, this));
-            },
-
-            onPageRefresh: function() {
-                var sortColumn = this.sortColumn;
-                this.renderPageItems();
-                this.$('.column-sort-link').removeClass('current-sort');
-                this.$('#' + sortColumn).addClass('current-sort');
-            },
-
-            onError: function() {
-                // Do nothing by default
-            },
-
-            nextPage: function() {
-                var collection = this.collection,
-                    currentPage = collection.currentPage,
-                    lastPage = collection.totalPages - 1;
-                if (currentPage < lastPage) {
-                    this.setPage(currentPage + 1);
+;(function (define) {
+    'use strict';
+    define(["underscore", "backbone", "gettext"],
+        function(_, Backbone, gettext) {
+
+            var PagingView = Backbone.View.extend({
+                // takes a Backbone Paginator as a model
+
+                sortableColumns: {},
+
+                filterableColumns: {},
+
+                filterColumn: '',
+
+                initialize: function() {
+                    Backbone.View.prototype.initialize.call(this);
+                    var collection = this.collection;
+                    collection.bind('add', _.bind(this.onPageRefresh, this));
+                    collection.bind('remove', _.bind(this.onPageRefresh, this));
+                    collection.bind('reset', _.bind(this.onPageRefresh, this));
+                    collection.bind('error', _.bind(this.onError, this));
+                    collection.bind('page_changed', function () { window.scrollTo(0, 0); });
+                },
+
+                onPageRefresh: function() {
+                    var sortColumn = this.collection.sortColumn;
+                    this.renderPageItems();
+                    this.$('.column-sort-link').removeClass('current-sort');
+                    this.$('#' + sortColumn).addClass('current-sort');
+                },
+
+                onError: function() {
+                    // Do nothing by default
+                },
+
+                setPage: function (page) {
+                    this.collection.setPage(page);
+                },
+
+                nextPage: function () {
+                    this.collection.nextPage();
+                },
+
+                previousPage: function () {
+                    this.collection.previousPage();
+                },
+
+                registerFilterableColumn: function(columnName, displayName, fieldName) {
+                    this.filterableColumns[columnName] = {
+                        displayName: displayName,
+                        fieldName: fieldName
+                    };
+                },
+
+                filterableColumnInfo: function(filterColumn) {
+                    var filterInfo = this.filterableColumns[filterColumn];
+                    if (!filterInfo) {
+                        throw "Unregistered filter column '" + filterInfo + '"';
+                    }
+                    return filterInfo;
+                },
+
+
+                filterDisplayName: function() {
+                    var filterColumn = this.filterColumn,
+                        filterInfo = this.filterableColumnInfo(filterColumn);
+                    return filterInfo.displayName;
+                },
+
+                setInitialFilterColumn: function(filterColumn) {
+                    var collection = this.collection,
+                        filterInfo = this.filterableColumns[filterColumn];
+                    collection.filterField = filterInfo.fieldName;
+                    this.filterColumn = filterColumn;
+                },
+
+                /**
+                * Registers information about a column that can be sorted.
+                * @param columnName The element name of the column.
+                * @param displayName The display name for the column in the current locale.
+                * @param fieldName The database field name that is represented by this column.
+                * @param defaultSortDirection The default sort direction for the column
+                */
+                registerSortableColumn: function(columnName, displayName, fieldName, defaultSortDirection) {
+                    this.sortableColumns[columnName] = {
+                        displayName: displayName,
+                        fieldName: fieldName,
+                        defaultSortDirection: defaultSortDirection
+                    };
+                },
+
+                sortableColumnInfo: function(sortColumn) {
+                    var sortInfo = this.sortableColumns[sortColumn];
+                    if (!sortInfo) {
+                        throw "Unregistered sort column '" + sortColumn + '"';
+                    }
+                    return sortInfo;
+                },
+
+                sortDisplayName: function() {
+                    var sortColumn = this.sortColumn,
+                        sortInfo = this.sortableColumnInfo(sortColumn);
+                    return sortInfo.displayName;
+                },
+
+                setInitialSortColumn: function(sortColumn) {
+                    var collection = this.collection,
+                        sortInfo = this.sortableColumns[sortColumn];
+                    collection.sortField = sortInfo.fieldName;
+                    collection.sortDirection = sortInfo.defaultSortDirection;
+                    this.sortColumn = sortColumn;
+                },
+
+                toggleSortOrder: function(sortColumn) {
+                    var collection = this.collection,
+                        sortInfo = this.sortableColumnInfo(sortColumn),
+                        sortField = sortInfo.fieldName,
+                        defaultSortDirection = sortInfo.defaultSortDirection;
+                    if (collection.sortField === sortField) {
+                        collection.sortDirection = collection.sortDirection === 'asc' ? 'desc' : 'asc';
+                    } else {
+                        collection.sortField = sortField;
+                        collection.sortDirection = defaultSortDirection;
+                    }
+                    this.sortColumn = sortColumn;
+                    this.collection.setPage(1);
+                },
+
+                selectFilter: function(filterColumn) {
+                    var collection = this.collection,
+                        filterInfo = this.filterableColumnInfo(filterColumn),
+                        filterField = filterInfo.fieldName,
+                        defaultFilterKey = false;
+                    if (collection.filterField !== filterField) {
+                        collection.filterField = filterField;
+                    }
+                    this.filterColumn = filterColumn;
+                    this.collection.setPage(1);
                 }
-            },
-
-            previousPage: function() {
-                var collection = this.collection,
-                    currentPage = collection.currentPage;
-                if (currentPage > 0) {
-                    this.setPage(currentPage - 1);
-                }
-            },
-
-            registerFilterableColumn: function(columnName, displayName, fieldName) {
-                this.filterableColumns[columnName] = {
-                    displayName: displayName,
-                    fieldName: fieldName
-                };
-            },
-
-            filterableColumnInfo: function(filterColumn) {
-                var filterInfo = this.filterableColumns[filterColumn];
-                if (!filterInfo) {
-                    throw "Unregistered filter column '" + filterInfo + '"';
-                }
-                return filterInfo;
-            },
-
-            filterDisplayName: function() {
-                var filterColumn = this.filterColumn,
-                    filterInfo = this.filterableColumnInfo(filterColumn);
-                return filterInfo.displayName;
-            },
-
-            setInitialFilterColumn: function(filterColumn) {
-                var collection = this.collection,
-                    filtertInfo = this.filterableColumns[filterColumn];
-                collection.filterField = filtertInfo.fieldName;
-                this.filterColumn = filterColumn;
-            },
-
-            /**
-             * Registers information about a column that can be sorted.
-             * @param columnName The element name of the column.
-             * @param displayName The display name for the column in the current locale.
-             * @param fieldName The database field name that is represented by this column.
-             * @param defaultSortDirection The default sort direction for the column
-             */
-            registerSortableColumn: function(columnName, displayName, fieldName, defaultSortDirection) {
-                this.sortableColumns[columnName] = {
-                    displayName: displayName,
-                    fieldName: fieldName,
-                    defaultSortDirection: defaultSortDirection
-                };
-            },
-
-            sortableColumnInfo: function(sortColumn) {
-                var sortInfo = this.sortableColumns[sortColumn];
-                if (!sortInfo) {
-                    throw "Unregistered sort column '" + sortColumn + '"';
-                }
-                return sortInfo;
-            },
-
-            sortDisplayName: function() {
-                var sortColumn = this.sortColumn,
-                    sortInfo = this.sortableColumnInfo(sortColumn);
-                return sortInfo.displayName;
-            },
-
-            setInitialSortColumn: function(sortColumn) {
-                var collection = this.collection,
-                    sortInfo = this.sortableColumns[sortColumn];
-                collection.sortField = sortInfo.fieldName;
-                collection.sortDirection = sortInfo.defaultSortDirection;
-                this.sortColumn = sortColumn;
-            },
-
-            toggleSortOrder: function(sortColumn) {
-                var collection = this.collection,
-                    sortInfo = this.sortableColumnInfo(sortColumn),
-                    sortField = sortInfo.fieldName,
-                    defaultSortDirection = sortInfo.defaultSortDirection;
-                if (collection.sortField === sortField) {
-                    collection.sortDirection = collection.sortDirection === 'asc' ? 'desc' : 'asc';
-                } else {
-                    collection.sortField = sortField;
-                    collection.sortDirection = defaultSortDirection;
-                }
-                this.sortColumn = sortColumn;
-                this.setPage(0);
-            },
-
-            selectFilter: function(filterColumn) {
-                var collection = this.collection,
-                    filterInfo = this.filterableColumnInfo(filterColumn),
-                    filterField = filterInfo.fieldName,
-                    defaultFilterKey = false;
-                if (collection.filterField !== filterField) {
-                    collection.filterField = filterField;
-                }
-                this.filterColumn = filterColumn;
-                this.setPage(0);
-            }
-        });
-        return PagingView;
-    }); // end define();
+            });
+            return PagingView;
+        }); // end define();
+}).call(this, define || RequireJS.define);
diff --git a/cms/static/js/views/paging_header.js b/cms/static/js/views/paging_header.js
new file mode 100644
index 0000000..25c23ac
--- /dev/null
+++ b/cms/static/js/views/paging_header.js
@@ -0,0 +1,113 @@
+define(["underscore", "backbone", "gettext", "text!templates/paging-header.underscore"],
+    function(_, Backbone, gettext, paging_header_template) {
+
+        var PagingHeader = Backbone.View.extend({
+            events : {
+                "click .next-page-link": "nextPage",
+                "click .previous-page-link": "previousPage"
+            },
+
+            initialize: function(options) {
+                var view = options.view,
+                    collection = view.collection;
+                this.view = view;
+                collection.bind('add', _.bind(this.render, this));
+                collection.bind('remove', _.bind(this.render, this));
+                collection.bind('reset', _.bind(this.render, this));
+            },
+
+            render: function() {
+                var view = this.view,
+                    collection = view.collection,
+                    currentPage = collection.currentPage,
+                    lastPage = collection.totalPages - 1,
+                    messageHtml = this.messageHtml();
+                this.$el.html(_.template(paging_header_template, {
+                    messageHtml: messageHtml
+                }));
+                this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);
+                this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
+                return this;
+            },
+
+            messageHtml: function() {
+                var message = '';
+                var asset_type = false;
+                if (this.view.collection.assetType) {
+                    if (this.view.collection.sortDirection === 'asc') {
+                        // Translators: sample result:
+                        // "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added ascending"
+                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s ascending');
+                    } else {
+                        // Translators: sample result:
+                        // "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added descending"
+                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s descending');
+                    }
+                    asset_type = this.filterNameLabel();
+                }
+                else {
+                    if (this.view.collection.sortDirection === 'asc') {
+                        // Translators: sample result:
+                        // "Showing 0-9 out of 25 total, sorted by Date Added ascending"
+                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
+                    } else {
+                        // Translators: sample result:
+                        // "Showing 0-9 out of 25 total, sorted by Date Added descending"
+                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
+                    }
+                }
+
+                return '<p>' + interpolate(message, {
+                        current_item_range: this.currentItemRangeLabel(),
+                        total_items_count: this.totalItemsCountLabel(),
+                        asset_type: asset_type,
+                        sort_name: this.sortNameLabel()
+                    }, true) + "</p>";
+            },
+
+            currentItemRangeLabel: function() {
+                var view = this.view,
+                    collection = view.collection,
+                    start = collection.start,
+                    count = collection.size(),
+                    end = start + count;
+                return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
+                    start: Math.min(start + 1, end),
+                    end: end
+                }, true);
+            },
+
+            totalItemsCountLabel: function() {
+                var totalItemsLabel;
+                // Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
+                totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
+                    total_items: this.view.collection.totalCount
+                }, true);
+                return interpolate('<span class="count-total">%(total_items_label)s</span>', {
+                    total_items_label: totalItemsLabel
+                }, true);
+            },
+
+            sortNameLabel: function() {
+                return interpolate('<span class="sort-order">%(sort_name)s</span>', {
+                    sort_name: this.view.sortDisplayName()
+                }, true);
+            },
+
+            filterNameLabel: function() {
+                return interpolate('<span class="filter-column">%(filter_name)s</span>', {
+                    filter_name: this.view.filterDisplayName()
+                }, true);
+            },
+
+            nextPage: function() {
+                this.view.nextPage();
+            },
+
+            previousPage: function() {
+                this.view.previousPage();
+            }
+        });
+
+        return PagingHeader;
+    }); // end define();
diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml
index a9bfac4..59c3a79 100644
--- a/cms/static/js_test.yml
+++ b/cms/static/js_test.yml
@@ -64,7 +64,6 @@ lib_paths:
     - xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
     - xmodule_js/common_static/js/xblock/
     - xmodule_js/common_static/coffee/src/xblock/
-    - xmodule_js/common_static/js/vendor/URI.min.js
     - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js
     - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js
     - xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js
diff --git a/cms/templates/js/paging-header.underscore b/cms/templates/js/paging-header.underscore
new file mode 100644
index 0000000..4496497
--- /dev/null
+++ b/cms/templates/js/paging-header.underscore
@@ -0,0 +1,11 @@
+<div class="meta-wrap">
+    <div class="meta">
+        <%= messageHtml %>
+    </div>
+    <nav class="pagination pagination-compact top" aria-label="Compact Pagination">
+        <ol>
+            <li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%- gettext("Previous") %></span></a></li>
+            <li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%- gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
+        </ol>
+    </nav>
+</div>
diff --git a/common/static/common/js/components/collections/paging_collection.js b/common/static/common/js/components/collections/paging_collection.js
new file mode 100644
index 0000000..ae93d25
--- /dev/null
+++ b/common/static/common/js/components/collections/paging_collection.js
@@ -0,0 +1,219 @@
+/**
+ * A generic paging collection for use with a ListView and PagingFooter.
+ *
+ * By default this collection is designed to work with Django Rest Framework APIs, but can be configured to work with
+ * others. There is support for ascending or descending sort on a particular field, as well as filtering on a field.
+ * While the backend API may use either zero or one indexed page numbers, this collection uniformly exposes a one
+ * indexed interface to make consumption easier for views.
+ *
+ * Subclasses may want to override the following properties:
+ *      - url (string): The base url for the API endpoint.
+ *      - isZeroIndexed (boolean): If true, API calls will use page numbers starting at zero. Defaults to false.
+ *      - perPage (number): Count of elements to fetch for each page.
+ *      - server_api (object): Query parameters for the API call. Subclasses may add entries as necessary. By default,
+ *          a 'sort_order' field is included to specify the field to sort on. This field may be removed for subclasses
+ *          that do not support sort ordering, or support it in a non-standard way. By default filterField and
+ *          sortDirection do not affect the API calls. It is up to subclasses to add this information to the appropriate
+ *          query string parameters in server_api.
+ */
+;(function (define) {
+    'use strict';
+    define(['backbone.paginator'], function (BackbonePaginator) {
+        var PagingCollection = BackbonePaginator.requestPager.extend({
+            initialize: function () {
+                // These must be initialized in the constructor because otherwise all PagingCollections would point
+                // to the same object references for sortableFields and filterableFields.
+                this.sortableFields = {};
+                this.filterableFields = {};
+            },
+
+            isZeroIndexed: false,
+            perPage: 10,
+
+            sortField: '',
+            sortDirection: 'descending',
+            sortableFields: {},
+
+            filterField: '',
+            filterableFields: {},
+
+            paginator_core: {
+                type: 'GET',
+                dataType: 'json',
+                url: function () { return this.url; }
+            },
+
+            paginator_ui: {
+                firstPage: function () { return this.isZeroIndexed ? 0 : 1; },
+                // Specifies the initial page during collection initialization
+                currentPage: function () { return this.isZeroIndexed ? 0 : 1; },
+                perPage: function () { return this.perPage; }
+            },
+
+            server_api: {
+                'page': function () { return this.currentPage; },
+                'page_size': function () { return this.perPage; },
+                'sort_order': function () { return this.sortField; }
+            },
+
+            parse: function (response) {
+                this.totalCount = response.count;
+                this.currentPage = response.current_page;
+                this.totalPages = response.num_pages;
+                this.start = response.start;
+                this.sortField = response.sort_order;
+                return response.results;
+            },
+
+            /**
+             * Returns the current page number as if numbering starts on page one, regardless of the indexing of the
+             * underlying server API.
+             */
+            getPage: function () {
+                return this.currentPage + (this.isZeroIndexed ? 1 : 0);
+            },
+
+            /**
+             * Sets the current page of the collection. Page is assumed to be one indexed, regardless of the indexing
+             * of the underlying server API. If there is an error fetching the page, the Backbone 'error' event is
+             * triggered and the page does not change. A 'page_changed' event is triggered on a successful page change.
+             * @param page one-indexed page to change to
+             */
+            setPage: function (page) {
+                var oldPage = this.currentPage,
+                    self = this;
+                this.goTo(page - (this.isZeroIndexed ? 1 : 0), {reset: true}).then(
+                    function () {
+                        self.trigger('page_changed');
+                    },
+                    function () {
+                        self.currentPage = oldPage;
+                    }
+                );
+            },
+
+            /**
+             * Returns true if the collection has a next page, false otherwise.
+             */
+            hasNextPage: function () {
+                return this.getPage() < this.totalPages;
+            },
+
+            /**
+             * Returns true if the collection has a previous page, false otherwise.
+             */
+            hasPreviousPage: function () {
+                return this.getPage() > 1;
+            },
+
+            /**
+             * Moves the collection to the next page if it exists.
+             */
+            nextPage: function () {
+                if (this.hasNextPage()) {
+                    this.setPage(this.getPage() + 1);
+                }
+            },
+
+            /**
+             * Moves the collection to the previous page if it exists.
+             */
+            previousPage: function () {
+                if (this.hasPreviousPage()) {
+                    this.setPage(this.getPage() - 1);
+                }
+            },
+
+            /**
+             * Adds the given field to the list of fields that can be sorted on.
+             * @param fieldName name of the field for the server API
+             * @param displayName name of the field to display to the user
+             */
+            registerSortableField: function (fieldName, displayName) {
+                this.addField(this.sortableFields, fieldName, displayName);
+            },
+
+            /**
+             * Adds the given field to the list of fields that can be filtered on.
+             * @param fieldName name of the field for the server API
+             * @param displayName name of the field to display to the user
+             */
+            registerFilterableField: function (fieldName, displayName) {
+                this.addField(this.filterableFields, fieldName, displayName);
+            },
+
+            /**
+             * For internal use only. Adds the given field to the given collection of fields.
+             * @param fields object of existing fields
+             * @param fieldName name of the field for the server API
+             * @param displayName name of the field to display to the user
+             */
+            addField: function (fields, fieldName, displayName) {
+                fields[fieldName] = {
+                    displayName: displayName
+                };
+            },
+
+            /**
+             * Returns the display name of the field that the collection is currently sorted on.
+             */
+            sortDisplayName: function () {
+                return this.sortableFields[this.sortField].displayName;
+            },
+
+            /**
+             * Returns the display name of the field that the collection is currently filtered on.
+             */
+            filterDisplayName: function () {
+                return this.filterableFields[this.filterField].displayName;
+            },
+
+            /**
+             * Sets the field to sort on. Sends a request to the server to fetch the first page of the collection with
+             * the new sort order. If successful, the collection resets to page one with the new data.
+             * @param fieldName name of the field to sort on
+             * @param toggleDirection if true, the sort direction is toggled if the given field was already set
+             */
+            setSortField: function (fieldName, toggleDirection) {
+                if (toggleDirection) {
+                    if (this.sortField === fieldName) {
+                        this.sortDirection = PagingCollection.SortDirection.flip(this.sortDirection);
+                    } else {
+                        this.sortDirection = PagingCollection.SortDirection.DESCENDING;
+                    }
+                }
+                this.sortField = fieldName;
+                this.setPage(1);
+            },
+
+            /**
+             * Sets the direction of the sort. Sends a request to the server to fetch the first page of the collection
+             * with the new sort order. If successful, the collection resets to page one with the new data.
+             * @param direction either ASCENDING or DESCENDING from PagingCollection.SortDirection.
+             */
+            setSortDirection: function (direction) {
+                this.sortDirection = direction;
+                this.setPage(1);
+            },
+
+            /**
+             * Sets the field to filter on. Sends a request to the server to fetch the first page of the collection
+             * with the new filter options. If successful, the collection resets to page one with the new data.
+             * @param fieldName name of the field to filter on
+             */
+            setFilterField: function (fieldName) {
+                this.filterField = fieldName;
+                this.setPage(1);
+            }
+        }, {
+            SortDirection: {
+                ASCENDING: 'ascending',
+                DESCENDING: 'descending',
+                flip: function (direction) {
+                    return direction === this.ASCENDING ? this.DESCENDING : this.ASCENDING;
+                }
+            }
+        });
+        return PagingCollection;
+    });
+}).call(this, define || RequireJS.define);
diff --git a/common/static/common/js/components/views/list.js b/common/static/common/js/components/views/list.js
new file mode 100644
index 0000000..b8c319a
--- /dev/null
+++ b/common/static/common/js/components/views/list.js
@@ -0,0 +1,44 @@
+/**
+ * Generic view to render a collection.
+ */
+;(function (define) {
+    'use strict';
+    define(['backbone', 'underscore'], function(Backbone, _) {
+        var ListView = Backbone.View.extend({
+            /**
+             * Override with the view used to render models in the collection.
+             */
+            itemViewClass: Backbone.View,
+
+            initialize: function (options) {
+                this.itemViewClass = options.itemViewClass || this.itemViewClass;
+                // TODO: at some point we will want 'add' and 'remove'
+                // not to re-render the whole collection, but this is
+                // not currently required.
+                this.collection.on('add', this.render, this);
+                this.collection.on('remove', this.render, this);
+                this.collection.on('reset', this.render, this);
+                this.collection.on('sync', this.render, this);
+                this.collection.on('sort', this.render, this);
+                // Keep track of our children for garbage collection
+                this.itemViews = [];
+            },
+
+            render: function () {
+                // Remove old children views
+                _.each(this.itemViews, function (childView) {
+                    childView.remove();
+                });
+                this.itemViews = [];
+                // Render the collection
+                this.collection.each(function (model) {
+                    var itemView = new this.itemViewClass({model: model});
+                    this.$el.append(itemView.render().el);
+                    this.itemViews.push(itemView);
+                }, this);
+                return this;
+            }
+        });
+        return ListView;
+    });
+}).call(this, define || RequireJS.define);
diff --git a/common/static/common/js/components/views/paging_footer.js b/common/static/common/js/components/views/paging_footer.js
index 8700a66..08f3257 100644
--- a/common/static/common/js/components/views/paging_footer.js
+++ b/common/static/common/js/components/views/paging_footer.js
@@ -1,65 +1,69 @@
-define(["underscore", "backbone", "text!common/templates/components/paging-footer.underscore"],
-    function(_, Backbone, paging_footer_template) {
+;(function (define) {
+    'use strict';
+    define(["underscore", "gettext", "backbone", "text!common/templates/components/paging-footer.underscore"],
+        function(_, gettext, Backbone, paging_footer_template) {
 
-        var PagingFooter = Backbone.View.extend({
-            events : {
-                "click .next-page-link": "nextPage",
-                "click .previous-page-link": "previousPage",
-                "change .page-number-input": "changePage"
-            },
+            var PagingFooter = Backbone.View.extend({
+                events : {
+                    "click .next-page-link": "nextPage",
+                    "click .previous-page-link": "previousPage",
+                    "change .page-number-input": "changePage"
+                },
 
-            initialize: function(options) {
-                var view = options.view,
-                    collection = view.collection;
-                this.view = view;
-                collection.bind('add', _.bind(this.render, this));
-                collection.bind('remove', _.bind(this.render, this));
-                collection.bind('reset', _.bind(this.render, this));
-                this.render();
-            },
+                initialize: function(options) {
+                    this.collection = options.collection;
+                    this.hideWhenOnePage = options.hideWhenOnePage || false;
+                    this.collection.bind('add', _.bind(this.render, this));
+                    this.collection.bind('remove', _.bind(this.render, this));
+                    this.collection.bind('reset', _.bind(this.render, this));
+                    this.render();
+                },
 
-            render: function() {
-                var view = this.view,
-                    collection = view.collection,
-                    currentPage = collection.currentPage,
-                    lastPage = collection.totalPages - 1;
-                this.$el.html(_.template(paging_footer_template, {
-                    current_page: collection.currentPage,
-                    total_pages: collection.totalPages
-                }));
-                this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);;
-                this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
-                return this;
-            },
+                render: function() {
+                    var onFirstPage = !this.collection.hasPreviousPage(),
+                        onLastPage = !this.collection.hasNextPage();
+                    if (this.hideWhenOnePage) {
+                        if (this.collection.totalPages <= 1) {
+                            this.$el.addClass('hidden');
+                        } else if (this.$el.hasClass('hidden')) {
+                            this.$el.removeClass('hidden');
+                        }
+                    }
+                    this.$el.html(_.template(paging_footer_template, {
+                        current_page: this.collection.getPage(),
+                        total_pages: this.collection.totalPages
+                    }));
+                    this.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage);
+                    this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage);
+                    return this;
+                },
 
-            changePage: function() {
-                var view = this.view,
-                    collection = view.collection,
-                    currentPage = collection.currentPage + 1,
-                    pageInput = this.$("#page-number-input"),
-                    pageNumber = parseInt(pageInput.val(), 10);
-                if (pageNumber > collection.totalPages) {
-                    pageNumber = false;
-                }
-                if (pageNumber <= 0) {
-                    pageNumber = false;
-                }
-                // If we still have a page number by this point,
-                // and it's not the current page, load it.
-                if (pageNumber && pageNumber !== currentPage) {
-                    view.setPage(pageNumber - 1);
-                }
-                pageInput.val(""); // Clear the value as the label will show beneath it
-            },
+                changePage: function() {
+                    var collection = this.collection,
+                        currentPage = collection.getPage(),
+                        pageInput = this.$("#page-number-input"),
+                        pageNumber = parseInt(pageInput.val(), 10),
+                        validInput = true;
+                    if (!pageNumber || pageNumber > collection.totalPages || pageNumber < 1) {
+                        validInput = false;
+                    }
+                    // If we still have a page number by this point,
+                    // and it's not the current page, load it.
+                    if (validInput && pageNumber !== currentPage) {
+                        collection.setPage(pageNumber);
+                    }
+                    pageInput.val(''); // Clear the value as the label will show beneath it
+                },
 
-            nextPage: function() {
-                this.view.nextPage();
-            },
+                nextPage: function() {
+                    this.collection.nextPage();
+                },
 
-            previousPage: function() {
-                this.view.previousPage();
-            }
-        });
+                previousPage: function() {
+                    this.collection.previousPage();
+                }
+            });
 
-        return PagingFooter;
-    }); // end define();
+            return PagingFooter;
+        }); // end define();
+}).call(this, define || RequireJS.define);
diff --git a/common/static/common/js/components/views/paging_header.js b/common/static/common/js/components/views/paging_header.js
index dc544e0..3489b01 100644
--- a/common/static/common/js/components/views/paging_header.js
+++ b/common/static/common/js/components/views/paging_header.js
@@ -1,113 +1,37 @@
-define(["underscore", "backbone", "gettext", "text!common/templates/components/paging-header.underscore"],
-    function(_, Backbone, gettext, paging_header_template) {
-
+;(function (define) {
+    'use strict';
+    define([
+        'backbone',
+        'underscore',
+        'gettext',
+        'text!common/templates/components/paging-header.underscore'
+    ], function (Backbone, _, gettext, headerTemplate) {
         var PagingHeader = Backbone.View.extend({
-            events : {
-                "click .next-page-link": "nextPage",
-                "click .previous-page-link": "previousPage"
-            },
-
-            initialize: function(options) {
-                var view = options.view,
-                    collection = view.collection;
-                this.view = view;
-                collection.bind('add', _.bind(this.render, this));
-                collection.bind('remove', _.bind(this.render, this));
-                collection.bind('reset', _.bind(this.render, this));
-            },
-
-            render: function() {
-                var view = this.view,
-                    collection = view.collection,
-                    currentPage = collection.currentPage,
-                    lastPage = collection.totalPages - 1,
-                    messageHtml = this.messageHtml();
-                this.$el.html(_.template(paging_header_template, {
-                    messageHtml: messageHtml
-                }));
-                this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);
-                this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
-                return this;
-            },
-
-            messageHtml: function() {
-                var message = '';
-                var asset_type = false;
-                if (this.view.collection.assetType) {
-                    if (this.view.collection.sortDirection === 'asc') {
-                        // Translators: sample result:
-                        // "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added ascending"
-                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s ascending');
-                    } else {
-                        // Translators: sample result:
-                        // "Showing 0-9 out of 25 total, filtered by Images, sorted by Date Added descending"
-                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, filtered by %(asset_type)s, sorted by %(sort_name)s descending');
-                    }
-                    asset_type = this.filterNameLabel();
-                }
-                else {
-                    if (this.view.collection.sortDirection === 'asc') {
-                        // Translators: sample result:
-                        // "Showing 0-9 out of 25 total, sorted by Date Added ascending"
-                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
-                    } else {
-                        // Translators: sample result:
-                        // "Showing 0-9 out of 25 total, sorted by Date Added descending"
-                        message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
-                    }
+            initialize: function (options) {
+                this.collections = options.collection;
+                this.collection.bind('add', _.bind(this.render, this));
+                this.collection.bind('remove', _.bind(this.render, this));
+                this.collection.bind('reset', _.bind(this.render, this));
+            },
+
+            render: function () {
+                var message,
+                    start = this.collection.start,
+                    end = start + this.collection.length,
+                    num_items = this.collection.totalCount,
+                    context = {first_index: Math.min(start + 1, end), last_index: end, num_items: num_items};
+                if (end <= 1) {
+                    message = interpolate(gettext('Showing %(first_index)s out of %(num_items)s total'), context, true);
+                } else {
+                    message = interpolate(
+                        gettext('Showing %(first_index)s-%(last_index)s out of %(num_items)s total'),
+                        context, true
+                    );
                 }
-
-                return '<p>' + interpolate(message, {
-                        current_item_range: this.currentItemRangeLabel(),
-                        total_items_count: this.totalItemsCountLabel(),
-                        asset_type: asset_type,
-                        sort_name: this.sortNameLabel()
-                    }, true) + "</p>";
-            },
-
-            currentItemRangeLabel: function() {
-                var view = this.view,
-                    collection = view.collection,
-                    start = collection.start,
-                    count = collection.size(),
-                    end = start + count;
-                return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
-                    start: Math.min(start + 1, end),
-                    end: end
-                }, true);
-            },
-
-            totalItemsCountLabel: function() {
-                var totalItemsLabel;
-                // Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
-                totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
-                    total_items: this.view.collection.totalCount
-                }, true);
-                return interpolate('<span class="count-total">%(total_items_label)s</span>', {
-                    total_items_label: totalItemsLabel
-                }, true);
-            },
-
-            sortNameLabel: function() {
-                return interpolate('<span class="sort-order">%(sort_name)s</span>', {
-                    sort_name: this.view.sortDisplayName()
-                }, true);
-            },
-
-            filterNameLabel: function() {
-                return interpolate('<span class="filter-column">%(filter_name)s</span>', {
-                    filter_name: this.view.filterDisplayName()
-                }, true);
-            },
-
-            nextPage: function() {
-                this.view.nextPage();
-            },
-
-            previousPage: function() {
-                this.view.previousPage();
+                this.$el.html(_.template(headerTemplate, {message: message}));
+                return this;
             }
         });
-
         return PagingHeader;
-    }); // end define();
+    });
+}).call(this, define || RequireJS.define);
diff --git a/common/static/common/js/components/views/paging_mixin.js b/common/static/common/js/components/views/paging_mixin.js
deleted file mode 100644
index 16d518f..0000000
--- a/common/static/common/js/components/views/paging_mixin.js
+++ /dev/null
@@ -1,37 +0,0 @@
-define([],
-    function () {
-        var PagedMixin = {
-            setPage: function (page) {
-                var self = this,
-                    collection = self.collection,
-                    oldPage = collection.currentPage;
-                collection.goTo(page, {
-                    reset: true,
-                    success: function () {
-                        window.scrollTo(0, 0);
-                    },
-                    error: function (collection) {
-                        collection.currentPage = oldPage;
-                        self.onError();
-                    }
-                });
-            },
-            nextPage: function() {
-                var collection = this.collection,
-                    currentPage = collection.currentPage,
-                    lastPage = collection.totalPages - 1;
-                if (currentPage < lastPage) {
-                    this.setPage(currentPage + 1);
-                }
-            },
-
-            previousPage: function() {
-                var collection = this.collection,
-                    currentPage = collection.currentPage;
-                if (currentPage > 0) {
-                    this.setPage(currentPage - 1);
-                }
-            }
-        };
-        return PagedMixin;
-    });
diff --git a/common/static/common/js/spec/components/list_spec.js b/common/static/common/js/spec/components/list_spec.js
new file mode 100644
index 0000000..e3c256a
--- /dev/null
+++ b/common/static/common/js/spec/components/list_spec.js
@@ -0,0 +1,90 @@
+define(['jquery', 'backbone', 'underscore', 'common/js/components/views/list'],
+    function ($, Backbone, _, ListView) {
+        'use strict';
+        describe('ListView', function () {
+            var Model = Backbone.Model.extend({
+                    defaults: {
+                        name: 'default name'
+                    }
+                }),
+                View = Backbone.View.extend({
+                    tagName: 'div',
+                    className: 'my-view',
+                    template: _.template('<p>Name: "<%- name %>"</p>'),
+                    render: function () {
+                        this.$el.html(this.template(this.model.attributes));
+                        return this;
+                    }
+                }),
+                Collection = Backbone.Collection.extend({
+                    model: Model
+                }),
+                expectListNames = function (names) {
+                    expect(listView.$('.my-view').length).toBe(names.length);
+                    _.each(names, function (name, index) {
+                        expect($(listView.$('.my-view')[index]).text()).toContain(name);
+                    });
+                },
+                listView;
+
+            beforeEach(function () {
+                setFixtures('<div class="list"></div>');
+                listView = new ListView({
+                    el: $('.list'),
+                    collection: new Collection(
+                        [{name: 'first model'}, {name: 'second model'}, {name: 'third model'}]
+                    ),
+                    itemViewClass: View
+                });
+                listView.render();
+            });
+
+            it('renders itself', function () {
+                expectListNames(['first model', 'second model', 'third model']);
+            });
+
+            it('does not render subviews for an empty collection', function () {
+                listView.collection.set([]);
+                expectListNames([]);
+            });
+
+            it('re-renders itself when the collection changes', function () {
+                expectListNames(['first model', 'second model', 'third model']);
+                listView.collection.set([{name: 'foo'}, {name: 'bar'}, {name: 'third model'}]);
+                expectListNames(['foo', 'bar', 'third model']);
+                listView.collection.reset([{name: 'baz'}, {name: 'bar'}, {name: 'quux'}]);
+                expectListNames(['baz', 'bar', 'quux']);
+            });
+
+            it('re-renders itself when items are added to the collection', function () {
+                expectListNames(['first model', 'second model', 'third model']);
+                listView.collection.add({name: 'fourth model'});
+                expectListNames(['first model', 'second model', 'third model', 'fourth model']);
+                listView.collection.add({name: 'zeroth model'}, {at: 0});
+                expectListNames(['zeroth model', 'first model', 'second model', 'third model', 'fourth model']);
+                listView.collection.add({name: 'second-and-a-half model'}, {at: 3});
+                expectListNames([
+                    'zeroth model', 'first model', 'second model',
+                    'second-and-a-half model', 'third model', 'fourth model'
+                ]);
+            });
+
+            it('re-renders itself when items are removed from the collection', function () {
+                listView.collection.reset([{name: 'one'}, {name: 'two'}, {name: 'three'}, {name: 'four'}]);
+                expectListNames(['one', 'two', 'three', 'four']);
+                listView.collection.remove(listView.collection.at(0));
+                expectListNames(['two', 'three', 'four']);
+                listView.collection.remove(listView.collection.at(1));
+                expectListNames(['two', 'four']);
+                listView.collection.remove(listView.collection.at(1));
+                expectListNames(['two']);
+                listView.collection.remove(listView.collection.at(0));
+                expectListNames([]);
+            });
+
+            it('removes old views', function () {
+                listView.collection.reset(null);
+                expect(listView.itemViews).toEqual([]);
+            });
+        });
+    });
diff --git a/common/static/common/js/spec/components/paging_collection.js b/common/static/common/js/spec/components/paging_collection.js
deleted file mode 100644
index b83f4f2..0000000
--- a/common/static/common/js/spec/components/paging_collection.js
+++ /dev/null
@@ -1,38 +0,0 @@
-define(["backbone.paginator", "backbone"], function(BackbonePaginator, Backbone) {
-    // This code was adapted from collections/asset.js.
-    var PagingCollection = BackbonePaginator.requestPager.extend({
-        model : Backbone.Model,
-        paginator_core: {
-            type: 'GET',
-            accepts: 'application/json',
-            dataType: 'json',
-            url: function() { return this.url; }
-        },
-        paginator_ui: {
-            firstPage: 0,
-            currentPage: 0,
-            perPage: 50
-        },
-        server_api: {
-            'page': function() { return this.currentPage; },
-            'page_size': function() { return this.perPage; },
-            'sort': function() { return this.sortField; },
-            'direction': function() { return this.sortDirection; },
-            'format': 'json'
-        },
-
-        parse: function(response) {
-            var totalCount = response.totalCount,
-                start = response.start,
-                currentPage = response.page,
-                pageSize = response.pageSize,
-                totalPages = Math.ceil(totalCount / pageSize);
-            this.totalCount = totalCount;
-            this.totalPages = Math.max(totalPages, 1); // Treat an empty collection as having 1 page...
-            this.currentPage = currentPage;
-            this.start = start;
-            return response.items;
-        }
-    });
-    return PagingCollection;
-});
diff --git a/common/static/common/js/spec/components/paging_collection_spec.js b/common/static/common/js/spec/components/paging_collection_spec.js
new file mode 100644
index 0000000..deb1ac6
--- /dev/null
+++ b/common/static/common/js/spec/components/paging_collection_spec.js
@@ -0,0 +1,247 @@
+define(['jquery',
+        'backbone',
+        'underscore',
+        'URI',
+        'common/js/components/collections/paging_collection',
+        'common/js/spec_helpers/ajax_helpers',
+        'common/js/spec_helpers/spec_helpers'
+    ],
+    function ($, Backbone, _, URI, PagingCollection, AjaxHelpers, SpecHelpers) {
+        'use strict';
+
+        describe('PagingCollection', function () {
+            var collection, requests, server, assertQueryParams;
+            server = {
+                isZeroIndexed: false,
+                count: 43,
+                respond: function () {
+                    var params = (new URI(requests[requests.length - 1].url)).query(true),
+                        page = parseInt(params['page'], 10),
+                        page_size = parseInt(params['page_size'], 10),
+                        page_count = Math.ceil(this.count / page_size);
+
+                    // Make zeroPage consistently start at zero for ease of calculation
+                    var zeroPage = page - (this.isZeroIndexed ? 0 : 1);
+                    if (zeroPage < 0 || zeroPage > page_count) {
+                        AjaxHelpers.respondWithError(requests, 404, {}, requests.length - 1);
+                    } else {
+                        AjaxHelpers.respondWithJson(requests, {
+                            'count': this.count,
+                            'current_page': page,
+                            'num_pages': page_count,
+                            'start': zeroPage * page_size,
+                            'results': []
+                        }, requests.length - 1);
+                    }
+                }
+            };
+            assertQueryParams = function (params) {
+                var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
+                _.each(params, function (value, key) {
+                    expect(urlParams[key]).toBe(value);
+                });
+            };
+
+            beforeEach(function () {
+                collection = new PagingCollection();
+                collection.perPage = 10;
+                requests = AjaxHelpers.requests(this);
+                server.isZeroIndexed = false;
+                server.count = 43;
+            });
+
+            it('can register sortable fields', function () {
+                collection.registerSortableField('test_field', 'Test Field');
+                expect('test_field' in collection.sortableFields).toBe(true);
+                expect(collection.sortableFields['test_field'].displayName).toBe('Test Field');
+            });
+
+            it('can register filterable fields', function () {
+                collection.registerFilterableField('test_field', 'Test Field');
+                expect('test_field' in collection.filterableFields).toBe(true);
+                expect(collection.filterableFields['test_field'].displayName).toBe('Test Field');
+            });
+
+            it('sets the sort field based on the server response', function () {
+                var sort_order = 'my_sort_order';
+                collection = new PagingCollection({sort_order: sort_order}, {parse: true});
+                expect(collection.sortField).toBe(sort_order);
+            });
+
+            it('can set the sort field', function () {
+                collection.registerSortableField('test_field', 'Test Field');
+                collection.setSortField('test_field', false);
+                expect(requests.length).toBe(1);
+                assertQueryParams({'sort_order': 'test_field'});
+                expect(collection.sortField).toBe('test_field');
+                expect(collection.sortDisplayName()).toBe('Test Field');
+            });
+
+            it('can set the filter field', function () {
+                collection.registerFilterableField('test_field', 'Test Field');
+                collection.setFilterField('test_field');
+                expect(requests.length).toBe(1);
+                // The default implementation does not send any query params for filtering
+                expect(collection.filterField).toBe('test_field');
+                expect(collection.filterDisplayName()).toBe('Test Field');
+            });
+
+            it('can set the sort direction', function () {
+                collection.setSortDirection(PagingCollection.SortDirection.ASCENDING);
+                expect(requests.length).toBe(1);
+                // The default implementation does not send any query params for sort direction
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
+                collection.setSortDirection(PagingCollection.SortDirection.DESCENDING);
+                expect(requests.length).toBe(2);
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
+            });
+
+            it('can toggle the sort direction when setting the sort field', function () {
+                collection.registerSortableField('test_field', 'Test Field');
+                collection.registerSortableField('test_field_2', 'Test Field 2');
+                collection.setSortField('test_field', true);
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
+                collection.setSortField('test_field', true);
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.ASCENDING);
+                collection.setSortField('test_field', true);
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
+                collection.setSortField('test_field_2', true);
+                expect(collection.sortDirection).toBe(PagingCollection.SortDirection.DESCENDING);
+            });
+
+            SpecHelpers.withData({
+                'queries with page, page_size, and sort_order parameters when zero indexed': [true, 2],
+                'queries with page, page_size, and sort_order parameters when one indexed': [false, 3],
+            }, function (isZeroIndexed, page) {
+                collection.isZeroIndexed = isZeroIndexed;
+                collection.perPage = 5;
+                collection.sortField = 'test_field';
+                collection.setPage(3);
+                assertQueryParams({'page': page.toString(), 'page_size': '5', 'sort_order': 'test_field'});
+            });
+
+            SpecHelpers.withConfiguration({
+                'using a zero indexed collection': [true],
+                'using a one indexed collection': [false]
+            }, function (isZeroIndexed) {
+                collection.isZeroIndexed = isZeroIndexed;
+                server.isZeroIndexed = isZeroIndexed;
+            }, function () {
+                describe('setPage', function() {
+                    it('triggers a reset event when the page changes successfully', function () {
+                        var resetTriggered = false;
+                        collection.on('reset', function () { resetTriggered = true; });
+                        collection.setPage(3);
+                        server.respond();
+                        expect(resetTriggered).toBe(true);
+                    });
+
+                    it('triggers an error event when the requested page is out of range', function () {
+                        var errorTriggered = false;
+                        collection.on('error', function () { errorTriggered = true; });
+                        collection.setPage(17);
+                        server.respond();
+                        expect(errorTriggered).toBe(true);
+                    });
+
+                    it('triggers an error event if the server responds with a 500', function () {
+                        var errorTriggered = false;
+                        collection.on('error', function () { errorTriggered = true; });
+                        collection.setPage(2);
+                        expect(collection.getPage()).toBe(2);
+                        server.respond();
+                        collection.setPage(3);
+                        AjaxHelpers.respondWithError(requests, 500, {}, requests.length - 1);
+                        expect(errorTriggered).toBe(true);
+                        expect(collection.getPage()).toBe(2);
+                    });
+                });
+
+                describe('getPage', function () {
+                    it('returns the correct page', function () {
+                        collection.setPage(1);
+                        server.respond();
+                        expect(collection.getPage()).toBe(1);
+                        collection.setPage(3);
+                        server.respond();
+                        expect(collection.getPage()).toBe(3);
+                    });
+                });
+
+                describe('hasNextPage', function () {
+                    SpecHelpers.withData(
+                        {
+                            'returns false for a single page': [1, 3, false],
+                            'returns true on the first page': [1, 43, true],
+                            'returns true on the penultimate page': [4, 43, true],
+                            'returns false on the last page': [5, 43, false]
+                        },
+                        function (page, count, result) {
+                            server.count = count;
+                            collection.setPage(page);
+                            server.respond();
+                            expect(collection.hasNextPage()).toBe(result);
+                        }
+                    );
+                });
+
+                describe('hasPreviousPage', function () {
+                    SpecHelpers.withData(
+                        {
+                            'returns false for a single page': [1, 3, false],
+                            'returns true on the last page': [5, 43, true],
+                            'returns true on the second page': [2, 43, true],
+                            'returns false on the first page': [1, 43, false]
+                        },
+                        function (page, count, result) {
+                            server.count = count;
+                            collection.setPage(page);
+                            server.respond();
+                            expect(collection.hasPreviousPage()).toBe(result);
+                        }
+                    );
+                });
+
+                describe('nextPage', function () {
+                    SpecHelpers.withData(
+                        {
+                            'advances to the next page': [2, 43, 3],
+                            'silently fails on the last page': [5, 43, 5]
+                        },
+                        function (page, count, newPage) {
+                            server.count = count;
+                            collection.setPage(page);
+                            server.respond();
+                            expect(collection.getPage()).toBe(page);
+                            collection.nextPage();
+                            if (requests.length > 1) {
+                                server.respond();
+                            }
+                            expect(collection.getPage()).toBe(newPage);
+                        }
+                    );
+                });
+
+                describe('previousPage', function () {
+                    SpecHelpers.withData(
+                        {
+                            'moves to the previous page': [2, 43, 1],
+                            'silently fails on the first page': [1, 43, 1]
+                        },
+                        function (page, count, newPage) {
+                            server.count = count;
+                            collection.setPage(page);
+                            server.respond();
+                            expect(collection.getPage()).toBe(page);
+                            collection.previousPage();
+                            if (requests.length > 1) {
+                                server.respond();
+                            }
+                            expect(collection.getPage()).toBe(newPage);
+                        }
+                    )
+                });
+            });
+        });
+    }
+);
diff --git a/common/static/common/js/spec/components/paging_footer_spec.js b/common/static/common/js/spec/components/paging_footer_spec.js
new file mode 100644
index 0000000..0b4d905
--- /dev/null
+++ b/common/static/common/js/spec/components/paging_footer_spec.js
@@ -0,0 +1,178 @@
+define([
+    'URI',
+    'underscore',
+    'common/js/spec_helpers/ajax_helpers',
+    'common/js/components/views/paging_footer',
+    'common/js/components/collections/paging_collection'
+], function (URI, _, AjaxHelpers, PagingFooter, PagingCollection) {
+    'use strict';
+    describe("PagingFooter", function () {
+        var pagingFooter,
+            mockPage = function (currentPage, numPages, collectionLength) {
+                if (_.isUndefined(collectionLength)) {
+                    collectionLength = 1;
+                }
+                return {
+                    count: null,
+                    current_page: currentPage,
+                    num_pages: numPages,
+                    start: null,
+                    results: _.map(_.range(collectionLength), function() { return {}; }) // need to have non-empty collection to render
+                };
+            },
+            nextPageCss = '.next-page-link',
+            previousPageCss = '.previous-page-link',
+            currentPageCss = '.current-page',
+            totalPagesCss = '.total-pages',
+            pageNumberInputCss = '.page-number-input';
+
+        beforeEach(function () {
+            setFixtures('<div class="paging-footer"></div>');
+            pagingFooter = new PagingFooter({
+                el: $('.paging-footer'),
+                collection: new PagingCollection(mockPage(1, 2), {parse: true})
+            }).render();
+        });
+
+        describe('when hideWhenOnePage is true', function () {
+            beforeEach(function () {
+                pagingFooter.hideWhenOnePage = true;
+            });
+
+            it('should not render itself for an empty collection', function () {
+                pagingFooter.collection.reset(mockPage(0, 0, 0), {parse: true});
+                expect(pagingFooter.$el).toHaveClass('hidden');
+            });
+
+            it('should not render itself for a dataset with just one page', function () {
+                pagingFooter.collection.reset(mockPage(1, 1), {parse: true});
+                expect(pagingFooter.$el).toHaveClass('hidden');
+            });
+        });
+
+        describe('when hideWhenOnepage is false', function () {
+            it('should render itself for an empty collection', function () {
+                pagingFooter.collection.reset(mockPage(0, 0, 0), {parse: true});
+                expect(pagingFooter.$el).not.toHaveClass('hidden');
+            });
+
+            it('should render itself for a dataset with just one page', function () {
+                pagingFooter.collection.reset(mockPage(1, 1), {parse: true});
+                expect(pagingFooter.$el).not.toHaveClass('hidden');
+            });
+        });
+
+        describe("Next page button", function () {
+            it('does not move forward if a server error occurs', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                requests[0].respond(500);
+                expect(pagingFooter.$(currentPageCss)).toHaveText('1');
+            });
+
+            it('can move to the next page', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                expect(pagingFooter.collection.currentPage).toBe(2);
+            });
+
+            it('should be enabled when there is at least one more page', function () {
+                // in beforeEach we're set up on page 1 out of 2
+                expect(pagingFooter.$(nextPageCss)).not.toHaveClass('is-disabled');
+            });
+
+            it('should be disabled on the final page', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                expect(pagingFooter.$(nextPageCss)).toHaveClass('is-disabled');
+            });
+        });
+
+        describe("Previous page button", function () {
+            it('does not move back if a server error occurs', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.collection.reset(mockPage(2, 2), {parse: true});
+                pagingFooter.$(previousPageCss).click();
+                requests[0].respond(500);
+                expect(pagingFooter.$(currentPageCss)).toHaveText('2');
+            });
+
+            it('can go back a page', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                pagingFooter.$(previousPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(1, 2));
+                expect(pagingFooter.$(currentPageCss)).toHaveText('1');
+            });
+
+            it('should be disabled on the first page', function () {
+                expect(pagingFooter.$(previousPageCss)).toHaveClass('is-disabled');
+            });
+
+            it('should be enabled on the second page', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                expect(pagingFooter.$(previousPageCss)).not.toHaveClass('is-disabled');
+            });
+        });
+
+        describe("Current page label", function () {
+            it('should show 1 on the first page', function () {
+                expect(pagingFooter.$(currentPageCss)).toHaveText('1');
+            });
+
+            it('should show 2 on the second page', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(nextPageCss).click();
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                expect(pagingFooter.$(currentPageCss)).toHaveText('2');
+            });
+        });
+
+        describe("Page total label", function () {
+            it('should show the correct value with more than one page', function () {
+                expect(pagingFooter.$(totalPagesCss)).toHaveText('2');
+            });
+        });
+
+        describe("Page input field", function () {
+            beforeEach(function () {
+                pagingFooter.render();
+            });
+
+            it('should initially have a blank page input', function () {
+                expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
+            });
+
+            it('should handle invalid page requests', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(pageNumberInputCss).val('abc');
+                pagingFooter.$(pageNumberInputCss).trigger('change');
+                expect(pagingFooter.$(currentPageCss)).toHaveText('1');
+                expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
+            });
+
+            it('should switch pages via the input field', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(pageNumberInputCss).val('2');
+                pagingFooter.$(pageNumberInputCss).trigger('change');
+                AjaxHelpers.respondWithJson(requests, mockPage(2, 2));
+                expect(pagingFooter.$(currentPageCss)).toHaveText('2');
+                expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
+            });
+
+            it('should handle AJAX failures when switching pages via the input field', function () {
+                var requests = AjaxHelpers.requests(this);
+                pagingFooter.$(pageNumberInputCss).val('2');
+                pagingFooter.$(pageNumberInputCss).trigger('change');
+                requests[0].respond(500);
+                expect(pagingFooter.$(currentPageCss)).toHaveText('1');
+                expect(pagingFooter.$(pageNumberInputCss)).toHaveValue('');
+            });
+        });
+    });
+});
diff --git a/common/static/common/js/spec/components/paging_header_spec.js b/common/static/common/js/spec/components/paging_header_spec.js
new file mode 100644
index 0000000..fe7a9d0
--- /dev/null
+++ b/common/static/common/js/spec/components/paging_header_spec.js
@@ -0,0 +1,50 @@
+define([
+    'common/js/components/views/paging_header',
+    'common/js/components/collections/paging_collection'
+], function (PagingHeader, PagingCollection) {
+        'use strict';
+        describe('PagingHeader', function () {
+            var pagingHeader,
+                newCollection = function (size, perPage) {
+                    var pageSize = 5,
+                        results = _.map(_.range(size), function () { return {}; });
+                    var collection = new PagingCollection(
+                        {
+                            count: results.length,
+                            num_pages: results.length / pageSize,
+                            current_page: 1,
+                            start: 0,
+                            results: _.first(results, perPage)
+                        },
+                        {parse: true}
+                    );
+                    collection.start = 0;
+                    collection.totalCount = results.length;
+                    return collection;
+                };
+
+            it('correctly displays which items are being viewed', function () {
+                pagingHeader = new PagingHeader({
+                    collection: newCollection(20, 5)
+                }).render();
+                expect(pagingHeader.$el.find('.search-count').text())
+                    .toContain('Showing 1-5 out of 20 total');
+            });
+
+            it('reports that all items are on the current page', function () {
+                pagingHeader = new PagingHeader({
+                    collection: newCollection(5, 5)
+                }).render();
+                expect(pagingHeader.$el.find('.search-count').text())
+                    .toContain('Showing 1-5 out of 5 total');
+            });
+
+            it('reports that the page contains a single item', function () {
+                pagingHeader = new PagingHeader({
+                    collection: newCollection(1, 1)
+                }).render();
+                expect(pagingHeader.$el.find('.search-count').text())
+                    .toContain('Showing 1 out of 1 total');
+            });
+        });
+    });
diff --git a/common/static/common/js/spec_helpers/spec_helpers.js b/common/static/common/js/spec_helpers/spec_helpers.js
new file mode 100644
index 0000000..158be18
--- /dev/null
+++ b/common/static/common/js/spec_helpers/spec_helpers.js
@@ -0,0 +1,48 @@
+/**
+ * Generally useful helper functions for writing Jasmine unit tests.
+ */
+define([], function () {
+    'use strict';
+
+    /**
+     * Runs func as a test case multiple times, using entries from data as arguments. Like Python's DDT.
+     * @param data An object mapping test names to arrays of function parameters. The name is passed to it() as the name
+     *      of the test case, and the list of arguments is applied as arguments to func.
+     * @param func The function that actually expresses the logic of the test.
+     */
+    var withData = function (data, func) {
+        for (var name in data) {
+            if (data.hasOwnProperty(name)) {
+                it(name, function () {
+                    func.apply(this, data[name]);
+                });
+            }
+        }
+    };
+
+    /**
+     * Runs test multiple times, wrapping each call in a describe with beforeEach specified by setup and arguments and
+     * name coming from entries in config.
+     * @param config An object mapping configuration names to arrays of setup function parameters. The name is passed
+     *      to describe as the name of the group of tests, and the list of arguments is applied as arguments to setup.
+     * @param setup The function to setup the given configuration before each test case. Runs in beforeEach.
+     * @param test The function that actually express the logic of the test. May include it() or more describe().
+     */
+    var withConfiguration = function (config, setup, test) {
+        for (var name in config) {
+            if (config.hasOwnProperty(name)) {
+                describe(name, function () {
+                    beforeEach(function () {
+                        setup.apply(this, config[name]);
+                    });
+                    test();
+                });
+            }
+        }
+    };
+
+    return {
+        withData: withData,
+        withConfiguration: withConfiguration
+    };
+});
diff --git a/common/static/common/templates/components/paging-footer.underscore b/common/static/common/templates/components/paging-footer.underscore
index bb039ae..96a7179 100644
--- a/common/static/common/templates/components/paging-footer.underscore
+++ b/common/static/common/templates/components/paging-footer.underscore
@@ -1,16 +1,15 @@
-<nav class="pagination pagination-full bottom">
-<ol>
-    <li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
-    <li class="nav-item page">
+<nav class="pagination pagination-full bottom" aria-label="Teams Pagination">
+    <div class="nav-item previous"><button class="nav-link previous-page-link"><i class="icon fa fa-angle-left" aria-hidden="true"></i> <span class="nav-label"><%= gettext("Previous") %></span></button></div>
+    <div class="nav-item page">
         <div class="pagination-form">
             <label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label>
-            <input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" />
+            <input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" />
         </div>
 
-        <span class="current-page"><%= current_page + 1 %></span>
-        <span class="page-divider">/</span>
+        <span class="current-page"><%= current_page %></span>
+        <span class="sr">&nbsp;out of&nbsp;</span>
+        <span class="page-divider" aria-hidden="true">/</span>
         <span class="total-pages"><%= total_pages %></span>
-    </li>
-    <li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
-</ol>
+    </div>
+    <div class="nav-item next"><button class="nav-link next-page-link"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right" aria-hidden="true"></i></button></div>
 </nav>
diff --git a/common/static/common/templates/components/paging-header.underscore b/common/static/common/templates/components/paging-header.underscore
index 2829809..6d2011a 100644
--- a/common/static/common/templates/components/paging-header.underscore
+++ b/common/static/common/templates/components/paging-header.underscore
@@ -1,11 +1,5 @@
-<div class="meta-wrap">
-    <div class="meta">
-        <%= messageHtml %>
-    </div>
-    <nav class="pagination pagination-compact top">
-        <ol>
-            <li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
-            <li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
-        </ol>
-    </nav>
+<div class="search-tools">
+    <span class="search-count">
+        <%= message %>
+    </span>
 </div>
diff --git a/common/static/js/spec/main_requirejs.js b/common/static/js/spec/main_requirejs.js
index 8c300a3..42a204e 100644
--- a/common/static/js/spec/main_requirejs.js
+++ b/common/static/js/spec/main_requirejs.js
@@ -155,7 +155,10 @@
 
     define([
         // Run the common tests that use RequireJS.
-        'common-requirejs/include/common/js/spec/components/paging_spec.js'
+        'common-requirejs/include/common/js/spec/components/list_spec.js',
+        'common-requirejs/include/common/js/spec/components/paging_collection_spec.js',
+        'common-requirejs/include/common/js/spec/components/paging_header_spec.js',
+        'common-requirejs/include/common/js/spec/components/paging_footer_spec.js'
     ]);
 
 }).call(this, requirejs, define);
diff --git a/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js b/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js
index e615376..332d895 100644
--- a/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js
+++ b/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js
@@ -15,7 +15,7 @@ define(['jquery',
                         'name': 'Renewable Energy',
                         'description': 'Explore how changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ will affect our lives.',
                         'team_count': 34
-                    }),
+                    })
                 });
             });
 
@@ -23,8 +23,8 @@ define(['jquery',
                 expect(view.$el).toHaveClass('square-card');
                 expect(view.$el.find('.card-title').text()).toContain('Renewable Energy');
                 expect(view.$el.find('.card-description').text()).toContain('changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ');
-                expect(view.$el.find('.card-meta-details').text()).toContain('34 Teams');
-                expect(view.$el.find('.action').text()).toContain('View');
+                expect(view.$el.find('.card-meta').text()).toContain('34 Teams');
+                expect(view.$el.find('.action .sr').text()).toContain('View Teams in the Renewable Energy Topic');
             });
 
             it('navigates when action button is clicked', function () {
diff --git a/lms/djangoapps/teams/static/teams/js/views/topic_card.js b/lms/djangoapps/teams/static/teams/js/views/topic_card.js
index 165a407..8f013c5 100644
--- a/lms/djangoapps/teams/static/teams/js/views/topic_card.js
+++ b/lms/djangoapps/teams/static/teams/js/views/topic_card.js
@@ -41,7 +41,13 @@
                 description: function () { return this.model.get('description'); },
                 details: function () { return this.detailViews; },
                 actionClass: 'action-view',
-                actionContent: _.escape(gettext('View')) + ' <span class="icon fa-arrow-right"></span>'
+                actionContent: function () {
+                    var screenReaderText = _.escape(interpolate(
+                        gettext('View Teams in the %(topic_name)s Topic'),
+                        { topic_name: this.model.get('name') }, true
+                    ));
+                    return '<span class="sr">' + screenReaderText + '</span><i class="icon fa fa-arrow-right" aria-hidden="true"></i>';
+                }
             });
 
             return TopicCardView;
diff --git a/lms/djangoapps/teams/tests/test_views.py b/lms/djangoapps/teams/tests/test_views.py
index 9babb1f..15e0c1e 100644
--- a/lms/djangoapps/teams/tests/test_views.py
+++ b/lms/djangoapps/teams/tests/test_views.py
@@ -531,18 +531,19 @@ class TestListTopicsAPI(TeamAPITestCase):
         self.get_topics_list(400)
 
     @ddt.data(
-        (None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
-        ('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power']),
-        ('no_such_field', 400, []),
+        (None, 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
+        ('name', 200, ['Coal Power', 'Nuclear Power', u'sólar power', 'Wind Power'], 'name'),
+        ('no_such_field', 400, [], None),
     )
     @ddt.unpack
-    def test_order_by(self, field, status, names):
+    def test_order_by(self, field, status, names, expected_ordering):
         data = {'course_id': self.test_course_1.id}
         if field:
             data['order_by'] = field
         topics = self.get_topics_list(status, data)
         if status == 200:
             self.assertEqual(names, [topic['name'] for topic in topics['results']])
+            self.assertEqual(topics['sort_order'], expected_ordering)
 
     def test_pagination(self):
         response = self.get_topics_list(data={
@@ -556,6 +557,10 @@ class TestListTopicsAPI(TeamAPITestCase):
         self.assertIsNone(response['previous'])
         self.assertIsNotNone(response['next'])
 
+    def test_default_ordering(self):
+        response = self.get_topics_list(data={'course_id': self.test_course_1.id})
+        self.assertEqual(response['sort_order'], 'name')
+
 
 @ddt.ddt
 class TestDetailTopicAPI(TeamAPITestCase):
diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py
index 9d678d9..045a458 100644
--- a/lms/djangoapps/teams/views.py
+++ b/lms/djangoapps/teams/views.py
@@ -1,14 +1,15 @@
 """HTTP endpoints for the Teams API."""
 
 from django.shortcuts import render_to_response
-from opaque_keys.edx.keys import CourseKey
 from courseware.courses import get_course_with_access, has_access
 from django.http import Http404
 from django.conf import settings
+from django.core.paginator import Paginator
 from django.views.generic.base import View
 
 from rest_framework.generics import GenericAPIView
 from rest_framework.response import Response
+from rest_framework.reverse import reverse
 from rest_framework.views import APIView
 from rest_framework.authentication import (
     SessionAuthentication,
@@ -45,6 +46,10 @@ from .serializers import CourseTeamSerializer, CourseTeamCreationSerializer, Top
 from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam
 
 
+# Constants
+TOPICS_PER_PAGE = 12
+
+
 class TeamsDashboardView(View):
     """
     View methods related to the teams dashboard.
@@ -67,7 +72,13 @@ class TeamsDashboardView(View):
                 not has_access(request.user, 'staff', course, course.id):
             raise Http404
 
-        context = {"course": course}
+        sort_order = 'name'
+        topics = get_ordered_topics(course, sort_order)
+        topics_page = Paginator(topics, TOPICS_PER_PAGE).page(1)
+        topics_serializer = PaginationSerializer(instance=topics_page, context={'sort_order': sort_order})
+        context = {
+            "course": course, "topics": topics_serializer.data, "topics_url": reverse('topics_list', request=request)
+        }
         return render_to_response("teams/teams.html", context)
 
 
@@ -479,7 +490,7 @@ class TopicListView(GenericAPIView):
     authentication_classes = (OAuth2Authentication, SessionAuthentication)
     permission_classes = (permissions.IsAuthenticated,)
 
-    paginate_by = 10
+    paginate_by = TOPICS_PER_PAGE
     paginate_by_param = 'page_size'
     pagination_serializer_class = PaginationSerializer
     serializer_class = TopicSerializer
@@ -510,11 +521,9 @@ class TopicListView(GenericAPIView):
         if not has_team_api_access(request.user, course_id):
             return Response(status=status.HTTP_403_FORBIDDEN)
 
-        topics = course_module.teams_topics
-
         ordering = request.QUERY_PARAMS.get('order_by', 'name')
         if ordering == 'name':
-            topics = sorted(topics, key=lambda t: t['name'].lower())
+            topics = get_ordered_topics(course_module, ordering)
         else:
             return Response({
                 'developer_message': "unsupported order_by value {}".format(ordering),
@@ -523,9 +532,23 @@ class TopicListView(GenericAPIView):
 
         page = self.paginate_queryset(topics)
         serializer = self.get_pagination_serializer(page)
+        serializer.context = {'sort_order': ordering}
         return Response(serializer.data)  # pylint: disable=maybe-no-member
 
 
+def get_ordered_topics(course_module, ordering):
+    """Return a sorted list of team topics.
+
+    Arguments:
+        course_module (xmodule): the course which owns the team topics
+        ordering (str): the key belonging to topic dicts by which we sort
+
+    Returns:
+        list: a list of sorted team topics
+    """
+    return sorted(course_module.teams_topics, key=lambda t: t[ordering].lower())
+
+
 class TopicDetailView(APIView):
     """
         **Use Cases**
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 38f3e1e..a030bf9 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1634,7 +1634,6 @@ STATICFILES_IGNORE_PATTERNS = (
 
     # Symlinks used by js-test-tool
     "xmodule_js",
-    "common",
 )
 
 PIPELINE_UGLIFYJS_BINARY = 'node_modules/.bin/uglifyjs'
diff --git a/lms/static/js/components/card/views/card.js b/lms/static/js/components/card/views/card.js
index afa7098..4dfb680 100644
--- a/lms/static/js/components/card/views/card.js
+++ b/lms/static/js/components/card/views/card.js
@@ -6,6 +6,7 @@
  *      "square_card" or "list_card". Defaults to "square_card".
  * - action (function): Action to take when the action button is clicked. Defaults to a no-op.
  * - cardClass (string or function): Class name for this card's DOM element. Defaults to the empty string.
+ * - pennant (string or function): Text of the card's pennant. No pennant is displayed if this value is falsy.
  * - title (string or function): Title of the card. Defaults to the empty string.
  * - description (string or function): Description of the card. Defaults to the empty string.
  * - details (array or function): Array of child views to be rendered as details of this card. The class "meta-detail"
@@ -17,10 +18,10 @@
 ;(function (define) {
     'use strict';
     define(['jquery',
+            'underscore',
             'backbone',
-            'text!templates/components/card/square_card.underscore',
-            'text!templates/components/card/list_card.underscore'],
-        function ($, Backbone, squareCardTemplate, listCardTemplate) {
+            'text!templates/components/card/card.underscore'],
+        function ($, _, Backbone, cardTemplate) {
             var CardView = Backbone.View.extend({
                 events: {
                     'click .action' : 'action'
@@ -40,13 +41,11 @@
                 },
 
                 initialize: function () {
-                    this.template = this.switchOnConfiguration(
-                        _.template(squareCardTemplate),
-                        _.template(listCardTemplate)
-                    );
                     this.render();
                 },
 
+                template: _.template(cardTemplate),
+
                 switchOnConfiguration: function (square_result, list_result) {
                     return this.callIfFunction(this.configuration) === 'square_card' ?
                         square_result : list_result;
@@ -61,20 +60,30 @@
                 },
 
                 className: function () {
-                    return 'card ' +
-                        this.switchOnConfiguration('square-card', 'list-card') +
-                        ' ' + this.callIfFunction(this.cardClass);
+                    var result = 'card ' +
+                                 this.switchOnConfiguration('square-card', 'list-card') + ' ' +
+                                 this.callIfFunction(this.cardClass);
+                    if (this.callIfFunction(this.pennant)) {
+                        result += ' has-pennant';
+                    }
+                    return result;
                 },
 
                 render: function () {
+                    var maxLength = 72,
+                        description = this.callIfFunction(this.description);
+                    if (description.length > maxLength) {
+                        description = description.substring(0, maxLength).trim() + '...'
+                    }
                     this.$el.html(this.template({
+                        pennant: this.callIfFunction(this.pennant),
                         title: this.callIfFunction(this.title),
-                        description: this.callIfFunction(this.description),
+                        description: description,
                         action_class: this.callIfFunction(this.actionClass),
                         action_url: this.callIfFunction(this.actionUrl),
                         action_content: this.callIfFunction(this.actionContent)
                     }));
-                    var detailsEl = this.$el.find('.card-meta-details');
+                    var detailsEl = this.$el.find('.card-meta');
                     _.each(this.callIfFunction(this.details), function (detail) {
                         // Call setElement to rebind event handlers
                         detail.setElement(detail.el).render();
@@ -86,6 +95,7 @@
 
                 action: function () { },
                 cardClass: '',
+                pennant: '',
                 title: '',
                 description: '',
                 details: [],
diff --git a/lms/static/js/spec/components/card/card_spec.js b/lms/static/js/spec/components/card/card_spec.js
index f7ed92e..fbb6f1f 100644
--- a/lms/static/js/spec/components/card/card_spec.js
+++ b/lms/static/js/spec/components/card/card_spec.js
@@ -12,13 +12,20 @@
                 it('can render itself as a square card', function () {
                     var view = new CardView({ configuration: 'square_card' });
                     expect(view.$el).toHaveClass('square-card');
-                    expect(view.$el.find('.card-meta-wrapper .action').length).toBe(1);
+                    expect(view.$el.find('.wrapper-card-meta .action').length).toBe(1);
                 });
 
                 it('can render itself as a list card', function () {
                     var view = new CardView({ configuration: 'list_card' });
                     expect(view.$el).toHaveClass('list-card');
-                    expect(view.$el.find('.card-core-wrapper .action').length).toBe(1);
+                    expect(view.$el.find('.wrapper-card-meta .action').length).toBe(1);
+                });
+
+                it('renders a pennant only if the pennant value is truthy', function () {
+                    var view = new (CardView.extend({ pennant: '' }))();
+                    expect(view.$el.find('.card-type').length).toBe(0);
+                    view = new (CardView.extend({ pennant: 'Test Pennant' }))();
+                    expect(view.$el.find('.card-type').length).toBe(1);
                 });
 
                 it('can render child views', function () {
@@ -38,6 +45,7 @@
 
                 var verifyContent = function (view) {
                     expect(view.$el).toHaveClass('test-card');
+                    expect(view.$el.find('.card-type').text()).toContain('Pennant');
                     expect(view.$el.find('.card-title').text()).toContain('A test title');
                     expect(view.$el.find('.card-description').text()).toContain('A test description');
                     expect(view.$el.find('.action')).toHaveClass('test-action');
@@ -45,9 +53,10 @@
                     expect(view.$el.find('.action').text()).toContain('A test action');
                 };
 
-                it('can have strings for cardClass, title, description, and action', function () {
+                it('can have strings for cardClass, pennant, title, description, and action', function () {
                     var view = new (CardView.extend({
                         cardClass: 'test-card',
+                        pennant: 'Pennant',
                         title: 'A test title',
                         description: 'A test description',
                         actionClass: 'test-action',
@@ -57,9 +66,10 @@
                     verifyContent(view);
                 });
 
-                it('can have functions for cardClass, title, description, and action', function () {
+                it('can have functions for cardClass, pennant, title, description, and action', function () {
                     var view = new (CardView.extend({
                         cardClass: function () { return 'test-card'; },
+                        pennant: function () { return 'Pennant'; },
                         title: function () { return 'A test title'; },
                         description: function () { return 'A test description'; },
                         actionClass: function () { return 'test-action'; },
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js
index b90b713..cc9236f 100644
--- a/lms/static/js/spec/main.js
+++ b/lms/static/js/spec/main.js
@@ -31,6 +31,7 @@
             'backbone': 'xmodule_js/common_static/js/vendor/backbone-min',
             'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
             'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min',
+            'URI': 'xmodule_js/common_static/js/vendor/URI.min',
             "backbone-super": "js/vendor/backbone-super",
             'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
             'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml
index 8470fc0..dab385e 100644
--- a/lms/static/js_test.yml
+++ b/lms/static/js_test.yml
@@ -57,6 +57,7 @@ lib_paths:
     - xmodule_js/common_static/js/vendor/underscore-min.js
     - xmodule_js/common_static/js/vendor/underscore.string.min.js
     - xmodule_js/common_static/js/vendor/backbone-min.js
+    - xmodule_js/common_static/js/vendor/backbone.paginator.min.js
     - xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js
     - xmodule_js/common_static/js/test/i18n.js
     - xmodule_js/common_static/js/vendor/date.js
diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js
index 039f288..7a7c760 100644
--- a/lms/static/require-config-lms.js
+++ b/lms/static/require-config-lms.js
@@ -49,6 +49,7 @@
             "text": 'js/vendor/requirejs/text',
             "backbone": "js/vendor/backbone-min",
             "backbone-super": "js/vendor/backbone-super",
+            "backbone.paginator": "js/vendor/backbone.paginator.min",
             "underscore.string": "js/vendor/underscore.string.min",
             // Files needed by OVA
             "annotator": "js/vendor/ova/annotator-full",
@@ -89,6 +90,10 @@
                 deps: ["underscore", "jquery"],
                 exports: "Backbone"
             },
+            "backbone.paginator": {
+                deps: ["backbone"],
+                exports: "Backbone.Paginator"
+            },
             "backbone-super": {
                 deps: ["backbone"]
             },
diff --git a/lms/templates/components/card/list_card.underscore b/lms/templates/components/card/card.underscore
similarity index 70%
rename from lms/templates/components/card/list_card.underscore
rename to lms/templates/components/card/card.underscore
index 3bbfc27..022a6b5 100644
--- a/lms/templates/components/card/list_card.underscore
+++ b/lms/templates/components/card/card.underscore
@@ -1,13 +1,16 @@
-<div class="card-core-wrapper">
+<div class="wrapper-card-core">
     <div class="card-core">
+        <% if (pennant) { %>
+            <small class="card-type"><%- pennant %></small>
+        <% } %>
         <h3 class="card-title"><%- title %></h3>
         <p class="card-description"><%- description %></p>
     </div>
+</div>
+<div class="wrapper-card-meta has-actions">
+    <div class="card-meta">
+    </div>
     <div class="card-actions">
         <a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
     </div>
 </div>
-<div class="card-meta-wrapper">
-    <div class="card-meta-details">
-    </div>
-</div>
diff --git a/lms/templates/components/card/square_card.underscore b/lms/templates/components/card/square_card.underscore
deleted file mode 100644
index 252ba58..0000000
--- a/lms/templates/components/card/square_card.underscore
+++ /dev/null
@@ -1,13 +0,0 @@
-<div class="card-core-wrapper">
-    <div class="card-core">
-        <h3 class="card-title"><%- title %></h3>
-        <p class="card-description"><%- description %></p>
-    </div>
-</div>
-<div class="card-meta-wrapper has-actions">
-    <div class="card-meta-details">
-    </div>
-    <div class="card-actions">
-        <a class="action <%= action_class %>" href="<%= action_url %>"><%= action_content %></a>
-    </div>
-</div>
diff --git a/openedx/core/lib/api/serializers.py b/openedx/core/lib/api/serializers.py
index abd8e64..29c20a5 100644
--- a/openedx/core/lib/api/serializers.py
+++ b/openedx/core/lib/api/serializers.py
@@ -3,9 +3,30 @@ from rest_framework import pagination, serializers
 
 class PaginationSerializer(pagination.PaginationSerializer):
     """
-    Custom PaginationSerializer to include num_pages field
+    Custom PaginationSerializer for openedx.
+
+    Adds the following fields:
+        - num_pages: total number of pages
+        - current_page: the current page being returned
+        - start: the index of the first page item within the overall collection
     """
+    start_page = 1  # django Paginator.page objects have 1-based indexes
     num_pages = serializers.Field(source='paginator.num_pages')
+    current_page = serializers.SerializerMethodField('get_current_page')
+    start = serializers.SerializerMethodField('get_start')
+    sort_order = serializers.SerializerMethodField('get_sort_order')
+
+    def get_current_page(self, page):
+        """Get the current page"""
+        return page.number
+
+    def get_start(self, page):
+        """Get the index of the first page item within the overall collection"""
+        return (self.get_current_page(page) - self.start_page) * page.paginator.per_page
+
+    def get_sort_order(self, page):  # pylint: disable=unused-argument
+        """Get the order by which this collection was sorted"""
+        return self.context.get('sort_order')
 
 
 class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):