diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index ecf18c2f64ca659827894476a9988ba2f71d8cb9..7fa8d7a7b6c438b0d593fc3d6e8a60907efb0732 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _
 import django.utils
 from django.contrib.auth.decorators import login_required
 from django.conf import settings
-from django.views.decorators.http import require_http_methods
+from django.views.decorators.http import require_http_methods, require_GET
 from django.core.exceptions import PermissionDenied
 from django.core.urlresolvers import reverse
 from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
@@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_response
 from xmodule.course_module import DEFAULT_START_DATE
 from xmodule.error_module import ErrorDescriptor
 from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
 from xmodule.contentstore.content import StaticContent
 from xmodule.tabs import PDFTextbookTabs
 from xmodule.partitions.partitions import UserPartition
@@ -75,6 +76,7 @@ from course_action_state.managers import CourseActionStateItemNotFoundError
 from microsite_configuration import microsite
 from xmodule.course_module import CourseFields
 from xmodule.split_test_module import get_split_user_partitions
+from student.auth import has_course_author_access
 
 from util.milestones_helpers import (
     set_prerequisite_courses,
@@ -90,7 +92,7 @@ CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can
 CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
 
 __all__ = ['course_info_handler', 'course_handler', 'course_listing',
-           'course_info_update_handler',
+           'course_info_update_handler', 'course_search_index_handler',
            'course_rerun_handler',
            'settings_handler',
            'grading_handler',
@@ -121,6 +123,15 @@ def get_course_and_check_access(course_key, user, depth=0):
     return course_module
 
 
+def reindex_course_and_check_access(course_key, user):
+    """
+    Internal method used to restart indexing on a course.
+    """
+    if not has_course_author_access(user, course_key):
+        raise PermissionDenied()
+    return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key)
+
+
 @login_required
 def course_notifications_handler(request, course_key_string=None, action_state_id=None):
     """
@@ -283,6 +294,28 @@ def course_rerun_handler(request, course_key_string):
             })
 
 
+@login_required
+@ensure_csrf_cookie
+@require_GET
+def course_search_index_handler(request, course_key_string):
+    """
+    The restful handler for course indexing.
+    GET
+        html: return status of indexing task
+    """
+    # Only global staff (PMs) are able to index courses
+    if not GlobalStaff().has_user(request.user):
+        raise PermissionDenied()
+    course_key = CourseKey.from_string(course_key_string)
+    with modulestore().bulk_operations(course_key):
+        try:
+            reindex_course_and_check_access(course_key, request.user)
+        except SearchIndexingError as search_err:
+            return HttpResponse(search_err.error_list, status=500)
+
+        return HttpResponse({}, status=200)
+
+
 def _course_outline_json(request, course_module):
     """
     Returns a JSON representation of the course module and recursively all of its children.
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index 32b5ccbf2cf2bffa9bd13a45e1ada817e9faf137..ed226a926edbc1eebf1bb42d4898c4d89514b103 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -4,21 +4,28 @@ Unit tests for getting the list of courses and the course outline.
 import json
 import lxml
 import datetime
+import os
+import mock
 
 from contentstore.tests.utils import CourseTestCase
 from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor
 from student.auth import has_course_author_access
-from contentstore.views.course import course_outline_initial_state
+from contentstore.views.course import course_outline_initial_state, reindex_course_and_check_access
 from contentstore.views.item import create_xblock_info, VisibilityState
 from course_action_state.models import CourseRerunState
 from util.date_utils import get_default_time_display
 from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
+from xmodule.modulestore.exceptions import ItemNotFoundError
 from xmodule.modulestore.django import modulestore
 from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
+from xmodule.modulestore.courseware_index import SearchIndexingError
 from opaque_keys.edx.locator import CourseLocator
 from student.tests.factories import UserFactory
 from course_action_state.managers import CourseRerunUIStateManager
 from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from search.api import perform_search
 import pytz
 
 
@@ -226,6 +233,7 @@ class TestCourseOutline(CourseTestCase):
         Set up the for the course outline tests.
         """
         super(TestCourseOutline, self).setUp()
+
         self.chapter = ItemFactory.create(
             parent_location=self.course.location, category='chapter', display_name="Week 1"
         )
@@ -330,3 +338,304 @@ class TestCourseOutline(CourseTestCase):
 
         self.assertEqual(_get_release_date(response), get_default_time_display(self.course.start))
         _assert_settings_link_present(response)
+
+
+class TestCourseReIndex(CourseTestCase):
+    """
+    Unit tests for the course outline.
+    """
+
+    TEST_INDEX_FILENAME = "test_root/index_file.dat"
+
+    def setUp(self):
+        """
+        Set up the for the course outline tests.
+        """
+
+        super(TestCourseReIndex, self).setUp()
+
+        self.course.start = datetime.datetime(2014, 1, 1, tzinfo=pytz.utc)
+        modulestore().update_item(self.course, self.user.id)
+
+        self.chapter = ItemFactory.create(
+            parent_location=self.course.location, category='chapter', display_name="Week 1"
+        )
+        self.sequential = ItemFactory.create(
+            parent_location=self.chapter.location, category='sequential', display_name="Lesson 1"
+        )
+        self.vertical = ItemFactory.create(
+            parent_location=self.sequential.location, category='vertical', display_name='Subsection 1'
+        )
+        self.video = ItemFactory.create(
+            parent_location=self.vertical.location, category="video", display_name="My Video"
+        )
+
+        self.html = ItemFactory.create(
+            parent_location=self.vertical.location, category="html", display_name="My HTML",
+            data="<div>This is my unique HTML content</div>",
+
+        )
+
+        # create test file in which index for this test will live
+        with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
+            json.dump({}, index_file)
+
+    def test_reindex_course(self):
+        """
+        Verify that course gets reindexed.
+        """
+        index_url = reverse_course_url('course_search_index_handler', self.course.id)
+        response = self.client.get(index_url, {}, HTTP_ACCEPT='application/json')
+
+        # A course with the default release date should display as "Unscheduled"
+        self.assertEqual(response.content, '')
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(index_url, {}, HTTP_ACCEPT='application/json')
+        self.assertEqual(response.content, '')
+        self.assertEqual(response.status_code, 405)
+
+        self.client.logout()
+        response = self.client.get(index_url, {}, HTTP_ACCEPT='application/json')
+        self.assertEqual(response.status_code, 302)
+
+    def test_negative_conditions(self):
+        """
+        Test the error conditions for the access
+        """
+        index_url = reverse_course_url('course_search_index_handler', self.course.id)
+        # register a non-staff member and try to delete the course branch
+        non_staff_client, _ = self.create_non_staff_authed_user_client()
+        response = non_staff_client.get(index_url, {}, HTTP_ACCEPT='application/json')
+        self.assertEqual(response.status_code, 403)
+
+    def test_reindex_json_responses(self):
+        """
+        Test json response with real data
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # Start manual reindex
+        reindex_course_and_check_access(self.course.id, self.user)
+
+        self.html.display_name = "My expanded HTML"
+        modulestore().update_item(self.html, ModuleStoreEnum.UserID.test)
+
+        # Start manual reindex
+        reindex_course_and_check_access(self.course.id, self.user)
+
+        # Check results indexed now
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['total'], 1)
+
+    @mock.patch('xmodule.video_module.VideoDescriptor.index_dictionary')
+    def test_reindex_video_error_json_responses(self, mock_index_dictionary):
+        """
+        Test json response with mocked error data for video
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            reindex_course_and_check_access(self.course.id, self.user)
+
+    @mock.patch('xmodule.html_module.HtmlDescriptor.index_dictionary')
+    def test_reindex_html_error_json_responses(self, mock_index_dictionary):
+        """
+        Test json response with rmocked error data for html
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            reindex_course_and_check_access(self.course.id, self.user)
+
+    @mock.patch('xmodule.seq_module.SequenceDescriptor.index_dictionary')
+    def test_reindex_seq_error_json_responses(self, mock_index_dictionary):
+        """
+        Test json response with rmocked error data for sequence
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            reindex_course_and_check_access(self.course.id, self.user)
+
+    @mock.patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_course')
+    def test_reindex_no_item(self, mock_get_course):
+        """
+        Test system logs an error if no item found.
+        """
+        # set mocked exception response
+        err = ItemNotFoundError
+        mock_get_course.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            reindex_course_and_check_access(self.course.id, self.user)
+
+    def test_reindex_no_permissions(self):
+        # register a non-staff member and try to delete the course branch
+        user2 = UserFactory()
+        with self.assertRaises(PermissionDenied):
+            reindex_course_and_check_access(self.course.id, user2)
+
+    def test_indexing_responses(self):
+        """
+        Test add_to_search_index response with real data
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # Start manual reindex
+        errors = CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+        self.assertEqual(errors, None)
+
+        self.html.display_name = "My expanded HTML"
+        modulestore().update_item(self.html, ModuleStoreEnum.UserID.test)
+
+        # Start manual reindex
+        errors = CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+        self.assertEqual(errors, None)
+
+        # Check results indexed now
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['total'], 1)
+
+    @mock.patch('xmodule.video_module.VideoDescriptor.index_dictionary')
+    def test_indexing_video_error_responses(self, mock_index_dictionary):
+        """
+        Test add_to_search_index response with mocked error data for video
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+
+    @mock.patch('xmodule.html_module.HtmlDescriptor.index_dictionary')
+    def test_indexing_html_error_responses(self, mock_index_dictionary):
+        """
+        Test add_to_search_index response with mocked error data for html
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+
+    @mock.patch('xmodule.seq_module.SequenceDescriptor.index_dictionary')
+    def test_indexing_seq_error_responses(self, mock_index_dictionary):
+        """
+        Test add_to_search_index response with mocked error data for sequence
+        """
+        # Check results not indexed
+        response = perform_search(
+            "unique",
+            user=self.user,
+            size=10,
+            from_=0,
+            course_id=unicode(self.course.id))
+        self.assertEqual(response['results'], [])
+
+        # set mocked exception response
+        err = Exception
+        mock_index_dictionary.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+
+    @mock.patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_course')
+    def test_indexing_no_item(self, mock_get_course):
+        """
+        Test system logs an error if no item found.
+        """
+        # set mocked exception response
+        err = ItemNotFoundError
+        mock_get_course.return_value = err
+
+        # Start manual reindex and check error in response
+        with self.assertRaises(SearchIndexingError):
+            CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
+
+    def tearDown(self):
+        os.remove(self.TEST_INDEX_FILENAME)
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index cd8afef3329819e12d43471b1a87557154ed8c3f..3132b03b377077e8fa5759ddd97aa7726989f485 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -316,3 +316,7 @@ API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
 # Video Caching. Pairing country codes with CDN URLs.
 # Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='}
 VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {})
+
+if FEATURES['ENABLE_COURSEWARE_INDEX']:
+    # Use ElasticSearch for the search engine
+    SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index 6c645e54543d5ebc48e0720a19853dbee2f841fc..0eaf66155fe5ae92429cbcdd40f63ebaeb913190 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -77,6 +77,13 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
 YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT)
 YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
 
+FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+# Path at which to store the mock index
+MOCK_SEARCH_BACKING_FILE = (
+    TEST_ROOT / "index_file.dat"  # pylint: disable=no-value-for-parameter
+).abspath()
+
 #####################################################################
 # Lastly, see if the developer has any local overrides.
 try:
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 7335f43a4fbcb3d50cec150e436c489ea170ce65..071091b94817e994589767ab56f47cb157f3e0a2 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -146,6 +146,9 @@ FEATURES = {
 
     # Toggle course entrance exams feature
     'ENTRANCE_EXAMS': False,
+
+    # Enable the courseware search functionality
+    'ENABLE_COURSEWARE_INDEX': False,
 }
 
 ENABLE_JASMINE = False
@@ -868,3 +871,11 @@ FILES_AND_UPLOAD_TYPE_FILTERS = {
         'application/vnd.ms-powerpoint',
     ],
 }
+
+# Default to no Search Engine
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+ELASTIC_FIELD_MAPPINGS = {
+    "start_date": {
+        "type": "date"
+    }
+}
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index f714172955441595e718ca30df3f8a5822e2487f..1b8f94c92ae1e9baa5986859157bfe2db76de5d2 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -83,6 +83,9 @@ FEATURES['MILESTONES_APP'] = True
 ################################ ENTRANCE EXAMS ################################
 FEATURES['ENTRANCE_EXAMS'] = True
 
+################################ SEARCH INDEX ################################
+FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
 
 ###############################################################################
 # See if the developer has any local overrides.
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 7b4be67f2f081cc3060077e9ac41a1c280323ae7..ecc9369277b5913255cc6ff939b0deecfbe612c8 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -254,3 +254,11 @@ ENTRANCE_EXAM_MIN_SCORE_PCT = 50
 VIDEO_CDN_URL = {
     'CN': 'http://api.xuetangx.com/edx/video?s3_url='
 }
+
+# Courseware Search Index
+FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+# Path at which to store the mock index
+MOCK_SEARCH_BACKING_FILE = (
+    TEST_ROOT / "index_file.dat"  # pylint: disable=no-value-for-parameter
+).abspath()
diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js
index 77462d11379e90593bcf7ae74d89fe66476f5bbe..bfc0285fe643508476a03189048f4a08b0d4ad73 100644
--- a/cms/static/js/spec/views/pages/course_outline_spec.js
+++ b/cms/static/js/spec/views/pages/course_outline_spec.js
@@ -8,7 +8,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
                 getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState,
                 collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON,
                 verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON,
-                createMockVerticalJSON,
+                createMockVerticalJSON, createMockIndexJSON,
                 mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'),
                 mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore');
 
@@ -88,6 +88,21 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
                 }, options);
             };
 
