Refs #30997 -- Added HttpRequest.accepts().
diff --git a/django/http/request.py b/django/http/request.py
index a0bdf49312863..790e4546d732c 100644
--- a/django/http/request.py
+++ b/django/http/request.py
@@ -20,6 +20,8 @@
 from django.utils.http import is_same_domain, limited_parse_qsl
 from django.utils.regex_helper import _lazy_re_compile
 
+from .multipartparser import parse_header
+
 RAISE_ERROR = object()
 host_validation_re = _lazy_re_compile(r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:\d+)?$")
 
@@ -71,6 +73,17 @@ def __repr__(self):
     def headers(self):
         return HttpHeaders(self.META)
 
+    @cached_property
+    def accepted_types(self):
+        """Return a list of MediaType instances."""
+        return parse_accept_header(self.headers.get('Accept', '*/*'))
+
+    def accepts(self, media_type):
+        return any(
+            accepted_type.match(media_type)
+            for accepted_type in self.accepted_types
+        )
+
     def _set_content_type_params(self, meta):
         """Set content_type, content_params, and encoding."""
         self.content_type, self.content_params = cgi.parse_header(meta.get('CONTENT_TYPE', ''))
@@ -557,6 +570,40 @@ def encode(k, v):
         return '&'.join(output)
 
 
+class MediaType:
+    def __init__(self, media_type_raw_line):
+        full_type, self.params = parse_header(
+            media_type_raw_line.encode('ascii') if media_type_raw_line else b''
+        )
+        self.main_type, _, self.sub_type = full_type.partition('/')
+
+    def __str__(self):
+        params_str = ''.join(
+            '; %s=%s' % (k, v.decode('ascii'))
+            for k, v in self.params.items()
+        )
+        return '%s%s%s' % (
+            self.main_type,
+            ('/%s' % self.sub_type) if self.sub_type else '',
+            params_str,
+        )
+
+    def __repr__(self):
+        return '<%s: %s>' % (self.__class__.__qualname__, self)
+
+    @property
+    def is_all_types(self):
+        return self.main_type == '*' and self.sub_type == '*'
+
+    def match(self, other):
+        if self.is_all_types:
+            return True
+        other = MediaType(other)
+        if self.main_type == other.main_type and self.sub_type in {'*', other.sub_type}:
+            return True
+        return False
+
+
 # It's neither necessary nor appropriate to use
 # django.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
 # this slightly more restricted function, used by QueryDict.
@@ -612,3 +659,7 @@ def validate_host(host, allowed_hosts):
     Return ``True`` for a valid host, ``False`` otherwise.
     """
     return any(pattern == '*' or is_same_domain(host, pattern) for pattern in allowed_hosts)
+
+
+def parse_accept_header(header):
+    return [MediaType(token) for token in header.split(',') if token.strip()]
diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt
index 370993444b419..2f9e78e358285 100644
--- a/docs/ref/request-response.txt
+++ b/docs/ref/request-response.txt
@@ -406,6 +406,29 @@ Methods
     Returns ``True`` if the request is secure; that is, if it was made with
     HTTPS.
 
+.. method:: HttpRequest.accepts(mime_type)
+
+    .. versionadded:: 3.1
+
+    Returns ``True`` if the request ``Accept`` header matches the ``mime_type``
+    argument::
+
+        >>> request.accepts('text/html')
+        True
+
+    Most browsers send ``Accept: */*`` by default, so this would return
+    ``True`` for all content types. Setting an explicit ``Accept`` header in
+    API requests can be useful for returning a different content type for those
+    consumers only. See :ref:`content-negotiation-example` of using
+    ``accepts()`` to return different content to API consumers.
+
+    If a response varies depending on the content of the ``Accept`` header and
+    you are using some form of caching like Django's :mod:`cache middleware
+    <django.middleware.cache>`, you should decorate the view with
+    :func:`vary_on_headers('Accept')
+    <django.views.decorators.vary.vary_on_headers>` so that the responses are
+    properly cached.
+
 .. method:: HttpRequest.is_ajax()
 
     Returns ``True`` if the request was made via an ``XMLHttpRequest``, by
diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt
index 709c3917bc57e..3c88ecadd751c 100644
--- a/docs/releases/3.1.txt
+++ b/docs/releases/3.1.txt
@@ -282,6 +282,9 @@ Requests and Responses
   now allow using ``samesite='None'`` (string) to explicitly state that the
   cookie is sent with all same-site and cross-site requests.
 
+* The new :meth:`.HttpRequest.accepts` method returns whether the request
+  accepts the given MIME type according to the ``Accept`` HTTP header.
+
 Serialization
 ~~~~~~~~~~~~~
 
diff --git a/docs/topics/class-based-views/generic-editing.txt b/docs/topics/class-based-views/generic-editing.txt
index c1e73fc2fb27e..7c61592142a9e 100644
--- a/docs/topics/class-based-views/generic-editing.txt
+++ b/docs/topics/class-based-views/generic-editing.txt
@@ -222,41 +222,43 @@ to edit, and override
 aren't logged in from accessing the form. If you omit that, you'll need to
 handle unauthorized users in :meth:`~.ModelFormMixin.form_valid()`.
 
-AJAX example
-============
+.. _content-negotiation-example:
+
+Content negotiation example
+===========================
 
 Here is an example showing how you might go about implementing a form that
-works for AJAX requests as well as 'normal' form POSTs::
+works with an API-based workflow as well as 'normal' form POSTs::
 
     from django.http import JsonResponse
     from django.views.generic.edit import CreateView
     from myapp.models import Author
 
-    class AjaxableResponseMixin:
+    class JsonableResponseMixin:
         """
