diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py
index d756424bccaa..08347dd9cd53 100644
--- a/cms/djangoapps/contentstore/signals/handlers.py
+++ b/cms/djangoapps/contentstore/signals/handlers.py
@@ -2,9 +2,11 @@
 
 
 import logging
+import requests
 from datetime import datetime, timezone
 from functools import wraps
 from typing import Optional
+from urllib.parse import urljoin
 
 from django.conf import settings
 from django.core.cache import cache
@@ -27,6 +29,7 @@
 from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines
 from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
 from openedx.core.lib.gating import api as gating_api
+from openedx.features.offline_mode.toggles import is_offline_mode_enabled
 from xmodule.modulestore import ModuleStoreEnum
 from xmodule.modulestore.django import SignalHandler, modulestore
 from .signals import GRADING_POLICY_CHANGED
@@ -34,6 +37,7 @@
 log = logging.getLogger(__name__)
 
 GRADING_POLICY_COUNTDOWN_SECONDS = 3600
+LMS_OFFLINE_HANDLER_URL = '/offline_mode/handle_course_published'
 
 
 def locked(expiry_seconds, key):  # lint-amnesty, pylint: disable=missing-function-docstring
@@ -155,6 +159,13 @@ def listen_for_course_publish(sender, course_key, **kwargs):  # pylint: disable=
     # Send to a signal for catalog info changes as well, but only once we know the transaction is committed.
     transaction.on_commit(lambda: emit_catalog_info_changed_signal(course_key))
 
+    if is_offline_mode_enabled(course_key):
+        requests.post(
+            url=urljoin(settings.LMS_ROOT_URL, LMS_OFFLINE_HANDLER_URL),
+            data={'course_id': str(course_key)},
+        )
+        log.info('Sent course_published event to offline mode handler')
+
 
 @receiver(SignalHandler.course_deleted)
 def listen_for_course_delete(sender, course_key, **kwargs):  # pylint: disable=unused-argument
diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py
index 40d586839680..562cf97b288f 100644
--- a/lms/djangoapps/mobile_api/course_info/views.py
+++ b/lms/djangoapps/mobile_api/course_info/views.py
@@ -2,13 +2,16 @@
 Views for course info API
 """
 
+import os
 import logging
 from typing import Dict, Optional, Union
 
 import django
+from django.conf import settings
 from django.contrib.auth import get_user_model
+from django.core.files.storage import default_storage
 from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.keys import CourseKey, UsageKey
 from rest_framework import generics, status
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
@@ -31,6 +34,9 @@
 from openedx.core.lib.api.view_utils import view_auth_classes
 from openedx.core.lib.xblock_utils import get_course_update_items
 from openedx.features.course_experience import ENABLE_COURSE_GOALS
+from openedx.features.offline_mode.assets_management import get_offline_block_content_path
+from openedx.features.offline_mode.toggles import is_offline_mode_enabled
+
 from ..decorators import mobile_course_access, mobile_view
 
 User = get_user_model()
@@ -369,6 +375,8 @@ def list(self, request, **kwargs):  # pylint: disable=W0221
                     course_key,
                     response.data['blocks'],
                 )
+                if api_version == 'v4' and is_offline_mode_enabled(course_key):
+                    self._extend_block_info_with_offline_data(response.data['blocks'])
 
                 course_info_context = {
                     'user': requested_user,
@@ -426,3 +434,21 @@ def _extend_sequential_info_with_assignment_progress(
                         }
                     }
                 )
+
+    @staticmethod
+    def _extend_block_info_with_offline_data(blocks_info_data: Dict[str, Dict]) -> None:
+        """
+        Extends block info with offline download data.
+
+        If offline content is available for the block, adds the offline download data to the block info.
+        """
+        for block_id, block_info in blocks_info_data.items():
+            if offline_content_path := get_offline_block_content_path(usage_key=UsageKey.from_string(block_id)):
+                file_url = os.path.join(settings.MEDIA_URL, offline_content_path)
+                block_info.update({
+                    'offline_download': {
+                        'file_url': file_url,
+                        'last_modified': default_storage.get_created_time(offline_content_path),
+                        'file_size': default_storage.size(offline_content_path)
+                    }
+                })
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 7fbddadf388c..ff2c14f1c0ed 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -3328,6 +3328,7 @@ def _make_locale_paths(settings):  # pylint: disable=missing-function-docstring
     'openedx.features.discounts',
     'openedx.features.effort_estimation',
     'openedx.features.name_affirmation_api.apps.NameAffirmationApiConfig',
+    'openedx.features.offline_mode',
 
     'lms.djangoapps.experiments',
 
diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html
index e8411c9e4217..f137f4a2a347 100644
--- a/lms/templates/courseware/courseware-chromeless.html
+++ b/lms/templates/courseware/courseware-chromeless.html
@@ -216,3 +216,45 @@
   }());
 </script>
 % endif
+
+% if is_offline_content:
+<script type="text/javascript">
+    (function() {
+        function sendMessageToiOS(message) {
+          window?.webkit?.messageHandlers?.iOSBridge?.postMessage(message);
+        }
+
+        function sendMessageToAndroid(message) {
+          window?.AndroidBridge?.postMessage(message);
+        }
+
+        function receiveMessageFromiOS(message) {
+          console.log("Message received from iOS:", message);
+        }
+
+        function receiveMessageFromAndroid(message) {
+          console.log("Message received from Android:", message);
+        }
+
+        if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iOSBridge) {
+          window.addEventListener("messageFromiOS", function (event) {
+            receiveMessageFromiOS(event.data);
+          });
+        }
+        if (window.AndroidBridge) {
+          window.addEventListener("messageFromAndroid", function (event) {
+            receiveMessageFromAndroid(event.data);
+          });
+        }
+        const originalAjax = $.ajax;
+        $.ajax = function (options) {
+          sendMessageToiOS(options);
+          sendMessageToiOS(JSON.stringify(options));
+          sendMessageToAndroid(options);
+          sendMessageToAndroid(JSON.stringify(options));
+          console.log(options, JSON.stringify(options));
+          return originalAjax.call(this, options);
+        };
+    }());
+</script>
+% endif
diff --git a/lms/urls.py b/lms/urls.py
index 15e374dd8551..98e02398de38 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -1053,3 +1053,7 @@
 urlpatterns += [
     path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
 ]
+
+urlpatterns += [
+    path('offline_mode/', include('openedx.features.offline_mode.urls')),
+]
diff --git a/openedx/features/offline_mode/__init__.py b/openedx/features/offline_mode/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/openedx/features/offline_mode/apps.py b/openedx/features/offline_mode/apps.py
new file mode 100644
index 000000000000..c504af09dd11
--- /dev/null
+++ b/openedx/features/offline_mode/apps.py
@@ -0,0 +1,14 @@
+"""
+OfflineMode application configuration
+"""
+
+
+from django.apps import AppConfig
+
+
+class OfflineModeConfig(AppConfig):
+    """
+    Application Configuration for Offline Mode module.
+    """
+
+    name = 'openedx.features.offline_mode'
diff --git a/openedx/features/offline_mode/assets_management.py b/openedx/features/offline_mode/assets_management.py
new file mode 100644
index 000000000000..e8b73e9213e6
--- /dev/null
+++ b/openedx/features/offline_mode/assets_management.py
@@ -0,0 +1,172 @@
+"""
+This module contains utility functions for managing assets and files.
+"""
+import shutil
+import logging
+import os
+import requests
+
+from django.conf import settings
+from django.core.files.storage import default_storage
+
+from xmodule.assetstore.assetmgr import AssetManager
+from xmodule.contentstore.content import StaticContent
+from xmodule.exceptions import NotFoundError
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH
+
+
+log = logging.getLogger(__name__)
+
+
+def get_static_file_path(relative_path):
+    """
+    Constructs the absolute path for a static file based on its relative path.
+    """
+    base_path = settings.STATIC_ROOT
+    return os.path.join(base_path, relative_path)
+
+
+def read_static_file(path):
+    """
+    Reads the contents of a static file in binary mode.
+    """
+    with open(path, 'rb') as file:
+        return file.read()
+
+
+def save_asset_file(temp_dir, xblock, path, filename):
+    """
+    Saves an asset file to the default storage.
+
+    If the filename contains a '/', it reads the static file directly from the file system.
+    Otherwise, it fetches the asset from the AssetManager.
+    Args:
+        temp_dir (str): The temporary directory where the assets are stored.
+        xblock (XBlock): The XBlock instance
+        path (str): The path where the asset is located.
+        filename (str): The name of the file to be saved.
+    """
+    try:
+        if filename.startswith('assets/'):
+            asset_filename = filename.split('/')[-1]
+            asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, asset_filename)
+            content = AssetManager.find(asset_key).data
+            file_path = os.path.join(temp_dir, filename)
+        else:
+            static_path = get_static_file_path(filename)
+            content = read_static_file(static_path)
+            file_path = os.path.join(temp_dir, 'assets', filename)
+    except (ItemNotFoundError, NotFoundError):
+        log.info(f"Asset not found: {filename}")
+
+    else:
+        create_subdirectories_for_asset(file_path)
+        with open(file_path, 'wb') as file:
+            file.write(content)
+
+
+def create_subdirectories_for_asset(file_path):
+    out_dir_name = '/'
+    for dir_name in file_path.split('/')[:-1]:
+        out_dir_name = os.path.join(out_dir_name, dir_name)
+        if out_dir_name and not os.path.exists(out_dir_name):
+            os.mkdir(out_dir_name)
+
+
+def remove_old_files(xblock):
+    """
+    Removes the 'assets' directory, 'index.html', and 'offline_content.zip' files
+    in the specified base path directory.
+    Args:
+        (XBlock): The XBlock instance
+    """
+    try:
+        base_path = block_storage_path(xblock)
+        assets_path = os.path.join(base_path, 'assets')
+        index_file_path = os.path.join(base_path, 'index.html')
+        offline_zip_path = os.path.join(base_path, f'{xblock.location.block_id}.zip')
+
+        # Delete the 'assets' directory if it exists
+        if os.path.isdir(assets_path):
+            shutil.rmtree(assets_path)
+            log.info(f"Successfully deleted the directory: {assets_path}")
+
+        # Delete the 'index.html' file if it exists
+        if default_storage.exists(index_file_path):
+            os.remove(index_file_path)
+            log.info(f"Successfully deleted the file: {index_file_path}")
+
+        # Delete the 'offline_content.zip' file if it exists
+        if default_storage.exists(offline_zip_path):
+            os.remove(offline_zip_path)
+            log.info(f"Successfully deleted the file: {offline_zip_path}")
+
+    except OSError as e:
+        log.error(f"Error occurred while deleting the files or directory: {e}")
+
+
+def get_offline_block_content_path(xblock=None, usage_key=None):
+    """
+    Checks whether 'offline_content.zip' file is present in the specified base path directory.
+
+    Args:
+        xblock (XBlock): The XBlock instance
+        usage_key (UsageKey): The UsageKey of the XBlock
+    Returns:
+        bool: True if the file is present, False otherwise
+    """
+    usage_key = usage_key or getattr(xblock, 'location', None)
+    base_path = block_storage_path(usage_key=usage_key)
+    offline_zip_path = os.path.join(base_path, f'{usage_key.block_id}.zip')
+    return offline_zip_path if default_storage.exists(offline_zip_path) else None
+
+
+def block_storage_path(xblock=None, usage_key=None):
+    """
+    Generates the base storage path for the given XBlock.
+
+    The path is constructed based on the XBlock's location, which includes the organization,
+    course, block type, and block ID.
+    Args:
+        xblock (XBlock): The XBlock instance for which to generate the storage path.
+        usage_key (UsageKey): The UsageKey of the XBlock.
+    Returns:
+        str: The constructed base storage path.
+    """
+    loc = usage_key or getattr(xblock, 'location', None)
+    return f'{str(loc.course_key)}/' if loc else ''
+
+
+def is_modified(xblock):
+    """
+    Check if the xblock has been modified since the last time the offline content was generated.
+
+    Args:
+        xblock (XBlock): The XBlock instance to check.
+    """
+    file_path = os.path.join(block_storage_path(xblock), f'{xblock.location.block_id}.zip')
+
+    try:
+        last_modified = default_storage.get_created_time(file_path)
+    except OSError:
+        return True
+
+    return xblock.published_on > last_modified
+
+
+def save_mathjax_to_xblock_assets(temp_dir):
+    """
+    Saves MathJax to the local static directory.
+
+    If MathJax is not already saved, it fetches MathJax from
+    the CDN and saves it to the local static directory.
+    """
+    file_path = os.path.join(temp_dir, MATHJAX_STATIC_PATH)
+    if not os.path.exists(file_path):
+        response = requests.get(MATHJAX_CDN_URL)
+        with open(file_path, 'wb') as file:
+            file.write(response.content)
+
+        log.info(f"Successfully saved MathJax to {file_path}")
diff --git a/openedx/features/offline_mode/constants.py b/openedx/features/offline_mode/constants.py
new file mode 100644
index 000000000000..401d84d0a271
--- /dev/null
+++ b/openedx/features/offline_mode/constants.py
@@ -0,0 +1,13 @@
+"""
+Constants for offline mode app.
+"""
+import os
+
+from django.conf import settings
+
+MATHJAX_VERSION = '2.7.5'
+MATHJAX_CDN_URL = f'https://cdn.jsdelivr.net/npm/mathjax@{MATHJAX_VERSION}/MathJax.js'
+MATHJAX_STATIC_PATH = os.path.join('assets', 'js', f'MathJax-{MATHJAX_VERSION}.js')
+
+DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['html']
+OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS)
diff --git a/openedx/features/offline_mode/html_manipulator.py b/openedx/features/offline_mode/html_manipulator.py
new file mode 100644
index 000000000000..fbbca2af6494
--- /dev/null
+++ b/openedx/features/offline_mode/html_manipulator.py
@@ -0,0 +1,93 @@
+"""
+Module to prepare HTML content for offline use.
+"""
+import os
+import re
+
+from bs4 import BeautifulSoup
+
+from django.conf import settings
+
+from .assets_management import save_asset_file, save_mathjax_to_xblock_assets
+from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH
+
+
+class HtmlManipulator:
+    """
+    Class to prepare HTML content for offline use.
+
+    Changes links to static files to paths to pre-generated static files for offline use.
+    """
+
+    def __init__(self, xblock, html_data, temp_dir):
+        self.html_data = html_data
+        self.xblock = xblock
+        self.temp_dir = temp_dir
+
+    def process_html(self):
+        """
+        Prepares HTML content for local usage.
+
+        Changes links to static files to paths to pre-generated static files for offline use.
+        """
+        self._replace_asset_links()
+        self._replace_static_links()
+        self._replace_mathjax_link()
+
+        soup = BeautifulSoup(self.html_data, 'html.parser')
+        self._replace_iframe(soup)
+        return str(soup)
+
+    def _replace_mathjax_link(self):
+        """
+        Replace MathJax CDN link with local path to MathJax.js file.
+        """
+        save_mathjax_to_xblock_assets(self.temp_dir)
+        mathjax_pattern = re.compile(fr'src="{MATHJAX_CDN_URL}[^"]*"')
+        self.html_data = mathjax_pattern.sub(f'src="{MATHJAX_STATIC_PATH}"', self.html_data)
+
+    def _replace_static_links(self):
+        """
+        Replace static links with local links.
+        """
+        static_links_pattern = os.path.join(settings.STATIC_URL, r'[\w./-]+')
+        pattern = re.compile(fr'{static_links_pattern}')
+        self.html_data = pattern.sub(self._replace_link, self.html_data)
+
+    def _replace_asset_links(self):
+        """
+        Replace static links with local links.
+        """
+        pattern = re.compile(r'/assets/[\w./@:+-]+')
+        self.html_data = pattern.sub(self._replace_asset_link, self.html_data)
+
+    def _replace_asset_link(self, match):
+        """
+        Returns the local path of the asset file.
+        """
+        link = match.group()
+        filename = link[1:] if link.startswith('/') else link  # Remove the leading '/'
+        save_asset_file(self.temp_dir, self.xblock, link, filename)
+        return filename
+
+    def _replace_link(self, match):
+        """
+        Returns the local path of the asset file.
+        """
+        link = match.group()
+        filename = link.split(settings.STATIC_URL)[-1]
+        save_asset_file(self.temp_dir, self.xblock, link, filename)
+        return f'assets/{filename}'
+
+    @staticmethod
+    def _replace_iframe(soup):
+        """
+        Replace iframe tags with anchor tags.
+        """
+        for node in soup.find_all('iframe'):
+            replacement = soup.new_tag('p')
+            tag_a = soup.new_tag('a')
+            tag_a['href'] = node.get('src')
+            tag_a.string = node.get('title', node.get('src'))
+            replacement.append(tag_a)
+            node.replace_with(replacement)
diff --git a/openedx/features/offline_mode/renderer.py b/openedx/features/offline_mode/renderer.py
new file mode 100644
index 000000000000..bb62792172bc
--- /dev/null
+++ b/openedx/features/offline_mode/renderer.py
@@ -0,0 +1,142 @@
+"""
+This module contains the XBlockRenderer class,
+which is responsible for rendering an XBlock HTML content from the LMS.
+"""
+import logging
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.contrib.sessions.backends.db import SessionStore
+from django.http import HttpRequest
+
+from opaque_keys.edx.keys import UsageKey
+from xmodule.modulestore.django import modulestore
+
+from common.djangoapps.edxmako.shortcuts import render_to_string
+from lms.djangoapps.courseware.block_render import get_block_by_usage_id
+from lms.djangoapps.courseware.views.views import get_optimization_flags_for_content
+
+from openedx.core.lib.courses import get_course_by_id
+from openedx.features.course_experience.utils import dates_banner_should_display
+from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
+
+User = get_user_model()
+log = logging.getLogger(__name__)
+
+
+class XBlockRenderer:
+    """
+    Renders an XBlock HTML content from the LMS.
+
+    Since imports from LMS are used here, XBlockRenderer can be called only in the LMS runtime.
+
+    :param usage_key_string: The string representation of the block UsageKey.
+    :param user: The user for whom the XBlock will be rendered.
+    """
+
+    SERVICE_USERNAME = 'offline_mode_worker'
+
+    def __init__(self, usage_key_string, user=None, request=None):
+        self.usage_key = UsageKey.from_string(usage_key_string)
+        self.usage_key = self.usage_key.replace(course_key=modulestore().fill_in_run(self.usage_key.course_key))
+        self.user = user or self.service_user
+        self.request = request or self.generate_request()
+
+    @property
+    def service_user(self):
+        """
+        Returns a valid user to be used as the service user.
+        """
+        try:
+            return User.objects.get(username=self.SERVICE_USERNAME)
+        except User.DoesNotExist as e:
+            log.error(f'Service user with username {self.SERVICE_USERNAME} to render XBlock does not exist.')
+            raise e
+
+    def generate_request(self):
+        """
+        Generates a request object with the service user and a session.
+        """
+        request = HttpRequest()
+        request.user = self.user
+        session = SessionStore()
+        session.create()
+        request.session = session
+        return request
+
+    def render_xblock_from_lms(self):
+        """
+        Returns a string representation of the HTML content of the XBlock as it appears in the LMS.
+
+        Blocks renders without header, footer and navigation.
+        Blocks view like a for regular user without staff or superuser access.
+        """
+        course_key = self.usage_key.course_key
+
+        with modulestore().bulk_operations(course_key):
+            course = get_course_by_id(course_key)
+            block, _ = get_block_by_usage_id(
+                self.request,
+                str(course_key),
+                str(self.usage_key),
+                disable_staff_debug_info=True,
+                course=course,
+                will_recheck_access='1',
+            )
+
+            enable_completion_on_view_service = False
+            wrap_xblock_data = None
+            completion_service = block.runtime.service(block, 'completion')
+            if completion_service and completion_service.completion_tracking_enabled():
+                if completion_service.blocks_to_mark_complete_on_view({block}):
+                    enable_completion_on_view_service = True
+                    wrap_xblock_data = {
+                        'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
+                    }
+
+            fragment = self.get_fragment(block, wrap_xblock_data)
+            optimization_flags = get_optimization_flags_for_content(block, fragment)
+            missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, self.user)
+
+            context = {
+                'fragment': fragment,
+                'course': course,
+                'block': block,
+                'enable_completion_on_view_service': enable_completion_on_view_service,
+                'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
+                'missed_deadlines': missed_deadlines,
+                'missed_gated_content': missed_gated_content,
+                'has_ended': course.has_ended(),
+                'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
+                'disable_accordion': True,
+                'allow_iframing': True,
+                'disable_header': True,
+                'disable_footer': True,
+                'disable_window_wrap': True,
+                'edx_notes_enabled': False,
+                'staff_access': False,
+                'on_courseware_page': True,
+                'is_learning_mfe': False,
+                'is_mobile_app': True,
+                'is_offline_content': True,
+                'render_course_wide_assets': True,
+                'LANGUAGE_CODE': 'en',
+
+                **optimization_flags,
+            }
+            return render_to_string('courseware/courseware-chromeless.html', context, namespace='main')
+
+    @staticmethod
+    def get_fragment(block, wrap_xblock_data=None):
+        """
+        Returns the HTML fragment of the XBlock.
+        """
+        student_view_context = {
+            'show_bookmark_button': '0',
+            'show_title': '1',
+            'hide_access_error_blocks': True,
+            'is_mobile_app': True,
+        }
+        if wrap_xblock_data:
+            student_view_context['wrap_xblock_data'] = wrap_xblock_data
+        return block.render('student_view', context=student_view_context)
diff --git a/openedx/features/offline_mode/tasks.py b/openedx/features/offline_mode/tasks.py
new file mode 100644
index 000000000000..75e786f43ca4
--- /dev/null
+++ b/openedx/features/offline_mode/tasks.py
@@ -0,0 +1,36 @@
+"""
+Tasks for offline mode feature.
+"""
+from celery import shared_task
+from edx_django_utils.monitoring import set_code_owner_attribute
+from opaque_keys.edx.keys import CourseKey, UsageKey
+
+from xmodule.modulestore.django import modulestore
+
+from .constants import OFFLINE_SUPPORTED_XBLOCKS
+from .renderer import XBlockRenderer
+from .utils import generate_offline_content
+
+
+@shared_task
+@set_code_owner_attribute
+def generate_offline_content_for_course(course_id):
+    """
+    Generates offline content for all supported XBlocks in the course.
+    """
+    course_key = CourseKey.from_string(course_id)
+    for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS:
+        for xblock in modulestore().get_items(course_key, qualifiers={'category': offline_supported_block_type}):
+            html_data = XBlockRenderer(str(xblock.location)).render_xblock_from_lms()
+            generate_offline_content_for_block.apply_async([str(xblock.location), html_data])
+
+
+@shared_task
+@set_code_owner_attribute
+def generate_offline_content_for_block(block_id, html_data):
+    """
+    Generates offline content for the specified block.
+    """
+    block_usage_key = UsageKey.from_string(block_id)
+    xblock = modulestore().get_item(block_usage_key)
+    generate_offline_content(xblock, html_data)
diff --git a/openedx/features/offline_mode/toggles.py b/openedx/features/offline_mode/toggles.py
new file mode 100644
index 000000000000..e76c5ce56803
--- /dev/null
+++ b/openedx/features/offline_mode/toggles.py
@@ -0,0 +1,23 @@
+"""
+Feature toggles for the offline mode app.
+"""
+from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
+
+WAFFLE_FLAG_NAMESPACE = 'offline_mode'
+
+# .. toggle_name: offline_mode.enable_offline_mode
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: This feature toggle enables the offline mode course
+#       content generation for mobile devices.
+# .. toggle_use_cases: opt_out, open_edx
+# .. toggle_creation_date: 2024-06-06
+# .. toggle_target_removal_date: None
+ENABLE_OFFLINE_MODE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_offline_mode', __name__)
+
+
+def is_offline_mode_enabled(course_key=None):
+    """
+    Returns True if the offline mode is enabled for the course, False otherwise.
+    """
+    return ENABLE_OFFLINE_MODE.is_enabled(course_key)
diff --git a/openedx/features/offline_mode/urls.py b/openedx/features/offline_mode/urls.py
new file mode 100644
index 000000000000..f5178a424316
--- /dev/null
+++ b/openedx/features/offline_mode/urls.py
@@ -0,0 +1,10 @@
+"""
+URLs for the offline_mode feature.
+"""
+from django.urls import path
+
+from .views import SudioCoursePublishedEventHandler
+
+urlpatterns = [
+    path('handle_course_published', SudioCoursePublishedEventHandler.as_view(), name='handle_course_published'),
+]
diff --git a/openedx/features/offline_mode/utils.py b/openedx/features/offline_mode/utils.py
new file mode 100644
index 000000000000..55ac7c93d161
--- /dev/null
+++ b/openedx/features/offline_mode/utils.py
@@ -0,0 +1,102 @@
+"""
+Utility functions and classes for offline mode.
+"""
+import os
+import logging
+import shutil
+from tempfile import mkdtemp
+
+from django.contrib.auth import get_user_model
+from django.core.files.storage import default_storage
+
+from zipfile import ZipFile
+
+from .assets_management import block_storage_path, remove_old_files, is_modified
+from .html_manipulator import HtmlManipulator
+
+User = get_user_model()
+log = logging.getLogger(__name__)
+
+
+def generate_offline_content(xblock, html_data):
+    """
+    Generates archive with XBlock content for offline mode.
+
+    Args:
+        xblock (XBlock): The XBlock instance
+        html_data (str): The rendered HTML representation of the XBlock
+    """
+    if not is_modified(xblock):
+        return
+
+    base_path = block_storage_path(xblock)
+    remove_old_files(xblock)
+    tmp_dir = mkdtemp()
+
+    try:
+        save_xblock_html(tmp_dir, xblock, html_data)
+        create_zip_file(tmp_dir, base_path, f'{xblock.location.block_id}.zip')
+    finally:
+        shutil.rmtree(tmp_dir, ignore_errors=True)
+
+
+def save_xblock_html(tmp_dir, xblock, html_data):
+    """
+    Saves the XBlock HTML content to a file.
+
+    Generates the 'index.html' file with the HTML added to use it locally.
+
+    Args:
+        tmp_dir (str): The temporary directory path to save the xblock content
+        xblock (XBlock): The XBlock instance
+        html_data (str): The rendered HTML representation of the XBlock
+    """
+    html_manipulator = HtmlManipulator(xblock, html_data, tmp_dir)
+    updated_html = html_manipulator.process_html()
+
+    with open(os.path.join(tmp_dir, 'index.html'), 'w') as file:
+        file.write(updated_html)
+
+
+def create_zip_file(temp_dir, base_path, file_name):
+    """
+    Creates a zip file with the content of the base_path directory.
+
+    Args:
+        temp_dir (str): The temporary directory path where the content is stored
+        base_path (str): The base path directory to save the zip file
+        file_name (str): The name of the zip file
+    """
+    if not os.path.exists(default_storage.path(base_path)):
+        os.makedirs(default_storage.path(base_path))
+
+    with ZipFile(default_storage.path(base_path + file_name), 'w') as zip_file:
+        zip_file.write(os.path.join(temp_dir, 'index.html'), 'index.html')
+        add_files_to_zip_recursively(
+            zip_file,
+            current_base_path=os.path.join(temp_dir, 'assets'),
+            current_path_in_zip='assets',
+        )
+    log.info(f'Offline content for {file_name} has been generated.')
+
+
+def add_files_to_zip_recursively(zip_file, current_base_path, current_path_in_zip):
+    """
+    Recursively adds files to the zip file.
+
+    Args:
+        zip_file (ZipFile): The zip file object
+        current_base_path (str): The current base path directory
+        current_path_in_zip (str): The current path in the zip file
+    """
+    try:
+        for resource_path in os.listdir(current_base_path):
+            full_path = os.path.join(current_base_path, resource_path)
+            full_path_in_zip = os.path.join(current_path_in_zip, resource_path)
+            if os.path.isfile(full_path):
+                zip_file.write(full_path, full_path_in_zip)
+            else:
+                add_files_to_zip_recursively(zip_file, full_path, full_path_in_zip)
+    except OSError:
+        log.error(f'Error while reading the directory: {current_base_path}')
+        return
diff --git a/openedx/features/offline_mode/views.py b/openedx/features/offline_mode/views.py
new file mode 100644
index 000000000000..111b69175770
--- /dev/null
+++ b/openedx/features/offline_mode/views.py
@@ -0,0 +1,48 @@
+"""
+Views for the offline_mode app.
+"""
+from opaque_keys.edx.keys import CourseKey
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from .tasks import generate_offline_content_for_course
+from .toggles import is_offline_mode_enabled
+
+
+class SudioCoursePublishedEventHandler(APIView):
+    """
+    Handle the event of a course being published in Studio.
+
+    This view is called by Studio when a course is published,
+    and it triggers the generation of offline content.
+    """
+
+    def post(self, request, *args, **kwargs):
+        """
+        Trigger the generation of offline content task.
+
+        Args:
+            request (Request): The incoming request object.
+            args: Additional positional arguments.
+            kwargs: Additional keyword arguments.
+
+        Returns:
+            Response: The response object.
+        """
+
+        course_id = request.data.get('course_id')
+        if not course_id:
+            return Response(
+                data={'error': 'course_id is required'},
+                status=status.HTTP_400_BAD_REQUEST
+            )
+        course_key = CourseKey.from_string(course_id)
+        if is_offline_mode_enabled(course_key):
+            generate_offline_content_for_course.apply_async(args=[course_id])
+            return Response(status=status.HTTP_200_OK)
+        else:
+            return Response(
+                data={'error': 'Offline mode is not enabled for this course'},
+                status=status.HTTP_400_BAD_REQUEST,
+            )