+            createMockIndexJSON = function(option) {
+                if(option){
+                    return {
+                        status: 200,
+                        responseText: ''
+                    };
+                }
+                else {
+                    return {
+                        status: 500,
+                        responseText: JSON.stringify('Could not index item: course/slashes:mock+item')
+                    };
+                }
+            };
+
             getItemsOfType = function(type) {
                 return outlinePage.$('.outline-' + type);
             };
@@ -308,6 +323,28 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
                     outlinePage.$('.nav-actions .button-toggle-expand-collapse .expand-all').click();
                     verifyItemsExpanded('section', true);
                 });
+
+                it('can start reindex of a course - respond success', function() {
+                    createCourseOutlinePage(this, mockSingleSectionCourseJSON);
+                    var reindexSpy = spyOn(outlinePage, 'startReIndex').andCallThrough();
+                    var successSpy = spyOn(outlinePage, 'onIndexSuccess').andCallThrough();
+                    var reindexButton = outlinePage.$('.button.button-reindex');
+                    reindexButton.trigger('click');
+                    AjaxHelpers.expectJsonRequest(requests, 'GET', '/course_search_index/5');
+                    AjaxHelpers.respondWithJson(requests, createMockIndexJSON(true));
+                    expect(reindexSpy).toHaveBeenCalled();
+                    expect(successSpy).toHaveBeenCalled();
+                });
+
+                it('can start reindex of a course - respond fail', function() {
+                    createCourseOutlinePage(this, mockSingleSectionCourseJSON);
+                    var reindexSpy = spyOn(outlinePage, 'startReIndex').andCallThrough();
+                    var reindexButton = outlinePage.$('.button.button-reindex');
+                    reindexButton.trigger('click');
+                    AjaxHelpers.expectJsonRequest(requests, 'GET', '/course_search_index/5');
+                    AjaxHelpers.respondWithJson(requests, createMockIndexJSON(false));
+                    expect(reindexSpy).toHaveBeenCalled();
+                });
             });
 
             describe("Empty course", function() {
diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js
index 4e815fd08bd375403ff98ced83f8101be2508416..806e135e2de130a833fd6cb31ccf3b8001609fca 100644
--- a/cms/static/js/views/pages/course_outline.js
+++ b/cms/static/js/views/pages/course_outline.js
@@ -2,8 +2,8 @@
  * This page is used to show the user an outline of the course.
  */
 define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/xblock_utils",
-        "js/views/course_outline", "js/views/utils/view_utils"],
-    function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils) {
+        "js/views/course_outline", "js/views/utils/view_utils", "js/views/feedback_alert"],
+    function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, AlertView) {
         var expandedLocators, CourseOutlinePage;
 
         CourseOutlinePage = BasePage.extend({
@@ -24,6 +24,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
                 this.$('.button-new').click(function(event) {
                     self.outlineView.handleAddEvent(event);
                 });
+                this.$('.button.button-reindex').click(function(event) {
+                    self.handleReIndexEvent(event);
+                });
                 this.model.on('change', this.setCollapseExpandVisibility, this);
                 $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
                     $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
@@ -100,6 +103,32 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
                         }
                     }, this);
                 }
+            },
+
+            handleReIndexEvent: function(event) {
+                var self = this;
+                event.preventDefault();
+                var target = $(event.currentTarget);
+                target.css('cursor', 'wait');
+                this.startReIndex()
+                    .done(function() {self.onIndexSuccess();})
+                    .always(function() {target.css('cursor', 'pointer');});
+            },
+
+            startReIndex: function() {
+                var locator =  window.course.id;
+                return $.ajax({
+                    url: '/course_search_index/' + locator,
+                    method: 'GET'
+                    });
+            },
+
+            onIndexSuccess: function() {
+                var msg = new AlertView.Announcement({
+                        title: gettext('Course Index'),
+                        message: gettext('Course has been successfully reindexed.')
+                    });
+                msg.show();
             }
         });
 
diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html
index 032c4e7891641871ec69aaccc1a033b4b2b6e63c..aac283fa0fa2381d0867fc3fdc7696da39c812b8 100644
--- a/cms/templates/course_outline.html
+++ b/cms/templates/course_outline.html
@@ -68,6 +68,11 @@ from contentstore.utils import reverse_usage_url
                         <i class="icon fa fa-plus"></i>${_('New Section')}
                     </a>
                 </li>
+                <li class="nav-item">
+                    <a href="#" class="button button-reindex" data-category="reindex" title="${_('Reindex current course')}">
+                        <i class="icon-arrow-right"></i>${_('Reindex')}
+                    </a>
+                </li>
                 <li class="nav-item">
                     <a href="#" class="button button-toggle button-toggle-expand-collapse collapse-all is-hidden">
                         <span class="collapse-all"><i class="icon fa fa-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></span>
diff --git a/cms/templates/js/mock/mock-course-outline-page.underscore b/cms/templates/js/mock/mock-course-outline-page.underscore
index ed1f8b81284c9b4800478bdbf8e6054230eb4636..2b543db7c497854edf1b119c948f5fec096c9579 100644
--- a/cms/templates/js/mock/mock-course-outline-page.underscore
+++ b/cms/templates/js/mock/mock-course-outline-page.underscore
@@ -21,6 +21,11 @@
                             <i class="icon fa fa-plus"></i>New Section
                         </a>
                     </li>
+                    <li class="nav-item">
+                        <a title="Reindex current course" data-category="reindex" class="button button-reindex" href="#">
+                            <i class="icon-arrow-right"></i>Reindex
+                        </a>
+                    </li>
                     <li class="nav-item">
                         <a href="#" rel="external" class="button view-button view-live-button" title="Click to open the courseware in the LMS in a new tab">View Live</a>
                     </li>
diff --git a/cms/urls.py b/cms/urls.py
index 4a216e1883c17b363c5f23dec8da2415571dcd00..4ca692f109758b290bf83cdd65fad7d67e29cd71 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -80,6 +80,11 @@ urlpatterns += patterns(
         'course_info_update_handler'
     ),
     url(r'^home/$', 'course_listing', name='home'),
+    url(
+        r'^course_search_index/{}?$'.format(settings.COURSE_KEY_PATTERN),
+        'course_search_index_handler',
+        name='course_search_index_handler'
+    ),
     url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'),
     url(r'^course_notifications/{}/(?P<action_state_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'),
     url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index acea5a3e55e5f00f687a297b50bede9dd645d662..13ca86894b48731095b2c5b3fecb0957746769ee 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -17,7 +17,8 @@ import textwrap
 from xmodule.contentstore.content import StaticContent
 from xblock.core import XBlock
 from xmodule.edxnotes_utils import edxnotes
-
+from xmodule.annotator_mixin import html_to_text
+import re
 
 log = logging.getLogger("edx.courseware")
 
@@ -253,6 +254,25 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
         non_editable_fields.append(HtmlDescriptor.use_latex_compiler)
         return non_editable_fields
 
+    def index_dictionary(self):
+        xblock_body = super(HtmlDescriptor, self).index_dictionary()
+        # Removing HTML-encoded non-breaking space characters
+        html_content = re.sub(r"(\s|&nbsp;|//)+", " ", html_to_text(self.data))
+        # Removing HTML CDATA
+        html_content = re.sub(r"<!\[CDATA\[.*\]\]>", "", html_content)
+        # Removing HTML comments
+        html_content = re.sub(r"<!--.*-->", "", html_content)
+        html_body = {
+            "html_content": html_content,
+            "display_name": self.display_name,
+        }
+        if "content" in xblock_body:
+            xblock_body["content"].update(html_body)
+        else:
+            xblock_body["content"] = html_body
+        xblock_body["content_type"] = "HTML Content"
+        return xblock_body
+
 
 class AboutFields(object):
     display_name = String(
diff --git a/common/lib/xmodule/xmodule/modulestore/courseware_index.py b/common/lib/xmodule/xmodule/modulestore/courseware_index.py
new file mode 100644
index 0000000000000000000000000000000000000000..1aebdd3babcb4f074c9408bddd767b9585c0aa43
--- /dev/null
+++ b/common/lib/xmodule/xmodule/modulestore/courseware_index.py
@@ -0,0 +1,135 @@
+""" Code to allow module store to interface with courseware index """
+from __future__ import absolute_import
+
+import logging
+
+from django.utils.translation import ugettext as _
+from opaque_keys.edx.locator import CourseLocator
+from search.search_engine_base import SearchEngine
+
+from . import ModuleStoreEnum
+from .exceptions import ItemNotFoundError
+
+# Use default index and document names for now
+INDEX_NAME = "courseware_index"
+DOCUMENT_TYPE = "courseware_content"
+
+log = logging.getLogger('edx.modulestore')
+
+
+class SearchIndexingError(Exception):
+    """ Indicates some error(s) occured during indexing """
+
+    def __init__(self, message, error_list):
+        super(SearchIndexingError, self).__init__(message)
+        self.error_list = error_list
+
+
+class CoursewareSearchIndexer(object):
+    """
+    Class to perform indexing for courseware search from different modulestores
+    """
+
+    @staticmethod
+    def add_to_search_index(modulestore, location, delete=False, raise_on_error=False):
+        """
+        Add to courseware search index from given location and its children
+        """
+        error_list = []
+        # TODO - inline for now, need to move this out to a celery task
+        searcher = SearchEngine.get_search_engine(INDEX_NAME)
+        if not searcher:
+            return
+
+        if isinstance(location, CourseLocator):
+            course_key = location
+        else:
+            course_key = location.course_key
+
+        location_info = {
+            "course": unicode(course_key),
+        }
+
+        def _fetch_item(item_location):
+            """ Fetch the item from the modulestore location, log if not found, but continue """
+            try:
+                if isinstance(item_location, CourseLocator):
+                    item = modulestore.get_course(item_location)
+                else:
+                    item = modulestore.get_item(item_location, revision=ModuleStoreEnum.RevisionOption.published_only)
+            except ItemNotFoundError:
+                log.warning('Cannot find: %s', item_location)
+                return None
+
+            return item
+
+        def index_item_location(item_location, current_start_date):
+            """ add this item to the search index """
+            item = _fetch_item(item_location)
+            if not item:
+                return
+
+            is_indexable = hasattr(item, "index_dictionary")
+            # if it's not indexable and it does not have children, then ignore
+            if not is_indexable and not item.has_children:
+                return
+
+            # if it has a defined start, then apply it and to it's children
+            if item.start and (not current_start_date or item.start > current_start_date):
+                current_start_date = item.start
+
+            if item.has_children:
+                for child_loc in item.children:
+                    index_item_location(child_loc, current_start_date)
+
+            item_index = {}
+            item_index_dictionary = item.index_dictionary() if is_indexable else None
+
+            # if it has something to add to the index, then add it
+            if item_index_dictionary:
+                try:
+                    item_index.update(location_info)
+                    item_index.update(item_index_dictionary)
+                    item_index['id'] = unicode(item.scope_ids.usage_id)
+                    if current_start_date:
+                        item_index['start_date'] = current_start_date
+
+                    searcher.index(DOCUMENT_TYPE, item_index)
+                except Exception as err:  # pylint: disable=broad-except
+                    # broad exception so that index operation does not fail on one item of many
+                    log.warning('Could not index item: %s - %s', item_location, unicode(err))
+                    error_list.append(_('Could not index item: {}').format(item_location))
+
+        def remove_index_item_location(item_location):
+            """ remove this item from the search index """
+            item = _fetch_item(item_location)
+            if item:
+                if item.has_children:
+                    for child_loc in item.children:
+                        remove_index_item_location(child_loc)
+
+                searcher.remove(DOCUMENT_TYPE, unicode(item.scope_ids.usage_id))
+
+        try:
+            if delete:
+                remove_index_item_location(location)
+            else:
+                index_item_location(location, None)
+        except Exception as err:  # pylint: disable=broad-except
+            # broad exception so that index operation does not prevent the rest of the application from working
+            log.exception(
+                "Indexing error encountered, courseware index may be out of date %s - %s",
+                course_key,
+                unicode(err)
+            )
+            error_list.append(_('General indexing error occurred'))
+
+        if raise_on_error and error_list:
+            raise SearchIndexingError(_('Error(s) present during indexing'), error_list)
+
+    @classmethod
+    def do_course_reindex(cls, modulestore, course_key):
+        """
+        (Re)index all content within the given course
+        """
+        return cls.add_to_search_index(modulestore, course_key, delete=False, raise_on_error=True)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index eaf808d18d8478e27b7f6ba371a4e030f6ea8961..a0d80013591f261a6a7f63c097791e52b8d17641 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -278,7 +278,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
                     raw_metadata = json_data.get('metadata', {})
                     # published_on was previously stored as a list of time components instead of a datetime
                     if raw_metadata.get('published_date'):
-                        module._edit_info['published_date'] = datetime(*raw_metadata.get('published_date')[0:6]).replace(tzinfo=UTC)
+                        module._edit_info['published_date'] = datetime(
+                            *raw_metadata.get('published_date')[0:6]
+                        ).replace(tzinfo=UTC)
                     module._edit_info['published_by'] = raw_metadata.get('published_by')
 
                 # decache any computed pending field settings
@@ -1804,3 +1806,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
 
         # To allow prioritizing draft vs published material
         self.collection.create_index('_id.revision')
+
+    # Some overrides that still need to be implemented by subclasses
+    def convert_to_draft(self, location, user_id):
+        raise NotImplementedError()
+
+    def delete_item(self, location, user_id, **kwargs):
+        raise NotImplementedError()
+
+    def has_changes(self, xblock):
+        raise NotImplementedError()
+
+    def has_published_version(self, xblock):
+        raise NotImplementedError()
+
+    def publish(self, location, user_id):
+        raise NotImplementedError()
+
+    def revert_to_published(self, location, user_id):
+        raise NotImplementedError()
+
+    def unpublish(self, location, user_id):
+        raise NotImplementedError()
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
index d603287b082324ae1fe13d7fdce788a7b42ec080..fc1c174b8fe2cac9d7def7323d9db62781953f84 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
@@ -12,6 +12,7 @@ import logging
 from opaque_keys.edx.locations import Location
 from xmodule.exceptions import InvalidVersionError
 from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
 from xmodule.modulestore.exceptions import (
     ItemNotFoundError, DuplicateItemError, DuplicateCourseError, InvalidBranchSetting
 )
@@ -509,7 +510,8 @@ class DraftModuleStore(MongoModuleStore):
                 parent_locations = [draft_parent.location]
         # there could be 2 parents if
         #   Case 1: the draft item moved from one parent to another
-        #   Case 2: revision==ModuleStoreEnum.RevisionOption.all and the single parent has 2 versions: draft and published
+        # Case 2: revision==ModuleStoreEnum.RevisionOption.all and the single
+        # parent has 2 versions: draft and published
         for parent_location in parent_locations:
             # don't remove from direct_only parent if other versions of this still exists (this code
             # assumes that there's only one parent_location in this case)
@@ -541,6 +543,10 @@ class DraftModuleStore(MongoModuleStore):
             )
         self._delete_subtree(location, as_functions)
 