-        Mixin to add AJAX support to a form.
+        Mixin to add JSON support to a form.
         Must be used with an object-based FormView (e.g. CreateView)
         """
         def form_invalid(self, form):
             response = super().form_invalid(form)
-            if self.request.is_ajax():
-                return JsonResponse(form.errors, status=400)
-            else:
+            if self.request.accepts('text/html'):
                 return response
+            else:
+                return JsonResponse(form.errors, status=400)
 
         def form_valid(self, form):
             # We make sure to call the parent's form_valid() method because
             # it might do some processing (in the case of CreateView, it will
             # call form.save() for example).
             response = super().form_valid(form)
-            if self.request.is_ajax():
+            if self.request.accepts('text/html'):
+                return response
+            else:
                 data = {
                     'pk': self.object.pk,
                 }
                 return JsonResponse(data)
-            else:
-                return response
 
-    class AuthorCreate(AjaxableResponseMixin, CreateView):
+    class AuthorCreate(JsonableResponseMixin, CreateView):
         model = Author
         fields = ['name']
diff --git a/tests/requests/test_accept_header.py b/tests/requests/test_accept_header.py
new file mode 100644
index 0000000000000..76559920af681
--- /dev/null
+++ b/tests/requests/test_accept_header.py
@@ -0,0 +1,101 @@
+from unittest import TestCase
+
+from django.http import HttpRequest
+from django.http.request import MediaType
+
+
+class MediaTypeTests(TestCase):
+    def test_empty(self):
+        for empty_media_type in (None, ''):
+            with self.subTest(media_type=empty_media_type):
+                media_type = MediaType(empty_media_type)
+                self.assertIs(media_type.is_all_types, False)
+                self.assertEqual(str(media_type), '')
+                self.assertEqual(repr(media_type), '<MediaType: >')
+
+    def test_str(self):
+        self.assertEqual(str(MediaType('*/*; q=0.8')), '*/*; q=0.8')
+        self.assertEqual(str(MediaType('application/xml')), 'application/xml')
+
+    def test_repr(self):
+        self.assertEqual(repr(MediaType('*/*; q=0.8')), '<MediaType: */*; q=0.8>')
+        self.assertEqual(
+            repr(MediaType('application/xml')),
+            '<MediaType: application/xml>',
+        )
+
+    def test_is_all_types(self):
+        self.assertIs(MediaType('*/*').is_all_types, True)
+        self.assertIs(MediaType('*/*; q=0.8').is_all_types, True)
+        self.assertIs(MediaType('text/*').is_all_types, False)
+        self.assertIs(MediaType('application/xml').is_all_types, False)
+
+    def test_match(self):
+        tests = [
+            ('*/*; q=0.8', '*/*'),
+            ('*/*', 'application/json'),
+            (' */* ', 'application/json'),
+            ('application/*', 'application/json'),
+            ('application/xml', 'application/xml'),
+            (' application/xml ', 'application/xml'),
+            ('application/xml', ' application/xml '),
+        ]
+        for accepted_type, mime_type in tests:
+            with self.subTest(accepted_type, mime_type=mime_type):
+                self.assertIs(MediaType(accepted_type).match(mime_type), True)
+
+    def test_no_match(self):
+        tests = [
+            (None, '*/*'),
+            ('', '*/*'),
+            ('; q=0.8', '*/*'),
+            ('application/xml', 'application/html'),
+            ('application/xml', '*/*'),
+        ]
+        for accepted_type, mime_type in tests:
+            with self.subTest(accepted_type, mime_type=mime_type):
+                self.assertIs(MediaType(accepted_type).match(mime_type), False)
+
+
+class AcceptHeaderTests(TestCase):
+    def test_no_headers(self):
+        """Absence of Accept header defaults to '*/*'."""
+        request = HttpRequest()
+        self.assertEqual(
+            [str(accepted_type) for accepted_type in request.accepted_types],
+            ['*/*'],
+        )
+
+    def test_accept_headers(self):
+        request = HttpRequest()
+        request.META['HTTP_ACCEPT'] = (
+            'text/html, application/xhtml+xml,application/xml ;q=0.9,*/*;q=0.8'
+        )
+        self.assertEqual(
+            [str(accepted_type) for accepted_type in request.accepted_types],
+            [
+                'text/html',
+                'application/xhtml+xml',
+                'application/xml; q=0.9',
+                '*/*; q=0.8',
+            ],
+        )
+
+    def test_request_accepts_any(self):
+        request = HttpRequest()
+        request.META['HTTP_ACCEPT'] = '*/*'
+        self.assertIs(request.accepts('application/json'), True)
+
+    def test_request_accepts_none(self):
+        request = HttpRequest()
+        request.META['HTTP_ACCEPT'] = ''
+        self.assertIs(request.accepts('application/json'), False)
+        self.assertEqual(request.accepted_types, [])
+
+    def test_request_accepts_some(self):
+        request = HttpRequest()
+        request.META['HTTP_ACCEPT'] = 'text/html,application/xhtml+xml,application/xml;q=0.9'
+        self.assertIs(request.accepts('text/html'), True)
+        self.assertIs(request.accepts('application/xhtml+xml'), True)
+        self.assertIs(request.accepts('application/xml'), True)
+        self.assertIs(request.accepts('application/json'), False)

ticket-30997