diff --git a/app/assets/javascripts/persons.js b/app/assets/javascripts/persons.js
index 7b9b097550..2a033d0991 100644
--- a/app/assets/javascripts/persons.js
+++ b/app/assets/javascripts/persons.js
@@ -1,53 +1,3 @@
-
-// Add params from the search fields to the bootstrap-table for on Ajax request.
-var personsTableAjax = {
-  queryParams: function(params) {
-    if (personsTableAjax.queriesOK !== true) {
-      return false; // Stop Bootstrap Table's uncontrollable initial load
-    }
-
-    return $.extend(params || {}, {
-      region: $('#region').val(),
-      search: $('#search').val(),
-    });
-  },
-  doAjax: function(options) {
-    $('.pagination li').addClass('disabled');
-    return window.wca.cancelPendingAjaxAndAjax('persons-index', options);
-  },
-};
-
-onPage('persons#index', function() {
-  var $table = $('.persons-table');
-  var options = $table.bootstrapTable('getOptions');
-
-  function reloadPersons() {
-    $('#search-box i').removeClass('fa-search').addClass('fa-spinner fa-spin');
-    options.pageNumber = 1;
-    $table.bootstrapTable('refresh');
-  }
-
-  // Set the table options from the url params.
-  options.pageNumber = parseInt(window.wca.getUrlParams().page) || options.pageNumber;
-  // Load the data using the options set above.
-  personsTableAjax.queriesOK = true;
-  $table.bootstrapTable('refresh');
-
-  $('#region').on('change', reloadPersons);
-  $('#search').on('input', window.wca.lodashDebounce(reloadPersons, window.wca.TEXT_INPUT_DEBOUNCE_MS));
-
-  $table.on('load-success.bs.table', function(e, data) {
-    $('#search-box i').removeClass('fa-spinner fa-spin').addClass('fa-search');
-
-    // Update params in the url.
-    var params = personsTableAjax.queryParams({
-      // Extended with region and search params.
-      page: options.pageNumber
-    });
-    window.wca.setUrlParams(params);
-  });
-});
-
 onPage('persons#show', function() {
   function scrollToTabs() {
     var top = $('.nav.nav-tabs').offset().top;
diff --git a/app/controllers/persons_controller.rb b/app/controllers/persons_controller.rb
index 32dd865a8f..eb43204df2 100644
--- a/app/controllers/persons_controller.rb
+++ b/app/controllers/persons_controller.rb
@@ -4,7 +4,7 @@ class PersonsController < ApplicationController
   def index
     respond_to do |format|
       format.html
-      format.js do
+      format.json do
         persons = Person.in_region(params[:region]).order(:name)
         params[:search]&.split&.each do |part|
           persons = persons.where("MATCH(Persons.name) AGAINST (:name_match IN BOOLEAN MODE) OR wca_id LIKE :wca_id_part", name_match: "#{part}*", wca_id_part: "#{part}%")
@@ -14,9 +14,9 @@ def index
           total: persons.count,
           rows: persons.limit(params[:limit]).offset(params[:offset]).map do |person|
             {
-              name: view_context.link_to(person.name, person_path(person.wca_id)),
+              name: person.name,
               wca_id: person.wca_id,
-              country: person.country.name,
+              country: person.country.iso2,
               competitions_count: person.competitions.count,
               podiums_count: person.results.podium.count,
             }
diff --git a/app/views/persons/index.html.erb b/app/views/persons/index.html.erb
index a80ef986a1..01c4e7b95b 100644
--- a/app/views/persons/index.html.erb
+++ b/app/views/persons/index.html.erb
@@ -1,39 +1,5 @@
 <% provide(:title, t('layouts.navigation.persons')) %>
 
 <div class="container" id="persons-index">
-  <h2><%= yield(:title) %></h2>
-
-  <div id="persons-query-fields" class="form-inline bs-table-query-fields">
-    <div class="form-group">
-      <%= select_tag(:region, region_option_tags(selected_id: params[:region], real_only: true), class: "form-control") %>
-    </div>
-    <div id="search-box" class="form-group">
-      <div class="input-group">
-        <%= content_tag :span, ui_icon("search"), class: "input-group-addon" %>
-        <%= text_field_tag :search, params[:search], placeholder: t('.name_or_wca_id'), class: "form-control" %>
-      </div>
-    </div>
-  </div>
-
-  <%= wca_table table_class: "persons-table", floatThead: false,
-                data: { toggle: "table", pagination: true, side_pagination: "server", url: persons_path,
-                        query_params: "personsTableAjax.queryParams", mobile_responsive: true, ajax: "personsTableAjax.doAjax" } do %>
-    <thead>
-      <tr>
-        <th class="name" data-field="name"><%=t '.name' %></th>
-        <th class="wca-id" data-field="wca_id"><%=t 'common.user.wca_id' %></th>
-        <th class="country" data-field="country"><%=t '.country' %></th>
-        <th class="competitions-count" data-field="competitions_count"><%=t 'layouts.navigation.competitions' %></th>
-        <th class="podiums-count" data-field="podiums_count"><%=t '.podiums' %></th>
-      </tr>
-    </thead>
-  <% end %>
+  <%= react_component("Persons/List") %>
 </div>
-
-<script>
-  $(function() {
-    $('.persons-table').bootstrapTable('getOptions').formatNoMatches = function() {
-      return '<%=j t('.no_persons_found') %>';
-    }
-  });
-</script>
diff --git a/app/webpacker/components/Persons/List/index.jsx b/app/webpacker/components/Persons/List/index.jsx
new file mode 100644
index 0000000000..7d46f5117f
--- /dev/null
+++ b/app/webpacker/components/Persons/List/index.jsx
@@ -0,0 +1,100 @@
+import React, { useState } from 'react';
+import {
+  Header, Input, Pagination, Table,
+} from 'semantic-ui-react';
+import { useQuery } from '@tanstack/react-query';
+import I18n from '../../../lib/i18n';
+import useDebounce from '../../../lib/hooks/useDebounce';
+import { getPersons } from '../api/getPersons';
+import Loading from '../../Requests/Loading';
+import WCAQueryClientProvider from '../../../lib/providers/WCAQueryClientProvider';
+import { RegionSelector } from '../../CompetitionsOverview/CompetitionsFilters';
+import { personUrl } from '../../../lib/requests/routes.js.erb';
+import { countries } from '../../../lib/wca-data.js.erb';
+
+export default function Wrapper() {
+  return (
+    <WCAQueryClientProvider>
+      <PersonList />
+    </WCAQueryClientProvider>
+  );
+}
+
+function PersonList() {
+  const [query, setQuery] = useState('');
+  const [page, setPage] = useState(1);
+  const [region, setRegion] = useState('all');
+
+  const debouncedSearch = useDebounce(query, 600);
+
+  const { data, isLoading } = useQuery({
+    queryKey: ['persons', debouncedSearch, region, page],
+    queryFn: () => getPersons(page, countries.byIso2[region]?.id ?? region, debouncedSearch),
+  });
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <>
+      <Header>
+        {I18n.t('layouts.navigation.persons')}
+      </Header>
+      <RegionSelector region={region} dispatchFilter={({ region: r }) => setRegion(r)} />
+      <Input type="text" placeholder={I18n.t('persons.index.name_or_wca_id')} value={query} onChange={(d) => setQuery(d.target.value)} />
+      <Table striped>
+        <Table.Header>
+          <Table.Row>
+            <Table.HeaderCell>
+              {I18n.t('persons.index.name')}
+            </Table.HeaderCell>
+            <Table.HeaderCell>
+              {I18n.t('common.user.wca_id')}
+            </Table.HeaderCell>
+            <Table.HeaderCell>
+              {I18n.t('persons.index.country')}
+            </Table.HeaderCell>
+            <Table.HeaderCell>
+              {I18n.t('layouts.navigation.competitions')}
+            </Table.HeaderCell>
+            <Table.HeaderCell>
+              {I18n.t('persons.index.podiums')}
+            </Table.HeaderCell>
+          </Table.Row>
+        </Table.Header>
+        <Table.Body>
+          {data.rows.length === 0 ? (
+            <Table.Row>
+              <Table.Cell>{I18n.t('persons.index.no_persons_found')}</Table.Cell>
+            </Table.Row>
+          ) : data.rows.map((row) => (
+            <Table.Row key={row.wca_id}>
+              <Table.Cell>
+                <a href={personUrl(row.wca_id)}>{row.name}</a>
+              </Table.Cell>
+              <Table.Cell>
+                {row.wca_id}
+              </Table.Cell>
+              <Table.Cell>
+                {countries.byIso2[row.country].name}
+              </Table.Cell>
+
+              <Table.Cell>
+                {row.competitions_count}
+              </Table.Cell>
+              <Table.Cell>
+                {row.podiums_count}
+              </Table.Cell>
+            </Table.Row>
+          ))}
+        </Table.Body>
+      </Table>
+      <Pagination
+        defaultActivePage={page}
+        totalPages={Math.ceil(data.total / 10)}
+        onPageChange={(e, p) => setPage(p.activePage)}
+      />
+    </>
+  );
+}
diff --git a/app/webpacker/components/Persons/api/getPersons.js b/app/webpacker/components/Persons/api/getPersons.js
new file mode 100644
index 0000000000..3bf2d26b32
--- /dev/null
+++ b/app/webpacker/components/Persons/api/getPersons.js
@@ -0,0 +1,14 @@
+import { fetchJsonOrError } from '../../../lib/requests/fetchWithAuthenticityToken';
+import { personsUrl } from '../../../lib/requests/routes.js.erb';
+// eslint-disable-next-line import/prefer-default-export
+export async function getPersons(page, region, query) {
+  const { data } = await fetchJsonOrError(
+    `${personsUrl}?search=${query}&order=asc&offset=${(page - 1) * 10}&limit=10&region=${region ?? 'all'}`,
+    {
+      headers: {
+        Accept: 'application/json',
+      },
+    },
+  );
+  return data;
+}
diff --git a/config/i18n.yml b/config/i18n.yml
index d43221c507..5f2ebbbc48 100644
--- a/config/i18n.yml
+++ b/config/i18n.yml
@@ -74,3 +74,4 @@ translations:
       - "*.attempts.*"
       - "*.time_limit.*"
       - "*.users.edit.*"
+      - "*.persons.index.*"
diff --git a/spec/controllers/persons_controller_spec.rb b/spec/controllers/persons_controller_spec.rb
index 06a993ad06..cfa32c8589 100644
--- a/spec/controllers/persons_controller_spec.rb
+++ b/spec/controllers/persons_controller_spec.rb
@@ -20,33 +20,33 @@
       let!(:result) { FactoryBot.create(:result, pos: 1, roundTypeId: "f", competitionId: competition.id, person: person1) }
 
       it "responds with correct JSON when region and search are specified" do
-        get :index, params: { search: "Jennifer", region: "USA" }, xhr: true
+        get :index, params: { search: "Jennifer", region: "USA" }, format: :json
         json = JSON.parse(response.body)
         expect(json['total']).to eq 1
         json_person = json['rows'][0]
         expect(json_person['name']).to include "Jennifer Lawrence"
         expect(json_person['wca_id']).to eq "2016LAWR01"
-        expect(json_person['country']).to eq "United States"
+        expect(json_person['country']).to eq "US"
         expect(json_person['competitions_count']).to eq 1
         expect(json_person['podiums_count']).to eq 1
       end
 
       it "selecting continent works" do
-        get :index, params: { region: "_Europe" }, xhr: true
+        get :index, params: { region: "_Europe" }, format: :json
         json = JSON.parse(response.body)
         expect(json['total']).to eq 1
         expect(json['rows'].count).to eq 1
       end
 
       it "searching by WCA ID works" do
-        get :index, params: { search: "2016" }, xhr: true
+        get :index, params: { search: "2016" }, format: :json
         json = JSON.parse(response.body)
         expect(json['total']).to eq 2
         expect(json['rows'].count).to eq 2
       end
 
       it "works well when parts of the name are given" do
-        get :index, params: { search: "Law Jenn" }, xhr: true
+        get :index, params: { search: "Law Jenn" }, format: :json
         json = JSON.parse(response.body)
         expect(json['total']).to eq 1
         expect(json['rows'].count).to eq 1