+        # Remove this location from the courseware search index so that searches
+        # will refrain from showing it as a result
+        CoursewareSearchIndexer.add_to_search_index(self, location, delete=True)
+
     def _delete_subtree(self, location, as_functions, draft_only=False):
         """
         Internal method for deleting all of the subtree whose revisions match the as_functions
@@ -713,6 +719,10 @@ class DraftModuleStore(MongoModuleStore):
             bulk_record = self._get_bulk_ops_record(location.course_key)
             bulk_record.dirty = True
             self.collection.remove({'_id': {'$in': to_be_deleted}})
+
+        # Now it's been published, add the object to the courseware search index so that it appears in search results
+        CoursewareSearchIndexer.add_to_search_index(self, location)
+
         return self.get_item(as_published(location))
 
     def unpublish(self, location, user_id, **kwargs):
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
index 7ebb9d94e535e5821dd914e29c1400c62c8943c8..1340f06c2b501bcb1ae81f2783292954a6179b26 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
@@ -5,6 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
 from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
 from xmodule.exceptions import InvalidVersionError
 from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
 from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
 from xmodule.modulestore.draft_and_published import (
     ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
@@ -203,6 +204,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
                 if branch == ModuleStoreEnum.BranchName.draft and branched_location.block_type in DIRECT_ONLY_CATEGORIES:
                     self.publish(parent_loc.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
 
+        # Remove this location from the courseware search index so that searches
+        # will refrain from showing it as a result
+        CoursewareSearchIndexer.add_to_search_index(self, location, delete=True)
+
     def _map_revision_to_branch(self, key, revision=None):
         """
         Maps RevisionOptions to BranchNames, inserting them into the key
@@ -345,6 +350,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
             [location],
             blacklist=blacklist
         )
+
+        # Now it's been published, add the object to the courseware search index so that it appears in search results
+        CoursewareSearchIndexer.add_to_search_index(self, location)
+
         return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.published), **kwargs)
 
     def unpublish(self, location, user_id, **kwargs):
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index 2156f0d18f70ab4f87b5627e61f721cbc155f65e..eab9168c1a33f5f39ab2eb891ee1b2741b32e5d0 100644
--- a/common/lib/xmodule/xmodule/seq_module.py
+++ b/common/lib/xmodule/xmodule/seq_module.py
@@ -188,3 +188,22 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
         for child in self.get_children():
             self.runtime.add_block_as_child_node(child, xml_object)
         return xml_object
+
+    def index_dictionary(self):
+        """
+        Return dictionary prepared with module content and type for indexing.
+        """
+        # return key/value fields in a Python dict object
+        # values may be numeric / string or dict
+        # default implementation is an empty dict
+        xblock_body = super(SequenceDescriptor, self).index_dictionary()
+        html_body = {
+            "display_name": self.display_name,
+        }
+        if "content" in xblock_body:
+            xblock_body["content"].update(html_body)
+        else:
+            xblock_body["content"] = html_body
+        xblock_body["content_type"] = self.category.title()
+
+        return xblock_body
diff --git a/common/lib/xmodule/xmodule/tests/test_html_module.py b/common/lib/xmodule/xmodule/tests/test_html_module.py
index da1e0d49a63f9caa4a450b0732de5f727a7292b2..e55882ebe5395d91a37c7fae818e603f6eabd73d 100644
--- a/common/lib/xmodule/xmodule/tests/test_html_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_html_module.py
@@ -3,9 +3,25 @@ import unittest
 from mock import Mock
 
 from xblock.field_data import DictFieldData
-from xmodule.html_module import HtmlModule
+from xmodule.html_module import HtmlModule, HtmlDescriptor
 
-from . import get_test_system
+from . import get_test_system, get_test_descriptor_system
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from xblock.fields import ScopeIds
+
+
+def instantiate_descriptor(**field_data):
+    """
+    Instantiate descriptor with most properties.
+    """
+    system = get_test_descriptor_system()
+    course_key = SlashSeparatedCourseKey('org', 'course', 'run')
+    usage_key = course_key.make_usage_key('html', 'SampleHtml')
+    return system.construct_xblock_from_class(
+        HtmlDescriptor,
+        scope_ids=ScopeIds(None, None, usage_key, usage_key),
+        field_data=DictFieldData(field_data),
+    )
 
 
 class HtmlModuleSubstitutionTestCase(unittest.TestCase):
@@ -36,3 +52,71 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase):
         module_system.anonymous_student_id = None
         module = HtmlModule(self.descriptor, module_system, field_data, Mock())
         self.assertEqual(module.get_html(), sample_xml)
+
+
+class HtmlDescriptorIndexingTestCase(unittest.TestCase):
+    """
+    Make sure that HtmlDescriptor can format data for indexing as expected.
+    """
+
+    def test_index_dictionary(self):
+        sample_xml = '''
+            <html>
+                <p>Hello World!</p>
+            </html>
+        '''
+        descriptor = instantiate_descriptor(data=sample_xml)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"html_content": " Hello World! ", "display_name": "Text"},
+            "content_type": "HTML Content"
+        })
+
+        sample_xml_cdata = '''
+            <html>
+                <p>This has CDATA in it.</p>
+                <![CDATA[This is just a CDATA!]]>
+            </html>
+        '''
+        descriptor = instantiate_descriptor(data=sample_xml_cdata)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"html_content": " This has CDATA in it. ", "display_name": "Text"},
+            "content_type": "HTML Content"
+        })
+
+        sample_xml_tab_spaces = '''
+            <html>
+                <p>     Text has spaces :)  </p>
+            </html>
+        '''
+        descriptor = instantiate_descriptor(data=sample_xml_tab_spaces)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"html_content": " Text has spaces :) ", "display_name": "Text"},
+            "content_type": "HTML Content"
+        })
+
+        sample_xml_comment = '''
+            <html>
+                <p>This has HTML comment in it.</p>
+                <!-- Html Comment -->
+            </html>
+        '''
+        descriptor = instantiate_descriptor(data=sample_xml_comment)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"html_content": " This has HTML comment in it. ", "display_name": "Text"},
+            "content_type": "HTML Content"
+        })
+
+        sample_xml_mix_comment_cdata = '''
+            <html>
+                <!-- Beginning of the html -->
+                <p>This has HTML comment in it.<!-- Commenting Content --></p>
+                <!-- Here comes CDATA -->
+                <![CDATA[This is just a CDATA!]]>
+                <p>HTML end.</p>
+            </html>
+        '''
+        descriptor = instantiate_descriptor(data=sample_xml_mix_comment_cdata)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"html_content": " This has HTML comment in it. HTML end. ", "display_name": "Text"},
+            "content_type": "HTML Content"
+        })
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index f92c3f9cb1fa058d08ddf5ffda84530191db60f5..de9de5dea651feaca62a8b8cb515277f064e0cfd 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -14,6 +14,7 @@ the course, section, subsection, unit, etc.
 """
 import unittest
 import datetime
+from uuid import uuid4
 from mock import Mock, patch
 
 from . import LogicTest
@@ -25,6 +26,63 @@ from xblock.field_data import DictFieldData
 from xblock.fields import ScopeIds
 
 from xmodule.tests import get_test_descriptor_system
+from xmodule.video_module.transcripts_utils import download_youtube_subs, save_to_store
+
+from django.conf import settings
+from django.test.utils import override_settings
+
+SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+sprechen sie deutsch?
+
+1
+00:00:02,720 --> 00:00:05,430
+Ja, ich spreche Deutsch
+'''
+
+CRO_SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+Dobar dan!
+
+1
+00:00:02,720 --> 00:00:05,430
+Kako ste danas?
+'''
+
+
+TEST_YOU_TUBE_SETTINGS = {
+    # YouTube JavaScript API
+    'API': 'www.youtube.com/iframe_api',
+
+    # URL to test YouTube availability
+    'TEST_URL': 'gdata.youtube.com/feeds/api/videos/',
+
+    # Current youtube api for requesting transcripts.
+    # For example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
+    'TEXT_API': {
+        'url': 'video.google.com/timedtext',
+        'params': {
+            'lang': 'en',
+            'v': 'set_youtube_id_of_11_symbols_here',
+        },
+    },
+}
+
+TEST_DATA_CONTENTSTORE = {
+    'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
+    'DOC_STORE_CONFIG': {
+        'host': 'localhost',
+        'db': 'test_xcontent_%s' % uuid4().hex,
+    },
+    # allow for additional options that can be keyed on a name, e.g. 'trashcan'
+    'ADDITIONAL_OPTIONS': {
+        'trashcan': {
+            'bucket': 'trash_fs'
+        }
+    }
+}
 
 
 def instantiate_descriptor(**field_data):
@@ -505,7 +563,8 @@ class VideoExportTestCase(VideoDescriptorTestBase):
         self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
 
         xml = self.descriptor.definition_to_xml(None)  # We don't use the `resource_fs` parameter
-        expected = etree.fromstring('''\
+        parser = etree.XMLParser(remove_blank_text=True)
+        xml_string = '''\
          <video url_name="SampleProblem" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00" download_video="true" download_track="true">
            <source src="http://www.example.com/source.mp4"/>
            <source src="http://www.example.com/source.ogg"/>
@@ -514,7 +573,8 @@ class VideoExportTestCase(VideoDescriptorTestBase):
            <transcript language="ge" src="german_translation.srt" />
            <transcript language="ua" src="ukrainian_translation.srt" />
          </video>
-        ''')
+        '''
+        expected = etree.XML(xml_string, parser=parser)
         self.assertXmlEqual(expected, xml)
 
     def test_export_to_xml_empty_end_time(self):
@@ -534,14 +594,15 @@ class VideoExportTestCase(VideoDescriptorTestBase):
         self.descriptor.download_video = True
 
         xml = self.descriptor.definition_to_xml(None)  # We don't use the `resource_fs` parameter
-        expected = etree.fromstring('''\
+        parser = etree.XMLParser(remove_blank_text=True)
+        xml_string = '''\
          <video url_name="SampleProblem" start_time="0:00:05" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" download_video="true" download_track="true">
            <source src="http://www.example.com/source.mp4"/>
            <source src="http://www.example.com/source.ogg"/>
            <track src="http://www.example.com/track"/>
          </video>
-        ''')
-
+        '''
+        expected = etree.XML(xml_string, parser=parser)
         self.assertXmlEqual(expected, xml)
 
     def test_export_to_xml_empty_parameters(self):
@@ -582,3 +643,155 @@ class VideoCdnTest(unittest.TestCase):
         cdn_response.return_value = Mock(status_code=404)
         fake_cdn_url = 'http://fake_cdn.com/'
         self.assertIsNone(get_video_from_cdn(fake_cdn_url, original_video_url))
+
+
+@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
+@override_settings(YOUTUBE=TEST_YOU_TUBE_SETTINGS)
+class VideoDescriptorIndexingTestCase(unittest.TestCase):
+    """
+    Make sure that VideoDescriptor can format data for indexing as expected.
+    """
+
+    def test_index_dictionary(self):
+        xml_data = '''
+            <video display_name="Test Video"
+                   youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
+                   show_captions="false"
+                   download_track="false"
+                   start_time="00:00:01"
+                   download_video="false"
+                   end_time="00:01:00">
+              <source src="http://www.example.com/source.mp4"/>
+              <track src="http://www.example.com/track"/>
+              <handout src="http://www.example.com/handout"/>
+            </video>
+        '''
+        descriptor = instantiate_descriptor(data=xml_data)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {"display_name": "Test Video"},
+            "content_type": "Video"
+        })
+
+        xml_data_sub = '''
+            <video display_name="Test Video"
+                   youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
+                   show_captions="false"
+                   download_track="false"
+                   sub="OEoXaMPEzfM"
+                   start_time="00:00:01"
+                   download_video="false"
+                   end_time="00:01:00">
+              <source src="http://www.example.com/source.mp4"/>
+              <track src="http://www.example.com/track"/>
+              <handout src="http://www.example.com/handout"/>
+            </video>
+        '''
+
+        descriptor = instantiate_descriptor(data=xml_data_sub)
+        download_youtube_subs('OEoXaMPEzfM', descriptor, settings)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {
+                "display_name": "Test Video",
+                "transcript_en": (
+                    "LILA FISHER: Hi, welcome to Edx. I'm Lila Fisher, an Edx fellow helping to put together these"
+                    "courses. As you know, our courses are entirely online. So before we start learning about the"
+                    "subjects that brought you here, let's learn about the tools that you will use to navigate through"
+                    "the course material. Let's start with what is on your screen right now. You are watching a video"
+                    "of me talking. You have several tools associated with these videos. Some of them are standard"
+                    "video buttons, like the play Pause Button on the bottom left. Like most video players, you can see"
+                    "how far you are into this particular video segment and how long the entire video segment is."
+                    "Something that you might not be used to is the speed option. While you are going through the"
+                    "videos, you can speed up or slow down the video player with these buttons. Go ahead and try that"
+                    "now. Make me talk faster and slower. If you ever get frustrated by the pace of speech, you can"
+                    "adjust it this way. Another great feature is the transcript on the side. This will follow along"
+                    "with everything that I am saying as I am saying it, so you can read along if you like. You can"
+                    "also click on any of the words, and you will notice that the video jumps to that word. The video"
+                    "slider at the bottom of the video will let you navigate through the video quickly. If you ever"
+                    "find the transcript distracting, you can toggle the captioning button in order to make it go away"
+                    "or reappear. Now that you know about the video player, I want to point out the sequence navigator."
+                    "Right now you're in a lecture sequence, which interweaves many videos and practice exercises. You"
+                    "can see how far you are in a particular sequence by observing which tab you're on. You can"
+                    "navigate directly to any video or exercise by clicking on the appropriate tab. You can also"
+                    "progress to the next element by pressing the Arrow button, or by clicking on the next tab. Try"
+                    "that now. The tutorial will continue in the next video."
+                )
+            },
+            "content_type": "Video"
+        })
+
+        xml_data_sub_transcript = '''
+            <video display_name="Test Video"
+                   youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
+                   show_captions="false"
+                   download_track="false"
+                   sub="OEoXaMPEzfM"
+                   start_time="00:00:01"
+                   download_video="false"
+                   end_time="00:01:00">
+              <source src="http://www.example.com/source.mp4"/>
+              <track src="http://www.example.com/track"/>
+              <handout src="http://www.example.com/handout"/>
+              <transcript language="ge" src="subs_grmtran1.srt" />
+            </video>
+        '''
+
+        descriptor = instantiate_descriptor(data=xml_data_sub_transcript)
+        save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', descriptor.location)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {
+                "display_name": "Test Video",
+                "transcript_en": (
+                    "LILA FISHER: Hi, welcome to Edx. I'm Lila Fisher, an Edx fellow helping to put together these"
+                    "courses. As you know, our courses are entirely online. So before we start learning about the"
+                    "subjects that brought you here, let's learn about the tools that you will use to navigate through"
+                    "the course material. Let's start with what is on your screen right now. You are watching a video"
+                    "of me talking. You have several tools associated with these videos. Some of them are standard"
+                    "video buttons, like the play Pause Button on the bottom left. Like most video players, you can see"
+                    "how far you are into this particular video segment and how long the entire video segment is."
+                    "Something that you might not be used to is the speed option. While you are going through the"
+                    "videos, you can speed up or slow down the video player with these buttons. Go ahead and try that"
+                    "now. Make me talk faster and slower. If you ever get frustrated by the pace of speech, you can"
+                    "adjust it this way. Another great feature is the transcript on the side. This will follow along"
+                    "with everything that I am saying as I am saying it, so you can read along if you like. You can"
+                    "also click on any of the words, and you will notice that the video jumps to that word. The video"
+                    "slider at the bottom of the video will let you navigate through the video quickly. If you ever"
+                    "find the transcript distracting, you can toggle the captioning button in order to make it go away"
+                    "or reappear. Now that you know about the video player, I want to point out the sequence navigator."
+                    "Right now you're in a lecture sequence, which interweaves many videos and practice exercises. You"
+                    "can see how far you are in a particular sequence by observing which tab you're on. You can"
+                    "navigate directly to any video or exercise by clicking on the appropriate tab. You can also"
+                    "progress to the next element by pressing the Arrow button, or by clicking on the next tab. Try"
+                    "that now. The tutorial will continue in the next video."
+                ),
+                "transcript_ge": "sprechen sie deutsch? Ja, ich spreche Deutsch"
+            },
+            "content_type": "Video"
+        })
+
+        xml_data_transcripts = '''
+            <video display_name="Test Video"
+                   youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
+                   show_captions="false"
+                   download_track="false"
+                   start_time="00:00:01"
+                   download_video="false"
+                   end_time="00:01:00">
+              <source src="http://www.example.com/source.mp4"/>
+              <track src="http://www.example.com/track"/>
+              <handout src="http://www.example.com/handout"/>
+              <transcript language="ge" src="subs_grmtran1.srt" />
+              <transcript language="hr" src="subs_croatian1.srt" />
+            </video>
+        '''
+
+        descriptor = instantiate_descriptor(data=xml_data_transcripts)
+        save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', descriptor.location)
+        save_to_store(CRO_SRT_FILEDATA, "subs_croatian1.srt", 'text/srt', descriptor.location)
+        self.assertEqual(descriptor.index_dictionary(), {
+            "content": {
+                "display_name": "Test Video",
+                "transcript_ge": "sprechen sie deutsch? Ja, ich spreche Deutsch",
+                "transcript_hr": "Dobar dan! Kako ste danas?"
+            },
+            "content_type": "Video"
+        })
diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py
index 2a374ab2f9e1b4d9088c484b2b00b6654660f977..56467e91a4e61c33f4cda0fea83aafba648719c0 100644
--- a/common/lib/xmodule/xmodule/video_module/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module/video_module.py
@@ -32,6 +32,7 @@ from xmodule.x_module import XModule, module_attr
 from xmodule.editing_module import TabsEditingDescriptor
 from xmodule.raw_module import EmptyDataRawDescriptor
 from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
+from xmodule.exceptions import NotFoundError
 
 from .transcripts_utils import VideoTranscriptsMixin
 from .video_utils import create_youtube_string, get_video_from_cdn
@@ -607,3 +608,34 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
             field_data['download_track'] = True
 
         return field_data
+
+    def index_dictionary(self):
+        xblock_body = super(VideoDescriptor, self).index_dictionary()
+        video_body = {
+            "display_name": self.display_name,
+        }
+
+        def _update_transcript_for_index(language=None):
+            """ Find video transcript - if not found, don't update index """
+            try:
+                transcript = self.get_transcript(transcript_format='txt', lang=language)[0].replace("\n", " ")
+                transcript_index_name = "transcript_{}".format(language if language else self.transcript_language)
+                video_body.update({transcript_index_name: transcript})
+            except NotFoundError:
+                pass
+
+        if self.sub:
+            _update_transcript_for_index()
+
+        # check to see if there are transcripts in other languages besides default transcript
+        if self.transcripts:
+            for language in self.transcripts.keys():
+                _update_transcript_for_index(language)
+
+        if "content" in xblock_body:
+            xblock_body["content"].update(video_body)
+        else:
+            xblock_body["content"] = video_body
+        xblock_body["content_type"] = "Video"
+
+        return xblock_body
diff --git a/common/test/acceptance/pages/lms/courseware_search.py b/common/test/acceptance/pages/lms/courseware_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..58cfec3b49ae061eb7114e9a41fe4b1dbae93e1e
--- /dev/null
+++ b/common/test/acceptance/pages/lms/courseware_search.py
@@ -0,0 +1,39 @@
+"""
+Courseware search
+"""
+
+from .course_page import CoursePage
+
+
+class CoursewareSearchPage(CoursePage):
+    """
+    Coursware page featuring a search form
+    """
+
+    url_path = "courseware/"
+    search_bar_selector = '#courseware-search-bar'
+
+    @property
+    def search_results(self):
+        """ search results list showing """
+        return self.q(css='#courseware-search-results')
+
+    def is_browser_on_page(self):
+        """ did we find the search bar in the UI """
+        return self.q(css=self.search_bar_selector).present
+
+    def enter_search_term(self, text):
+        """ enter the search term into the box """
+        self.q(css=self.search_bar_selector + ' input[type="text"]').fill(text)
+
+    def search(self):
+        """ execute the search """
+        self.q(css=self.search_bar_selector + ' [type="submit"]').click()
+        self.wait_for_element_visibility('.search-info', 'Search results are shown')
+
+    def search_for_term(self, text):
+        """
+        Search and return results
+        """
+        self.enter_search_term(text)
+        self.search()
diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py
index 6db9c9cc2be44a38f2ba8b19c9ef19786a3f89f6..1dd1cb0f504d910ab484fcb47a4bbaa76e97d04c 100644
--- a/common/test/acceptance/pages/studio/overview.py
+++ b/common/test/acceptance/pages/studio/overview.py
@@ -505,6 +505,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
         """
         self.q(css=self.EXPAND_COLLAPSE_CSS).click()
 
+    def start_reindex(self):
+        """
+        Starts course reindex by clicking reindex button
+        """
+        self.reindex_button.click()
+
     @property
     def bottom_add_section_button(self):
         """
@@ -545,6 +551,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
         else:
             return ExpandCollapseLinkState.EXPAND
 
+    @property
+    def reindex_button(self):
+        """
+        Returns reindex button.
+        """
+        return self.q(css=".button.button-reindex")[0]
+
     def expand_all_subsections(self):
         """
         Expands all the subsections in this course.
diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py
index df86d5decae481956875761ba1d1db1739507b53..1c611e7ba3d96e7b5c70bbce21eda28d2fede1fa 100644
--- a/common/test/acceptance/pages/studio/utils.py
+++ b/common/test/acceptance/pages/studio/utils.py
@@ -130,6 +130,34 @@ def add_component(page, item_type, specific_type):
     page.wait_for_ajax()
 
 
+def add_html_component(page, menu_index, boilerplate=None):
+    """
+    Adds an instance of the HTML component with the specified name.
+
+    menu_index specifies which instance of the menus should be used (based on vertical
+    placement within the page).
+    """
+    # Click on the HTML icon.
+    page.wait_for_component_menu()
+    click_css(page, 'a>span.large-html-icon', menu_index, require_notification=False)
+
+    # Make sure that the menu of HTML components is visible before clicking
+    page.wait_for_element_visibility('.new-component-html', 'HTML component menu is visible')
+
+    # Now click on the component to add it.
+    component_css = 'a[data-category=html]'
+    if boilerplate:
+        component_css += '[data-boilerplate={}]'.format(boilerplate)
+    else:
+        component_css += ':not([data-boilerplate])'
+
+    page.wait_for_element_visibility(component_css, 'HTML component {} is visible'.format(boilerplate))
+
+    # Adding some components will make an ajax call but we should be OK because
+    # the click_css method is written to handle that.
+    click_css(page, component_css, 0)
+
+
 @js_defined('window.jQuery')
 def type_in_codemirror(page, index, text, find_prefix="$"):
     script = """
diff --git a/common/test/acceptance/tests/lms/test_lms_courseware_search.py b/common/test/acceptance/tests/lms/test_lms_courseware_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6a91a7ba6d986f62037351bdfc5aa109764d278
--- /dev/null
+++ b/common/test/acceptance/tests/lms/test_lms_courseware_search.py
@@ -0,0 +1,194 @@
+"""
+Test courseware search
+"""
+import os
+import json
+
+from ..helpers import UniqueCourseTest
+from ...pages.common.logout import LogoutPage
+from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.studio.overview import CourseOutlinePage
+from ...pages.studio.container import ContainerPage
+from ...pages.lms.courseware_search import CoursewareSearchPage
+from ...fixtures.course import CourseFixture, XBlockFixtureDesc
+
+
+class CoursewareSearchTest(UniqueCourseTest):
+    """
+    Test courseware search.
+    """
+    USERNAME = 'STUDENT_TESTER'
+    EMAIL = 'student101@example.com'
+
+    STAFF_USERNAME = "STAFF_TESTER"
+    STAFF_EMAIL = "staff101@example.com"
+
+    HTML_CONTENT = """
+            Someday I'll wish upon a star
+            And wake up where the clouds are far
+            Behind me.
+            Where troubles melt like lemon drops
+            Away above the chimney tops
+            That's where you'll find me.
+        """
+    SEARCH_STRING = "chimney"
+    EDITED_CHAPTER_NAME = "Section 2 - edited"
+    EDITED_SEARCH_STRING = "edited"
+
+    TEST_INDEX_FILENAME = "test_root/index_file.dat"
+
+    def setUp(self):
+        """
+        Create search page and course content to search
+        """
+        # create test file in which index for this test will live
+        with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
+            json.dump({}, index_file)
+
+        super(CoursewareSearchTest, self).setUp()
+        self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
+
+        self.course_outline = CourseOutlinePage(
+            self.browser,
+            self.course_info['org'],
+            self.course_info['number'],
+            self.course_info['run']
+        )
+
+        course_fix = CourseFixture(
+            self.course_info['org'],
+            self.course_info['number'],
+            self.course_info['run'],
+            self.course_info['display_name']
+        )
+
+        course_fix.add_children(
+            XBlockFixtureDesc('chapter', 'Section 1').add_children(
+                XBlockFixtureDesc('sequential', 'Subsection 1')
+            )
+        ).add_children(
+            XBlockFixtureDesc('chapter', 'Section 2').add_children(
+                XBlockFixtureDesc('sequential', 'Subsection 2')
+            )
+        ).install()
+
+    def tearDown(self):
+        os.remove(self.TEST_INDEX_FILENAME)
+
+    def _auto_auth(self, username, email, staff):
+        """
+        Logout and login with given credentials.
+        """
+        LogoutPage(self.browser).visit()
+        AutoAuthPage(self.browser, username=username, email=email,
+                     course_id=self.course_id, staff=staff).visit()
+
+    def test_page_existence(self):
+        """
+        Make sure that the page is accessible.
+        """
+        self._auto_auth(self.USERNAME, self.EMAIL, False)
+        self.courseware_search_page.visit()
+
+    def _studio_publish_content(self, section_index):
+        """
+        Publish content on studio course page under specified section
+        """
+        self.course_outline.visit()
+        subsection = self.course_outline.section_at(section_index).subsection_at(0)
+        subsection.toggle_expand()
+        unit = subsection.unit_at(0)
+        unit.publish()
+
+    def _studio_edit_chapter_name(self, section_index):
+        """
+        Edit chapter name on studio course page under specified section
+        """
+        self.course_outline.visit()
+        section = self.course_outline.section_at(section_index)
+        section.change_name(self.EDITED_CHAPTER_NAME)
+
+    def _studio_add_content(self, section_index):
+        """
+        Add content on studio course page under specified section
+        """
+
+        # create a unit in course outline
+        self.course_outline.visit()
+        subsection = self.course_outline.section_at(section_index).subsection_at(0)
+        subsection.toggle_expand()
+        subsection.add_unit()
+
+        # got to unit and create an HTML component and save (not publish)
+        unit_page = ContainerPage(self.browser, None)
+        unit_page.wait_for_page()
+        add_html_component(unit_page, 0)
+        unit_page.wait_for_element_presence('.edit-button', 'Edit button is visible')
+        click_css(unit_page, '.edit-button', 0, require_notification=False)
+        unit_page.wait_for_element_visibility('.modal-editor', 'Modal editor is visible')
+        type_in_codemirror(unit_page, 0, self.HTML_CONTENT)
+        click_css(unit_page, '.action-save', 0)
+
+    def _studio_reindex(self):
+        """
+        Reindex course content on studio course page
+        """
+
+        self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+        self.course_outline.visit()
+        self.course_outline.start_reindex()
+        self.course_outline.wait_for_ajax()
+
+    def test_search(self):
+        """
+        Make sure that you can search for something.
+        """
+
+        # Create content in studio without publishing.
+        self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+        self._studio_add_content(0)
+
+        # Do a search, there should be no results shown.
+        self._auto_auth(self.USERNAME, self.EMAIL, False)
+        self.courseware_search_page.visit()
+        self.courseware_search_page.search_for_term(self.SEARCH_STRING)
+        assert self.SEARCH_STRING not in self.courseware_search_page.search_results.html[0]
+
+        # Publish in studio to trigger indexing.
+        self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+        self._studio_publish_content(0)
+
+        # Do the search again, this time we expect results.
+        self._auto_auth(self.USERNAME, self.EMAIL, False)
+        self.courseware_search_page.visit()
+        self.courseware_search_page.search_for_term(self.SEARCH_STRING)
+        assert self.SEARCH_STRING in self.courseware_search_page.search_results.html[0]
+
+    def test_reindex(self):
+        """
+        Make sure new content gets reindexed on button press.
+        """
+
+        # Create content in studio without publishing.
+        self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+        self._studio_add_content(1)
+
+        # Publish in studio to trigger indexing, and edit chapter name afterwards.
+        self._studio_publish_content(1)
+        self._studio_edit_chapter_name(1)
+
+        # Do a search, there should be no results shown.
+        self._auto_auth(self.USERNAME, self.EMAIL, False)
+        self.courseware_search_page.visit()
+        self.courseware_search_page.search_for_term(self.EDITED_SEARCH_STRING)
+        assert self.EDITED_SEARCH_STRING not in self.courseware_search_page.search_results.html[0]
+
+        # Do a ReIndex from studio, to add edited chapter name
+        self._studio_reindex()
+
+        # Do the search again, this time we expect results.
+        self._auto_auth(self.USERNAME, self.EMAIL, False)
+        self.courseware_search_page.visit()
+        self.courseware_search_page.search_for_term(self.EDITED_SEARCH_STRING)
+        assert self.EDITED_SEARCH_STRING in self.courseware_search_page.search_results.html[0]
diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py
index 60509a12bfd7250a1d354b6a48e30bf65c28ed8f..c87aee700a7ee74ca263890397723a5faff8d102 100644
--- a/lms/envs/acceptance.py
+++ b/lms/envs/acceptance.py
@@ -179,3 +179,7 @@ XQUEUE_INTERFACE = {
 YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
 YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT)
 YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
+
+if FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+    # Use MockSearchEngine as the search engine for test scenario
+    SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index e0f117246db42de6f6f2f7946c8302311b37e208..72c26b34fbe52d89868c67b10e834a85c1518216 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -500,3 +500,7 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = ENV_TOKENS.get('PDF_RECEIPT_LOGO_HEIGHT_MM', PDF_RE
 PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get(
     'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM
 )
+
+if FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+    # Use ElasticSearch as the search engine herein
+    SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 325812a5464f922b87cd640bd4062c0a84ab5efd..9517e63e5e3512b2d5c36c9e8003437f6dd2cfc7 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -118,6 +118,15 @@ FEATURES['ADVANCED_SECURITY'] = False
 PASSWORD_MIN_LENGTH = None
 PASSWORD_COMPLEXITY = {}
 
+# Enable courseware search for tests
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+# Use MockSearchEngine as the search engine for test scenario
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+# Path at which to store the mock index
+MOCK_SEARCH_BACKING_FILE = (
+    TEST_ROOT / "index_file.dat"  # pylint: disable=no-value-for-parameter
+).abspath()
+
 #####################################################################
 # Lastly, see if the developer has any local overrides.
 try:
diff --git a/lms/envs/common.py b/lms/envs/common.py
index c409c3138a99d439f2eec4a9252e6b6ebf2e3ffc..28050f4876e5703a7d1c55ac25f3f372a47d2f85 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -328,6 +328,9 @@ FEATURES = {
 
     # For easily adding modes to courses during acceptance testing
     'MODE_CREATION_FOR_TESTING': False,
+
+    # Courseware search feature
+    'ENABLE_COURSEWARE_SEARCH': False,
 }
 
 # Ignore static asset files on import which match this pattern
@@ -1039,6 +1042,7 @@ courseware_js = (
         for pth in ['courseware', 'histogram', 'navigation', 'time']
     ] +
     ['js/' + pth + '.js' for pth in ['ajax-error']] +
+    ['js/search/main.js'] +
     sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
 )
 
@@ -2011,3 +2015,8 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = 12
 PDF_RECEIPT_COBRAND_LOGO_PATH = PROJECT_ROOT + '/static/images/default-theme/logo.png'
 # Height of the Co-brand Logo in mm
 PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12
+
+# Use None for the default search engine
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+# Use the LMS specific result processor
+SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor"
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index f9d4012cebfd63018d10de1367cb41e88843dabc..4606d6f3023f321e565f1f8ab377e7ee0895be91 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -113,6 +113,11 @@ FEATURES['MILESTONES_APP'] = True
 FEATURES['ENTRANCE_EXAMS'] = True
 
 
+########################## Courseware Search #######################
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
+
+
 #####################################################################
 # See if the developer has any local overrides.
 try:
diff --git a/lms/envs/test.py b/lms/envs/test.py
index c26cfdeec6b6f81d89f34d9ee188b5a6722cfed6..43f52dc4ea13863f1b68d3b31148aecaad12af12 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -455,3 +455,8 @@ FEATURES['MILESTONES_APP'] = True
 
 # ENTRANCE EXAMS
 FEATURES['ENTRANCE_EXAMS'] = True
+
+# Enable courseware search for tests
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+# Use MockSearchEngine as the search engine for test scenario
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
diff --git a/lms/lib/courseware_search/__init__.py b/lms/lib/courseware_search/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e21f753229b99d9bd35f9629a6fc3ce6014bf2d
--- /dev/null
+++ b/lms/lib/courseware_search/__init__.py
@@ -0,0 +1,10 @@
+"""
+Search overrides for courseware search
+Implement overrides for:
+* SearchResultProcessor
+    - to mix in path to result
+    - to provide last-ditch access check
+* SearchFilterGenerator
+    - to provide additional filter fields (for cohorted values etc.)
+    - to inject specific field restrictions if/when desired
+"""
diff --git a/lms/lib/courseware_search/lms_result_processor.py b/lms/lib/courseware_search/lms_result_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9a2cec97b421c755d1f35290ebb323586037980
--- /dev/null
+++ b/lms/lib/courseware_search/lms_result_processor.py
@@ -0,0 +1,100 @@
+"""
+This file contains implementation override of SearchResultProcessor which will allow
+    * Blends in "location" property
+    * Confirms user access to object
+"""
+from django.core.urlresolvers import reverse
+
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from search.result_processor import SearchResultProcessor
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.search import path_to_location
+
+from courseware.access import has_access
+
+
+class LmsSearchResultProcessor(SearchResultProcessor):
+
+    """ SearchResultProcessor for LMS Search """
+    _course_key = None
+    _usage_key = None
+    _module_store = None
+    _module_temp_dictionary = {}
+
+    def get_course_key(self):
+        """ fetch course key object from string representation - retain result for subsequent uses """
+        if self._course_key is None:
+            self._course_key = SlashSeparatedCourseKey.from_deprecated_string(self._results_fields["course"])
+        return self._course_key
+
+    def get_usage_key(self):
+        """ fetch usage key for component from string representation - retain result for subsequent uses """
+        if self._usage_key is None:
+            self._usage_key = self.get_course_key().make_usage_key_from_deprecated_string(self._results_fields["id"])
+        return self._usage_key
+
+    def get_module_store(self):
+        """ module store accessor - retain result for subsequent uses """
+        if self._module_store is None:
+            self._module_store = modulestore()
+        return self._module_store
+
+    def get_item(self, usage_key):
+        """ fetch item from the modulestore - don't refetch if we've already retrieved it beforehand """
+        if usage_key not in self._module_temp_dictionary:
+            self._module_temp_dictionary[usage_key] = self.get_module_store().get_item(usage_key)
+        return self._module_temp_dictionary[usage_key]
+
+    @property
+    def url(self):
+        """
+        Property to display the url for the given location, useful for allowing navigation
+        """
+        if "course" not in self._results_fields or "id" not in self._results_fields:
+            raise ValueError("Must have course and id in order to build url")
+
+        return reverse(
+            "jump_to",
+            kwargs={"course_id": self._results_fields["course"], "location": self._results_fields["id"]}
+        )
+
+    @property
+    def location(self):
+        """
+        Blend "location" property into the resultset, so that the path to the found component can be shown within the UI
+        """
+        # TODO: update whern changes to "cohorted-courseware" branch are merged in
+        (course_key, chapter, section, position) = path_to_location(self.get_module_store(), self.get_usage_key())
+
+        def get_display_name(category, item_id):
+            """ helper to get display name from object """
+            item = self.get_item(course_key.make_usage_key(category, item_id))
+            return getattr(item, "display_name", None)
+
+        def get_position_name(section, position):
+            """ helper to fetch name corresponding to the position therein """
+            pos = int(position)
+            section_item = self.get_item(course_key.make_usage_key("sequential", section))
+            if section_item.has_children and len(section_item.children) >= pos:
+                item = self.get_item(section_item.children[pos - 1])
+                return getattr(item, "display_name", None)
+            return None
+
+        location_description = []
+        if chapter:
+            location_description.append(get_display_name("chapter", chapter))
+        if section:
+            location_description.append(get_display_name("sequential", section))
+        if position:
+            location_description.append(get_position_name(section, position))
+
+        return location_description
+
+    def should_remove(self, user):
+        """ Test to see if this result should be removed due to access restriction """
+        return not has_access(
+            user,
+            "load",
+            self.get_item(self.get_usage_key()),
+            self.get_course_key()
+        )
diff --git a/lms/lib/courseware_search/test/test_lms_result_processor.py b/lms/lib/courseware_search/test/test_lms_result_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f2a77e7e0ca1fa66fdc405ec5590e1f018dfb3f
--- /dev/null
+++ b/lms/lib/courseware_search/test/test_lms_result_processor.py
@@ -0,0 +1,138 @@
+"""
+Tests for the lms_result_processor
+"""
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+
+from courseware.tests.factories import UserFactory
+
+from lms.lib.courseware_search.lms_result_processor import LmsSearchResultProcessor
+
+
+class LmsSearchResultProcessorTestCase(ModuleStoreTestCase):
+    """ Test case class to test search result processor """
+
+    def build_course(self):
+        """
+        Build up a course tree with an html control
+        """
+        self.global_staff = UserFactory(is_staff=True)
+
+        self.course = CourseFactory.create(
+            org='Elasticsearch',
+            course='ES101',
+            run='test_run',
+            display_name='Elasticsearch test course',
+        )
+        self.section = ItemFactory.create(
+            parent=self.course,
+            category='chapter',
+            display_name='Test Section',
+        )
+        self.subsection = ItemFactory.create(
+            parent=self.section,
+            category='sequential',
+            display_name='Test Subsection',
+        )
+        self.vertical = ItemFactory.create(
+            parent=self.subsection,
+            category='vertical',
+            display_name='Test Unit',
+        )
+        self.html = ItemFactory.create(
+            parent=self.vertical, category='html',
+            display_name='Test Html control',
+        )
+
+    def setUp(self):
+        # from nose.tools import set_trace
+        # set_trace()
+        self.build_course()
+
+    def test_url_parameter(self):
+        fake_url = ""
+        srp = LmsSearchResultProcessor({}, "test")
+        with self.assertRaises(ValueError):
+            fake_url = srp.url
+        self.assertEqual(fake_url, "")
+
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.html.scope_ids.usage_id),
+                "content": {"text": "This is the html text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(
+            srp.url, "/courses/{}/jump_to/{}".format(unicode(self.course.id), unicode(self.html.scope_ids.usage_id)))
+
+    def test_location_parameter(self):
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.html.scope_ids.usage_id),
+                "content": {"text": "This is html test text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(len(srp.location), 3)
+        self.assertEqual(srp.location[0], 'Test Section')
+        self.assertEqual(srp.location[1], 'Test Subsection')
+        self.assertEqual(srp.location[2], 'Test Unit')
+
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.vertical.scope_ids.usage_id),
+                "content": {"text": "This is html test text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(len(srp.location), 3)
+        self.assertEqual(srp.location[0], 'Test Section')
+        self.assertEqual(srp.location[1], 'Test Subsection')
+        self.assertEqual(srp.location[2], 'Test Unit')
+
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.subsection.scope_ids.usage_id),
+                "content": {"text": "This is html test text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(len(srp.location), 2)
+        self.assertEqual(srp.location[0], 'Test Section')
+        self.assertEqual(srp.location[1], 'Test Subsection')
+
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.section.scope_ids.usage_id),
+                "content": {"text": "This is html test text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(len(srp.location), 1)
+        self.assertEqual(srp.location[0], 'Test Section')
+
+    def test_should_remove(self):
+        """
+        Tests that "visible_to_staff_only" overrides start date.
+        """
+        srp = LmsSearchResultProcessor(
+            {
+                "course": unicode(self.course.id),
+                "id": unicode(self.html.scope_ids.usage_id),
+                "content": {"text": "This is html test text"}
+            },
+            "test"
+        )
+
+        self.assertEqual(srp.should_remove(self.global_staff), False)
diff --git a/lms/static/js/fixtures/search_form.html b/lms/static/js/fixtures/search_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..77c9a1dad84f6cd77bdc7d8d95ce45d3061a6040
--- /dev/null
+++ b/lms/static/js/fixtures/search_form.html
@@ -0,0 +1,7 @@
+<div id="courseware-search-bar" class="search-container">
+    <form role="search-form">
+        <input type="text" class="search-field"/>
+        <button type="submit" class="search-button" aria-label="Search">search <i class="icon-search"></i></button>
+        <button type="button" class="cancel-button" aria-label="Cancel"><i class="icon-remove"></i></button>
+    </form>
+</div>
diff --git a/lms/static/js/search/collections/search_collection.js b/lms/static/js/search/collections/search_collection.js
new file mode 100644
index 0000000000000000000000000000000000000000..bd5689f5129aa4d57f080735816d83e3fc143c18
--- /dev/null
+++ b/lms/static/js/search/collections/search_collection.js
@@ -0,0 +1,92 @@
+;(function (define) {
+
+define([
+    'backbone',
+    'js/search/models/search_result'
+], function (Backbone, SearchResult) {
+    'use strict';
+
+    return Backbone.Collection.extend({
+
+        model: SearchResult,
+        pageSize: 20,
+        totalCount: 0,
+        accessDeniedCount: 0,
+        searchTerm: '',
+        page: 0,
+        url: '/search/',
+        fetchXhr: null,
+
+        initialize: function (models, options) {
+            // call super constructor
+            Backbone.Collection.prototype.initialize.apply(this, arguments);
+            if (options && options.course_id) {
+                this.url += options.course_id;
+            }
+        },
+
+        performSearch: function (searchTerm) {
+            this.fetchXhr && this.fetchXhr.abort();
+            this.searchTerm = searchTerm || '';
+            this.totalCount = 0;
+            this.accessDeniedCount = 0;
+            this.page = 0;
+            this.fetchXhr = this.fetch({
+                data: {
+                    search_string: searchTerm,
+                    page_size: this.pageSize,
+                    page_index: 0
+                },
+                type: 'POST',
+                success: function (self, xhr) {
+                    self.trigger('search');
+                },
+                error: function (self, xhr) {
+                    self.trigger('error');
+                }
+            });
+        },
+
+        loadNextPage: function () {
+            this.fetchXhr && this.fetchXhr.abort();
+            this.fetchXhr = this.fetch({
+                data: {
+                    search_string: this.searchTerm,
+                    page_size: this.pageSize,
+                    page_index: this.page + 1
+                },
+                type: 'POST',
+                success: function (self, xhr) {
+                    self.page += 1;
+                    self.trigger('next');
+                },
+                error: function (self, xhr) {
+                    self.trigger('error');
+                }
+            });
+        },
+
+        cancelSearch: function () {
+            this.fetchXhr && this.fetchXhr.abort();
+            this.page = 0;
+            this.totalCount = 0;
+            this.accessDeniedCount = 0;
+        },
+
+        parse: function(response) {
+            this.totalCount = response.total;
+            this.accessDeniedCount += response.access_denied_count;
+            this.totalCount -= this.accessDeniedCount;
+            return _.map(response.results, function(result){ return result.data; });
+        },
+
+        hasNextPage: function () {
+            return this.totalCount - ((this.page + 1) * this.pageSize) > 0;
+        }
+
+    });
+
+});
+
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/main.js b/lms/static/js/search/main.js
new file mode 100644
index 0000000000000000000000000000000000000000..f97d3eb95c92010bb5918ea3f624026788237598
--- /dev/null
+++ b/lms/static/js/search/main.js
@@ -0,0 +1,12 @@
+RequireJS.require([
+    'jquery',
+    'backbone',
+    'js/search/search_app'
+], function ($, Backbone, SearchApp) {
+    'use strict';
+
+    var course_id = $('#courseware-search-results').attr('data-course-id');
+    var app = new SearchApp(course_id);
+    Backbone.history.start();
+
+});
diff --git a/lms/static/js/search/models/search_result.js b/lms/static/js/search/models/search_result.js
new file mode 100644
index 0000000000000000000000000000000000000000..63821f71599241a46ebe98d7c46ffe7e92dcca22
--- /dev/null
+++ b/lms/static/js/search/models/search_result.js
@@ -0,0 +1,17 @@
+;(function (define) {
+
+define(['backbone'], function (Backbone) {
+    'use strict';
+
+    return Backbone.Model.extend({
+        defaults: {
+            location: [],
+            content_type: '',
+            excerpt: '',
+            url: ''
+        }
+    });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/search_app.js b/lms/static/js/search/search_app.js
new file mode 100644
index 0000000000000000000000000000000000000000..bf69ac393062c2e9d5fd0515a138e236f6a437cf
--- /dev/null
+++ b/lms/static/js/search/search_app.js
@@ -0,0 +1,37 @@
+;(function (define) {
+
+define([
+    'backbone',
+    'js/search/search_router',
+    'js/search/views/search_form',
+    'js/search/views/search_list_view',
+    'js/search/collections/search_collection'
+], function(Backbone, SearchRouter, SearchForm, SearchListView, SearchCollection) {
+    'use strict';
+
+    return function (course_id) {
+
+        var self = this;
+
+        this.router = new SearchRouter();
+        this.form = new SearchForm();
+        this.collection = new SearchCollection([], { course_id: course_id });
+        this.results = new SearchListView({ collection: this.collection });
+
+        this.form.on('search', this.results.showLoadingMessage, this.results);
+        this.form.on('search', this.collection.performSearch, this.collection);
+        this.form.on('search', function (term) {
+            self.router.navigate('search/' + term, { replace: true });
+        });
+        this.form.on('clear', this.collection.cancelSearch, this.collection);
+        this.form.on('clear', this.results.clear, this.results);
+        this.form.on('clear', this.router.navigate, this.router);
+
+        this.results.on('next', this.collection.loadNextPage, this.collection);
+        this.router.on('route:search', this.form.doSearch, this.form);
+
+    };
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/search_router.js b/lms/static/js/search/search_router.js
new file mode 100644
index 0000000000000000000000000000000000000000..65683b3f1e80f3bdbbd34872adba99bb61f344ca
--- /dev/null
+++ b/lms/static/js/search/search_router.js
@@ -0,0 +1,14 @@
+;(function (define) {
+
+define(['backbone'], function (Backbone) {
+    'use strict';
+
+    return Backbone.Router.extend({
+      routes: {
+        'search/:query': 'search'
+      }
+    });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/views/search_form.js b/lms/static/js/search/views/search_form.js
new file mode 100644
index 0000000000000000000000000000000000000000..3eb407ac6aef9c8ff538b0bbf102593b6b1b8499
--- /dev/null
+++ b/lms/static/js/search/views/search_form.js
@@ -0,0 +1,65 @@
+;(function (define) {
+
+define(['jquery', 'backbone'], function ($, Backbone) {
+   'use strict';
+
+    return Backbone.View.extend({
+
+        el: '#courseware-search-bar',
+        events: {
+            'submit form': 'submitForm',
+            'click .cancel-button': 'clearSearch',
+        },
+
+        initialize: function () {
+            this.$searchField = this.$el.find('.search-field');
+            this.$searchButton = this.$el.find('.search-button');
+            this.$cancelButton = this.$el.find('.cancel-button');
+        },
+
+        submitForm: function (event) {
+            event.preventDefault();
+            this.doSearch();
+        },
+
+        doSearch: function (term) {
+            if (term) {
+                this.$searchField.val(term);
+            }
+            else {
+                term = this.$searchField.val();
+            }
+
+            var trimmed = $.trim(term);
+            if (trimmed) {
+                this.setActiveStyle();
+                this.trigger('search', trimmed);
+            }
+            else {
+                this.clearSearch();
+            }
+        },
+
+        clearSearch: function () {
+            this.$searchField.val('');
+            this.setInitialStyle();
+            this.trigger('clear');
+        },
+
+        setActiveStyle: function () {
+            this.$searchField.addClass('is-active');
+            this.$searchButton.hide();
+            this.$cancelButton.show();
+        },
+
+        setInitialStyle: function () {
+            this.$searchField.removeClass('is-active');
+            this.$searchButton.show();
+            this.$cancelButton.hide();
+        }
+
+    });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/views/search_item_view.js b/lms/static/js/search/views/search_item_view.js
new file mode 100644
index 0000000000000000000000000000000000000000..439c4fd3577eba39d4345427cee07fbb1d70908f
--- /dev/null
+++ b/lms/static/js/search/views/search_item_view.js
@@ -0,0 +1,32 @@
+;(function (define) {
+
+define([
+    'jquery',
+    'underscore',
+    'backbone',
+    'gettext'
+], function ($, _, Backbone, gettext) {
+   'use strict';
+
+    return Backbone.View.extend({
+
+        tagName: 'li',
+        className: 'search-results-item',
+        attributes: {
+            'role': 'region',
+            'aria-label': 'search result'
+        },
+
+        initialize: function () {
+            this.tpl = _.template($('#search_item-tpl').html());
+        },
+
+        render: function () {
+            this.$el.html(this.tpl(this.model.attributes));
+            return this;
+        }
+    });
+
+});
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/search/views/search_list_view.js b/lms/static/js/search/views/search_list_view.js
new file mode 100644
index 0000000000000000000000000000000000000000..b981264b4ce404323631c713d7909bcd128da973
--- /dev/null
+++ b/lms/static/js/search/views/search_list_view.js
@@ -0,0 +1,97 @@
+;(function (define) {
+
+define([
+    'jquery',
+    'underscore',
+    'backbone',
+    'gettext',
+    'js/search/views/search_item_view'
+], function ($, _, Backbone, gettext, SearchItemView) {
+
+   'use strict';
+
+    return Backbone.View.extend({
+
+        el: '#courseware-search-results',
+        events: {
+            'click .search-load-next': 'loadNext'
+        },
+        spinner: '.icon',
+
+        initialize: function () {
+            this.courseName = this.$el.attr('data-course-name');
+            this.$courseContent = $('#course-content');
+            this.listTemplate = _.template($('#search_list-tpl').html());
+            this.loadingTemplate = _.template($('#search_loading-tpl').html());
+            this.errorTemplate = _.template($('#search_error-tpl').html());
+            this.collection.on('search', this.render, this);
+            this.collection.on('next', this.renderNext, this);
+            this.collection.on('error', this.showErrorMessage, this);
+        },
+
+        render: function () {
+            this.$el.html(this.listTemplate({
+                courseName: this.courseName,
+                totalCount: this.collection.totalCount,
+                totalCountMsg: this.totalCountMsg(),
+                pageSize: this.collection.pageSize,
+                hasMoreResults: this.collection.hasNextPage()
+            }));
+            this.renderItems();
+            this.$courseContent.hide();
+            this.$el.show();
+            return this;
+        },
+
+        renderNext: function () {
+            // total count may have changed
+            this.$el.find('.search-count').text(this.totalCountMsg());
+            this.renderItems();
+            if (! this.collection.hasNextPage()) {
+                this.$el.find('.search-load-next').remove();
+            }
+            this.$el.find(this.spinner).hide();
+        },
+
+        renderItems: function () {
+            var items = this.collection.map(function (result) {
+                var item = new SearchItemView({ model: result });
+                return item.render().el;
+            });
+            this.$el.find('.search-results').append(items);
+        },
+
+        totalCountMsg: function () {
+            var fmt = ngettext('%s result', '%s results', this.collection.totalCount);
+            return interpolate(fmt, [this.collection.totalCount]);
+        },
+
+        clear: function () {
+            this.$el.hide().empty();
+            this.$courseContent.show();
+        },
+
+        showLoadingMessage: function () {
+            this.$el.html(this.loadingTemplate());
+            this.$el.show();
+            this.$courseContent.hide();
+        },
+
+        showErrorMessage: function () {
+            this.$el.html(this.errorTemplate());
+            this.$el.show();
+            this.$courseContent.hide();
+        },
+
+        loadNext: function (event) {
+            event && event.preventDefault();
+            this.$el.find(this.spinner).show();
+            this.trigger('next');
+        }
+
+    });
+
+});
+
+
+})(define || RequireJS.define);
diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js
index d408a7988ab685802afabee0da441c4fdcefb40f..76036ab9b07175cabadf3bc0c2734fd0b386e5e1 100644
--- a/lms/static/js/spec/main.js
+++ b/lms/static/js/spec/main.js
@@ -560,7 +560,8 @@
         'lms/include/js/spec/edxnotes/models/note_spec.js',
         'lms/include/js/spec/edxnotes/plugins/events_spec.js',
         'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
-        'lms/include/js/spec/edxnotes/collections/notes_spec.js'
+        'lms/include/js/spec/edxnotes/collections/notes_spec.js',
+        'lms/include/js/spec/search/search_spec.js'
     ]);
 
 }).call(this, requirejs, define);
diff --git a/lms/static/js/spec/search/search_spec.js b/lms/static/js/spec/search/search_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..cf0aa5fa3a283aa4e81a5b9dec5fbe8ae0161fa7
--- /dev/null
+++ b/lms/static/js/spec/search/search_spec.js
@@ -0,0 +1,513 @@
+define([
+    'jquery',
+    'sinon',
+    'backbone',
+    'js/common_helpers/template_helpers',
+    'js/search/views/search_form',
+    'js/search/views/search_item_view',
+    'js/search/views/search_list_view',
+    'js/search/models/search_result',
+    'js/search/collections/search_collection',
+    'js/search/search_router',
+    'js/search/search_app'
+], function(
+    $,
+    Sinon,
+    Backbone,
+    TemplateHelpers,
+    SearchForm,
+    SearchItemView,
+    SearchListView,
+    SearchResult,
+    SearchCollection,
+    SearchRouter,
+    SearchApp
+) {
+    'use strict';
+
+    describe('SearchForm', function () {
+
+        beforeEach(function () {
+            loadFixtures('js/fixtures/search_form.html');
+            this.form = new SearchForm();
+            this.onClear = jasmine.createSpy('onClear');
+            this.onSearch = jasmine.createSpy('onSearch');
+            this.form.on('clear', this.onClear);
+            this.form.on('search', this.onSearch);
+        });
+
+        it('trims input string', function () {
+            var term = '  search string  ';
+            $('.search-field').val(term);
+            $('form').trigger('submit');
+            expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
+        });
+
+        it('handles calls to doSearch', function () {
+            var term = '  search string  ';
+            $('.search-field').val(term);
+            this.form.doSearch(term);
+            expect(this.onSearch).toHaveBeenCalledWith($.trim(term));
+            expect($('.search-field').val()).toEqual(term);
+            expect($('.search-field')).toHaveClass('is-active');
+            expect($('.search-button')).toBeHidden();
+            expect($('.cancel-button')).toBeVisible();
+        });
+
+        it('triggers a search event and changes to active state', function () {
+            var term = 'search string';
+            $('.search-field').val(term);
+            $('form').trigger('submit');
+            expect(this.onSearch).toHaveBeenCalledWith(term);
+            expect($('.search-field')).toHaveClass('is-active');
+            expect($('.search-button')).toBeHidden();
+            expect($('.cancel-button')).toBeVisible();
+        });
+
+        it('clears search when clicking on cancel button', function () {
+            $('.search-field').val('search string');
+            $('.cancel-button').trigger('click');
+            expect($('.search-field')).not.toHaveClass('is-active');
+            expect($('.search-button')).toBeVisible();
+            expect($('.cancel-button')).toBeHidden();
+            expect($('.search-field')).toHaveValue('');
+        });
+
+        it('clears search when search box is empty', function() {
+            $('.search-field').val('');
+            $('form').trigger('submit');
+            expect(this.onClear).toHaveBeenCalled();
+            expect($('.search-field')).not.toHaveClass('is-active');
+            expect($('.cancel-button')).toBeHidden();
+            expect($('.search-button')).toBeVisible();
+        });
+
+    });
+
+
+    describe('SearchItemView', function () {
+
+        beforeEach(function () {
+            TemplateHelpers.installTemplate('templates/courseware_search/search_item');
+            this.model = {
+                attributes: {
+                    location: ['section', 'subsection', 'unit'],
+                    content_type: 'Video',
+                    excerpt: 'A short excerpt.',
+                    url: 'path/to/content'
+                }
+            };
+            this.item = new SearchItemView({ model: this.model });
+        });
+
+        it('has useful html attributes', function () {
+            expect(this.item.$el).toHaveAttr('role', 'region');
+            expect(this.item.$el).toHaveAttr('aria-label', 'search result');
+        });
+
+        it('renders correctly', function () {
+            var href = this.model.attributes.url;
+            var breadcrumbs = 'section â–¸ subsection â–¸ unit';
+
+            this.item.render();
+            expect(this.item.$el).toContainHtml(this.model.attributes.content_type);
+            expect(this.item.$el).toContainHtml(this.model.attributes.excerpt);
+            expect(this.item.$el).toContain('a[href="'+href+'"]');
+            expect(this.item.$el).toContainHtml(breadcrumbs);
+        });
+
+    });
+
+
+    describe('SearchResult', function () {
+
+        beforeEach(function () {
+            this.result = new SearchResult();
+        });
+
+        it('has properties', function () {
+            expect(this.result.get('location')).toBeDefined();
+            expect(this.result.get('content_type')).toBeDefined();
+            expect(this.result.get('excerpt')).toBeDefined();
+            expect(this.result.get('url')).toBeDefined();
+        });
+
+    });
+
+
+    describe('SearchCollection', function () {
+
+        beforeEach(function () {
+            this.server = Sinon.fakeServer.create();
+            this.collection = new SearchCollection();
+
+            this.onSearch = jasmine.createSpy('onSearch');
+            this.collection.on('search', this.onSearch);
+
+            this.onNext = jasmine.createSpy('onNext');
+            this.collection.on('next', this.onNext);
+
+            this.onError = jasmine.createSpy('onError');
+            this.collection.on('error', this.onError);
+        });
+
+        afterEach(function () {
+            this.server.restore();
+        });
+
+        it('appends course_id to url', function () {
+            var collection = new SearchCollection([], { course_id: 'edx101' });
+            expect(collection.url).toEqual('/search/edx101');
+        });
+
+        it('sends a request and parses the json result', function () {
+            this.collection.performSearch('search string');
+            var response = {
+                total: 2,
+                access_denied_count: 1,
+                results: [{
+                    data: {
+                        location: ['section', 'subsection', 'unit'],
+                        url: '/some/url/to/content',
+                        content_type: 'text',
+                        excerpt: 'this is a short excerpt'
+                    }
+                }]
+            };
+            this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            this.server.respond();
+
+            expect(this.onSearch).toHaveBeenCalled();
+            expect(this.collection.totalCount).toEqual(1);
+            expect(this.collection.accessDeniedCount).toEqual(1);
+            expect(this.collection.page).toEqual(0);
+            expect(this.collection.first().attributes).toEqual(response.results[0].data);
+        });
+
+        it('handles errors', function () {
+            this.collection.performSearch('search string');
+            this.server.respond();
+            expect(this.onSearch).not.toHaveBeenCalled();
+            expect(this.onError).toHaveBeenCalled();
+        });
+
+        it('loads next page', function () {
+            var response = { total: 35, results: [] };
+            this.collection.loadNextPage();
+            this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            expect(this.onNext).toHaveBeenCalled();
+            expect(this.onError).not.toHaveBeenCalled();
+        });
+
+        it('sends correct paging parameters', function () {
+            this.collection.performSearch('search string');
+            var response = { total: 52, results: [] };
+            this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            this.server.respond();
+            this.collection.loadNextPage();
+            this.server.respond();
+            spyOn($, 'ajax');
+            this.collection.loadNextPage();
+            expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url);
+            expect($.ajax.mostRecentCall.args[0].data).toEqual({
+                search_string : 'search string',
+                page_size : this.collection.pageSize,
+                page_index : 2
+            });
+        });
+
+        it('has next page', function () {
+            var response = { total: 35, access_denied_count: 5, results: [] };
+            this.collection.performSearch('search string');
+            this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            expect(this.collection.hasNextPage()).toEqual(true);
+            this.collection.loadNextPage();
+            this.server.respond();
+            expect(this.collection.hasNextPage()).toEqual(false);
+        });
+
+        it('aborts any previous request', function () {
+            var response = { total: 35, results: [] };
+
+            this.collection.performSearch('old search');
+            this.collection.performSearch('new search');
+            this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            expect(this.onSearch.calls.length).toEqual(1);
+
+            this.collection.performSearch('old search');
+            this.collection.cancelSearch();
+            this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            expect(this.onSearch.calls.length).toEqual(1);
+
+            this.collection.loadNextPage();
+            this.collection.loadNextPage();
+            this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]);
+            expect(this.onNext.calls.length).toEqual(1);
+        });
+
+        describe('reset state', function () {
+
+            beforeEach(function () {
+                this.collection.page = 2;
+                this.collection.totalCount = 35;
+            });
+
+            it('resets state when performing new search', function () {
+                this.collection.performSearch('search string');
+                expect(this.collection.page).toEqual(0);
+                expect(this.collection.totalCount).toEqual(0);
+            });
+
+            it('resets state when canceling a search', function () {
+                this.collection.cancelSearch();
+                expect(this.collection.page).toEqual(0);
+                expect(this.collection.totalCount).toEqual(0);
+            });
+
+        });
+
+    });
+
+
+    describe('SearchListView', function () {
+
+        beforeEach(function () {
+            setFixtures(
+                '<section id="courseware-search-results" data-course-name="Test Course"></section>' +
+                '<section id="course-content"></section>'
+            );
+
+            TemplateHelpers.installTemplates([
+                'templates/courseware_search/search_item',
+                'templates/courseware_search/search_list',
+                'templates/courseware_search/search_loading',
+                'templates/courseware_search/search_error'
+            ]);
+
+            var MockCollection = Backbone.Collection.extend({
+                hasNextPage: function (){}
+            });
+            this.collection = new MockCollection();
+
+            // spy on these methods before they are bound to events
+            spyOn(SearchListView.prototype, 'render').andCallThrough();
+            spyOn(SearchListView.prototype, 'renderNext').andCallThrough();
+            spyOn(SearchListView.prototype, 'showErrorMessage').andCallThrough();
+
+            this.listView = new SearchListView({ collection: this.collection });
+        });
+
+        it('shows loading message', function () {
+            this.listView.showLoadingMessage();
+            expect($('#course-content')).toBeHidden();
+            expect(this.listView.$el).toBeVisible();
+            expect(this.listView.$el).not.toBeEmpty();
+        });
+
+        it('shows error message', function () {
+            this.listView.showErrorMessage();
+            expect($('#course-content')).toBeHidden();
+            expect(this.listView.$el).toBeVisible();
+            expect(this.listView.$el).not.toBeEmpty();
+        });
+
+        it('returns to content', function () {
+            this.listView.clear();
+            expect($('#course-content')).toBeVisible();
+            expect(this.listView.$el).toBeHidden();
+            expect(this.listView.$el).toBeEmpty();
+        });
+
+        it('handles events', function () {
+            this.collection.trigger('search');
+            this.collection.trigger('next');
+            this.collection.trigger('error');
+
+            expect(this.listView.render).toHaveBeenCalled();
+            expect(this.listView.renderNext).toHaveBeenCalled();
+            expect(this.listView.showErrorMessage).toHaveBeenCalled();
+        });
+
+        it('renders a message when there are no results', function () {
+            this.collection.reset();
+            this.listView.render();
+            expect(this.listView.$el).toContainHtml('no results');
+            expect(this.listView.$el.find('ol')).not.toExist();
+        });
+
+        it('renders search results', function () {
+            var searchResults = [{
+                location: ['section', 'subsection', 'unit'],
+                url: '/some/url/to/content',
+                content_type: 'text',
+                excerpt: 'this is a short excerpt'
+            }];
+            this.collection.set(searchResults);
+            this.collection.totalCount = 1;
+
+            this.listView.render();
+            expect(this.listView.$el.find('ol')[0]).toExist();
+            expect(this.listView.$el.find('li').length).toEqual(1);
+            expect(this.listView.$el).toContainHtml('Test Course');
+            expect(this.listView.$el).toContainHtml('this is a short excerpt');
+
+            this.collection.set(searchResults);
+            this.collection.totalCount = 2;
+            this.listView.renderNext();
+            expect(this.listView.$el.find('.search-count')).toContainHtml('2');
+            expect(this.listView.$el.find('li').length).toEqual(2);
+        });
+
+        it('shows a link to load more results', function () {
+            this.collection.totalCount = 123;
+            this.collection.hasNextPage = function () { return true; };
+            this.listView.render();
+            expect(this.listView.$el.find('a.search-load-next')[0]).toExist();
+
+            this.collection.totalCount = 123;
+            this.collection.hasNextPage = function () { return false; };
+            this.listView.render();
+            expect(this.listView.$el.find('a.search-load-next')[0]).not.toExist();
+        });
+
+        it('triggers an event for next page', function () {
+            var onNext = jasmine.createSpy('onNext');
+            this.listView.on('next', onNext);
+            this.collection.totalCount = 123;
+            this.collection.hasNextPage = function () { return true; };
+            this.listView.render();
+            this.listView.$el.find('a.search-load-next').click();
+            expect(onNext).toHaveBeenCalled();
+        });
+
+        it('shows a spinner when loading more results', function () {
+            this.collection.totalCount = 123;
+            this.collection.hasNextPage = function () { return true; };
+            this.listView.render();
+            this.listView.loadNext();
+            expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeVisible();
+            this.listView.renderNext();
+            expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeHidden();
+        });
+
+    });
+
+
+    describe('SearchRouter', function () {
+
+        beforeEach(function () {
+            this.router = new SearchRouter();
+        });
+
+        it ('has a search route', function () {
+            expect(this.router.routes['search/:query']).toEqual('search');
+        });
+
+    });
+
+
+    describe('SearchApp', function () {
+
+        beforeEach(function () {
+            loadFixtures('js/fixtures/search_form.html');
+            appendSetFixtures(
+                '<section id="courseware-search-results" data-course-name="Test Course"></section>' +
+                '<section id="course-content"></section>'
+            );
+            TemplateHelpers.installTemplates([
+                'templates/courseware_search/search_item',
+                'templates/courseware_search/search_list',
+                'templates/courseware_search/search_loading',
+                'templates/courseware_search/search_error'
+            ]);
+
+            this.server = Sinon.fakeServer.create();
+            this.server.respondWith([200, {}, JSON.stringify({
+                total: 1337,
+                access_denied_count: 12,
+                results: [{
+                    data: {
+                        location: ['section', 'subsection', 'unit'],
+                        url: '/some/url/to/content',
+                        content_type: 'text',
+                        excerpt: 'this is a short excerpt'
+                    }
+                }]
+            })]);
+
+            Backbone.history.stop();
+            this.app = new SearchApp('a/b/c');
+
+            // start history after the application has finished creating
+            //  all of its routers
+            Backbone.history.start();
+        });
+
+        afterEach(function () {
+            this.server.restore();
+        });
+
+        it ('shows loading message on search', function () {
+            $('.search-field').val('search string');
+            $('.search-button').trigger('click');
+            expect($('#course-content')).toBeHidden();
+            expect($('#courseware-search-results')).toBeVisible();
+            expect($('#courseware-search-results')).not.toBeEmpty();
+        });
+
+        it ('performs search', function () {
+            $('.search-field').val('search string');
+            $('.search-button').trigger('click');
+            this.server.respond();
+            expect($('.search-info')).toExist();
+            expect($('.search-results')).toBeVisible();
+        });
+
+        it ('updates navigation history on search', function () {
+            $('.search-field').val('edx');
+            $('.search-button').trigger('click');
+            expect(Backbone.history.fragment).toEqual('search/edx');
+        });
+
+        it ('aborts sent search request', function () {
+            // send search request to server
+            $('.search-field').val('search string');
+            $('.search-button').trigger('click');
+            // cancel search
+            $('.cancel-button').trigger('click');
+            this.server.respond();
+            // there should be no results
+            expect($('#course-content')).toBeVisible();
+            expect($('#courseware-search-results')).toBeHidden();
+        });
+
+        it ('clears results', function () {
+            $('.cancel-button').trigger('click');
+            expect($('#course-content')).toBeVisible();
+            expect($('#courseware-search-results')).toBeHidden();
+        });
+
+        it ('updates navigation history on clear', function () {
+            $('.cancel-button').trigger('click');
+            expect(Backbone.history.fragment).toEqual('');
+        });
+
+        it ('loads next page', function () {
+            $('.search-field').val('query');
+            $('.search-button').trigger('click');
+            this.server.respond();
+            expect($('.search-load-next')).toBeVisible();
+            $('.search-load-next').trigger('click');
+            var body = this.server.requests[1].requestBody;
+            expect(body).toContain('search_string=query');
+            expect(body).toContain('page_index=1');
+        });
+
+        it ('navigates to search', function () {
+            Backbone.history.loadUrl('search/query');
+            expect(this.server.requests[0].requestBody).toContain('search_string=query');
+        });
+
+    });
+
+});
diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml
index 5dfa944db2c0ed7da0b0a0ba025a4a6ff7c8480a..2106ec155e589656a8f3b07f9de6108bfef7d5e2 100644
--- a/lms/static/js_test.yml
+++ b/lms/static/js_test.yml
@@ -78,6 +78,7 @@ spec_paths:
 #   loadFixtures('path/to/fixture/fixture.html');
 #
 fixture_paths:
+    - js/fixtures
     - templates/instructor/instructor_dashboard_2
     - templates/dashboard
     - templates/edxnotes
@@ -86,6 +87,7 @@ fixture_paths:
     - templates/verify_student
     - templates/file-upload.underscore
     - js/fixtures/edxnotes
+    - templates/courseware_search
 
 requirejs:
   paths:
diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako
index 7fb09e267712eb1e49f1e7a316ec6061fe56e925..b6cb030d79ca0948e2eaff0e3e1a81ead03aca21 100644
--- a/lms/static/sass/course.scss.mako
+++ b/lms/static/sass/course.scss.mako
@@ -41,6 +41,11 @@
 @import 'course/courseware/sidebar';
 @import 'course/courseware/amplifier';
 
+## Import styles for courseware search
+% if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"):
+    @import 'course/courseware/courseware_search';
+% endif
+
 // course - modules
 @import 'course/modules/student-notes'; // student notes
 @import 'course/modules/calculator'; // calculator utility
diff --git a/lms/static/sass/course/courseware/_courseware_search.scss b/lms/static/sass/course/courseware/_courseware_search.scss
new file mode 100644
index 0000000000000000000000000000000000000000..3d91102e3581ae93210ca3262e9a8e053db5f7f2
--- /dev/null
+++ b/lms/static/sass/course/courseware/_courseware_search.scss
@@ -0,0 +1,101 @@
+.course-index .courseware-search-bar {
+
+  @include box-sizing(border-box);
+  position: relative;
+  padding: 5px;
+  box-shadow: 0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset;
+  font-family: $sans-serif;
+
+  .search-field {
+    @include box-sizing(border-box);
+    top: 5px;
+    width: 100%;
+    @include border-radius(4px);
+    background: $white-t1;
+    &.is-active {
+      background: $white;
+    }
+  }
+
+  .search-button, .cancel-button {
+    @include box-sizing(border-box);
+    color: #888;
+    font-size: 14px;
+    font-weight: normal;
+    display: block;
+    position: absolute;
+    right: 12px;
+    top: 5px;
+    height: 35px;
+    line-height: 35px;
+    padding: 0;
+    border: none;
+    box-shadow: none;
+    background: transparent;
+  }
+
+  .cancel-button {
+    display: none;
+  }
+
+}
+
+
+.courseware-search-results {
+
+  display: none;
+  padding: 40px;
+
+  .search-info {
+    padding-bottom: lh(.75);
+    border-bottom: 1px solid lighten($border-color, 10%);
+    .search-count {
+      float: right;
+      color: $gray;
+    }
+  }
+
+  .search-results {
+    padding: 0;
+  }
+
+  .search-results-item {
+    position: relative;
+    border-bottom: 1px solid lighten($border-color, 10%);
+    list-style-type: none;
+    margin-bottom: lh(.75);
+    padding-bottom: lh(.75);
+    padding-right: 140px;
+
+    .sri-excerpt {
+      color: $gray;
+      margin-bottom: lh(1);
+    }
+    .sri-type {
+      position: absolute;
+      right: 0;
+      top: 0;
+      color: $gray;
+    }
+    .sri-link {
+      position: absolute;
+      right: 0;
+      line-height: 1.6em;
+      bottom: lh(.75);
+      text-transform: uppercase;
+    }
+  }
+
+  .search-load-next {
+    display: block;
+    text-transform: uppercase;
+    color: $base-font-color;
+    border: 2px solid $link-color;
+    @include border-radius(3px);
+    padding: 1rem;
+    .icon-spin {
+      display: none;
+    }
+  }
+
+}
diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html
index bcc4e89d7441a9adf5b6b01434fb36a2bdb4c33e..e62c5313797e905a9a04266ac49c1bf0f1b873fd 100644
--- a/lms/templates/courseware/courseware.html
+++ b/lms/templates/courseware/courseware.html
@@ -23,8 +23,14 @@ ${page_title_breadcrumbs(course_name())}
     <%static:include path="js/${template_name}.underscore" />
 </script>
 % endfor
-</%block>
 
+% for template_name in ["search_item", "search_list", "search_loading", "search_error"]:
+<script type="text/template" id="${template_name}-tpl">
+    <%static:include path="courseware_search/${template_name}.underscore" />
+</script>
+% endfor
+
+</%block>
 
 <%block name="headextra">
 <%static:css group='style-course-vendor'/>
@@ -201,6 +207,16 @@ ${fragment.foot_html()}
         <a href="#">${_("close")}</a>
       </header>
 
+      % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+        <div id="courseware-search-bar" class="courseware-search-bar">
+          <form role="search-form">
+            <input type="text" class="search-field"/>
+            <button type="submit" class="search-button" aria-label="${_('Search')}">${_('search')} <i class="icon fa fa-search"></i></button>
+            <button type="button" class="cancel-button" aria-label="${_('Cancel')}"><i class="icon fa fa-remove"></i></button>
+          </form>
+        </div>
+      % endif
+
       <div id="accordion" style="display: none">
         <nav aria-label="${_('Course Navigation')}">
           % if accordion.strip():
@@ -212,10 +228,13 @@ ${fragment.foot_html()}
       </div>
     </div>
 % endif
-
     <section class="course-content" id="course-content">
       ${fragment.body_html()}
     </section>
+    % if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+      <section class="courseware-search-results" id="courseware-search-results" data-course-id="${course.id}" data-course-name="${course.display_name_with_default}">
+      </section>
+    % endif
   </div>
 </div>
 
diff --git a/lms/templates/courseware_search/search_error.underscore b/lms/templates/courseware_search/search_error.underscore
new file mode 100644
index 0000000000000000000000000000000000000000..12b2f36125e376ddbebd568d24f3c4aee6e02925
--- /dev/null
+++ b/lms/templates/courseware_search/search_error.underscore
@@ -0,0 +1 @@
+<%= gettext("There was an error, try searching again.") %>
diff --git a/lms/templates/courseware_search/search_item.underscore b/lms/templates/courseware_search/search_item.underscore
new file mode 100644
index 0000000000000000000000000000000000000000..4bf7724ce0dc8d1e46d589c0a92448eafa593338
--- /dev/null
+++ b/lms/templates/courseware_search/search_item.underscore
@@ -0,0 +1,4 @@
+<div class='sri-excerpt'><%= excerpt %></div>
+<span class='sri-type'><%- content_type %></span>
+<span class='sri-location'><%- location.join(' â–¸ ') %></span>
+<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon-arrow-right"></i></a>
diff --git a/lms/templates/courseware_search/search_list.underscore b/lms/templates/courseware_search/search_list.underscore
new file mode 100644
index 0000000000000000000000000000000000000000..fdc2d843fc6b43903a167c30b009a6e3b3f5bc8e
--- /dev/null
+++ b/lms/templates/courseware_search/search_list.underscore
@@ -0,0 +1,21 @@
+<div class="search-info">
+    <%- interpolate(gettext("Searching %s"), [courseName]) %>
+    <div class="search-count"><%- totalCountMsg %></div>
+</div>
+
+<% if (totalCount > 0 ) { %>
+
+    <ol class='search-results'></ol>
+
+    <% if (hasMoreResults) { %>
+        <a class="search-load-next" href="javascript:void(0);">
+            <%- interpolate(gettext("Load next %s results"), [pageSize]) %>
+            <i class="icon fa-spinner fa-spin"></i>
+        </a>
+    <% } %>
+
+<% } else { %>
+
+    <p><%- gettext("Sorry, no results were found.") %></p>
+
+<% } %>
diff --git a/lms/templates/courseware_search/search_loading.underscore b/lms/templates/courseware_search/search_loading.underscore
new file mode 100644
index 0000000000000000000000000000000000000000..d301f39a3871950308382879725288d1a5a55888
--- /dev/null
+++ b/lms/templates/courseware_search/search_loading.underscore
@@ -0,0 +1,2 @@
+<i class="icon fa fa-spinner fa-spin"></i> <%= gettext("Loading") %>
+
diff --git a/lms/urls.py b/lms/urls.py
index 3bf18ff8d37ac335d11d3e0e35d14dc657207467..3e752032f349be058dbfeaa58df0046845eea83d 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -10,6 +10,8 @@ from microsite_configuration import microsite
 if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
     admin.autodiscover()
 
+# Use urlpatterns formatted as within the Django docs with first parameter "stuck" to the open parenthesis
+# pylint: disable=bad-continuation
 urlpatterns = ('',  # nopep8
     # certificate view
     url(r'^update_certificate$', 'certificates.views.update_certificate'),
@@ -79,6 +81,9 @@ urlpatterns = ('',  # nopep8
     # CourseInfo API RESTful endpoints
     url(r'^api/course/details/v0/', include('course_about.urls')),
 
+    # Courseware search endpoints
+    url(r'^search/', include('search.urls')),
+
 )
 
 if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index fa2059243fc9642a0f551f5b78c83ac39adc5261..96ecd2ae8774780ac0b76099445eb09d37b51a51 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -35,6 +35,7 @@ django-threaded-multihost==1.4-1
 django-method-override==0.1.0
 djangorestframework==2.3.14
 django==1.4.18
+elasticsearch==0.4.5
 feedparser==5.1.3
 firebase-token-generator==1.3.2
 # Master pyfs has a bug working with VPC auth. This is a fix. We should switch
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 059c185e2a64eff091b42ff36d73317d4d9e88c7..9e383373ebe9ddb8233479d63ddf9ad7a0313508 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -21,7 +21,7 @@
 git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas
 
 # Our libraries:
--e git+https://github.com/edx/XBlock.git@9c634481dfc85a17dcb3351ca232d7098a38e10e#egg=XBlock
+-e git+https://github.com/edx/XBlock.git@3682847a91acac6640a330fbe797ef56ce988517#egg=XBlock
 -e git+https://github.com/edx/codejail.git@2b095e820ff752a108653bb39d518b122f7154db#egg=codejail
 -e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool
 -e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking
@@ -36,3 +36,4 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a
 -e git+https://github.com/edx/edx-val.git@ba00a5f2e0571e9a3f37d293a98efe4cbca850d5#egg=edx-val
 -e git+https://github.com/pmitros/RecommenderXBlock.git@9b07e807c89ba5761827d0387177f71aa57ef056#egg=recommender-xblock
 -e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones
+-e git+https://github.com/edx/edx-search.git@264bb3317f98e9cb22b932aa11b89d0651fd741c#egg=edx-search